@docupace/pipeline 1.0.137 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +15 -0
- package/package.json +12 -11
- package/pnpm-workspace.yaml +4 -0
- package/src/auth0/cli.js +0 -0
- package/src/changeset/cli.js +0 -0
- package/src/merge/cli.js +0 -0
- package/src/micro/cli.js +0 -0
- package/src/next/cli.js +66 -4
- package/src/next/connect-aws-cli.js +206 -0
- package/src/next/connect-aws.js +1858 -0
- package/src/next/connect-aws.test.js +131 -0
- package/src/next/deploy.js +95 -2
- package/src/next/deploy.test.js +1 -0
- package/src/tenable/cli.js +0 -0
- package/src/veracode/cli.js +0 -0
- package/src/xray/cli.js +0 -0
- package/.gitignore +0 -109
|
@@ -0,0 +1,1858 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { execFile } from 'node:child_process';
|
|
5
|
+
import { parseArgs, promisify, isDeepStrictEqual } from 'node:util';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import yaml from 'js-yaml';
|
|
8
|
+
import {
|
|
9
|
+
CreateSecretCommand,
|
|
10
|
+
SecretsManagerClient,
|
|
11
|
+
} from '@aws-sdk/client-secrets-manager';
|
|
12
|
+
import { runPipeline } from './pipline.js';
|
|
13
|
+
import { environmentConfig, getBitbucketInfo } from './config.js';
|
|
14
|
+
|
|
15
|
+
// Loaded lazily so deploy.js can import this module without pulling in the Auth0 and AWS secret helpers.
|
|
16
|
+
const loadSecretsManager = () => import('../auth0/secrets-manager.js');
|
|
17
|
+
const loadAwsSecrets = () => import('../aws/aws-secrets.js');
|
|
18
|
+
|
|
19
|
+
// Existing AWS CONNECT deployment
|
|
20
|
+
export const CONNECT_AWS_GITOPS_REPO = 'dp-eks-connect-ui-gitops';
|
|
21
|
+
export const CONNECT_AWS_UPDATE_PIPELINE = 'update-site-image';
|
|
22
|
+
export const CONNECT_AWS_DEFAULT_BRANCH = 'main';
|
|
23
|
+
|
|
24
|
+
const ALLOWED_ENVS = new Set(['dev', 'qa', 'stage', 'uat', 'prod']);
|
|
25
|
+
const SITE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
26
|
+
const VERSION_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._+-]*$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Normalize sites input to a non-empty list of site names.
|
|
30
|
+
* Accepts a single site string, comma-separated string, or string[].
|
|
31
|
+
*
|
|
32
|
+
* @param {string|string[]} sites
|
|
33
|
+
* @returns {string[]}
|
|
34
|
+
*/
|
|
35
|
+
export function normalizeConnectAwsSites(sites) {
|
|
36
|
+
const raw = Array.isArray(sites) ? sites : String(sites ?? '').split(',');
|
|
37
|
+
return raw.map((s) => String(s).trim()).filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Trigger the Bitbucket custom pipeline that updates
|
|
42
|
+
* envs/<env>/<site>/values.yaml image.tag in dp-eks-connect-ui-gitops.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} env - Target environment (dev|qa|stage|uat|prod)
|
|
45
|
+
* @param {string|string[]} sites - Site name, or comma-separated / array of sites
|
|
46
|
+
* @param {string} version - Image tag to deploy
|
|
47
|
+
* @param {{ wait?: boolean, branch?: string }} [options]
|
|
48
|
+
*/
|
|
49
|
+
export async function deployConnectAws(
|
|
50
|
+
env,
|
|
51
|
+
sites,
|
|
52
|
+
version,
|
|
53
|
+
{ wait = true, branch = CONNECT_AWS_DEFAULT_BRANCH } = {},
|
|
54
|
+
) {
|
|
55
|
+
const siteList = normalizeConnectAwsSites(sites);
|
|
56
|
+
validateConnectAwsArgs(env, siteList, version);
|
|
57
|
+
|
|
58
|
+
const siteParam = siteList.join(',');
|
|
59
|
+
console.log(
|
|
60
|
+
`Deploying AWS Connect UI: env=${env} site(s)=${siteParam} version=${version} (pipeline=${CONNECT_AWS_UPDATE_PIPELINE})`,
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await runPipeline(
|
|
64
|
+
CONNECT_AWS_GITOPS_REPO,
|
|
65
|
+
CONNECT_AWS_UPDATE_PIPELINE,
|
|
66
|
+
branch,
|
|
67
|
+
{
|
|
68
|
+
ENV: env,
|
|
69
|
+
SITE: siteParam,
|
|
70
|
+
VERSION: version,
|
|
71
|
+
},
|
|
72
|
+
wait,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {string} env
|
|
78
|
+
* @param {string|string[]} sites - single site, comma-separated, or array
|
|
79
|
+
* @param {string} version
|
|
80
|
+
*/
|
|
81
|
+
export function validateConnectAwsArgs(env, sites, version) {
|
|
82
|
+
if (!ALLOWED_ENVS.has(env)) {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`Invalid env "${env}". Expected one of: ${[...ALLOWED_ENVS].join(', ')}`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const siteList = normalizeConnectAwsSites(sites);
|
|
89
|
+
if (siteList.length === 0) {
|
|
90
|
+
throw new Error('At least one site is required');
|
|
91
|
+
}
|
|
92
|
+
for (const site of siteList) {
|
|
93
|
+
if (!SITE_PATTERN.test(site)) {
|
|
94
|
+
throw new Error(`Invalid site "${site}"`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (!version || !VERSION_PATTERN.test(version)) {
|
|
99
|
+
throw new Error(`Invalid version "${version}"`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function isAllowedConnectAwsEnv(env) {
|
|
104
|
+
return ALLOWED_ENVS.has(env);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Environment normalization
|
|
108
|
+
const aliases = {
|
|
109
|
+
preprod: 'qa',
|
|
110
|
+
staging: 'stage',
|
|
111
|
+
stg: 'stage',
|
|
112
|
+
prd: 'prod',
|
|
113
|
+
'prod-www3': 'prod',
|
|
114
|
+
'prod-las': 'prod',
|
|
115
|
+
'prod-lax': 'prod',
|
|
116
|
+
'prod-athene': 'prod',
|
|
117
|
+
'prod-staging': 'prod',
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
export function normalizeConnectEnvironment(env) {
|
|
121
|
+
const normalized = aliases[env] || env;
|
|
122
|
+
if (!['dev', 'qa', 'stage', 'uat', 'prod'].includes(normalized)) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
'CONNECT environment must be dev, qa, stage, uat, or prod.',
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return normalized;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function getConnectEnvironmentConfig(env) {
|
|
131
|
+
env = normalizeConnectEnvironment(env);
|
|
132
|
+
const siteSettingsEnv = { qa: 'preprod', stage: 'staging' }[env] || env;
|
|
133
|
+
return { env, siteSettingsEnv };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The pipeline wrappers use the same aliases as the copy, secrets, SAML and fleet commands.
|
|
137
|
+
|
|
138
|
+
// Target Core settings
|
|
139
|
+
// Read Java properties without executing repository content or logging values.
|
|
140
|
+
export function parseCoreProperties(content) {
|
|
141
|
+
const result = {};
|
|
142
|
+
const lines = content.replace(/\\\r?\n[ \t]*/g, '').split(/\r?\n/);
|
|
143
|
+
for (const line of lines) {
|
|
144
|
+
if (/^\s*[#!]/.test(line) || !line.trim()) continue;
|
|
145
|
+
const match = line.match(/^\s*([^\s=:]+)\s*(?:[=:]\s*|\s+)(.*)$/);
|
|
146
|
+
if (!match) continue;
|
|
147
|
+
result[match[1]] = match[2]
|
|
148
|
+
.trim()
|
|
149
|
+
.replace(/\\u([\da-f]{4})|\\([trnf\\:= ])/gi, (_, hex, escaped) =>
|
|
150
|
+
hex
|
|
151
|
+
? String.fromCharCode(parseInt(hex, 16))
|
|
152
|
+
: ({ t: '\t', r: '\r', n: '\n', f: '\f' }[escaped] ?? escaped),
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function readProperties(file, required = false) {
|
|
159
|
+
if (!(await fs.pathExists(file))) {
|
|
160
|
+
if (required)
|
|
161
|
+
throw new Error(
|
|
162
|
+
`Target Core site settings are missing: ${path.basename(file)}. Complete Core site setup first.`,
|
|
163
|
+
);
|
|
164
|
+
return {};
|
|
165
|
+
}
|
|
166
|
+
return parseCoreProperties(await fs.readFile(file, 'utf8'));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export async function readConnectSsoSettings({
|
|
170
|
+
siteSettingsPath,
|
|
171
|
+
site,
|
|
172
|
+
coreSchema,
|
|
173
|
+
}) {
|
|
174
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,49}$/.test(site || ''))
|
|
175
|
+
throw new Error('Invalid target site.');
|
|
176
|
+
const root = await fs.realpath(path.resolve(siteSettingsPath));
|
|
177
|
+
const schema = coreSchema || site.toUpperCase();
|
|
178
|
+
if (!/^[A-Z0-9][A-Z0-9_-]{0,49}$/i.test(schema))
|
|
179
|
+
throw new Error('Invalid target Core schema.');
|
|
180
|
+
const folder = path.join(root, schema);
|
|
181
|
+
if (!(await fs.realpath(folder)).startsWith(`${root}${path.sep}`))
|
|
182
|
+
throw new Error('Target settings resolve outside their checkout.');
|
|
183
|
+
const sso = await readProperties(
|
|
184
|
+
path.join(folder, 'docucore-sharedKeySSO.properties'),
|
|
185
|
+
true,
|
|
186
|
+
);
|
|
187
|
+
const matches = Object.entries(sso).filter(
|
|
188
|
+
([key, value]) =>
|
|
189
|
+
/^com\.docupace\.dpflow\.web\.security\.token\.CommonSSOConfig\.tokenRealms\d+\.publicId$/.test(
|
|
190
|
+
key,
|
|
191
|
+
) && /^(Connect|NEWUI)$/i.test(value),
|
|
192
|
+
);
|
|
193
|
+
if (matches.length !== 1)
|
|
194
|
+
throw new Error(
|
|
195
|
+
'Target site settings require exactly one Connect/NEWUI realm.',
|
|
196
|
+
);
|
|
197
|
+
const prefix = matches[0][0].replace(/publicId$/, '');
|
|
198
|
+
return {
|
|
199
|
+
key: resolved(sso[`${prefix}key`], 'CONNECT SSO key'),
|
|
200
|
+
iv: resolved(sso[`${prefix}iv`], 'CONNECT SSO IV'),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function resolved(value, label) {
|
|
205
|
+
if (!value || /\$\{|[\r\n]/.test(value)) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`Cannot resolve ${label} from target Core site settings. Complete Core setup or supply an explicit override.`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return value;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export async function resolveConnectSiteSettings({
|
|
214
|
+
siteSettingsPath,
|
|
215
|
+
env,
|
|
216
|
+
site,
|
|
217
|
+
coreSchema,
|
|
218
|
+
coreRealm,
|
|
219
|
+
dbUrl,
|
|
220
|
+
coreApiUrl,
|
|
221
|
+
}) {
|
|
222
|
+
env = normalizeConnectEnvironment(env);
|
|
223
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,49}$/.test(site || ''))
|
|
224
|
+
throw new Error('Invalid target site name.');
|
|
225
|
+
const root = await fs.realpath(path.resolve(siteSettingsPath));
|
|
226
|
+
// The existing Core convention uses the uppercase site directory/client name.
|
|
227
|
+
const schema = coreSchema || site.toUpperCase();
|
|
228
|
+
if (!/^[A-Z0-9][A-Z0-9_-]{0,49}$/i.test(schema))
|
|
229
|
+
throw new Error('Invalid target Core schema.');
|
|
230
|
+
const siteDir = path.join(root, schema);
|
|
231
|
+
if (
|
|
232
|
+
!(await fs.pathExists(siteDir)) ||
|
|
233
|
+
!(await fs.realpath(siteDir)).startsWith(`${root}${path.sep}`)
|
|
234
|
+
) {
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Target Core site directory ${schema} is missing from site settings. Complete Core site setup first.`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
let corePublicRealm;
|
|
240
|
+
if (!coreRealm) {
|
|
241
|
+
const sso = await readProperties(
|
|
242
|
+
path.join(siteDir, 'docucore-sharedKeySSO.properties'),
|
|
243
|
+
true,
|
|
244
|
+
);
|
|
245
|
+
const realms = Object.entries(sso).filter(
|
|
246
|
+
([key, value]) =>
|
|
247
|
+
/^com\.docupace\.dpflow\.web\.security\.token\.CommonSSOConfig\.tokenRealms\d+\.publicId$/.test(
|
|
248
|
+
key,
|
|
249
|
+
) && /^(Connect|NEWUI)$/i.test(value),
|
|
250
|
+
);
|
|
251
|
+
if (realms.length !== 1)
|
|
252
|
+
throw new Error(
|
|
253
|
+
'Target Core site settings require exactly one Connect or NEWUI SSO realm.',
|
|
254
|
+
);
|
|
255
|
+
coreRealm = resolved(
|
|
256
|
+
sso[realms[0][0].replace(/publicId$/, 'privateId')],
|
|
257
|
+
'CONNECT private realm',
|
|
258
|
+
);
|
|
259
|
+
corePublicRealm = realms[0][1];
|
|
260
|
+
}
|
|
261
|
+
if (!dbUrl) {
|
|
262
|
+
const database = {
|
|
263
|
+
...(await readProperties(path.join(root, 'docucore.properties'))),
|
|
264
|
+
...(await readProperties(path.join(root, 'docucore-jndi.properties'))),
|
|
265
|
+
...(await readProperties(path.join(siteDir, 'docucore.properties'))),
|
|
266
|
+
...(await readProperties(path.join(siteDir, 'docucore-jndi.properties'))),
|
|
267
|
+
};
|
|
268
|
+
const type = resolved(database['docupace.db.type'], 'Core database type');
|
|
269
|
+
if (type !== 'oracle')
|
|
270
|
+
throw new Error(
|
|
271
|
+
'Automatic CONNECT database resolution currently requires Oracle; supply an explicit JDBC override for this site.',
|
|
272
|
+
);
|
|
273
|
+
dbUrl = resolved(
|
|
274
|
+
database[`docupace.db.${type}.dataSourceURL`],
|
|
275
|
+
'Core JDBC URL',
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
coreSchema: schema,
|
|
280
|
+
coreRealm,
|
|
281
|
+
corePublicRealm,
|
|
282
|
+
dbUrl,
|
|
283
|
+
coreApiUrl:
|
|
284
|
+
coreApiUrl || `https://${environmentConfig[env].docupaceHost()}/core_b2b`,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// Pod Identity and runtime readiness
|
|
289
|
+
const exec = promisify(execFile);
|
|
290
|
+
export const CONNECT_AWS_ACCOUNTS = {
|
|
291
|
+
dev: '772533039138',
|
|
292
|
+
qa: '431655581418',
|
|
293
|
+
stage: '352311918908',
|
|
294
|
+
uat: '497172038832',
|
|
295
|
+
prod: '520326035622',
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
export async function readConnectAwsJson(args, region) {
|
|
299
|
+
try {
|
|
300
|
+
const { stdout } = await exec(
|
|
301
|
+
'aws',
|
|
302
|
+
[...args, '--region', region, '--output', 'json', '--no-cli-pager'],
|
|
303
|
+
{ timeout: 30000, maxBuffer: 2 * 1024 * 1024 },
|
|
304
|
+
);
|
|
305
|
+
return JSON.parse(stdout);
|
|
306
|
+
} catch (error) {
|
|
307
|
+
if (/ResourceNotFoundException/.test(error.stderr || '')) return null;
|
|
308
|
+
// Never include captured secret responses or credential-bearing arguments.
|
|
309
|
+
throw new Error(
|
|
310
|
+
`AWS ${args[0]} ${args[1]} lookup failed; readiness cannot be confirmed.`,
|
|
311
|
+
);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function usable(value) {
|
|
316
|
+
return (
|
|
317
|
+
typeof value === 'string' &&
|
|
318
|
+
value.trim() &&
|
|
319
|
+
!/^(?:null|undefined|changeme|placeholder)$/i.test(value.trim()) &&
|
|
320
|
+
!value.includes('${') &&
|
|
321
|
+
!value.trim().startsWith('vault:')
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function configHasValue(config, key) {
|
|
326
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
327
|
+
const match = config.match(
|
|
328
|
+
new RegExp(`^[ \\t]*${escaped}[ \\t]*=[ \\t]*(.+)$`, 'm'),
|
|
329
|
+
);
|
|
330
|
+
if (!match) return false;
|
|
331
|
+
let value = match[1].trim();
|
|
332
|
+
if (value.startsWith('"')) {
|
|
333
|
+
try {
|
|
334
|
+
value = JSON.parse(value);
|
|
335
|
+
} catch {
|
|
336
|
+
return false;
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return usable(value);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
export async function checkConnectReadiness({
|
|
343
|
+
env,
|
|
344
|
+
site,
|
|
345
|
+
region = process.env.AWS_REGION || 'us-west-2',
|
|
346
|
+
aws = readConnectAwsJson,
|
|
347
|
+
podIdentityOnly = false,
|
|
348
|
+
}) {
|
|
349
|
+
env = normalizeConnectEnvironment(env);
|
|
350
|
+
if (!/^[a-z0-9](?:[a-z0-9_-]{0,48}[a-z0-9])?$/.test(site || ''))
|
|
351
|
+
throw new Error('Invalid target site name.');
|
|
352
|
+
const identity = await aws(['sts', 'get-caller-identity'], region);
|
|
353
|
+
if (identity?.Account !== CONNECT_AWS_ACCOUNTS[env])
|
|
354
|
+
throw new Error(
|
|
355
|
+
'AWS account does not match the target CONNECT environment.',
|
|
356
|
+
);
|
|
357
|
+
const name = site.replaceAll('_', '-');
|
|
358
|
+
const cluster = `docupace-${env}-eks`;
|
|
359
|
+
const namespace = `dp-${name}`;
|
|
360
|
+
const serviceAccount = `${name}-connect`;
|
|
361
|
+
const shortRegion = region.replace(
|
|
362
|
+
/^([a-z]+)-([a-z])[a-z]*-(\d+)$/,
|
|
363
|
+
'$1$2$3',
|
|
364
|
+
);
|
|
365
|
+
const expectedRole = `arn:aws:iam::${identity.Account}:role/${cluster}-connect-ui-pod-identity-${shortRegion}`;
|
|
366
|
+
const listed = await aws(
|
|
367
|
+
[
|
|
368
|
+
'eks',
|
|
369
|
+
'list-pod-identity-associations',
|
|
370
|
+
'--cluster-name',
|
|
371
|
+
cluster,
|
|
372
|
+
'--namespace',
|
|
373
|
+
namespace,
|
|
374
|
+
'--service-account',
|
|
375
|
+
serviceAccount,
|
|
376
|
+
],
|
|
377
|
+
region,
|
|
378
|
+
);
|
|
379
|
+
const reasons = [];
|
|
380
|
+
if (listed?.associations?.length !== 1) {
|
|
381
|
+
return {
|
|
382
|
+
ready: false,
|
|
383
|
+
reasons: [
|
|
384
|
+
`Expected one Pod Identity association for ${cluster}/${namespace}/${serviceAccount}.`,
|
|
385
|
+
],
|
|
386
|
+
env,
|
|
387
|
+
site,
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
const described = await aws(
|
|
391
|
+
[
|
|
392
|
+
'eks',
|
|
393
|
+
'describe-pod-identity-association',
|
|
394
|
+
'--cluster-name',
|
|
395
|
+
cluster,
|
|
396
|
+
'--association-id',
|
|
397
|
+
listed.associations[0].associationId,
|
|
398
|
+
],
|
|
399
|
+
region,
|
|
400
|
+
);
|
|
401
|
+
const association = described?.association;
|
|
402
|
+
if (
|
|
403
|
+
association?.clusterName !== cluster ||
|
|
404
|
+
association?.namespace !== namespace ||
|
|
405
|
+
association?.serviceAccount !== serviceAccount ||
|
|
406
|
+
association?.roleArn !== expectedRole
|
|
407
|
+
) {
|
|
408
|
+
return {
|
|
409
|
+
ready: false,
|
|
410
|
+
reasons: [
|
|
411
|
+
'Pod Identity association does not match the expected target identity and role.',
|
|
412
|
+
],
|
|
413
|
+
env,
|
|
414
|
+
site,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
if (podIdentityOnly)
|
|
418
|
+
return {
|
|
419
|
+
ready: true,
|
|
420
|
+
reasons: [],
|
|
421
|
+
env,
|
|
422
|
+
site,
|
|
423
|
+
cluster,
|
|
424
|
+
namespace,
|
|
425
|
+
serviceAccount,
|
|
426
|
+
expectedRole,
|
|
427
|
+
};
|
|
428
|
+
const required = [
|
|
429
|
+
[
|
|
430
|
+
`docupace/eks/dp-connect-${name}/${name}-secret`,
|
|
431
|
+
['db.default.user', 'db.default.password', 'sso.key', 'sso.iv'],
|
|
432
|
+
],
|
|
433
|
+
[
|
|
434
|
+
`docupace/eks/connect-ui/${env}/common`,
|
|
435
|
+
[
|
|
436
|
+
'application.secret',
|
|
437
|
+
'application.internalEncryption.key',
|
|
438
|
+
'application.generateTokenKey',
|
|
439
|
+
'saml.keystorePassword',
|
|
440
|
+
'saml.privateKeyPassword',
|
|
441
|
+
'saml.connect.keystorePassword',
|
|
442
|
+
],
|
|
443
|
+
],
|
|
444
|
+
['docupace/eks/artifactory-ms', ['registryHost', 'username', 'password']],
|
|
445
|
+
[
|
|
446
|
+
`docupace/eks/dp-${site}/${site}`,
|
|
447
|
+
['COMMON_AUTH0_CLIENT_ID', 'COMMON_AUTH0_CLIENT_SECRET'],
|
|
448
|
+
],
|
|
449
|
+
];
|
|
450
|
+
for (const [secretName, keys] of required) {
|
|
451
|
+
const response = await aws(
|
|
452
|
+
['secretsmanager', 'get-secret-value', '--secret-id', secretName],
|
|
453
|
+
region,
|
|
454
|
+
);
|
|
455
|
+
if (!response?.SecretString) {
|
|
456
|
+
reasons.push(`Required secret is missing: ${secretName}.`);
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
let secret;
|
|
460
|
+
try {
|
|
461
|
+
secret = JSON.parse(response.SecretString);
|
|
462
|
+
} catch {
|
|
463
|
+
reasons.push(`Required secret is not a JSON object: ${secretName}.`);
|
|
464
|
+
continue;
|
|
465
|
+
}
|
|
466
|
+
if (!secret || typeof secret !== 'object' || Array.isArray(secret)) {
|
|
467
|
+
reasons.push(`Required secret is not a JSON object: ${secretName}.`);
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
for (const key of keys) {
|
|
471
|
+
const present = key.includes('.')
|
|
472
|
+
? typeof secret['secret.conf'] === 'string' &&
|
|
473
|
+
configHasValue(secret['secret.conf'], key)
|
|
474
|
+
: usable(secret[key]);
|
|
475
|
+
if (!present)
|
|
476
|
+
reasons.push(
|
|
477
|
+
`Required field ${key} is missing or unresolved in ${secretName}.`,
|
|
478
|
+
);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
ready: reasons.length === 0,
|
|
483
|
+
reasons,
|
|
484
|
+
env,
|
|
485
|
+
site,
|
|
486
|
+
cluster,
|
|
487
|
+
namespace,
|
|
488
|
+
serviceAccount,
|
|
489
|
+
expectedRole,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
// Dedicated CONNECT runtime secret
|
|
494
|
+
const keys = ['db.default.user', 'db.default.password', 'sso.key', 'sso.iv'];
|
|
495
|
+
export function populated(value) {
|
|
496
|
+
return (
|
|
497
|
+
typeof value === 'string' &&
|
|
498
|
+
!!value.trim() &&
|
|
499
|
+
!/^(null|undefined|changeme|placeholder)$/i.test(value.trim()) &&
|
|
500
|
+
!value.includes('${') &&
|
|
501
|
+
!value.trim().startsWith('vault:')
|
|
502
|
+
);
|
|
503
|
+
}
|
|
504
|
+
export function readRuntimeConfig(content = '') {
|
|
505
|
+
const values = {};
|
|
506
|
+
for (const key of keys) {
|
|
507
|
+
const escaped = key.replaceAll('.', '\\.');
|
|
508
|
+
const matches = [
|
|
509
|
+
...content.matchAll(
|
|
510
|
+
new RegExp(`^[ \\t]*${escaped}[ \\t]*=[ \\t]*(.+)$`, 'gm'),
|
|
511
|
+
),
|
|
512
|
+
];
|
|
513
|
+
if (matches.length > 1)
|
|
514
|
+
throw new Error(`Duplicate runtime setting: ${key}.`);
|
|
515
|
+
let value = matches[0]?.[1]?.trim();
|
|
516
|
+
if (value?.startsWith('"')) {
|
|
517
|
+
try {
|
|
518
|
+
value = JSON.parse(value);
|
|
519
|
+
} catch {
|
|
520
|
+
value = '';
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
if (populated(value)) values[key] = value;
|
|
524
|
+
}
|
|
525
|
+
return values;
|
|
526
|
+
}
|
|
527
|
+
async function optionalSecret(name) {
|
|
528
|
+
const response = await readConnectAwsJson(
|
|
529
|
+
['secretsmanager', 'get-secret-value', '--secret-id', name],
|
|
530
|
+
process.env.AWS_REGION || 'us-west-2',
|
|
531
|
+
);
|
|
532
|
+
if (!response) return null;
|
|
533
|
+
let secret;
|
|
534
|
+
try {
|
|
535
|
+
secret = JSON.parse(response.SecretString);
|
|
536
|
+
} catch {
|
|
537
|
+
throw new Error(`Secret ${name} must contain a JSON object.`);
|
|
538
|
+
}
|
|
539
|
+
if (!secret || typeof secret !== 'object' || Array.isArray(secret))
|
|
540
|
+
throw new Error(`Secret ${name} must contain a JSON object.`);
|
|
541
|
+
return secret;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// Keep the dedicated CONNECT secret contract used by existing deployments.
|
|
545
|
+
// Complete secrets are reused; only missing runtime values are populated.
|
|
546
|
+
export async function ensureConnectRuntimeSecret({
|
|
547
|
+
env,
|
|
548
|
+
site,
|
|
549
|
+
coreSchema,
|
|
550
|
+
siteSettingsPath,
|
|
551
|
+
dbSourceSecret,
|
|
552
|
+
dbUserProperty = 'CONNECT_DB_USER',
|
|
553
|
+
dbPasswordProperty = 'CONNECT_DB_PASSWORD',
|
|
554
|
+
dryRun = false,
|
|
555
|
+
}) {
|
|
556
|
+
env = normalizeConnectEnvironment(env);
|
|
557
|
+
if (!/^[a-z0-9](?:[a-z0-9_-]{0,48}[a-z0-9])?$/.test(site || ''))
|
|
558
|
+
throw new Error('Invalid target site name.');
|
|
559
|
+
const identity = await readConnectAwsJson(
|
|
560
|
+
['sts', 'get-caller-identity'],
|
|
561
|
+
process.env.AWS_REGION || 'us-west-2',
|
|
562
|
+
);
|
|
563
|
+
if (identity?.Account !== CONNECT_AWS_ACCOUNTS[env])
|
|
564
|
+
throw new Error(
|
|
565
|
+
'AWS account does not match the target environment; no secret was written.',
|
|
566
|
+
);
|
|
567
|
+
if (dbSourceSecret?.startsWith('arn:'))
|
|
568
|
+
throw new Error(
|
|
569
|
+
'Use a target-account secret name for the DB credential source.',
|
|
570
|
+
);
|
|
571
|
+
if (dbPasswordProperty === 'CORE_PASSWORD')
|
|
572
|
+
throw new Error(
|
|
573
|
+
'CORE_PASSWORD is a Core API credential, not a database password.',
|
|
574
|
+
);
|
|
575
|
+
const normalized = site.replaceAll('_', '-');
|
|
576
|
+
const runtimeName = `docupace/eks/dp-connect-${normalized}/${normalized}-secret`;
|
|
577
|
+
const sharedName = `docupace/eks/dp-${site}/${site}`;
|
|
578
|
+
const siteSecret = await optionalSecret(sharedName);
|
|
579
|
+
if (
|
|
580
|
+
!siteSecret ||
|
|
581
|
+
!populated(siteSecret.COMMON_AUTH0_CLIENT_ID) ||
|
|
582
|
+
!populated(siteSecret.COMMON_AUTH0_CLIENT_SECRET)
|
|
583
|
+
)
|
|
584
|
+
throw new Error(
|
|
585
|
+
'Create the target site secret and management credentials with Step 1 before CONNECT onboarding.',
|
|
586
|
+
);
|
|
587
|
+
const runtimeSecret = await optionalSecret(runtimeName);
|
|
588
|
+
const property = 'secret.conf';
|
|
589
|
+
const targetName = runtimeName;
|
|
590
|
+
const existing = runtimeSecret || {};
|
|
591
|
+
const content = existing[property];
|
|
592
|
+
if (content !== undefined && typeof content !== 'string')
|
|
593
|
+
throw new Error('CONNECT runtime configuration must be a string.');
|
|
594
|
+
const fields = readRuntimeConfig(content);
|
|
595
|
+
if (keys.every((key) => populated(fields[key])))
|
|
596
|
+
return {
|
|
597
|
+
secretName: targetName,
|
|
598
|
+
secretProperty: null,
|
|
599
|
+
status: 'existing',
|
|
600
|
+
};
|
|
601
|
+
if (!fields['sso.key'] || !fields['sso.iv']) {
|
|
602
|
+
const sso = await readConnectSsoSettings({
|
|
603
|
+
siteSettingsPath,
|
|
604
|
+
site,
|
|
605
|
+
coreSchema,
|
|
606
|
+
});
|
|
607
|
+
if (
|
|
608
|
+
(fields['sso.key'] && fields['sso.key'] !== sso.key) ||
|
|
609
|
+
(fields['sso.iv'] && fields['sso.iv'] !== sso.iv)
|
|
610
|
+
)
|
|
611
|
+
throw new Error(
|
|
612
|
+
'Existing partial SSO credentials differ from target Core settings; review the target runtime secret.',
|
|
613
|
+
);
|
|
614
|
+
fields['sso.key'] ||= sso.key;
|
|
615
|
+
fields['sso.iv'] ||= sso.iv;
|
|
616
|
+
}
|
|
617
|
+
if (!fields['db.default.user'] || !fields['db.default.password']) {
|
|
618
|
+
const common =
|
|
619
|
+
(await optionalSecret(
|
|
620
|
+
(await loadSecretsManager()).getAwsSharedSecretName(),
|
|
621
|
+
)) || {};
|
|
622
|
+
const siteHasDb =
|
|
623
|
+
populated(siteSecret.CONNECT_DB_USER) ||
|
|
624
|
+
populated(siteSecret.CONNECT_DB_PASSWORD);
|
|
625
|
+
const configured = siteHasDb ? siteSecret : common;
|
|
626
|
+
// DB credentials must be a pair from one target-account source. Never use
|
|
627
|
+
// CORE_PASSWORD (Core API login) or another environment's source-site secret.
|
|
628
|
+
if (
|
|
629
|
+
!dbSourceSecret &&
|
|
630
|
+
(populated(configured.CONNECT_DB_USER) ||
|
|
631
|
+
populated(configured.CONNECT_DB_PASSWORD))
|
|
632
|
+
) {
|
|
633
|
+
if (
|
|
634
|
+
!populated(configured.CONNECT_DB_USER) ||
|
|
635
|
+
!populated(configured.CONNECT_DB_PASSWORD)
|
|
636
|
+
)
|
|
637
|
+
throw new Error(
|
|
638
|
+
'CONNECT_DB_USER and CONNECT_DB_PASSWORD must be populated together in one target-account secret.',
|
|
639
|
+
);
|
|
640
|
+
if (
|
|
641
|
+
fields['db.default.user'] &&
|
|
642
|
+
fields['db.default.user'] !== configured.CONNECT_DB_USER
|
|
643
|
+
)
|
|
644
|
+
throw new Error(
|
|
645
|
+
'Existing DB username differs from the shared credential source.',
|
|
646
|
+
);
|
|
647
|
+
if (
|
|
648
|
+
fields['db.default.password'] &&
|
|
649
|
+
fields['db.default.password'] !== configured.CONNECT_DB_PASSWORD
|
|
650
|
+
)
|
|
651
|
+
throw new Error(
|
|
652
|
+
'Existing DB password differs from the shared credential source.',
|
|
653
|
+
);
|
|
654
|
+
fields['db.default.user'] ||= configured.CONNECT_DB_USER;
|
|
655
|
+
fields['db.default.password'] ||= configured.CONNECT_DB_PASSWORD;
|
|
656
|
+
} else {
|
|
657
|
+
let sourceName =
|
|
658
|
+
dbSourceSecret || 'docupace/eks/dp-connect-template/template-secret';
|
|
659
|
+
let source = await optionalSecret(sourceName);
|
|
660
|
+
if (!source && !dbSourceSecret) {
|
|
661
|
+
sourceName = 'docupace/eks/dp-template/template';
|
|
662
|
+
source = await optionalSecret(sourceName);
|
|
663
|
+
}
|
|
664
|
+
if (!source)
|
|
665
|
+
throw new Error(
|
|
666
|
+
`Target DB credential source is missing: ${sourceName}. Configure CONNECT_DB_SOURCE_SECRET or shared CONNECT_DB_USER/CONNECT_DB_PASSWORD.`,
|
|
667
|
+
);
|
|
668
|
+
const sourceContent = source['secret.conf'];
|
|
669
|
+
const sourceFields =
|
|
670
|
+
typeof sourceContent === 'string'
|
|
671
|
+
? readRuntimeConfig(sourceContent)
|
|
672
|
+
: {};
|
|
673
|
+
const useProperties =
|
|
674
|
+
populated(source[dbUserProperty]) ||
|
|
675
|
+
populated(source[dbPasswordProperty]);
|
|
676
|
+
const user = useProperties
|
|
677
|
+
? source[dbUserProperty]
|
|
678
|
+
: sourceFields['db.default.user'];
|
|
679
|
+
const password = useProperties
|
|
680
|
+
? source[dbPasswordProperty]
|
|
681
|
+
: sourceFields['db.default.password'];
|
|
682
|
+
if (!populated(user) || !populated(password))
|
|
683
|
+
throw new Error(
|
|
684
|
+
`Target DB credential source ${sourceName} requires a nonempty username/password pair.`,
|
|
685
|
+
);
|
|
686
|
+
if (fields['db.default.user'] && fields['db.default.user'] !== user)
|
|
687
|
+
throw new Error(
|
|
688
|
+
'Existing DB username differs from the credential source; refusing to combine credentials from different accounts.',
|
|
689
|
+
);
|
|
690
|
+
if (
|
|
691
|
+
fields['db.default.password'] &&
|
|
692
|
+
fields['db.default.password'] !== password
|
|
693
|
+
)
|
|
694
|
+
throw new Error(
|
|
695
|
+
'Existing DB password differs from the credential source; refusing to combine credentials from different accounts.',
|
|
696
|
+
);
|
|
697
|
+
fields['db.default.user'] = user;
|
|
698
|
+
fields['db.default.password'] = password;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
for (const key of keys)
|
|
702
|
+
if (!populated(fields[key]))
|
|
703
|
+
throw new Error(
|
|
704
|
+
`CONNECT runtime setting ${key} is empty or unresolved; no secret was written.`,
|
|
705
|
+
);
|
|
706
|
+
let updated = content || '';
|
|
707
|
+
for (const key of keys) {
|
|
708
|
+
const pattern = new RegExp(
|
|
709
|
+
`^[ \\t]*${key.replaceAll('.', '\\.')}[ \\t]*=.*$`,
|
|
710
|
+
'gm',
|
|
711
|
+
);
|
|
712
|
+
const line = `${key}=${JSON.stringify(fields[key])}`;
|
|
713
|
+
updated = pattern.test(updated)
|
|
714
|
+
? updated.replace(pattern, () => line)
|
|
715
|
+
: `${updated.trimEnd()}\n${line}\n`;
|
|
716
|
+
}
|
|
717
|
+
if (!dryRun) {
|
|
718
|
+
// Re-read before writing; preserve other properties and reject concurrent changes.
|
|
719
|
+
const latest = await optionalSecret(targetName);
|
|
720
|
+
if (
|
|
721
|
+
!!latest !== !!runtimeSecret ||
|
|
722
|
+
latest?.[property] !== existing[property]
|
|
723
|
+
)
|
|
724
|
+
throw new Error(
|
|
725
|
+
'CONNECT runtime secret changed concurrently; retry onboarding.',
|
|
726
|
+
);
|
|
727
|
+
if (latest) {
|
|
728
|
+
await (
|
|
729
|
+
await loadAwsSecrets()
|
|
730
|
+
).saveSecretToAws(targetName, { ...latest, [property]: updated });
|
|
731
|
+
} else {
|
|
732
|
+
// Create only the dedicated runtime object, without the shared-site schema.
|
|
733
|
+
const client = new SecretsManagerClient({
|
|
734
|
+
region: process.env.AWS_REGION || 'us-west-2',
|
|
735
|
+
});
|
|
736
|
+
try {
|
|
737
|
+
await client.send(
|
|
738
|
+
new CreateSecretCommand({
|
|
739
|
+
Name: targetName,
|
|
740
|
+
SecretString: JSON.stringify({ [property]: updated }),
|
|
741
|
+
Description: `CONNECT runtime configuration for ${env}/${site}`,
|
|
742
|
+
}),
|
|
743
|
+
);
|
|
744
|
+
} catch (error) {
|
|
745
|
+
if (error.name === 'ResourceExistsException')
|
|
746
|
+
throw new Error(
|
|
747
|
+
'CONNECT runtime secret was created concurrently; retry onboarding.',
|
|
748
|
+
);
|
|
749
|
+
throw new Error(
|
|
750
|
+
'CONNECT runtime secret creation failed; check target AWS permissions and secret state.',
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return {
|
|
756
|
+
secretName: targetName,
|
|
757
|
+
secretProperty: null,
|
|
758
|
+
status: dryRun ? 'planned' : 'prepared',
|
|
759
|
+
};
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Exact source image validation
|
|
763
|
+
export async function checkConnectImage({
|
|
764
|
+
valuesPath,
|
|
765
|
+
aws = readConnectAwsJson,
|
|
766
|
+
request = fetch,
|
|
767
|
+
}) {
|
|
768
|
+
const config = yaml.load(await fs.readFile(valuesPath, 'utf8'));
|
|
769
|
+
const app = config?.['dp-connect-ui'];
|
|
770
|
+
const host = config?.global?.systemRegistry || 'registry.paperout.com';
|
|
771
|
+
if (
|
|
772
|
+
!/^[a-z0-9.-]+(?::\d+)?$/i.test(host) ||
|
|
773
|
+
!/^[a-z0-9][a-z0-9/_.-]*$/.test(app?.image?.repository || '') ||
|
|
774
|
+
!/^[\w][\w.-]{0,127}$/.test(app?.image?.tag || '')
|
|
775
|
+
)
|
|
776
|
+
throw new Error('Generated image reference is invalid.');
|
|
777
|
+
const result = await aws(
|
|
778
|
+
[
|
|
779
|
+
'secretsmanager',
|
|
780
|
+
'get-secret-value',
|
|
781
|
+
'--secret-id',
|
|
782
|
+
'docupace/eks/artifactory-ms',
|
|
783
|
+
],
|
|
784
|
+
process.env.AWS_REGION || 'us-west-2',
|
|
785
|
+
);
|
|
786
|
+
const credentials = JSON.parse(result?.SecretString || '{}');
|
|
787
|
+
if (
|
|
788
|
+
credentials.registryHost !== host ||
|
|
789
|
+
!credentials.username ||
|
|
790
|
+
!credentials.password
|
|
791
|
+
)
|
|
792
|
+
throw new Error(
|
|
793
|
+
'Registry credentials do not match the generated image registry.',
|
|
794
|
+
);
|
|
795
|
+
const headers = {
|
|
796
|
+
Authorization: `Basic ${Buffer.from(`${credentials.username}:${credentials.password}`).toString('base64')}`,
|
|
797
|
+
Accept:
|
|
798
|
+
'application/vnd.docker.distribution.manifest.v2+json, application/vnd.docker.distribution.manifest.list.v2+json, application/vnd.oci.image.index.v1+json, application/vnd.oci.image.manifest.v1+json',
|
|
799
|
+
};
|
|
800
|
+
const tag = String(app.image.tag);
|
|
801
|
+
const response = await request(
|
|
802
|
+
`https://${host}/v2/${app.image.repository}/manifests/${tag}`,
|
|
803
|
+
{
|
|
804
|
+
method: 'HEAD',
|
|
805
|
+
headers,
|
|
806
|
+
redirect: 'manual',
|
|
807
|
+
signal: AbortSignal.timeout(30000),
|
|
808
|
+
},
|
|
809
|
+
);
|
|
810
|
+
if (response.status === 404)
|
|
811
|
+
throw new Error(
|
|
812
|
+
`CONNECT image ${host}/${app.image.repository}:${tag} is not published; refusing to merge. Select a published source image or explicitly override IMAGE_TAG in the inherited repository.`,
|
|
813
|
+
);
|
|
814
|
+
if (!response.ok)
|
|
815
|
+
throw new Error(
|
|
816
|
+
`Image registry check failed with HTTP ${response.status}; refusing to merge.`,
|
|
817
|
+
);
|
|
818
|
+
console.log(
|
|
819
|
+
`Published CONNECT image confirmed: ${host}/${app.image.repository}:${tag}`,
|
|
820
|
+
);
|
|
821
|
+
return tag;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
// CONNECT AWS Auth0 metadata and configuration
|
|
825
|
+
// Match active HOCON assignments by their exact dotted key, preserving comments.
|
|
826
|
+
export function setConfigValues(config, values) {
|
|
827
|
+
let result = config;
|
|
828
|
+
for (const [key, value] of Object.entries(values)) {
|
|
829
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
830
|
+
const pattern = new RegExp(`^[ \\t]*${escaped}[ \\t]*=.*$`, 'gm');
|
|
831
|
+
const assignment = `${key}=${value}`;
|
|
832
|
+
result = pattern.test(result)
|
|
833
|
+
? result.replace(pattern, () => assignment)
|
|
834
|
+
: `${result.trimEnd()}\n${assignment}\n`;
|
|
835
|
+
}
|
|
836
|
+
return result;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
function readConfigString(config, key) {
|
|
840
|
+
const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
841
|
+
const match = config.match(
|
|
842
|
+
new RegExp(
|
|
843
|
+
`^[ \\t]*${escaped}[ \\t]*=[ \\t]*(?:"([^"\\r\\n]+)"|([^ \\t\\r\\n#]+))`,
|
|
844
|
+
'm',
|
|
845
|
+
),
|
|
846
|
+
);
|
|
847
|
+
const value = match?.[1] || match?.[2];
|
|
848
|
+
if (!value) throw new Error(`${key} not found in application.conf.`);
|
|
849
|
+
return value;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
export function getConnectEndpoint(config) {
|
|
853
|
+
const context = readConfigString(config, 'application.context');
|
|
854
|
+
const hostConfig = /^[ \t]*application\.hostUrl[ \t]*=/m.test(config)
|
|
855
|
+
? 'application.hostUrl'
|
|
856
|
+
: 'docupace.baseUrlClient';
|
|
857
|
+
const baseUrl = new URL(readConfigString(config, hostConfig));
|
|
858
|
+
if (baseUrl.protocol !== 'https:' || !context.startsWith('/')) {
|
|
859
|
+
throw new Error(
|
|
860
|
+
'CONNECT requires an HTTPS base URL and an absolute context path.',
|
|
861
|
+
);
|
|
862
|
+
}
|
|
863
|
+
return `${baseUrl.origin}${context}`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// Generates settings without cloning, changing cwd, writing files or pushing.
|
|
867
|
+
// Both site setup and the metadata CLI use this operation.
|
|
868
|
+
export async function prepareAuth0Config({
|
|
869
|
+
site,
|
|
870
|
+
env,
|
|
871
|
+
auth0Tenant = '',
|
|
872
|
+
appConf,
|
|
873
|
+
connectEndpoint,
|
|
874
|
+
}) {
|
|
875
|
+
const { getAuth0AuthToken } = await import('../auth0/configure.js');
|
|
876
|
+
const normalizedEnv = normalizeConnectEnvironment(env);
|
|
877
|
+
const tenant =
|
|
878
|
+
auth0Tenant || `docupace-${normalizedEnv}-${site.replaceAll('_', '-')}`;
|
|
879
|
+
const auth0Domain = tenant.endsWith('.auth0.com')
|
|
880
|
+
? tenant
|
|
881
|
+
: `${tenant}.us.auth0.com`;
|
|
882
|
+
if (!/^[a-z0-9-]+(?:\.[a-z0-9-]+)?\.auth0\.com$/i.test(auth0Domain)) {
|
|
883
|
+
throw new Error(
|
|
884
|
+
'Auth0 tenant must be a tenant name or an auth0.com domain.',
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
const endpoint = connectEndpoint || getConnectEndpoint(appConf);
|
|
888
|
+
const secrets = await (await loadSecretsManager()).getSiteSecrets(site);
|
|
889
|
+
const clientId = secrets?.COMMON_AUTH0_CLIENT_ID;
|
|
890
|
+
const clientSecret = secrets?.COMMON_AUTH0_CLIENT_SECRET;
|
|
891
|
+
if (
|
|
892
|
+
typeof clientId !== 'string' ||
|
|
893
|
+
!clientId.trim() ||
|
|
894
|
+
typeof clientSecret !== 'string' ||
|
|
895
|
+
!clientSecret.trim()
|
|
896
|
+
) {
|
|
897
|
+
throw new Error(
|
|
898
|
+
'Target site requires COMMON_AUTH0_CLIENT_ID and COMMON_AUTH0_CLIENT_SECRET.',
|
|
899
|
+
);
|
|
900
|
+
}
|
|
901
|
+
const token = await getAuth0AuthToken(auth0Domain, clientId, clientSecret);
|
|
902
|
+
if (!token) throw new Error('Auth0 did not return an access token.');
|
|
903
|
+
const clients = [];
|
|
904
|
+
for (let page = 0; ; page += 1) {
|
|
905
|
+
const response = await axios.get(`https://${auth0Domain}/api/v2/clients`, {
|
|
906
|
+
headers: { authorization: `Bearer ${token}` },
|
|
907
|
+
params: {
|
|
908
|
+
page,
|
|
909
|
+
per_page: 100,
|
|
910
|
+
fields: 'name,client_id',
|
|
911
|
+
include_fields: true,
|
|
912
|
+
},
|
|
913
|
+
timeout: 30000,
|
|
914
|
+
});
|
|
915
|
+
if (!Array.isArray(response.data))
|
|
916
|
+
throw new Error('Invalid Auth0 clients response.');
|
|
917
|
+
clients.push(
|
|
918
|
+
...response.data.filter((client) => client.name === 'docupace-connect'),
|
|
919
|
+
);
|
|
920
|
+
if (response.data.length < 100) break;
|
|
921
|
+
if (page >= 99) throw new Error('Auth0 client listing exceeded 100 pages.');
|
|
922
|
+
}
|
|
923
|
+
if (
|
|
924
|
+
clients.length !== 1 ||
|
|
925
|
+
typeof clients[0].client_id !== 'string' ||
|
|
926
|
+
!/^[\w-]+$/.test(clients[0].client_id)
|
|
927
|
+
) {
|
|
928
|
+
throw new Error(
|
|
929
|
+
'Expected exactly one Auth0 client named "docupace-connect".',
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
const connectClientId = clients[0].client_id;
|
|
933
|
+
const { data: metadata } = await axios.get(
|
|
934
|
+
`https://${auth0Domain}/samlp/metadata/${connectClientId}`,
|
|
935
|
+
{ responseType: 'text', timeout: 30000 },
|
|
936
|
+
);
|
|
937
|
+
const matchingSso =
|
|
938
|
+
typeof metadata === 'string' &&
|
|
939
|
+
[
|
|
940
|
+
...metadata.matchAll(
|
|
941
|
+
/<(?:[\w-]+:)?SingleSignOnService\b[^>]*\bLocation\s*=\s*["']([^"']+)["']/g,
|
|
942
|
+
),
|
|
943
|
+
].some(([, location]) => {
|
|
944
|
+
try {
|
|
945
|
+
const url = new URL(location);
|
|
946
|
+
return (
|
|
947
|
+
url.origin === `https://${auth0Domain}` &&
|
|
948
|
+
url.pathname === `/samlp/${connectClientId}`
|
|
949
|
+
);
|
|
950
|
+
} catch {
|
|
951
|
+
return false;
|
|
952
|
+
}
|
|
953
|
+
});
|
|
954
|
+
if (
|
|
955
|
+
typeof metadata !== 'string' ||
|
|
956
|
+
!/<(?:[\w-]+:)?EntityDescriptor\b/.test(metadata) ||
|
|
957
|
+
!/<(?:[\w-]+:)?IDPSSODescriptor\b/.test(metadata) ||
|
|
958
|
+
!matchingSso
|
|
959
|
+
)
|
|
960
|
+
throw new Error(
|
|
961
|
+
'Downloaded SAML metadata does not identify the target Auth0 client.',
|
|
962
|
+
);
|
|
963
|
+
|
|
964
|
+
const logout = new URL(`https://${auth0Domain}/v2/logout`);
|
|
965
|
+
logout.searchParams.set('client_id', connectClientId);
|
|
966
|
+
logout.searchParams.set('returnTo', endpoint);
|
|
967
|
+
const config = setConfigValues(appConf, {
|
|
968
|
+
useSAML: 'true',
|
|
969
|
+
'saml.defaultClient': '"auth0"',
|
|
970
|
+
'saml.clients.auth0.idpMetadataPath': '${samlEnvPath}"/auth0.xml"',
|
|
971
|
+
'saml.clients.auth0.spEntityId': JSON.stringify(endpoint),
|
|
972
|
+
'saml.clients.auth0.redirectFormInput': '"RelayState"',
|
|
973
|
+
'saml.clients.auth0.idpLoginUrl': JSON.stringify(logout.toString()),
|
|
974
|
+
});
|
|
975
|
+
return { appConf: config, metadata, endpoint, auth0Domain };
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
// Site copy and target configuration
|
|
979
|
+
const sitePattern = /^[a-z0-9](?:[a-z0-9_-]{0,48}[a-z0-9])?$/;
|
|
980
|
+
const secretAssignment =
|
|
981
|
+
/^[ \t]*(?:db\.default\.(?:user|password)|sso\.(?:key|iv)|saml\.(?:connect\.)?(?:keystorePassword|privateKeyPassword)|application\.(?:secret|internalEncryption\.key|generateTokenKey))[ \t]*[:=]/m;
|
|
982
|
+
|
|
983
|
+
function jdbcHasCredentials(value) {
|
|
984
|
+
return /(?:[?;](?:user(?:name)?|password|pwd)\s*=|jdbc:oracle:thin:(?!@)[^@\s]+@|:\/\/[^/@\s]+:[^/@\s]+@)/i.test(
|
|
985
|
+
value,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function httpsUrl(value, label, originOnly = false) {
|
|
990
|
+
const url = new URL(value);
|
|
991
|
+
if (
|
|
992
|
+
url.protocol !== 'https:' ||
|
|
993
|
+
url.username ||
|
|
994
|
+
url.password ||
|
|
995
|
+
url.search ||
|
|
996
|
+
url.hash ||
|
|
997
|
+
(originOnly && url.pathname !== '/')
|
|
998
|
+
) {
|
|
999
|
+
throw new Error(
|
|
1000
|
+
`${label} must be an HTTPS ${originOnly ? 'origin' : 'URL'} without credentials, query, or fragment.`,
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
return url;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
async function assertPlainTree(root) {
|
|
1007
|
+
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
|
1008
|
+
const file = path.join(root, entry.name);
|
|
1009
|
+
if (entry.isSymbolicLink())
|
|
1010
|
+
throw new Error(`Template contains a symbolic link: ${entry.name}`);
|
|
1011
|
+
if (entry.isDirectory()) await assertPlainTree(file);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Works in an existing checkout. Git push, review branches and AWS authentication
|
|
1016
|
+
// belong to the calling pipeline. Replacement requires explicit forceOverwrite.
|
|
1017
|
+
export async function copyConnectSite({
|
|
1018
|
+
repoPath = process.cwd(),
|
|
1019
|
+
env,
|
|
1020
|
+
templateSite = 'template',
|
|
1021
|
+
templateEnv,
|
|
1022
|
+
siteSettingsPath,
|
|
1023
|
+
site,
|
|
1024
|
+
coreSchema,
|
|
1025
|
+
coreRealm,
|
|
1026
|
+
dbUrl,
|
|
1027
|
+
coreApiUrl,
|
|
1028
|
+
imageTag,
|
|
1029
|
+
baseUrl,
|
|
1030
|
+
coreUrl,
|
|
1031
|
+
contextPath = '/connect',
|
|
1032
|
+
auth0Tenant = '',
|
|
1033
|
+
forceOverwrite = false,
|
|
1034
|
+
runtimeSecretName,
|
|
1035
|
+
}) {
|
|
1036
|
+
env = normalizeConnectEnvironment(env);
|
|
1037
|
+
templateEnv = normalizeConnectEnvironment(templateEnv || env);
|
|
1038
|
+
let corePublicRealm;
|
|
1039
|
+
if (siteSettingsPath) {
|
|
1040
|
+
({ coreSchema, coreRealm, corePublicRealm, dbUrl, coreApiUrl } =
|
|
1041
|
+
await resolveConnectSiteSettings({
|
|
1042
|
+
siteSettingsPath,
|
|
1043
|
+
env,
|
|
1044
|
+
site,
|
|
1045
|
+
coreSchema,
|
|
1046
|
+
coreRealm,
|
|
1047
|
+
dbUrl,
|
|
1048
|
+
coreApiUrl,
|
|
1049
|
+
}));
|
|
1050
|
+
}
|
|
1051
|
+
if (templateEnv !== env && (!dbUrl?.trim() || !coreApiUrl?.trim())) {
|
|
1052
|
+
throw new Error(
|
|
1053
|
+
'Cross-environment copies require the target DB URL and Core backend URL.',
|
|
1054
|
+
);
|
|
1055
|
+
}
|
|
1056
|
+
if (
|
|
1057
|
+
dbUrl &&
|
|
1058
|
+
(!dbUrl.startsWith('jdbc:') ||
|
|
1059
|
+
/[\r\n]/.test(dbUrl) ||
|
|
1060
|
+
jdbcHasCredentials(dbUrl))
|
|
1061
|
+
) {
|
|
1062
|
+
throw new Error(
|
|
1063
|
+
'DB URL must be a JDBC URL without inline credentials or newlines.',
|
|
1064
|
+
);
|
|
1065
|
+
}
|
|
1066
|
+
if (!sitePattern.test(site || '') || !sitePattern.test(templateSite)) {
|
|
1067
|
+
throw new Error(
|
|
1068
|
+
'Site and template names must use lowercase letters, numbers, underscores or hyphens (maximum 50 characters).',
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
const dedicatedSecretName = `docupace/eks/dp-connect-${site.replaceAll('_', '-')}/${site.replaceAll('_', '-')}-secret`;
|
|
1072
|
+
if (runtimeSecretName && runtimeSecretName !== dedicatedSecretName)
|
|
1073
|
+
throw new Error(
|
|
1074
|
+
'Runtime secret must use the dedicated target CONNECT secret.',
|
|
1075
|
+
);
|
|
1076
|
+
if (
|
|
1077
|
+
!coreSchema?.trim() ||
|
|
1078
|
+
!coreRealm?.trim() ||
|
|
1079
|
+
/[\r\n]/.test(coreSchema + coreRealm)
|
|
1080
|
+
) {
|
|
1081
|
+
throw new Error('Target Core schema and private realm are required.');
|
|
1082
|
+
}
|
|
1083
|
+
if (!/^\/[a-zA-Z0-9_/-]*$/.test(contextPath) || contextPath.includes('//')) {
|
|
1084
|
+
throw new Error('CONNECT context must be an absolute URL path.');
|
|
1085
|
+
}
|
|
1086
|
+
if (imageTag && !/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(imageTag)) {
|
|
1087
|
+
throw new Error('Image tag must be a valid Docker tag.');
|
|
1088
|
+
}
|
|
1089
|
+
const origin = httpsUrl(
|
|
1090
|
+
baseUrl || environmentConfig[env].newUrl(site.replaceAll('_', '-')),
|
|
1091
|
+
'CONNECT base URL',
|
|
1092
|
+
true,
|
|
1093
|
+
);
|
|
1094
|
+
const core = httpsUrl(coreUrl || `${origin.origin}/core`, 'Core URL');
|
|
1095
|
+
const backend = coreApiUrl ? httpsUrl(coreApiUrl, 'Core backend URL') : null;
|
|
1096
|
+
const repo = await fs.realpath(path.resolve(repoPath));
|
|
1097
|
+
const envDir = path.join(repo, 'envs', env);
|
|
1098
|
+
const source = path.join(repo, 'envs', templateEnv, templateSite);
|
|
1099
|
+
const target = path.join(envDir, site);
|
|
1100
|
+
if (!(await fs.pathExists(source)))
|
|
1101
|
+
throw new Error(
|
|
1102
|
+
`Source site not found: envs/${templateEnv}/${templateSite}`,
|
|
1103
|
+
);
|
|
1104
|
+
if (!(await fs.pathExists(envDir)))
|
|
1105
|
+
throw new Error(`Target environment is not configured: ${env}`);
|
|
1106
|
+
const realEnvDir = await fs.realpath(envDir);
|
|
1107
|
+
if (
|
|
1108
|
+
!realEnvDir.startsWith(`${repo}${path.sep}`) ||
|
|
1109
|
+
(await fs.lstat(envDir)).isSymbolicLink()
|
|
1110
|
+
) {
|
|
1111
|
+
throw new Error(
|
|
1112
|
+
'Target environment must be a directory inside the GitOps checkout.',
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
const realSource = await fs.realpath(source);
|
|
1116
|
+
if (!realSource.startsWith(`${repo}${path.sep}`))
|
|
1117
|
+
throw new Error('Template resolves outside the GitOps checkout.');
|
|
1118
|
+
const k8sName = site.replaceAll('_', '-');
|
|
1119
|
+
const siblings = await fs.readdir(envDir);
|
|
1120
|
+
if (
|
|
1121
|
+
siblings.some(
|
|
1122
|
+
(name) => name !== site && name.replaceAll('_', '-') === k8sName,
|
|
1123
|
+
)
|
|
1124
|
+
) {
|
|
1125
|
+
throw new Error(`Target site or Kubernetes name already exists: ${site}`);
|
|
1126
|
+
}
|
|
1127
|
+
const existingTarget = siblings.includes(site);
|
|
1128
|
+
if (existingTarget && !forceOverwrite) {
|
|
1129
|
+
throw new Error(
|
|
1130
|
+
`Target site already exists: ${site}. Use --force-overwrite to replace it.`,
|
|
1131
|
+
);
|
|
1132
|
+
}
|
|
1133
|
+
if (existingTarget) {
|
|
1134
|
+
const targetStat = await fs.lstat(target);
|
|
1135
|
+
if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
|
|
1136
|
+
throw new Error(
|
|
1137
|
+
'Existing target must be a plain directory inside the target environment.',
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
await assertPlainTree(source);
|
|
1142
|
+
const sourceConfig = await fs.readFile(
|
|
1143
|
+
path.join(source, 'conf', 'application.conf'),
|
|
1144
|
+
'utf8',
|
|
1145
|
+
);
|
|
1146
|
+
const sourceJdbc = sourceConfig.match(
|
|
1147
|
+
/^[ \t]*db\.default\.url[ \t]*[:=][ \t]*"([^"\r\n]+)"/m,
|
|
1148
|
+
)?.[1];
|
|
1149
|
+
if (
|
|
1150
|
+
secretAssignment.test(sourceConfig) ||
|
|
1151
|
+
(sourceJdbc && jdbcHasCredentials(sourceJdbc))
|
|
1152
|
+
) {
|
|
1153
|
+
throw new Error(
|
|
1154
|
+
'Template contains inline credentials. Migrate them to Secrets Manager before copying.',
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
const values = yaml.load(
|
|
1158
|
+
await fs.readFile(path.join(source, 'values.yaml'), 'utf8'),
|
|
1159
|
+
);
|
|
1160
|
+
const app = values?.['dp-connect-ui'];
|
|
1161
|
+
if (
|
|
1162
|
+
!app?.image?.repository ||
|
|
1163
|
+
!app?.image?.tag ||
|
|
1164
|
+
!app?.initContainer?.s3Bucket
|
|
1165
|
+
) {
|
|
1166
|
+
throw new Error(
|
|
1167
|
+
'Template values require dp-connect-ui.image.repository, image.tag and initContainer.s3Bucket.',
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
if (
|
|
1171
|
+
app.initContainer.s3Bucket !==
|
|
1172
|
+
`docupace-${templateEnv}-connect-ui-configs-usw2`
|
|
1173
|
+
) {
|
|
1174
|
+
throw new Error('Source S3 bucket does not match the source environment.');
|
|
1175
|
+
}
|
|
1176
|
+
const config = setConfigValues(sourceConfig, {
|
|
1177
|
+
'application.context': JSON.stringify(contextPath),
|
|
1178
|
+
'application.hostUrl': JSON.stringify(origin.origin),
|
|
1179
|
+
'application.baseUrl': JSON.stringify(origin.origin),
|
|
1180
|
+
'application.hostName': JSON.stringify(origin.hostname),
|
|
1181
|
+
'application.cors.allowedOrigins.common': JSON.stringify([origin.origin]),
|
|
1182
|
+
'docupace.baseUrlClient': JSON.stringify(
|
|
1183
|
+
core.toString().replace(/\/$/, ''),
|
|
1184
|
+
),
|
|
1185
|
+
'docupace.baseUrlSSO': JSON.stringify(core.toString().replace(/\/$/, '')),
|
|
1186
|
+
'docupace.context': JSON.stringify(core.pathname),
|
|
1187
|
+
'docupace.core.context': JSON.stringify(core.pathname),
|
|
1188
|
+
'docupace.db.schemaName': JSON.stringify(coreSchema.trim()),
|
|
1189
|
+
'sso.privateRealm': JSON.stringify(coreRealm.trim()),
|
|
1190
|
+
'saml.connect.enableAutoLogin': 'true',
|
|
1191
|
+
'application.integrations.launchNextUI': 'true',
|
|
1192
|
+
...(corePublicRealm
|
|
1193
|
+
? { 'sso.publicRealm': JSON.stringify(corePublicRealm) }
|
|
1194
|
+
: {}),
|
|
1195
|
+
...(dbUrl ? { 'db.default.url': JSON.stringify(dbUrl.trim()) } : {}),
|
|
1196
|
+
...(backend
|
|
1197
|
+
? {
|
|
1198
|
+
'docupace.baseUrl': JSON.stringify(
|
|
1199
|
+
backend.toString().replace(/\/$/, ''),
|
|
1200
|
+
),
|
|
1201
|
+
}
|
|
1202
|
+
: {}),
|
|
1203
|
+
});
|
|
1204
|
+
// Finish remote reads before creating the target directory.
|
|
1205
|
+
const prepared = await prepareAuth0Config({
|
|
1206
|
+
site,
|
|
1207
|
+
env,
|
|
1208
|
+
auth0Tenant,
|
|
1209
|
+
appConf: config,
|
|
1210
|
+
connectEndpoint: `${origin.origin}${contextPath}`,
|
|
1211
|
+
});
|
|
1212
|
+
app.name = k8sName;
|
|
1213
|
+
app.namespace = `dp-${k8sName}`;
|
|
1214
|
+
app.siteName = site;
|
|
1215
|
+
app.clientName = coreSchema.trim();
|
|
1216
|
+
app.envName = env;
|
|
1217
|
+
// Inherit the selected source image; shared-image migration is a separate action.
|
|
1218
|
+
if (imageTag) app.image.tag = imageTag;
|
|
1219
|
+
app.gateway = { ...app.gateway, hostnames: [origin.hostname], contextPath };
|
|
1220
|
+
app.serviceAccount = {
|
|
1221
|
+
...app.serviceAccount,
|
|
1222
|
+
create: true,
|
|
1223
|
+
name: `${k8sName}-connect`,
|
|
1224
|
+
imagePullSecrets: ['artifactory-connect'],
|
|
1225
|
+
};
|
|
1226
|
+
if (app.serviceAccount.annotations)
|
|
1227
|
+
delete app.serviceAccount.annotations['eks.amazonaws.com/role-arn'];
|
|
1228
|
+
app.secretName = `${k8sName}-secret`;
|
|
1229
|
+
app.externalSecret = {
|
|
1230
|
+
...app.externalSecret,
|
|
1231
|
+
enabled: true,
|
|
1232
|
+
clusterSecretStoreName: 'aws-secrets-manager',
|
|
1233
|
+
awsSecretKey:
|
|
1234
|
+
runtimeSecretName ||
|
|
1235
|
+
`docupace/eks/dp-connect-${k8sName}/${k8sName}-secret`,
|
|
1236
|
+
};
|
|
1237
|
+
delete app.externalSecret.awsSecretProperty;
|
|
1238
|
+
app.commonExternalSecret = {
|
|
1239
|
+
...app.commonExternalSecret,
|
|
1240
|
+
enabled: true,
|
|
1241
|
+
targetSecretName: 'connect-ui-common-secret',
|
|
1242
|
+
awsSecretKey: `docupace/eks/connect-ui/${env}/common`,
|
|
1243
|
+
};
|
|
1244
|
+
app.artifactorySecret = {
|
|
1245
|
+
...app.artifactorySecret,
|
|
1246
|
+
enabled: true,
|
|
1247
|
+
targetSecretName: 'artifactory-connect',
|
|
1248
|
+
awsSecretKey: 'docupace/eks/artifactory-ms',
|
|
1249
|
+
};
|
|
1250
|
+
app.initContainer.s3Bucket = `docupace-${env}-connect-ui-configs-usw2`;
|
|
1251
|
+
app.initContainer.s3Prefix = 'main';
|
|
1252
|
+
delete app.configVersion;
|
|
1253
|
+
delete app.fullnameOverride;
|
|
1254
|
+
delete app.imagePullSecrets;
|
|
1255
|
+
// Build beside the target so validation/copy failures leave the old site intact.
|
|
1256
|
+
const workDir = await fs.mkdtemp(path.join(realEnvDir, '.connect-copy-'));
|
|
1257
|
+
const generated = path.join(workDir, 'generated');
|
|
1258
|
+
const backup = path.join(workDir, 'previous');
|
|
1259
|
+
let backedUp = false;
|
|
1260
|
+
let installed = false;
|
|
1261
|
+
try {
|
|
1262
|
+
await fs.mkdir(generated);
|
|
1263
|
+
for (const dir of ['conf', 'stylesheets']) {
|
|
1264
|
+
if (await fs.pathExists(path.join(source, dir))) {
|
|
1265
|
+
await fs.copy(path.join(source, dir), path.join(generated, dir), {
|
|
1266
|
+
overwrite: false,
|
|
1267
|
+
errorOnExist: true,
|
|
1268
|
+
});
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
await fs.outputFile(
|
|
1272
|
+
path.join(generated, 'conf', 'application.conf'),
|
|
1273
|
+
prepared.appConf,
|
|
1274
|
+
);
|
|
1275
|
+
await fs.outputFile(
|
|
1276
|
+
path.join(generated, 'conf', 'saml', 'auth0.xml'),
|
|
1277
|
+
prepared.metadata,
|
|
1278
|
+
);
|
|
1279
|
+
await fs.writeFile(
|
|
1280
|
+
path.join(generated, 'values.yaml'),
|
|
1281
|
+
yaml.dump(values, { lineWidth: -1 }),
|
|
1282
|
+
);
|
|
1283
|
+
if (existingTarget) {
|
|
1284
|
+
await fs.rename(target, backup);
|
|
1285
|
+
backedUp = true;
|
|
1286
|
+
}
|
|
1287
|
+
await fs.rename(generated, target);
|
|
1288
|
+
installed = true;
|
|
1289
|
+
} catch (error) {
|
|
1290
|
+
if (backedUp && !installed) {
|
|
1291
|
+
try {
|
|
1292
|
+
await fs.rename(backup, target);
|
|
1293
|
+
backedUp = false;
|
|
1294
|
+
} catch {
|
|
1295
|
+
// Preserve the backup if the filesystem prevents restoration.
|
|
1296
|
+
throw new Error(
|
|
1297
|
+
`Replacement failed; the previous site is preserved at ${backup}.`,
|
|
1298
|
+
{ cause: error },
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
throw error;
|
|
1303
|
+
} finally {
|
|
1304
|
+
// This absolute directory was created by this run, directly inside realEnvDir.
|
|
1305
|
+
if ((!backedUp || installed) && path.dirname(workDir) === realEnvDir) {
|
|
1306
|
+
await fs.remove(workDir);
|
|
1307
|
+
}
|
|
1308
|
+
}
|
|
1309
|
+
console.log(
|
|
1310
|
+
`${existingTarget ? 'Replaced' : 'Created'} envs/${env}/${site} from ${templateEnv}/${templateSite}; CONNECT endpoint: ${prepared.endpoint}`,
|
|
1311
|
+
);
|
|
1312
|
+
return {
|
|
1313
|
+
siteDir: target,
|
|
1314
|
+
relativeSiteDir: `envs/${env}/${site}`,
|
|
1315
|
+
env,
|
|
1316
|
+
site,
|
|
1317
|
+
namespace: app.namespace,
|
|
1318
|
+
serviceAccount: app.serviceAccount.name,
|
|
1319
|
+
endpoint: prepared.endpoint,
|
|
1320
|
+
sourceEnv: templateEnv,
|
|
1321
|
+
sourceSite: templateSite,
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
// Fleet registration and guarded CONNECT fleet publication
|
|
1326
|
+
async function insideFile(rootPath, relative) {
|
|
1327
|
+
const root = await fs.realpath(path.resolve(rootPath));
|
|
1328
|
+
const file = await fs.realpath(path.join(root, relative));
|
|
1329
|
+
if (!file.startsWith(`${root}${path.sep}`))
|
|
1330
|
+
throw new Error(`File resolves outside its checkout: ${relative}`);
|
|
1331
|
+
return file;
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
export async function getFleetValuesSource({ env, nextRepoPath }) {
|
|
1335
|
+
env = normalizeConnectEnvironment(env);
|
|
1336
|
+
const relative = `apps-fleet/values/us-west-2/docupace-${env}-eks/values.yaml`;
|
|
1337
|
+
const fleet = yaml.load(
|
|
1338
|
+
await fs.readFile(await insideFile(nextRepoPath, relative), 'utf8'),
|
|
1339
|
+
);
|
|
1340
|
+
const defaults =
|
|
1341
|
+
yaml.load(
|
|
1342
|
+
await fs.readFile(
|
|
1343
|
+
await insideFile(nextRepoPath, 'apps-fleet/values.yaml'),
|
|
1344
|
+
'utf8',
|
|
1345
|
+
),
|
|
1346
|
+
) || {};
|
|
1347
|
+
const repo = fleet.gitopsRepo || defaults.gitopsRepo;
|
|
1348
|
+
const revision = fleet.gitopsRevision || defaults.gitopsRevision;
|
|
1349
|
+
if (repo !== `git@bitbucket.org:dp-cloud/gitops-${env}.git` || !revision)
|
|
1350
|
+
throw new Error(
|
|
1351
|
+
'Fleet must declare the target environment values repository and revision.',
|
|
1352
|
+
);
|
|
1353
|
+
return { repo, revision };
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
export async function validateConnectFleetChange({
|
|
1357
|
+
env,
|
|
1358
|
+
site,
|
|
1359
|
+
beforeFile,
|
|
1360
|
+
afterFile,
|
|
1361
|
+
}) {
|
|
1362
|
+
env = normalizeConnectEnvironment(env);
|
|
1363
|
+
if (!/^[a-z0-9](?:[a-z0-9_-]{0,48}[a-z0-9])?$/.test(site || ''))
|
|
1364
|
+
throw new Error('Invalid target site name.');
|
|
1365
|
+
const projectName = `dp-${env}-eks-connect-ui-project`;
|
|
1366
|
+
const appName = `dp-${site.replaceAll('_', '-')}`;
|
|
1367
|
+
const documents = await Promise.all(
|
|
1368
|
+
[beforeFile, afterFile].map(async (file) =>
|
|
1369
|
+
yaml.load(await fs.readFile(file, 'utf8')),
|
|
1370
|
+
),
|
|
1371
|
+
);
|
|
1372
|
+
const entries = documents.map((document) => {
|
|
1373
|
+
const projects =
|
|
1374
|
+
document?.projects?.filter((project) => project.name === projectName) ||
|
|
1375
|
+
[];
|
|
1376
|
+
if (projects.length !== 1 || !Array.isArray(projects[0].applications))
|
|
1377
|
+
throw new Error(
|
|
1378
|
+
'Cannot validate CONNECT fleet changes: target project is missing.',
|
|
1379
|
+
);
|
|
1380
|
+
const apps = projects[0].applications.filter((app) => app.name === appName);
|
|
1381
|
+
if (apps.length > 1)
|
|
1382
|
+
throw new Error('Duplicate target CONNECT fleet application.');
|
|
1383
|
+
projects[0].applications = projects[0].applications.filter(
|
|
1384
|
+
(app) => app.name !== appName,
|
|
1385
|
+
);
|
|
1386
|
+
return apps;
|
|
1387
|
+
});
|
|
1388
|
+
if (
|
|
1389
|
+
!isDeepStrictEqual(documents[0], documents[1]) ||
|
|
1390
|
+
(entries[0].length && !isDeepStrictEqual(entries[0], entries[1]))
|
|
1391
|
+
)
|
|
1392
|
+
throw new Error(
|
|
1393
|
+
'Branch changes existing or unrelated fleet configuration; refusing automatic merge.',
|
|
1394
|
+
);
|
|
1395
|
+
if (entries[1].length !== 1)
|
|
1396
|
+
throw new Error('Target CONNECT fleet application is missing.');
|
|
1397
|
+
const entry = entries[1][0];
|
|
1398
|
+
if (
|
|
1399
|
+
entry.enabled === false ||
|
|
1400
|
+
!isDeepStrictEqual(entry.valueFiles, [`../envs/${env}/${site}/values.yaml`])
|
|
1401
|
+
)
|
|
1402
|
+
throw new Error(
|
|
1403
|
+
'Target CONNECT fleet application has an unexpected values path or is disabled.',
|
|
1404
|
+
);
|
|
1405
|
+
return { env, site, valid: true };
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
// Append entries only within the selected project, retaining existing formatting,
|
|
1409
|
+
// comments, pinned revisions and unrelated applications.
|
|
1410
|
+
function appendApplications(text, projectName, entries) {
|
|
1411
|
+
const lines = text.split(/\r?\n/);
|
|
1412
|
+
const start = lines.findIndex((line) => {
|
|
1413
|
+
const match = line.match(/^(\s*)-\s+name:\s*(.*?)\s*$/);
|
|
1414
|
+
return match && match[2].replace(/^["']|["']$/g, '') === projectName;
|
|
1415
|
+
});
|
|
1416
|
+
if (start < 0) throw new Error(`Fleet project is missing: ${projectName}`);
|
|
1417
|
+
const indent = lines[start].match(/^\s*/)[0].length;
|
|
1418
|
+
let end = start + 1;
|
|
1419
|
+
while (end < lines.length) {
|
|
1420
|
+
const line = lines[end];
|
|
1421
|
+
if (
|
|
1422
|
+
line.trim() &&
|
|
1423
|
+
!line.trim().startsWith('#') &&
|
|
1424
|
+
line.match(/^\s*/)[0].length <= indent
|
|
1425
|
+
)
|
|
1426
|
+
break;
|
|
1427
|
+
end += 1;
|
|
1428
|
+
}
|
|
1429
|
+
const appsLine = lines.findIndex(
|
|
1430
|
+
(line, i) =>
|
|
1431
|
+
i > start && i < end && /^\s*applications:\s*(?:\[\])?\s*$/.test(line),
|
|
1432
|
+
);
|
|
1433
|
+
if (appsLine < 0)
|
|
1434
|
+
throw new Error('Fleet applications must use an existing block sequence.');
|
|
1435
|
+
lines[appsLine] = lines[appsLine].replace(/:\s*\[\]\s*$/, ':');
|
|
1436
|
+
const sample = lines
|
|
1437
|
+
.slice(appsLine + 1, end)
|
|
1438
|
+
.find((line) => /^\s*-\s+name:/.test(line));
|
|
1439
|
+
const appIndent = sample ? sample.match(/^\s*/)[0].length : indent + 4;
|
|
1440
|
+
const added = yaml
|
|
1441
|
+
.dump(entries, { lineWidth: -1, noRefs: true })
|
|
1442
|
+
.trimEnd()
|
|
1443
|
+
.split('\n')
|
|
1444
|
+
.map((line) => ' '.repeat(appIndent) + line);
|
|
1445
|
+
lines.splice(end, 0, ...added);
|
|
1446
|
+
return lines.join('\n');
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1449
|
+
export async function registerSiteApps({
|
|
1450
|
+
env,
|
|
1451
|
+
site,
|
|
1452
|
+
apps = 'CONNECT',
|
|
1453
|
+
connectRepoPath,
|
|
1454
|
+
nextRepoPath,
|
|
1455
|
+
configRepoPath,
|
|
1456
|
+
configRevision,
|
|
1457
|
+
chartRevision = 'develop',
|
|
1458
|
+
dryRun = false,
|
|
1459
|
+
}) {
|
|
1460
|
+
env = normalizeConnectEnvironment(env);
|
|
1461
|
+
if (!/^[a-z0-9](?:[a-z0-9_-]{0,48}[a-z0-9])?$/.test(site || ''))
|
|
1462
|
+
throw new Error('Invalid target site name.');
|
|
1463
|
+
const requested = [
|
|
1464
|
+
...new Set(
|
|
1465
|
+
apps
|
|
1466
|
+
.toUpperCase()
|
|
1467
|
+
.split(',')
|
|
1468
|
+
.map((value) => value.trim()),
|
|
1469
|
+
),
|
|
1470
|
+
];
|
|
1471
|
+
if (
|
|
1472
|
+
!requested.length ||
|
|
1473
|
+
requested.some((value) => !['CONNECT', 'CORE', 'NEXT'].includes(value))
|
|
1474
|
+
)
|
|
1475
|
+
throw new Error('APPS must contain CONNECT, CORE and/or NEXT.');
|
|
1476
|
+
if (!/^[a-zA-Z0-9][a-zA-Z0-9_./-]*$/.test(chartRevision || ''))
|
|
1477
|
+
throw new Error('A valid CORE/NEXT chart revision is required.');
|
|
1478
|
+
const name = site.replaceAll('_', '-');
|
|
1479
|
+
const relative = `apps-fleet/values/us-west-2/docupace-${env}-eks/values.yaml`;
|
|
1480
|
+
const plans = [];
|
|
1481
|
+
for (const kind of ['CONNECT', 'NEXT']) {
|
|
1482
|
+
const selected =
|
|
1483
|
+
kind === 'CONNECT'
|
|
1484
|
+
? requested.includes('CONNECT')
|
|
1485
|
+
: requested.some((value) => value !== 'CONNECT');
|
|
1486
|
+
if (!selected) continue;
|
|
1487
|
+
const repo = kind === 'CONNECT' ? connectRepoPath : nextRepoPath;
|
|
1488
|
+
if (!repo) throw new Error(`${kind} GitOps checkout is required.`);
|
|
1489
|
+
const file = await insideFile(repo, relative);
|
|
1490
|
+
const text = await fs.readFile(file, 'utf8');
|
|
1491
|
+
const fleet = yaml.load(text);
|
|
1492
|
+
if (fleet.env !== env || fleet.name !== `docupace-${env}-eks`)
|
|
1493
|
+
throw new Error('Fleet environment/cluster does not match the target.');
|
|
1494
|
+
const projectName = `dp-${env}-eks-${kind === 'CONNECT' ? 'connect-ui' : 'next'}-project`;
|
|
1495
|
+
const projects =
|
|
1496
|
+
fleet.projects?.filter((project) => project.name === projectName) || [];
|
|
1497
|
+
if (projects.length !== 1 || !Array.isArray(projects[0].applications))
|
|
1498
|
+
throw new Error(
|
|
1499
|
+
`Expected exactly one configured project ${projectName}.`,
|
|
1500
|
+
);
|
|
1501
|
+
const candidates = [];
|
|
1502
|
+
if (kind === 'CONNECT') {
|
|
1503
|
+
const valuesFile = `envs/${env}/${site}/values.yaml`;
|
|
1504
|
+
const values = yaml.load(
|
|
1505
|
+
await fs.readFile(await insideFile(repo, valuesFile), 'utf8'),
|
|
1506
|
+
)?.['dp-connect-ui'];
|
|
1507
|
+
if (
|
|
1508
|
+
values?.name !== name ||
|
|
1509
|
+
values?.envName !== env ||
|
|
1510
|
+
!values?.image?.tag
|
|
1511
|
+
)
|
|
1512
|
+
throw new Error(
|
|
1513
|
+
'CONNECT site values are missing the expected target identity/image.',
|
|
1514
|
+
);
|
|
1515
|
+
candidates.push({
|
|
1516
|
+
name: `dp-${name}`,
|
|
1517
|
+
enabled: true,
|
|
1518
|
+
revision: 'main',
|
|
1519
|
+
valueFiles: [`../${valuesFile}`],
|
|
1520
|
+
});
|
|
1521
|
+
} else {
|
|
1522
|
+
if (!configRepoPath || !configRevision)
|
|
1523
|
+
throw new Error(
|
|
1524
|
+
'CORE/NEXT registration requires a checked-out target values repository and its revision.',
|
|
1525
|
+
);
|
|
1526
|
+
const defaults =
|
|
1527
|
+
yaml.load(
|
|
1528
|
+
await fs.readFile(
|
|
1529
|
+
await insideFile(repo, 'apps-fleet/values.yaml'),
|
|
1530
|
+
'utf8',
|
|
1531
|
+
),
|
|
1532
|
+
) || {};
|
|
1533
|
+
const expectedRepo = `git@bitbucket.org:dp-cloud/gitops-${env}.git`;
|
|
1534
|
+
if (
|
|
1535
|
+
(fleet.gitopsRepo || defaults.gitopsRepo) !== expectedRepo ||
|
|
1536
|
+
(fleet.gitopsRevision || defaults.gitopsRevision) !== configRevision
|
|
1537
|
+
)
|
|
1538
|
+
throw new Error(
|
|
1539
|
+
'Target values checkout/revision does not match the configured fleet source.',
|
|
1540
|
+
);
|
|
1541
|
+
const components = requested.includes('NEXT')
|
|
1542
|
+
? ['ui', 'graphql-server']
|
|
1543
|
+
: [];
|
|
1544
|
+
if (requested.includes('CORE')) components.push('core');
|
|
1545
|
+
for (const component of components) {
|
|
1546
|
+
const valuesFile = `dp/${site}/${component}.values.yaml`;
|
|
1547
|
+
const values = yaml.load(
|
|
1548
|
+
await fs.readFile(
|
|
1549
|
+
await insideFile(configRepoPath, valuesFile),
|
|
1550
|
+
'utf8',
|
|
1551
|
+
),
|
|
1552
|
+
);
|
|
1553
|
+
if (!values || typeof values !== 'object' || !values.image?.tag)
|
|
1554
|
+
throw new Error(
|
|
1555
|
+
`Prepared target values with an image tag are required: ${valuesFile}`,
|
|
1556
|
+
);
|
|
1557
|
+
const allowedSecrets = [`docupace/eks/dp-${site}/${site}`];
|
|
1558
|
+
if (component === 'core')
|
|
1559
|
+
allowedSecrets.push(`docupace/eks/dp-core-${name}/core-secrets`);
|
|
1560
|
+
if (
|
|
1561
|
+
values.externalSecrets?.enabled !== true ||
|
|
1562
|
+
!allowedSecrets.includes(values.externalSecrets.remoteSecretName)
|
|
1563
|
+
)
|
|
1564
|
+
throw new Error(
|
|
1565
|
+
`Prepared target AWS ExternalSecret configuration is required: ${valuesFile}`,
|
|
1566
|
+
);
|
|
1567
|
+
if (
|
|
1568
|
+
component === 'core' &&
|
|
1569
|
+
((values.core?.site &&
|
|
1570
|
+
values.core.site !== site &&
|
|
1571
|
+
values.core.site !== name) ||
|
|
1572
|
+
(values.core?.environment && values.core.environment !== env))
|
|
1573
|
+
)
|
|
1574
|
+
throw new Error(
|
|
1575
|
+
'Prepared CORE values identify a different target site/environment.',
|
|
1576
|
+
);
|
|
1577
|
+
const chart = {
|
|
1578
|
+
ui: 'dp-ui',
|
|
1579
|
+
'graphql-server': 'dp-gqs',
|
|
1580
|
+
core: 'dp-core',
|
|
1581
|
+
}[component];
|
|
1582
|
+
await insideFile(repo, `charts/${chart}/Chart.yaml`);
|
|
1583
|
+
candidates.push({
|
|
1584
|
+
name: `${env}-dp-${name}-${component}`,
|
|
1585
|
+
category: 'dp',
|
|
1586
|
+
site: name,
|
|
1587
|
+
repoURL: 'git@bitbucket.org:dp-cloud/dp-eks-next-gitops.git',
|
|
1588
|
+
path: `charts/${chart}`,
|
|
1589
|
+
revision: chartRevision,
|
|
1590
|
+
valuesFile,
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
const existing = (fleet.projects || []).flatMap(
|
|
1595
|
+
(project) => project.applications || [],
|
|
1596
|
+
);
|
|
1597
|
+
const added = [];
|
|
1598
|
+
for (const candidate of candidates) {
|
|
1599
|
+
const matches = existing.filter((app) => app.name === candidate.name);
|
|
1600
|
+
if (matches.length > 1)
|
|
1601
|
+
throw new Error(`Duplicate fleet application: ${candidate.name}`);
|
|
1602
|
+
if (matches.length) {
|
|
1603
|
+
const entry = matches[0];
|
|
1604
|
+
const samePath =
|
|
1605
|
+
kind === 'CONNECT'
|
|
1606
|
+
? isDeepStrictEqual(entry.valueFiles, candidate.valueFiles)
|
|
1607
|
+
: entry.valuesFile === candidate.valuesFile &&
|
|
1608
|
+
entry.path === candidate.path &&
|
|
1609
|
+
entry.site === candidate.site &&
|
|
1610
|
+
entry.category === candidate.category &&
|
|
1611
|
+
entry.repoURL === candidate.repoURL;
|
|
1612
|
+
if (
|
|
1613
|
+
!samePath ||
|
|
1614
|
+
!projects[0].applications.includes(entry) ||
|
|
1615
|
+
entry.enabled === false ||
|
|
1616
|
+
entry.autoSync === false
|
|
1617
|
+
)
|
|
1618
|
+
throw new Error(
|
|
1619
|
+
`Existing fleet application conflicts or is deliberately disabled: ${candidate.name}. Review it explicitly.`,
|
|
1620
|
+
);
|
|
1621
|
+
if (
|
|
1622
|
+
(entry.namespace && entry.namespace !== `dp-${name}`) ||
|
|
1623
|
+
(entry.targetName && entry.targetName !== fleet.name) ||
|
|
1624
|
+
entry.server ||
|
|
1625
|
+
entry.destination ||
|
|
1626
|
+
entry.valuesObject ||
|
|
1627
|
+
entry.chart ||
|
|
1628
|
+
entry.repo
|
|
1629
|
+
)
|
|
1630
|
+
throw new Error(
|
|
1631
|
+
`Existing fleet application overrides its deployment target/settings: ${candidate.name}. Review it explicitly.`,
|
|
1632
|
+
);
|
|
1633
|
+
} else added.push(candidate);
|
|
1634
|
+
}
|
|
1635
|
+
const output = added.length
|
|
1636
|
+
? appendApplications(text, projectName, added)
|
|
1637
|
+
: text;
|
|
1638
|
+
const parsed = yaml.load(output);
|
|
1639
|
+
const expected = yaml.load(text);
|
|
1640
|
+
expected.projects
|
|
1641
|
+
.find((project) => project.name === projectName)
|
|
1642
|
+
.applications.push(...added);
|
|
1643
|
+
if (!isDeepStrictEqual(parsed, expected))
|
|
1644
|
+
throw new Error('Fleet append validation failed.');
|
|
1645
|
+
plans.push({
|
|
1646
|
+
repoPath: repo,
|
|
1647
|
+
relativeFile: relative,
|
|
1648
|
+
added: added.map((app) => app.name),
|
|
1649
|
+
file,
|
|
1650
|
+
output,
|
|
1651
|
+
});
|
|
1652
|
+
}
|
|
1653
|
+
// All requested components and values are checked before any file is written.
|
|
1654
|
+
if (!dryRun)
|
|
1655
|
+
for (const plan of plans)
|
|
1656
|
+
if (plan.added.length) await fs.writeFile(plan.file, plan.output);
|
|
1657
|
+
return {
|
|
1658
|
+
env,
|
|
1659
|
+
site,
|
|
1660
|
+
files: plans.map(({ repoPath, relativeFile, added }) => ({
|
|
1661
|
+
repoPath,
|
|
1662
|
+
relativeFile,
|
|
1663
|
+
added,
|
|
1664
|
+
})),
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
// CONNECT configuration sync uses strict success checks; legacy pipeline behavior is unchanged.
|
|
1669
|
+
const BITBUCKET_BASE_URL = 'https://api.bitbucket.org/2.0';
|
|
1670
|
+
const BITBUCKET_WS_URL = 'https://bitbucket.org/dp-cloud';
|
|
1671
|
+
|
|
1672
|
+
// Repository access tokens must be sent as Bearer; the REST API rejects them as Basic x-token-auth.
|
|
1673
|
+
function getConnectPipelineRequestConfig() {
|
|
1674
|
+
const token = process.env.CONNECT_GITOPS_PIPELINE_TOKEN;
|
|
1675
|
+
if (token) return { headers: { Authorization: `Bearer ${token}` } };
|
|
1676
|
+
const { username, password } = getBitbucketInfo();
|
|
1677
|
+
return { auth: { username, password } };
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
async function triggerConnectPipeline(
|
|
1681
|
+
repository,
|
|
1682
|
+
pipelineName,
|
|
1683
|
+
branch,
|
|
1684
|
+
variables,
|
|
1685
|
+
) {
|
|
1686
|
+
const { workspace } = getBitbucketInfo();
|
|
1687
|
+
const url = `${BITBUCKET_BASE_URL}/repositories/${workspace}/${repository}/pipelines/`;
|
|
1688
|
+
|
|
1689
|
+
const payload = {
|
|
1690
|
+
target: {
|
|
1691
|
+
type: 'pipeline_ref_target',
|
|
1692
|
+
ref_type: 'branch',
|
|
1693
|
+
ref_name: branch,
|
|
1694
|
+
selector: {
|
|
1695
|
+
type: 'custom',
|
|
1696
|
+
pattern: pipelineName,
|
|
1697
|
+
},
|
|
1698
|
+
},
|
|
1699
|
+
variables: variables
|
|
1700
|
+
? Object.entries(variables).map(([key, value]) => ({
|
|
1701
|
+
key,
|
|
1702
|
+
value,
|
|
1703
|
+
secured: false, // Set to true if the variable should be secure
|
|
1704
|
+
}))
|
|
1705
|
+
: undefined,
|
|
1706
|
+
};
|
|
1707
|
+
|
|
1708
|
+
try {
|
|
1709
|
+
const response = await axios.post(url, payload, {
|
|
1710
|
+
...getConnectPipelineRequestConfig(),
|
|
1711
|
+
timeout: 30000,
|
|
1712
|
+
});
|
|
1713
|
+
return response.data;
|
|
1714
|
+
} catch (error) {
|
|
1715
|
+
console.error(
|
|
1716
|
+
'Error triggering custom pipeline:',
|
|
1717
|
+
error.response?.data || error.message,
|
|
1718
|
+
);
|
|
1719
|
+
throw error;
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
async function getConnectPipelineState(repository, pipelineUUID) {
|
|
1724
|
+
const { workspace } = getBitbucketInfo();
|
|
1725
|
+
const url = `${BITBUCKET_BASE_URL}/repositories/${workspace}/${repository}/pipelines/${pipelineUUID}`;
|
|
1726
|
+
|
|
1727
|
+
try {
|
|
1728
|
+
const response = await axios.get(url, {
|
|
1729
|
+
...getConnectPipelineRequestConfig(),
|
|
1730
|
+
timeout: 30000,
|
|
1731
|
+
});
|
|
1732
|
+
return response.data.state;
|
|
1733
|
+
} catch (error) {
|
|
1734
|
+
console.error(
|
|
1735
|
+
'Error fetching pipeline status:',
|
|
1736
|
+
error.response?.data || error.message,
|
|
1737
|
+
);
|
|
1738
|
+
throw error;
|
|
1739
|
+
}
|
|
1740
|
+
}
|
|
1741
|
+
|
|
1742
|
+
// Onboarding must not report success when the downstream config sync fails.
|
|
1743
|
+
export async function runPipelineChecked(
|
|
1744
|
+
repository,
|
|
1745
|
+
pipelineName,
|
|
1746
|
+
branch,
|
|
1747
|
+
variables,
|
|
1748
|
+
{ timeoutMs = 60 * 60 * 1000 } = {},
|
|
1749
|
+
) {
|
|
1750
|
+
const { username, password } = getBitbucketInfo();
|
|
1751
|
+
if (!process.env.CONNECT_GITOPS_PIPELINE_TOKEN && (!username || !password))
|
|
1752
|
+
throw new Error(
|
|
1753
|
+
'CONNECT_GITOPS_PIPELINE_TOKEN or Bitbucket API credentials are required to start the configuration sync.',
|
|
1754
|
+
);
|
|
1755
|
+
let pipeline;
|
|
1756
|
+
try {
|
|
1757
|
+
pipeline = await triggerConnectPipeline(
|
|
1758
|
+
repository,
|
|
1759
|
+
pipelineName,
|
|
1760
|
+
branch,
|
|
1761
|
+
variables,
|
|
1762
|
+
);
|
|
1763
|
+
} catch {
|
|
1764
|
+
throw new Error(
|
|
1765
|
+
`Could not start ${repository}/${pipelineName}; the GitOps merge remains committed.`,
|
|
1766
|
+
);
|
|
1767
|
+
}
|
|
1768
|
+
const url = `${BITBUCKET_WS_URL}/${repository}/pipelines/results/${pipeline.build_number}`;
|
|
1769
|
+
console.log(`Configuration sync started: ${url}`);
|
|
1770
|
+
const deadline = Date.now() + timeoutMs;
|
|
1771
|
+
while (Date.now() < deadline) {
|
|
1772
|
+
let state;
|
|
1773
|
+
try {
|
|
1774
|
+
state = await getConnectPipelineState(repository, pipeline.uuid);
|
|
1775
|
+
} catch {
|
|
1776
|
+
throw new Error(
|
|
1777
|
+
`Cannot confirm configuration sync status: ${url}. The GitOps merge remains committed.`,
|
|
1778
|
+
);
|
|
1779
|
+
}
|
|
1780
|
+
console.log(
|
|
1781
|
+
`Configuration sync: ${state.name}${state.result?.name ? ` / ${state.result.name}` : ''}`,
|
|
1782
|
+
);
|
|
1783
|
+
if (state.name === 'COMPLETED' && state.result?.name === 'SUCCESSFUL')
|
|
1784
|
+
return pipeline;
|
|
1785
|
+
if (!['PARSING', 'PENDING', 'IN_PROGRESS', 'RUNNING'].includes(state.name))
|
|
1786
|
+
throw new Error(
|
|
1787
|
+
`Configuration sync did not succeed: ${url}. The GitOps merge remains committed.`,
|
|
1788
|
+
);
|
|
1789
|
+
await new Promise((resolve) => setTimeout(resolve, 10000));
|
|
1790
|
+
}
|
|
1791
|
+
throw new Error(
|
|
1792
|
+
`Timed out waiting for configuration sync: ${url}. The pipeline may still be running; the GitOps merge remains committed.`,
|
|
1793
|
+
);
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
export async function syncConnectConfig({ env }) {
|
|
1797
|
+
env = normalizeConnectEnvironment(env);
|
|
1798
|
+
if (env === 'dev') {
|
|
1799
|
+
console.log(
|
|
1800
|
+
'Dev configuration sync is triggered automatically by the main push.',
|
|
1801
|
+
);
|
|
1802
|
+
return;
|
|
1803
|
+
}
|
|
1804
|
+
return runPipelineChecked(
|
|
1805
|
+
CONNECT_AWS_GITOPS_REPO,
|
|
1806
|
+
`sync-${env}`,
|
|
1807
|
+
CONNECT_AWS_DEFAULT_BRANCH,
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// Standalone entry points used by site-setup; importing this module does not run them.
|
|
1812
|
+
if (
|
|
1813
|
+
process.argv[1] &&
|
|
1814
|
+
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)
|
|
1815
|
+
) {
|
|
1816
|
+
try {
|
|
1817
|
+
const command = process.argv[2];
|
|
1818
|
+
if (command === 'environment') {
|
|
1819
|
+
console.log(JSON.stringify(getConnectEnvironmentConfig(process.argv[3])));
|
|
1820
|
+
} else if (command === 'readiness') {
|
|
1821
|
+
const { values } = parseArgs({
|
|
1822
|
+
args: process.argv.slice(3),
|
|
1823
|
+
options: {
|
|
1824
|
+
env: { type: 'string' },
|
|
1825
|
+
site: { type: 'string' },
|
|
1826
|
+
'pod-identity-only': { type: 'boolean', default: false },
|
|
1827
|
+
},
|
|
1828
|
+
});
|
|
1829
|
+
const result = await checkConnectReadiness({
|
|
1830
|
+
...values,
|
|
1831
|
+
podIdentityOnly: values['pod-identity-only'],
|
|
1832
|
+
});
|
|
1833
|
+
if (!result.ready) {
|
|
1834
|
+
console.log(
|
|
1835
|
+
`SKIPPED: CONNECT ${values.env}/${values.site} is not ready. ${result.reasons.join(' ')}`,
|
|
1836
|
+
);
|
|
1837
|
+
process.exitCode = 3;
|
|
1838
|
+
} else {
|
|
1839
|
+
console.log(
|
|
1840
|
+
`READY: ${result.cluster}/${result.namespace}/${result.serviceAccount}; expected role${values['pod-identity-only'] ? '' : ' and required secret fields'} confirmed.`,
|
|
1841
|
+
);
|
|
1842
|
+
}
|
|
1843
|
+
} else if (command === 'check-image') {
|
|
1844
|
+
const { values } = parseArgs({
|
|
1845
|
+
args: process.argv.slice(3),
|
|
1846
|
+
options: { 'values-path': { type: 'string' } },
|
|
1847
|
+
});
|
|
1848
|
+
await checkConnectImage({ valuesPath: values['values-path'] });
|
|
1849
|
+
} else {
|
|
1850
|
+
throw new Error(
|
|
1851
|
+
'Expected CONNECT AWS command: environment, readiness, or check-image.',
|
|
1852
|
+
);
|
|
1853
|
+
}
|
|
1854
|
+
} catch (error) {
|
|
1855
|
+
console.error(error.message);
|
|
1856
|
+
process.exitCode = 1;
|
|
1857
|
+
}
|
|
1858
|
+
}
|