-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathdeployment.ts
More file actions
577 lines (501 loc) · 20 KB
/
deployment.ts
File metadata and controls
577 lines (501 loc) · 20 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
import { KubernetesClientHelper } from "../../utils/kubernetes-client.js";
import { WorkspacePaths } from "../../utils/workspace-paths.js";
import { $ } from "../../utils/bash.js";
import yaml from "js-yaml";
import os from "os";
import path from "path";
import { test, request, expect } from "@playwright/test";
import { mergeYamlFilesIfExists, deepMerge } from "../../utils/merge-yamls.js";
import {
generatePluginsFromMetadata,
processPluginsForDeployment,
getNormalizedPluginMergeKey,
disablePluginWrappers,
type DynamicPluginsConfig,
} from "../../utils/plugin-metadata.js";
import { envsubst } from "../../utils/common.js";
import { runOnce } from "../../playwright/run-once.js";
import cloneDeepWith from "lodash.clonedeepwith";
import fs from "fs-extra";
import {
DEFAULT_CONFIG_PATHS,
AUTH_CONFIG_PATHS,
CHART_URL,
} from "./constants.js";
import type {
DeploymentOptions,
DeploymentConfig,
DeploymentConfigBase,
DeploymentMethod,
} from "./types.js";
export class RHDHDeployment {
public k8sClient = new KubernetesClientHelper();
public rhdhUrl: string;
public deploymentConfig: DeploymentConfig;
constructor(namespace: string) {
this.deploymentConfig = this._buildDeploymentConfig({ namespace });
this.rhdhUrl = this._buildBaseUrl();
}
async deploy(options?: {
timeout?: number | null;
forceUpdate?: boolean;
}): Promise<void> {
// Default 600s, custom number to override, null to skip and let consumer control the timeout
const timeout = options?.timeout === undefined ? 600_000 : options.timeout;
if (timeout !== null) {
test.setTimeout(timeout);
}
const deployFunc = async () => {
this._log("Starting RHDH deployment...");
this._log("RHDH Base URL: " + this.rhdhUrl);
console.table(this.deploymentConfig);
await this.k8sClient.createNamespaceIfNotExists(
this.deploymentConfig.namespace,
);
await this._applyAppConfig();
await this._applySecrets();
if (this.deploymentConfig.method === "helm") {
const isUpgrade = await this._deploymentExists();
await this._deployWithHelm(this.deploymentConfig.valueFile);
if (isUpgrade) {
await this.scaleDownAndRestart(); // Restart as helm does not monitor config changes
}
} else {
await this._applyDynamicPlugins();
await this._deployWithOperator(this.deploymentConfig.subscription);
}
await this.waitUntilReady();
};
let executed: boolean;
if (options?.forceUpdate) {
await deployFunc();
executed = true;
} else {
executed = await runOnce(
`deploy-${this.deploymentConfig.namespace}`,
deployFunc,
);
}
if (!executed) {
this._log(
`Deployment already completed for namespace "${this.deploymentConfig.namespace}", skipping`,
);
}
}
private async _applyAppConfig(): Promise<void> {
const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth];
const appConfigYaml = await mergeYamlFilesIfExists(
[
DEFAULT_CONFIG_PATHS.appConfig,
authConfig.appConfig,
this.deploymentConfig.appConfig,
],
authConfig.mergeStrategy,
);
this._logBoxen("App Config", appConfigYaml);
await this.k8sClient.applyConfigMapFromObject(
"app-config-rhdh",
appConfigYaml,
this.deploymentConfig.namespace,
);
}
private async _applySecrets(): Promise<void> {
const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth];
const secretsYaml = await mergeYamlFilesIfExists([
DEFAULT_CONFIG_PATHS.secrets,
authConfig.secrets,
this.deploymentConfig.secrets,
]);
// Use cloneDeepWith to substitute env vars in-place, avoiding JSON.parse issues
// with control characters in secrets (e.g., private keys with newlines)
const substituted = cloneDeepWith(secretsYaml, (value: unknown) => {
if (typeof value === "string") return envsubst(value);
});
await this.k8sClient.applySecretFromObject(
"rhdh-secrets",
substituted as { stringData?: Record<string, string> },
this.deploymentConfig.namespace,
);
}
/** Shared merge strategy for dynamic plugin arrays. */
private static readonly pluginMergeOpts = {
arrayMergeStrategy: { byKey: "package" },
} as const;
/**
* Merges package defaults + auth config (+ optional user config) into a
* single dynamic plugins configuration.
*/
private async _mergeBaseConfigs(
userConfigPath?: string,
): Promise<Record<string, unknown>> {
const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth];
const paths = [
DEFAULT_CONFIG_PATHS.dynamicPlugins,
authConfig.dynamicPlugins,
...(userConfigPath ? [userConfigPath] : []),
];
return await mergeYamlFilesIfExists(paths, RHDHDeployment.pluginMergeOpts);
}
/**
* Merges a generated plugin config with the base (defaults + auth) config.
*/
private async _mergeGeneratedWithBase(
generatedConfig: Record<string, unknown>,
): Promise<Record<string, unknown>> {
const baseConfig = await this._mergeBaseConfigs();
// Use normalizeKey so OCI and local path for the same logical plugin
// (e.g., keycloak from metadata OCI + auth local path with -dynamic suffix)
// are deduplicated; generated (metadata) wins so OCI URL is kept.
return deepMerge(baseConfig, generatedConfig, {
arrayMergeStrategy: {
byKey: "package",
normalizeKey: (item) =>
getNormalizedPluginMergeKey(item as Record<string, unknown>),
},
});
}
/**
* Builds the merged dynamic plugins configuration.
*
* 1. Assembles raw config: user-provided OR auto-generated from metadata
* 2. Processes for deployment: injects metadata (PR) + resolves all packages to OCI
*
* The processing step is shared — processPluginsForDeployment handles
* both PR and nightly via isNightlyJob() and GIT_PR_NUMBER detection.
*/
private async _buildDynamicPluginsConfig(): Promise<Record<string, unknown>> {
const userConfigPath = this.deploymentConfig.dynamicPlugins;
const userConfigExists = userConfigPath && fs.existsSync(userConfigPath);
const wrapperPlugins = disablePluginWrappers(
this.deploymentConfig.disableWrappers,
);
let config: Record<string, unknown>;
if (userConfigExists) {
this._log(`Using user config: ${userConfigPath}`);
config = await this._mergeBaseConfigs(userConfigPath);
} else {
this._log(
`No user config at '${userConfigPath}', auto-generating from metadata...`,
);
const generated = await generatePluginsFromMetadata(
WorkspacePaths.metadataDir,
);
config = await this._mergeGeneratedWithBase(generated);
}
// Process for deployment: inject metadata (PR only) + resolve all packages to OCI
let result = await processPluginsForDeployment(
config as DynamicPluginsConfig,
WorkspacePaths.metadataDir,
);
// Disable wrapper plugins (PR builds only)
if (process.env.GIT_PR_NUMBER) {
result = deepMerge(result, wrapperPlugins, {
arrayMergeStrategy: "concat",
}) as DynamicPluginsConfig;
}
return result;
}
private async _applyDynamicPlugins(): Promise<void> {
const dynamicPluginsYaml = await this._buildDynamicPluginsConfig();
this._logBoxen("Dynamic Plugins", dynamicPluginsYaml);
await this.k8sClient.applyConfigMapFromObject(
"dynamic-plugins",
dynamicPluginsYaml,
this.deploymentConfig.namespace,
);
}
private async _deployWithHelm(valueFile: string): Promise<void> {
const chartVersion = await this._resolveChartVersion(
this.deploymentConfig.version,
);
this._log(`Helm chart version resolved to: ${chartVersion}`);
const valueFileObject = (await mergeYamlFilesIfExists([
DEFAULT_CONFIG_PATHS.helm.valueFile,
valueFile,
])) as Record<string, Record<string, unknown>>;
this._logBoxen("Value File", valueFileObject);
// Merge dynamic plugins into the values file (including auth-specific plugins)
if (!valueFileObject.global) {
valueFileObject.global = {};
}
valueFileObject.global.dynamic = await this._buildDynamicPluginsConfig();
// Set catalog index image if CATALOG_INDEX_IMAGE env var is provided.
// The catalog index provides dynamic-plugins.default.yaml with default plugin
// configurations and versions for the RHDH release.
const catalogIndexImage = process.env.CATALOG_INDEX_IMAGE;
if (catalogIndexImage) {
const [imageRef, tag] = catalogIndexImage.split(":");
const firstSlash = imageRef.indexOf("/");
valueFileObject.global.catalogIndex = {
image: {
registry: imageRef.substring(0, firstSlash),
repository: imageRef.substring(firstSlash + 1),
tag: tag || "latest",
},
};
this._log(`Catalog index image: ${catalogIndexImage}`);
}
this._logBoxen("Dynamic Plugins", valueFileObject.global.dynamic);
// Escape {{inherit}} for Helm's Go template engine.
// The RHDH chart uses `tpl` on dynamic plugin values, so {{inherit}} would be
// interpreted as a Go template action. Escaping to {{ "{{inherit}}" }} produces
// the literal string {{inherit}} after template rendering.
const valuesYaml = yaml
.dump(valueFileObject)
.replace(/\{\{inherit\}\}/g, '{{ "{{inherit}}" }}');
const valueFilePath = path.join(
os.tmpdir(),
`${this.deploymentConfig.namespace}-value-file.yaml`,
);
fs.writeFileSync(valueFilePath, valuesYaml);
await $`
helm upgrade redhat-developer-hub -i "${process.env.CHART_URL || CHART_URL}" --version "${chartVersion}" \
-f "${valueFilePath}" \
--set global.clusterRouterBase="${process.env.K8S_CLUSTER_ROUTER_BASE}" \
--namespace="${this.deploymentConfig.namespace}"
`;
this._log(`Helm deployment completed successfully`);
}
private async _deployWithOperator(subscription: string): Promise<void> {
const subscriptionObject = (await mergeYamlFilesIfExists([
DEFAULT_CONFIG_PATHS.operator.subscription,
subscription,
])) as Record<string, Record<string, Record<string, unknown>>>;
// Set catalog index image if CATALOG_INDEX_IMAGE env var is provided.
const catalogIndexImage = process.env.CATALOG_INDEX_IMAGE;
if (catalogIndexImage) {
const spec = (subscriptionObject.spec ??= {});
const app = (spec.application ??= {}) as Record<string, unknown>;
const extraEnvs = ((app.extraEnvs as Record<string, unknown>) ??=
{}) as Record<string, unknown>;
const envs = ((extraEnvs.envs as Array<Record<string, unknown>>) ??=
[]) as Array<Record<string, unknown>>;
envs.push({
name: "CATALOG_INDEX_IMAGE",
value: catalogIndexImage,
containers: ["install-dynamic-plugins"],
});
this._log(`Catalog index image: ${catalogIndexImage}`);
}
this._logBoxen("Subscription", subscriptionObject);
const subscriptionFilePath = path.join(
os.tmpdir(),
`${this.deploymentConfig.namespace}-subscription.yaml`,
);
fs.writeFileSync(subscriptionFilePath, yaml.dump(subscriptionObject));
const version = this.deploymentConfig.version;
const isSemanticVersion = /^\d+(\.\d+)?$/.test(version);
// Use main branch for non-semantic versions (e.g., "next", "latest")
const branch = isSemanticVersion ? `release-${version}` : "main";
// Build version argument based on version type
let versionArg: string;
if (isSemanticVersion) {
versionArg = `-v ${version}`;
} else if (version === "next") {
versionArg = "--next";
} else {
throw new Error(
`Invalid RHDH version "${version}". Use semantic version (e.g., "1.5") or "next".`,
);
}
this._log(`Using operator branch: ${branch}, version arg: ${versionArg}`);
await $`
set -e;
curl -sf https://raw.githubusercontent.com/redhat-developer/rhdh-operator/refs/heads/${branch}/.rhdh/scripts/install-rhdh-catalog-source.sh | bash -s -- ${versionArg} --install-operator rhdh
timeout 300 bash -c '
while ! oc get crd/backstages.rhdh.redhat.com -n "${this.deploymentConfig.namespace}" >/dev/null 2>&1; do
echo "Waiting for Backstage CRD to be created..."
sleep 20
done
echo "Backstage CRD is created."
' || echo "Error: Timed out waiting for Backstage CRD creation."
oc apply -f "${subscriptionFilePath}" -n "${this.deploymentConfig.namespace}"
`;
this._log("Operator deployment executed successfully.");
}
async rolloutRestart(): Promise<void> {
this._log(
`Restarting RHDH deployment in namespace ${this.deploymentConfig.namespace}...`,
);
await $`oc rollout restart deployment -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)' -n ${this.deploymentConfig.namespace}`;
this._log(
`RHDH deployment restarted successfully in namespace ${this.deploymentConfig.namespace}`,
);
await this.waitUntilReady();
}
/**
* Performs a clean restart by scaling down to 0 first, waiting for pods to terminate,
* then scaling back up. This prevents MigrationLocked errors by ensuring no pods
* hold database locks when new pods start.
*/
async scaleDownAndRestart(): Promise<void> {
const namespace = this.deploymentConfig.namespace;
await $`oc scale deployment -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)' --replicas=0 -n ${namespace}`;
await $`oc wait --for=delete pod -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub),app.kubernetes.io/name!=postgresql' -n ${namespace} --timeout=120s || true`;
await $`oc scale deployment -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)' --replicas=1 -n ${namespace}`;
}
async waitUntilReady(timeout: number = 500): Promise<void> {
const namespace = this.deploymentConfig.namespace;
const labelSelector =
"app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)";
const startTime = Date.now();
try {
await this.k8sClient.waitForPodsWithFailureDetection(
namespace,
labelSelector,
timeout,
);
} catch (error) {
throw new Error(
`RHDH deployment failed in ${namespace}: ${error instanceof Error ? error.message : error}`,
{ cause: error },
);
}
// Use remaining timeout for route readiness check
const remaining = timeout * 1000 - (Date.now() - startTime);
await expect(async () => {
const context = await request.newContext({ ignoreHTTPSErrors: true });
const response = await context.get(this.rhdhUrl);
await context.dispose();
expect(response.ok()).toBeTruthy();
}).toPass({ timeout: Math.max(remaining, 30_000), intervals: [5_000] });
this._log(`RHDH is ready in ${namespace}`);
}
async teardown(): Promise<void> {
await this.k8sClient.deleteNamespace(this.deploymentConfig.namespace);
}
private async _deploymentExists(): Promise<boolean> {
try {
await $`oc get deployment redhat-developer-hub -n ${this.deploymentConfig.namespace} --no-headers 2>/dev/null`;
return true;
} catch {
return false;
}
}
private async _resolveChartVersion(version: string): Promise<string> {
let resolvedVersion = version;
// Handle "next" tag by looking up the corresponding version from downstream image
if (version === "next") {
resolvedVersion = await this._resolveVersionFromNextTag();
this._log(`Resolved "next" tag to version: ${resolvedVersion}`);
}
// Semantic versions (e.g., 1.2, 1.10)
if (/^(\d+(\.\d+)?)$/.test(resolvedVersion)) {
const response = await fetch(
"https://quay.io/api/v1/repository/rhdh/chart/tag/?onlyActiveTags=true&limit=600",
);
if (!response.ok)
throw new Error(
`Failed to fetch chart versions: ${response.statusText}`,
);
const data = (await response.json()) as { tags: Array<{ name: string }> };
const matching = data.tags
.map((t) => t.name)
.filter((name) => name.startsWith(`${resolvedVersion}-`))
.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }));
const latest = matching.at(-1);
if (!latest)
throw new Error(`No chart version found for ${resolvedVersion}`);
return latest;
}
// CI build versions (e.g., 1.2.3-CI)
if (resolvedVersion.endsWith("CI")) return resolvedVersion;
throw new Error(`Invalid Helm chart version format: "${version}"`);
}
/**
* Resolve the semantic version from the "next" tag by looking up the
* downstream image (rhdh-hub-rhel9) and finding tags with the same digest.
*/
private async _resolveVersionFromNextTag(): Promise<string> {
// Fetch all active tags in a single API call
const response = await fetch(
"https://quay.io/api/v1/repository/rhdh/rhdh-hub-rhel9/tag/?onlyActiveTags=true&limit=75",
);
if (!response.ok) {
throw new Error(`Failed to fetch image tags: ${response.statusText}`);
}
// Use Record to avoid snake_case linting issues with Quay API response
const data = (await response.json()) as {
tags: Array<Record<string, unknown>>;
};
// Find the "next" tag and get its digest
const nextTag = data.tags.find((t) => t["name"] === "next");
if (!nextTag) {
throw new Error('No "next" tag found in rhdh-hub-rhel9 repository');
}
const digest = nextTag["manifest_digest"] as string;
this._log(`"next" tag digest: ${digest}`);
// Find semantic version tag (e.g., "1.10") with the same digest
const semanticVersionTag = data.tags.find(
(t) =>
t["manifest_digest"] === digest &&
/^\d+\.\d+$/.test(t["name"] as string),
);
if (!semanticVersionTag) {
throw new Error(
`Could not find semantic version tag for "next" (digest: ${digest})`,
);
}
return semanticVersionTag["name"] as string;
}
private _buildDeploymentConfig(input: DeploymentOptions): DeploymentConfig {
// Default to "next" if RHDH_VERSION not set
const version = input.version ?? process.env.RHDH_VERSION ?? "next";
// Default to "helm" if INSTALLATION_METHOD not set
const method =
input.method ??
(process.env.INSTALLATION_METHOD as DeploymentMethod) ??
"helm";
const base: DeploymentConfigBase = {
version,
namespace: input.namespace ?? this.deploymentConfig.namespace,
auth: input.auth ?? "keycloak",
appConfig: input.appConfig ?? WorkspacePaths.appConfig,
secrets: input.secrets ?? WorkspacePaths.secrets,
dynamicPlugins: input.dynamicPlugins ?? WorkspacePaths.dynamicPlugins,
disableWrappers: input.disableWrappers ?? [],
};
if (method === "helm") {
return {
...base,
method,
valueFile: input.valueFile ?? WorkspacePaths.valueFile,
};
} else if (method === "operator") {
return {
...base,
method,
subscription: input.subscription ?? WorkspacePaths.subscription,
};
} else {
throw new Error(`Invalid RHDH installation method: ${method}`);
}
}
async configure(deploymentOptions?: DeploymentOptions): Promise<void> {
if (deploymentOptions) {
this.deploymentConfig = this._buildDeploymentConfig(deploymentOptions);
this.rhdhUrl = this._buildBaseUrl();
}
await this.k8sClient.createNamespaceIfNotExists(
this.deploymentConfig.namespace,
);
}
private _buildBaseUrl(): string {
const prefix =
this.deploymentConfig.method === "helm"
? "redhat-developer-hub"
: "backstage-developer-hub";
const baseUrl = `https://${prefix}-${this.deploymentConfig.namespace}.${process.env.K8S_CLUSTER_ROUTER_BASE}`;
process.env.RHDH_BASE_URL = baseUrl;
return baseUrl;
}
private _log(...args: unknown[]): void {
console.log("[RHDHDeployment]", ...args);
}
private _logBoxen(title: string, data: unknown): void {
const content = yaml.dump(data, { lineWidth: -1 });
console.log(`\n┌─ ${title} ${"─".repeat(60)}`);
console.log(content);
console.log(`└${"─".repeat(60 + title.length + 3)}\n`);
}
}