diff --git a/package.json b/package.json index d00bfab..f0c9913 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,10 @@ "./pages": { "types": "./dist/playwright/pages/index.d.ts", "default": "./dist/playwright/pages/index.js" + }, + "./keycloak": { + "types": "./dist/deployment/keycloak/index.d.ts", + "default": "./dist/deployment/keycloak/index.js" } }, "files": [ @@ -42,7 +46,7 @@ "tsconfig.base.json" ], "scripts": { - "build": "yarn clean && tsc -p tsconfig.build.json && cp -r src/deployment/rhdh/config src/deployment/rhdh/helm src/deployment/rhdh/operator dist/deployment/rhdh/", + "build": "yarn clean && tsc -p tsconfig.build.json && cp -r src/deployment/rhdh/config dist/deployment/rhdh/ && cp -r src/deployment/keycloak/config dist/deployment/keycloak/", "check": "yarn typecheck && yarn lint:check && yarn prettier:check", "clean": "rm -rf dist", "lint:check": "eslint . --ignore-pattern dist --ignore-pattern README.md", @@ -76,6 +80,7 @@ "dependencies": { "@axe-core/playwright": "^4.11.0", "@eslint/js": "^9.39.1", + "@keycloak/keycloak-admin-client": "^26.0.0", "@kubernetes/client-node": "^1.4.0", "boxen": "^8.0.1", "eslint": "^9.39.1", diff --git a/src/deployment/keycloak/config/keycloak-values.yaml b/src/deployment/keycloak/config/keycloak-values.yaml new file mode 100644 index 0000000..4921cc1 --- /dev/null +++ b/src/deployment/keycloak/config/keycloak-values.yaml @@ -0,0 +1,94 @@ +global: + security: + allowInsecureImages: true + +replicaCount: 1 + +# Use Bitnami legacy repository (Bitnami images moved to bitnamilegacy as of Aug 2025) +# Note: Legacy images are not updated/maintained. Consider migrating to official Keycloak image for long-term. +image: + registry: docker.io + repository: bitnamilegacy/keycloak + tag: "26.3.3-debian-12-r0" + pullPolicy: IfNotPresent + +auth: + adminUser: admin + adminPassword: admin123 + +service: + type: ClusterIP + port: 8080 + +# OpenShift Route configuration +route: + enabled: true + host: "" # Will be auto-generated by OpenShift + tls: + enabled: false + +ingress: + enabled: false + +postgresql: + enabled: true + image: + registry: docker.io + repository: bitnamilegacy/postgresql + tag: "17.6.0-debian-12-r4" + pullPolicy: IfNotPresent + auth: + postgresPassword: postgres123 + username: keycloak + password: keycloak123 + database: keycloak + primary: + resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 100m + memory: 256Mi + persistence: + enabled: true + size: 1Gi + +resources: + limits: + cpu: 1000m + memory: 1Gi + requests: + cpu: 100m + memory: 256Mi + +extraEnvVars: + - name: KEYCLOAK_ADMIN + value: admin + - name: KEYCLOAK_ADMIN_PASSWORD + value: admin123 + - name: KC_HTTP_ENABLED + value: "true" + - name: KC_PROXY_HEADERS + value: "xforwarded" + - name: KC_HOSTNAME_STRICT + value: "false" + - name: JAVA_OPTS_APPEND + value: "-Djava.net.preferIPv4Stack=true -Xms256m -Xmx512m" + +# Increase probe timeouts for slower startup on resource-constrained clusters +livenessProbe: + enabled: true + initialDelaySeconds: 120 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + successThreshold: 1 + +readinessProbe: + enabled: true + initialDelaySeconds: 60 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 6 + successThreshold: 1 diff --git a/src/deployment/keycloak/constants.ts b/src/deployment/keycloak/constants.ts new file mode 100644 index 0000000..b83918b --- /dev/null +++ b/src/deployment/keycloak/constants.ts @@ -0,0 +1,87 @@ +import path from "path"; +import type { KeycloakClientConfig } from "./types.js"; + +// Navigate from dist/deployment/keycloak/ to package root +const PACKAGE_ROOT = path.resolve(import.meta.dirname, "../../.."); + +export const DEFAULT_KEYCLOAK_CONFIG = { + namespace: "rhdh-keycloak", + releaseName: "keycloak", + adminUser: "admin", + adminPassword: "admin123", + realm: "rhdh", +}; + +export const DEFAULT_CONFIG_PATHS = { + valuesFile: path.join( + PACKAGE_ROOT, + "dist/deployment/keycloak/config/keycloak-values.yaml", + ), +}; + +export const BITNAMI_CHART_REPO = "https://charts.bitnami.com/bitnami"; +export const BITNAMI_CHART_NAME = "bitnami/keycloak"; + +export const DEFAULT_RHDH_CLIENT: KeycloakClientConfig = { + clientId: "rhdh-client", + clientSecret: "rhdh-client-secret", + name: "RHDH Client", + redirectUris: ["*"], + webOrigins: ["*"], + standardFlowEnabled: true, + implicitFlowEnabled: true, + directAccessGrantsEnabled: true, + serviceAccountsEnabled: true, + authorizationServicesEnabled: true, + publicClient: false, + defaultClientScopes: [ + "service_account", + "web-origins", + "roles", + "profile", + "basic", + "email", + ], + optionalClientScopes: [ + "address", + "phone", + "offline_access", + "microprofile-jwt", + ], +}; + +export const DEFAULT_GROUPS = [ + { name: "developers" }, + { name: "admins" }, + { name: "viewers" }, +]; + +export const DEFAULT_USERS = [ + { + username: "test1", + email: "test1@example.com", + firstName: "Test", + lastName: "User1", + enabled: true, + emailVerified: true, + password: "test1@123", + groups: ["developers"], + }, + { + username: "test2", + email: "test2@example.com", + firstName: "Test", + lastName: "User2", + enabled: true, + emailVerified: true, + password: "test2@123", + groups: ["developers"], + }, +]; + +// Service account roles required for RHDH integration +export const SERVICE_ACCOUNT_ROLES = [ + "view-authorization", + "manage-authorization", + "view-users", +]; diff --git a/src/deployment/keycloak/deployment.ts b/src/deployment/keycloak/deployment.ts new file mode 100644 index 0000000..640f3ae --- /dev/null +++ b/src/deployment/keycloak/deployment.ts @@ -0,0 +1,547 @@ +import KeycloakAdminClient from "@keycloak/keycloak-admin-client"; +import { KubernetesClientHelper } from "../../utils/kubernetes-client.js"; +import { $ } from "../../utils/bash.js"; +import { + DEFAULT_KEYCLOAK_CONFIG, + BITNAMI_CHART_REPO, + BITNAMI_CHART_NAME, + DEFAULT_CONFIG_PATHS, + DEFAULT_RHDH_CLIENT, + SERVICE_ACCOUNT_ROLES, + DEFAULT_USERS, + DEFAULT_GROUPS, +} from "./constants.js"; +import type { + KeycloakDeploymentOptions, + KeycloakDeploymentConfig, + KeycloakClientConfig, + KeycloakUserConfig, + KeycloakGroupConfig, + KeycloakRealmConfig, + KeycloakConnectionConfig, +} from "./types.js"; + +export class KeycloakHelper { + public k8sClient = new KubernetesClientHelper(); + public deploymentConfig: KeycloakDeploymentConfig; + public keycloakUrl: string = ""; + public realm: string = ""; + public clientId: string = ""; + public clientSecret: string = ""; + private _adminClient: KeycloakAdminClient | null = null; + + constructor(options: KeycloakDeploymentOptions = {}) { + this.deploymentConfig = this._buildDeploymentConfig(options); + } + + /** + * Deploy Keycloak using Helm and configure it for RHDH. + */ + async deploy(): Promise { + this._log("Starting Keycloak deployment..."); + + await this.k8sClient.createNamespaceIfNotExists( + this.deploymentConfig.namespace, + ); + + await this._deployWithHelm(); + await this._createRoute(); + await this._waitForKeycloak(); + await this._initializeAdminClient(); + } + + /** + * Check if Keycloak is already running + */ + async isRunning(): Promise { + try { + this.keycloakUrl = await this.getRouteLocation(); + const response = await fetch(`${this.keycloakUrl}/realms/master`); + return response.ok; + } catch { + return false; + } + } + + /** + * Configure Keycloak with realm, client, groups, and users for RHDH + */ + async configureForRHDH(options?: { + realm?: string; + client?: Partial; + groups?: KeycloakGroupConfig[]; + users?: KeycloakUserConfig[]; + }): Promise { + this._log("Configuring Keycloak for RHDH..."); + + await this._ensureAdminClient(); + + const realmName = options?.realm ?? DEFAULT_KEYCLOAK_CONFIG.realm; + + // Create realm + await this.createRealm({ realm: realmName, enabled: true }); + + // Create client + const clientConfig = { + ...DEFAULT_RHDH_CLIENT, + ...options?.client, + }; + await this.createClient(realmName, clientConfig); + + // Store realm and client info for external access + this.realm = realmName; + this.clientId = clientConfig.clientId; + this.clientSecret = clientConfig.clientSecret; + + // Assign service account roles + await this._assignServiceAccountRoles(realmName, clientConfig.clientId); + + // Create groups + const groups = options?.groups ?? DEFAULT_GROUPS; + for (const group of groups) { + await this.createGroup(realmName, group); + } + + // Create users + const users = options?.users ?? DEFAULT_USERS; + for (const user of users) { + await this.createUser(realmName, user); + } + } + + /** + * Connect to an existing Keycloak instance + */ + async connect(config: KeycloakConnectionConfig): Promise { + this.keycloakUrl = config.baseUrl; + this._adminClient = new KeycloakAdminClient({ + baseUrl: config.baseUrl, + realmName: config.realm ?? "master", + }); + + if (config.username && config.password) { + await this._adminClient.auth({ + username: config.username, + password: config.password, + grantType: "password", + clientId: config.clientId ?? "admin-cli", + }); + } else if (config.clientId && config.clientSecret) { + await this._adminClient.auth({ + grantType: "client_credentials", + clientId: config.clientId, + clientSecret: config.clientSecret, + }); + } + } + + /** + * Create a new realm + */ + async createRealm(config: KeycloakRealmConfig): Promise { + await this._ensureAdminClient(); + + try { + await this._adminClient!.realms.create({ + realm: config.realm, + displayName: config.displayName ?? config.realm, + enabled: config.enabled ?? true, + }); + this._log(`Created realm: ${config.realm}`); + } catch (error) { + if (this._isConflictError(error)) { + this._log(`Realm ${config.realm} already exists`); + } else { + throw error; + } + } + } + + /** + * Create a new client in a realm + */ + async createClient( + realm: string, + config: KeycloakClientConfig, + ): Promise { + await this._ensureAdminClient(); + + try { + this._adminClient!.setConfig({ realmName: realm }); + + await this._adminClient!.clients.create({ + clientId: config.clientId, + secret: config.clientSecret, + name: config.name ?? config.clientId, + description: config.description ?? "", + redirectUris: config.redirectUris ?? ["*"], + webOrigins: config.webOrigins ?? ["*"], + standardFlowEnabled: config.standardFlowEnabled ?? true, + implicitFlowEnabled: config.implicitFlowEnabled ?? true, + directAccessGrantsEnabled: config.directAccessGrantsEnabled ?? true, + serviceAccountsEnabled: config.serviceAccountsEnabled ?? true, + authorizationServicesEnabled: + config.authorizationServicesEnabled ?? true, + publicClient: config.publicClient ?? false, + enabled: true, + protocol: "openid-connect", + fullScopeAllowed: true, + attributes: config.attributes, + defaultClientScopes: config.defaultClientScopes, + optionalClientScopes: config.optionalClientScopes, + }); + this._log(`Created client: ${config.clientId}`); + } catch (error) { + if (this._isConflictError(error)) { + this._log(`Client ${config.clientId} already exists`); + } else { + throw error; + } + } + } + + /** + * Create a group in a realm + */ + async createGroup(realm: string, config: KeycloakGroupConfig): Promise { + await this._ensureAdminClient(); + + try { + this._adminClient!.setConfig({ realmName: realm }); + await this._adminClient!.groups.create({ + name: config.name, + }); + this._log(`Created group: ${config.name}`); + } catch (error) { + if (this._isConflictError(error)) { + this._log(`Group ${config.name} already exists`); + } else { + throw error; + } + } + } + + /** + * Create a user in a realm with optional group membership + */ + async createUser(realm: string, config: KeycloakUserConfig): Promise { + await this._ensureAdminClient(); + + try { + this._adminClient!.setConfig({ realmName: realm }); + + // Create user + const createResponse = await this._adminClient!.users.create({ + username: config.username, + email: config.email, + firstName: config.firstName, + lastName: config.lastName, + enabled: config.enabled ?? true, + emailVerified: config.emailVerified ?? true, + }); + this._log(`Created user: ${config.username}`); + + const userId = createResponse.id; + + // Set password if provided + if (config.password) { + await this._adminClient!.users.resetPassword({ + id: userId, + credential: { + type: "password", + value: config.password, + temporary: config.temporary ?? false, + }, + }); + } + + // Add to groups if specified + if (config.groups?.length) { + for (const groupName of config.groups) { + await this._addUserToGroup(realm, userId, groupName); + } + } + } catch (error) { + if (this._isConflictError(error)) { + this._log(`User ${config.username} already exists`); + } else { + throw error; + } + } + } + + /** + * Get all users in a realm + */ + async getUsers(realm: string): Promise { + await this._ensureAdminClient(); + this._adminClient!.setConfig({ realmName: realm }); + + const users = await this._adminClient!.users.find(); + return users.map((u) => ({ + username: u.username!, + email: u.email, + firstName: u.firstName, + lastName: u.lastName, + enabled: u.enabled, + emailVerified: u.emailVerified, + })); + } + + /** + * Get all groups in a realm + */ + async getGroups(realm: string): Promise { + await this._ensureAdminClient(); + this._adminClient!.setConfig({ realmName: realm }); + + const groups = await this._adminClient!.groups.find(); + return groups.map((g) => ({ name: g.name! })); + } + + /** + * Delete a user from a realm + */ + async deleteUser(realm: string, username: string): Promise { + await this._ensureAdminClient(); + this._adminClient!.setConfig({ realmName: realm }); + + const users = await this._adminClient!.users.find({ username }); + if (users.length > 0) { + await this._adminClient!.users.del({ id: users[0].id! }); + this._log(`Deleted user: ${username}`); + } + } + + /** + * Delete a group from a realm + */ + async deleteGroup(realm: string, groupName: string): Promise { + await this._ensureAdminClient(); + this._adminClient!.setConfig({ realmName: realm }); + + const groups = await this._adminClient!.groups.find({ search: groupName }); + const group = groups.find((g) => g.name === groupName); + if (group) { + await this._adminClient!.groups.del({ id: group.id! }); + this._log(`Deleted group: ${groupName}`); + } + } + + /** + * Delete a realm + */ + async deleteRealm(realm: string): Promise { + await this._ensureAdminClient(); + + try { + await this._adminClient!.realms.del({ realm }); + this._log(`Deleted realm: ${realm}`); + } catch (error) { + this._log(`Failed to delete realm ${realm}: ${error}`); + } + } + + /** + * Teardown Keycloak deployment + */ + async teardown(): Promise { + await this.k8sClient.deleteNamespace(this.deploymentConfig.namespace); + this._log(`Keycloak deployment torn down`); + } + + /** + * Wait for Keycloak to be ready + */ + async waitUntilReady(timeout: number = 300): Promise { + this._log(`Waiting for Keycloak to be ready...`); + await this.k8sClient.waitForStatefulSetReady( + this.deploymentConfig.namespace, + this.deploymentConfig.releaseName, + timeout, + ); + } + + // Private methods + + private _buildDeploymentConfig( + options: KeycloakDeploymentOptions, + ): KeycloakDeploymentConfig { + return { + namespace: options.namespace ?? DEFAULT_KEYCLOAK_CONFIG.namespace, + releaseName: options.releaseName ?? DEFAULT_KEYCLOAK_CONFIG.releaseName, + valuesFile: options.valuesFile ?? DEFAULT_CONFIG_PATHS.valuesFile, + adminUser: options.adminUser ?? DEFAULT_KEYCLOAK_CONFIG.adminUser, + adminPassword: + options.adminPassword ?? DEFAULT_KEYCLOAK_CONFIG.adminPassword, + }; + } + + private async _deployWithHelm(): Promise { + await $`helm repo add bitnami ${BITNAMI_CHART_REPO} || true`; + await $`helm repo update > /dev/null 2>&1`; + + await $`helm upgrade --install ${this.deploymentConfig.releaseName} ${BITNAMI_CHART_NAME} \ + --namespace ${this.deploymentConfig.namespace} \ + --values ${this.deploymentConfig.valuesFile} > /dev/null 2>&1`; + + await this.waitUntilReady(); + } + + private async _createRoute(): Promise { + // Use TLS edge termination with Allow policy to support both HTTP and HTTPS + const routeManifest = ` +apiVersion: route.openshift.io/v1 +kind: Route +metadata: + name: ${this.deploymentConfig.releaseName} + namespace: ${this.deploymentConfig.namespace} + labels: + app.kubernetes.io/name: keycloak + app.kubernetes.io/instance: ${this.deploymentConfig.releaseName} +spec: + to: + kind: Service + name: ${this.deploymentConfig.releaseName} + weight: 100 + port: + targetPort: http + tls: + termination: edge + insecureEdgeTerminationPolicy: Allow + wildcardPolicy: None +`; + + await $`echo ${routeManifest} | kubectl apply -f -`; + } + + async getRouteLocation(): Promise { + return await this.k8sClient.getRouteLocation( + this.deploymentConfig.namespace, + this.deploymentConfig.releaseName, + ); + } + + private async _waitForKeycloak(): Promise { + this._log("Waiting for Keycloak API to be ready..."); + + const timeout = 300; + const startTime = Date.now(); + + while (true) { + if (await this.isRunning()) { + break; + } + + if ((Date.now() - startTime) / 1000 >= timeout) { + throw new Error("Keycloak API not ready after 5 minutes"); + } + + await new Promise((resolve) => setTimeout(resolve, 5000)); + this._log(" Waiting for Keycloak API to be ready..."); + } + } + + private async _initializeAdminClient(): Promise { + this._adminClient = new KeycloakAdminClient({ + baseUrl: this.keycloakUrl, + realmName: "master", + }); + + await this._adminClient.auth({ + username: this.deploymentConfig.adminUser, + password: this.deploymentConfig.adminPassword, + grantType: "password", + clientId: "admin-cli", + }); + } + + private async _ensureAdminClient(): Promise { + if (!this._adminClient) { + throw new Error( + "Admin client not initialized. Call deploy() or connect() first.", + ); + } + } + + private async _assignServiceAccountRoles( + realm: string, + clientId: string, + ): Promise { + await this._ensureAdminClient(); + this._adminClient!.setConfig({ realmName: realm }); + + // Get service account user + const clients = await this._adminClient!.clients.find({ clientId }); + if (clients.length === 0) { + throw new Error(`Client ${clientId} not found`); + } + const client = clients[0]; + + const serviceAccountUser = + await this._adminClient!.clients.getServiceAccountUser({ + id: client.id!, + }); + + // Get realm-management client + const realmMgmtClients = await this._adminClient!.clients.find({ + clientId: "realm-management", + }); + if (realmMgmtClients.length === 0) { + throw new Error("realm-management client not found"); + } + const realmMgmtClient = realmMgmtClients[0]; + + // Get roles + const allRoles = await this._adminClient!.clients.listRoles({ + id: realmMgmtClient.id!, + }); + const rolesToAssign = allRoles.filter((r) => + SERVICE_ACCOUNT_ROLES.includes(r.name!), + ); + + if (rolesToAssign.length > 0) { + await this._adminClient!.users.addClientRoleMappings({ + id: serviceAccountUser.id!, + clientUniqueId: realmMgmtClient.id!, + roles: rolesToAssign.map((r) => ({ + id: r.id!, + name: r.name!, + })), + }); + this._log( + `Assigned service account roles: ${rolesToAssign.map((r) => r.name).join(", ")}`, + ); + } + } + + private async _addUserToGroup( + realm: string, + userId: string, + groupName: string, + ): Promise { + this._adminClient!.setConfig({ realmName: realm }); + + const groups = await this._adminClient!.groups.find({ search: groupName }); + const group = groups.find((g) => g.name === groupName); + + if (group) { + await this._adminClient!.users.addToGroup({ + id: userId, + groupId: group.id!, + }); + this._log(` Added user to group: ${groupName}`); + } else { + this._log(` Warning: Group ${groupName} not found`); + } + } + + private _isConflictError(error: unknown): boolean { + const err = error as { response?: { status?: number }; status?: number }; + return err.response?.status === 409 || err.status === 409; + } + + private _log(...args: unknown[]): void { + console.log("[Keycloak]", ...args); + } +} diff --git a/src/deployment/keycloak/index.ts b/src/deployment/keycloak/index.ts new file mode 100644 index 0000000..93ede1f --- /dev/null +++ b/src/deployment/keycloak/index.ts @@ -0,0 +1 @@ +export { KeycloakHelper } from "./deployment.js"; diff --git a/src/deployment/keycloak/types.ts b/src/deployment/keycloak/types.ts new file mode 100644 index 0000000..2ea5f3d --- /dev/null +++ b/src/deployment/keycloak/types.ts @@ -0,0 +1,64 @@ +export type KeycloakDeploymentOptions = { + namespace?: string; + releaseName?: string; + valuesFile?: string; + adminUser?: string; + adminPassword?: string; +}; + +export type KeycloakDeploymentConfig = { + namespace: string; + releaseName: string; + valuesFile: string; + adminUser: string; + adminPassword: string; +}; + +export type KeycloakClientConfig = { + clientId: string; + clientSecret: string; + name?: string; + description?: string; + redirectUris?: string[]; + webOrigins?: string[]; + standardFlowEnabled?: boolean; + implicitFlowEnabled?: boolean; + directAccessGrantsEnabled?: boolean; + serviceAccountsEnabled?: boolean; + authorizationServicesEnabled?: boolean; + publicClient?: boolean; + attributes?: Record; + defaultClientScopes?: string[]; + optionalClientScopes?: string[]; +}; + +export type KeycloakUserConfig = { + username: string; + email?: string; + firstName?: string; + lastName?: string; + enabled?: boolean; + emailVerified?: boolean; + password?: string; + temporary?: boolean; + groups?: string[]; +}; + +export type KeycloakGroupConfig = { + name: string; +}; + +export type KeycloakRealmConfig = { + realm: string; + displayName?: string; + enabled?: boolean; +}; + +export type KeycloakConnectionConfig = { + baseUrl: string; + realm?: string; + clientId?: string; + clientSecret?: string; + username?: string; + password?: string; +}; diff --git a/src/deployment/rhdh/config/auth/guest/app-config.yaml b/src/deployment/rhdh/config/auth/guest/app-config.yaml new file mode 100644 index 0000000..a867aee --- /dev/null +++ b/src/deployment/rhdh/config/auth/guest/app-config.yaml @@ -0,0 +1,5 @@ +auth: + environment: development + providers: + guest: + dangerouslyAllowOutsideDevelopment: true diff --git a/src/deployment/rhdh/config/auth/keycloak/app-config.yaml b/src/deployment/rhdh/config/auth/keycloak/app-config.yaml new file mode 100644 index 0000000..71b2705 --- /dev/null +++ b/src/deployment/rhdh/config/auth/keycloak/app-config.yaml @@ -0,0 +1,19 @@ +auth: + environment: production + session: + secret: superSecretSecret + providers: + oidc: + production: + metadataUrl: "${KEYCLOAK_METADATA_URL}" + clientId: "${KEYCLOAK_CLIENT_ID}" + clientSecret: "${KEYCLOAK_CLIENT_SECRET}" + prompt: auto + callbackUrl: "${RHDH_BASE_URL}/api/auth/oidc/handler/frame" + signIn: + resolvers: + - resolver: emailLocalPartMatchingUserEntityName +signInPage: oidc +catalog: + rules: + - allow: [User, Group] diff --git a/src/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml b/src/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml new file mode 100644 index 0000000..51bc2ad --- /dev/null +++ b/src/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml @@ -0,0 +1,3 @@ +plugins: + - package: ./dynamic-plugins/dist/backstage-community-plugin-catalog-backend-module-keycloak-dynamic + disabled: false diff --git a/src/deployment/rhdh/config/auth/keycloak/secrets.yaml b/src/deployment/rhdh/config/auth/keycloak/secrets.yaml new file mode 100644 index 0000000..7d2bc18 --- /dev/null +++ b/src/deployment/rhdh/config/auth/keycloak/secrets.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Secret +metadata: + name: rhdh-secrets +type: Opaque +stringData: + KEYCLOAK_BASE_URL: $KEYCLOAK_BASE_URL + KEYCLOAK_METADATA_URL: $KEYCLOAK_METADATA_URL + KEYCLOAK_CLIENT_ID: $KEYCLOAK_CLIENT_ID + KEYCLOAK_CLIENT_SECRET: $KEYCLOAK_CLIENT_SECRET + KEYCLOAK_REALM: $KEYCLOAK_REALM + KEYCLOAK_LOGIN_REALM: $KEYCLOAK_LOGIN_REALM diff --git a/src/deployment/rhdh/config/app-config-rhdh.yaml b/src/deployment/rhdh/config/common/app-config-rhdh.yaml similarity index 100% rename from src/deployment/rhdh/config/app-config-rhdh.yaml rename to src/deployment/rhdh/config/common/app-config-rhdh.yaml diff --git a/src/deployment/rhdh/config/dynamic-plugins.yaml b/src/deployment/rhdh/config/common/dynamic-plugins.yaml similarity index 78% rename from src/deployment/rhdh/config/dynamic-plugins.yaml rename to src/deployment/rhdh/config/common/dynamic-plugins.yaml index cc9134c..e0d486d 100644 --- a/src/deployment/rhdh/config/dynamic-plugins.yaml +++ b/src/deployment/rhdh/config/common/dynamic-plugins.yaml @@ -1,2 +1,3 @@ includes: - dynamic-plugins.default.yaml +plugins: [] diff --git a/src/deployment/rhdh/config/rhdh-secrets.yaml b/src/deployment/rhdh/config/common/rhdh-secrets.yaml similarity index 100% rename from src/deployment/rhdh/config/rhdh-secrets.yaml rename to src/deployment/rhdh/config/common/rhdh-secrets.yaml diff --git a/src/deployment/rhdh/helm/value_file.yaml b/src/deployment/rhdh/config/helm/value_file.yaml similarity index 100% rename from src/deployment/rhdh/helm/value_file.yaml rename to src/deployment/rhdh/config/helm/value_file.yaml diff --git a/src/deployment/rhdh/operator/subscription.yaml b/src/deployment/rhdh/config/operator/subscription.yaml similarity index 100% rename from src/deployment/rhdh/operator/subscription.yaml rename to src/deployment/rhdh/config/operator/subscription.yaml diff --git a/src/deployment/rhdh/constants.ts b/src/deployment/rhdh/constants.ts index 098b5ca..6cff7bb 100644 --- a/src/deployment/rhdh/constants.ts +++ b/src/deployment/rhdh/constants.ts @@ -1,4 +1,5 @@ import path from "path"; +import type { AuthProvider } from "./types.js"; // Navigate from dist/deployment/rhdh/ to package root const PACKAGE_ROOT = path.resolve(import.meta.dirname, "../../.."); @@ -6,26 +7,54 @@ const PACKAGE_ROOT = path.resolve(import.meta.dirname, "../../.."); export const DEFAULT_CONFIG_PATHS = { appConfig: path.join( PACKAGE_ROOT, - "dist/deployment/rhdh/config/app-config-rhdh.yaml", + "dist/deployment/rhdh/config/common/app-config-rhdh.yaml", ), secrets: path.join( PACKAGE_ROOT, - "dist/deployment/rhdh/config/rhdh-secrets.yaml", + "dist/deployment/rhdh/config/common/rhdh-secrets.yaml", ), dynamicPlugins: path.join( PACKAGE_ROOT, - "dist/deployment/rhdh/config/dynamic-plugins.yaml", + "dist/deployment/rhdh/config/common/dynamic-plugins.yaml", ), helm: { valueFile: path.join( PACKAGE_ROOT, - "src/deployment/rhdh/helm/value_file.yaml", + "dist/deployment/rhdh/config/helm/value_file.yaml", ), }, operator: { subscription: path.join( PACKAGE_ROOT, - "src/deployment/rhdh/operator/subscription.yaml", + "dist/deployment/rhdh/config/operator/subscription.yaml", + ), + }, +}; + +export const AUTH_CONFIG_PATHS: Record< + AuthProvider, + { appConfig: string; secrets: string; dynamicPlugins: string } +> = { + guest: { + appConfig: path.join( + PACKAGE_ROOT, + "dist/deployment/rhdh/config/auth/guest/app-config.yaml", + ), + secrets: "", + dynamicPlugins: "", + }, + keycloak: { + appConfig: path.join( + PACKAGE_ROOT, + "dist/deployment/rhdh/config/auth/keycloak/app-config.yaml", + ), + secrets: path.join( + PACKAGE_ROOT, + "dist/deployment/rhdh/config/auth/keycloak/secrets.yaml", + ), + dynamicPlugins: path.join( + PACKAGE_ROOT, + "dist/deployment/rhdh/config/auth/keycloak/dynamic-plugins.yaml", ), }, }; diff --git a/src/deployment/rhdh/deployment.ts b/src/deployment/rhdh/deployment.ts index ee5d608..207a496 100644 --- a/src/deployment/rhdh/deployment.ts +++ b/src/deployment/rhdh/deployment.ts @@ -6,7 +6,11 @@ import { mergeYamlFilesIfExists } from "../../utils/merge-yamls.js"; import { envsubst } from "../../utils/common.js"; import fs from "fs-extra"; import boxen from "boxen"; -import { DEFAULT_CONFIG_PATHS, CHART_URL } from "./constants.js"; +import { + DEFAULT_CONFIG_PATHS, + AUTH_CONFIG_PATHS, + CHART_URL, +} from "./constants.js"; import type { DeploymentOptions, DeploymentConfig, @@ -19,12 +23,13 @@ export class RHDHDeployment { public rhdhUrl: string; public deploymentConfig: DeploymentConfig; - constructor(deploymentOptions: DeploymentOptions) { - this.deploymentConfig = this._buildDeploymentConfig(deploymentOptions); + constructor(namespace: string) { + this.deploymentConfig = this._buildDeploymentConfig({ namespace }); this.rhdhUrl = this._buildBaseUrl(); this._log( `RHDH deployment initialized (namespace: ${this.deploymentConfig.namespace})`, ); + this._log("RHDH Base URL: " + this.rhdhUrl); console.table(this.deploymentConfig); } @@ -41,6 +46,7 @@ export class RHDHDeployment { if (this.deploymentConfig.method === "helm") { await this._deployWithHelm(this.deploymentConfig.valueFile); + await this.scaleDownAndRestart(); // Restart as helm does not monitor config changes } else { await this._applyDynamicPlugins(); await this._deployWithOperator(this.deploymentConfig.subscription); @@ -49,17 +55,13 @@ export class RHDHDeployment { } private async _applyAppConfig(): Promise { + const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth]; const appConfigYaml = await mergeYamlFilesIfExists([ DEFAULT_CONFIG_PATHS.appConfig, + authConfig.appConfig, this.deploymentConfig.appConfig, ]); - console.log( - boxen(yaml.dump(appConfigYaml), { - title: "App Config", - padding: 1, - align: "left", - }), - ); + this._logBoxen("App Config", appConfigYaml); await this.k8sClient.applyConfigMapFromObject( "app-config-rhdh", @@ -69,8 +71,10 @@ export class RHDHDeployment { } private async _applySecrets(): Promise { + const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth]; const secretsYaml = await mergeYamlFilesIfExists([ DEFAULT_CONFIG_PATHS.secrets, + authConfig.secrets, this.deploymentConfig.secrets, ]); @@ -82,17 +86,16 @@ export class RHDHDeployment { } private async _applyDynamicPlugins(): Promise { - const dynamicPluginsYaml = await mergeYamlFilesIfExists([ - DEFAULT_CONFIG_PATHS.dynamicPlugins, - this.deploymentConfig.dynamicPlugins, - ]); - console.log( - boxen(yaml.dump(dynamicPluginsYaml), { - title: "Dynamic Plugins", - padding: 1, - align: "left", - }), + const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth]; + const dynamicPluginsYaml = await mergeYamlFilesIfExists( + [ + DEFAULT_CONFIG_PATHS.dynamicPlugins, + authConfig.dynamicPlugins, + this.deploymentConfig.dynamicPlugins, + ], + { arrayMergeStrategy: { byKey: "package" } }, ); + this._logBoxen("Dynamic Plugins", dynamicPluginsYaml); await this.k8sClient.applyConfigMapFromObject( "dynamic-plugins", dynamicPluginsYaml, @@ -110,31 +113,24 @@ export class RHDHDeployment { valueFile, ])) as Record>; - console.log( - boxen(yaml.dump(valueFileObject), { - title: "Value File", - padding: 1, - align: "left", - }), - ); + this._logBoxen("Value File", valueFileObject); - // Merge dynamic plugins into the values file + // Merge dynamic plugins into the values file (including auth-specific plugins) + const authConfig = AUTH_CONFIG_PATHS[this.deploymentConfig.auth]; if (!valueFileObject.global) { valueFileObject.global = {}; } - valueFileObject.global.dynamic = await mergeYamlFilesIfExists([ - DEFAULT_CONFIG_PATHS.dynamicPlugins, - this.deploymentConfig.dynamicPlugins, - ]); - - console.log( - boxen(yaml.dump(valueFileObject.global.dynamic), { - title: "Dynamic Plugins", - padding: 1, - align: "left", - }), + valueFileObject.global.dynamic = await mergeYamlFilesIfExists( + [ + DEFAULT_CONFIG_PATHS.dynamicPlugins, + authConfig.dynamicPlugins, + this.deploymentConfig.dynamicPlugins, + ], + { arrayMergeStrategy: { byKey: "package" } }, ); + this._logBoxen("Dynamic Plugins", valueFileObject.global.dynamic); + fs.writeFileSync( `/tmp/${this.deploymentConfig.namespace}-value-file.yaml`, yaml.dump(valueFileObject), @@ -155,13 +151,7 @@ export class RHDHDeployment { DEFAULT_CONFIG_PATHS.operator.subscription, subscription, ]); - console.log( - boxen(yaml.dump(subscriptionObject), { - title: "Subscription", - padding: 1, - align: "left", - }), - ); + this._logBoxen("Subscription", subscriptionObject); fs.writeFileSync( `/tmp/${this.deploymentConfig.namespace}-subscription.yaml`, yaml.dump(subscriptionObject), @@ -195,6 +185,18 @@ export class RHDHDeployment { 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 { + 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 = 300): Promise { this._log( `Waiting for RHDH deployment to be ready in namespace ${this.deploymentConfig.namespace}...`, @@ -205,10 +207,17 @@ export class RHDHDeployment { `RHDH deployment is ready in namespace ${this.deploymentConfig.namespace}`, ); } catch (error) { - this._log( + console.log( + "----------------------------------------------------------------", + ); + console.log("Deployment Failed Logs"); + console.log( + "----------------------------------------------------------------", + ); + await $`oc logs -l 'app.kubernetes.io/instance in (redhat-developer-hub,developer-hub)' -n ${this.deploymentConfig.namespace} --tail=100`; + throw new Error( `Error waiting for RHDH deployment to be ready in timeout ${timeout}s in namespace ${this.deploymentConfig.namespace}: ${error}`, ); - throw error; } } @@ -256,7 +265,8 @@ export class RHDHDeployment { const base: DeploymentConfigBase = { version, - namespace: input.namespace, + namespace: input.namespace ?? this.deploymentConfig.namespace, + auth: input.auth ?? "keycloak", appConfig: input.appConfig ?? `tests/config/app-config-rhdh.yaml`, secrets: input.secrets ?? `tests/config/rhdh-secrets.yaml`, dynamicPlugins: @@ -307,4 +317,8 @@ export class RHDHDeployment { private _log(...args: unknown[]): void { console.log("[RHDHDeployment]", ...args); } + + private _logBoxen(title: string, data: unknown): void { + console.log(boxen(yaml.dump(data), { title, padding: 1 })); + } } diff --git a/src/deployment/rhdh/index.ts b/src/deployment/rhdh/index.ts index fc47a87..8e83699 100644 --- a/src/deployment/rhdh/index.ts +++ b/src/deployment/rhdh/index.ts @@ -1,9 +1 @@ export { RHDHDeployment } from "./deployment.js"; -export type { - DeploymentOptions, - DeploymentConfig, - DeploymentConfigBase, - DeploymentMethod, - HelmDeploymentConfig, - OperatorDeploymentConfig, -} from "./types.js"; diff --git a/src/deployment/rhdh/types.ts b/src/deployment/rhdh/types.ts index 363667c..6f56033 100644 --- a/src/deployment/rhdh/types.ts +++ b/src/deployment/rhdh/types.ts @@ -1,8 +1,10 @@ export type DeploymentMethod = "helm" | "operator"; +export type AuthProvider = "guest" | "keycloak"; export type DeploymentOptions = { version?: string; - namespace: string; + namespace?: string; + auth?: AuthProvider; appConfig?: string; secrets?: string; dynamicPlugins?: string; @@ -24,6 +26,7 @@ export type OperatorDeploymentConfig = { export type DeploymentConfigBase = { version: string; namespace: string; + auth: AuthProvider; appConfig: string; secrets: string; dynamicPlugins: string; diff --git a/src/eslint/base.config.ts b/src/eslint/base.config.ts index c00395f..331cfc6 100644 --- a/src/eslint/base.config.ts +++ b/src/eslint/base.config.ts @@ -93,8 +93,6 @@ export function createEslintConfig(tsconfigRootDir: string): Linter.Config[] { "@typescript-eslint/no-misused-promises": "error", // Allow any type in tests (for mocking, test data) "@typescript-eslint/no-explicit-any": "warn", - // Modern import style - "@typescript-eslint/consistent-type-imports": "error", // Prefer modern syntax "@typescript-eslint/prefer-optional-chain": "error", // Allow unused vars starting with underscore diff --git a/src/playwright/base-config.ts b/src/playwright/base-config.ts index 8678407..3e61285 100644 --- a/src/playwright/base-config.ts +++ b/src/playwright/base-config.ts @@ -1,4 +1,7 @@ -import type { PlaywrightTestConfig } from "@playwright/test"; +import { + defineConfig as baseDefineConfig, + PlaywrightTestConfig, +} from "@playwright/test"; import { resolve } from "path"; /** @@ -36,16 +39,17 @@ export const baseConfig: PlaywrightTestConfig = { }; /** - * Creates a workspace-specific config by merging with base config. + * Defines a workspace-specific config by merging with base config. * Only allows overriding the projects configuration. * @param overrides - Object containing projects to override * @returns Merged Playwright configuration */ -export function createPlaywrightConfig( + +export function defineConfig( overrides: Pick = {}, ): PlaywrightTestConfig { - return { + return baseDefineConfig({ ...baseConfig, projects: overrides.projects, - }; + }); } diff --git a/src/playwright/fixtures/test.ts b/src/playwright/fixtures/test.ts index ed737b4..f7c783d 100644 --- a/src/playwright/fixtures/test.ts +++ b/src/playwright/fixtures/test.ts @@ -12,6 +12,8 @@ type RHDHDeploymentWorkerFixtures = { rhdhDeploymentWorker: RHDHDeployment; }; +export * from "@playwright/test"; + export const test = base.extend< RHDHDeploymentTestFixtures, RHDHDeploymentWorkerFixtures @@ -23,11 +25,10 @@ export const test = base.extend< `Deploying rhdh for plugin ${workerInfo.project.name} in namespace ${workerInfo.project.name}`, ); - const rhdhDeployment = new RHDHDeployment({ - namespace: workerInfo.project.name, - }); + const rhdhDeployment = new RHDHDeployment(workerInfo.project.name); try { + await rhdhDeployment.configure(); await use(rhdhDeployment); } finally { if (process.env.CI) { @@ -64,5 +65,3 @@ export const test = base.extend< { scope: "test" }, ] as const, }); - -export { expect } from "@playwright/test"; diff --git a/src/playwright/global-setup.ts b/src/playwright/global-setup.ts index 6bef55f..271f25c 100644 --- a/src/playwright/global-setup.ts +++ b/src/playwright/global-setup.ts @@ -5,6 +5,12 @@ import { KubernetesClientHelper } from "../utils/kubernetes-client.js"; import { $ } from "../utils/bash.js"; +import { KeycloakHelper } from "../deployment/keycloak/index.js"; +import { + DEFAULT_KEYCLOAK_CONFIG, + DEFAULT_RHDH_CLIENT, + DEFAULT_USERS, +} from "../deployment/keycloak/constants.js"; const REQUIRED_BINARIES = ["oc", "kubectl", "helm"] as const; @@ -13,7 +19,7 @@ async function checkRequiredBinaries(): Promise { for (const binary of REQUIRED_BINARIES) { try { - await $`which ${binary}`; + await $`command -v ${binary} > /dev/null 2>&1`; } catch { missingBinaries.push(binary); } @@ -33,8 +39,47 @@ async function setClusterRouterBaseEnv(): Promise { console.log(`Cluster router base: ${process.env.K8S_CLUSTER_ROUTER_BASE}`); } +async function deployKeycloak(): Promise { + if (process.env.SKIP_KEYCLOAK_DEPLOYMENT === "true") { + console.log("Skipping Keycloak deployment"); + return; + } + console.log( + "Set SKIP_KEYCLOAK_DEPLOYMENT=true if test doesn't require keycloak/oidc as auth provider", + ); + + const keycloak = new KeycloakHelper({ namespace: "rhdh-keycloak" }); + + // Check if Keycloak is already running + if (await keycloak.isRunning()) { + console.log("Keycloak is already running, skipping deployment"); + } else { + await keycloak.deploy(); + await keycloak.configureForRHDH(); + } + + // Set environment variables for RHDH integration + const realm = DEFAULT_KEYCLOAK_CONFIG.realm; + process.env.KEYCLOAK_CLIENT_SECRET = DEFAULT_RHDH_CLIENT.clientSecret; + process.env.KEYCLOAK_CLIENT_ID = DEFAULT_RHDH_CLIENT.clientId; + process.env.KEYCLOAK_REALM = realm; + process.env.KEYCLOAK_LOGIN_REALM = realm; + process.env.KEYCLOAK_METADATA_URL = `${keycloak.keycloakUrl}/realms/${realm}`; + process.env.KEYCLOAK_BASE_URL = keycloak.keycloakUrl; + + console.table({ + keycloakURL: keycloak.keycloakUrl, + adminUser: keycloak.deploymentConfig.adminUser, + adminPassword: keycloak.deploymentConfig.adminPassword, + testUsername: DEFAULT_USERS[0].username, + testPassword: DEFAULT_USERS[0].password, + }); +} + export default async function globalSetup(): Promise { console.log("Running global setup..."); await checkRequiredBinaries(); await setClusterRouterBaseEnv(); + await deployKeycloak(); + console.log("Global setup completed successfully"); } diff --git a/src/playwright/helpers/common.ts b/src/playwright/helpers/common.ts index 2fcd084..ea2fcd1 100644 --- a/src/playwright/helpers/common.ts +++ b/src/playwright/helpers/common.ts @@ -6,6 +6,7 @@ import { SETTINGS_PAGE_COMPONENTS } from "../page-objects/page-obj.js"; import { UI_HELPER_ELEMENTS } from "../page-objects/global-obj.js"; import * as path from "path"; import * as fs from "fs"; +import { DEFAULT_USERS } from "../../deployment/keycloak/constants.js"; export class LoginHelper { page: Page; @@ -84,8 +85,8 @@ export class LoginHelper { } async loginAsKeycloakUser( - userid: string = process.env.GH_USER_ID as string, - password: string = process.env.GH_USER_PASS as string, + userid: string = DEFAULT_USERS[0].username, + password: string = DEFAULT_USERS[0].password, ) { await this.page.goto("/"); await this.uiHelper.waitForLoad(240000); diff --git a/src/utils/kubernetes-client.ts b/src/utils/kubernetes-client.ts index 21e666f..2e2a182 100644 --- a/src/utils/kubernetes-client.ts +++ b/src/utils/kubernetes-client.ts @@ -12,13 +12,41 @@ $.verbose = true; class KubernetesClientHelper { private _kc: k8s.KubeConfig; private _k8sApi: k8s.CoreV1Api; + private _appsApi: k8s.AppsV1Api; private _customObjectsApi: k8s.CustomObjectsApi; constructor() { this._kc = new k8s.KubeConfig(); this._kc.loadFromDefault(); - this._k8sApi = this._kc.makeApiClient(k8s.CoreV1Api); - this._customObjectsApi = this._kc.makeApiClient(k8s.CustomObjectsApi); + + try { + this._k8sApi = this._kc.makeApiClient(k8s.CoreV1Api); + this._appsApi = this._kc.makeApiClient(k8s.AppsV1Api); + this._customObjectsApi = this._kc.makeApiClient(k8s.CustomObjectsApi); + } catch (error) { + if ( + error instanceof Error && + error.message.includes("No active cluster") + ) { + const currentContext = this._kc.getCurrentContext(); + const contexts = this._kc.getContexts().map((c) => c.name); + + throw new Error( + `No active Kubernetes cluster found.\n\n` + + `The kubeconfig was loaded but no cluster is configured or the current context is invalid.\n\n` + + `Current context: ${currentContext || "(none)"}\n` + + `Available contexts: ${contexts.length > 0 ? contexts.join(", ") : "(none)"}\n\n` + + `To fix this:\n` + + ` 1. Log in to your k8s cluster: oc login or kubectl login\n` + + ` 2. Or set a valid context: kubectl config use-context \n` + + ` 3. Verify your connection: oc whoami && oc cluster-info\n\n` + + `Kubeconfig locations checked:\n` + + ` - KUBECONFIG env: ${process.env.KUBECONFIG || "(not set)"}\n` + + ` - Default: ~/.kube/config`, + ); + } + throw error; + } } /** @@ -313,6 +341,49 @@ class KubernetesClientHelper { } } } + + /** + * Check if a StatefulSet is ready (all replicas are available) + */ + async isStatefulSetReady(namespace: string, name: string): Promise { + try { + const statefulSet = await this._appsApi.readNamespacedStatefulSet({ + name, + namespace, + }); + const replicas = statefulSet.spec?.replicas ?? 1; + const readyReplicas = statefulSet.status?.readyReplicas ?? 0; + return readyReplicas >= replicas; + } catch { + return false; + } + } + + /** + * Wait for a StatefulSet to be ready (all replicas available) + */ + async waitForStatefulSetReady( + namespace: string, + name: string, + timeoutSeconds: number = 300, + pollIntervalMs: number = 5000, + ): Promise { + const startTime = Date.now(); + const timeoutMs = timeoutSeconds * 1000; + + while (Date.now() - startTime < timeoutMs) { + if (await this.isStatefulSetReady(namespace, name)) { + console.log(`✓ StatefulSet ${name} is ready`); + return true; + } + await new Promise((resolve) => setTimeout(resolve, pollIntervalMs)); + } + + throw new Error( + `StatefulSet ${name} in namespace ${namespace} not ready after ${timeoutSeconds}s`, + ); + } + /** * Get the cluster's ingress domain from OpenShift config * Equivalent to: oc get ingresses.config.openshift.io cluster -o jsonpath='{.spec.domain}' diff --git a/src/utils/merge-yamls.ts b/src/utils/merge-yamls.ts index ae5ba0b..22352f7 100644 --- a/src/utils/merge-yamls.ts +++ b/src/utils/merge-yamls.ts @@ -2,36 +2,114 @@ import fs from "fs-extra"; import yaml from "js-yaml"; import mergeWith from "lodash.mergewith"; +/** + * Array merge strategy options for YAML merging. + */ +export type ArrayMergeStrategy = + | "replace" // Replace arrays entirely (default, Kustomize-style) + | "concat" // Concatenate arrays + | { byKey: string }; // Merge arrays of objects by a specific key + +/** + * Options for YAML merging. + */ +export interface MergeOptions { + /** + * Strategy for merging arrays. + * - "replace": Replace arrays entirely (default) + * - "concat": Concatenate arrays + * - { byKey: "keyName" }: Merge arrays of objects by a specific key + */ + arrayMergeStrategy?: ArrayMergeStrategy; +} + +/** + * Merges two arrays of objects by a specific key. + * Objects with matching keys are deeply merged, new objects are appended. + */ +function mergeArraysByKey( + target: unknown[], + source: unknown[], + key: string, + mergeOptions: MergeOptions, +): unknown[] { + const result = [...target]; + + for (const srcItem of source) { + if (typeof srcItem === "object" && srcItem !== null && key in srcItem) { + const srcKeyValue = (srcItem as Record)[key]; + const existingIndex = result.findIndex( + (item) => + typeof item === "object" && + item !== null && + (item as Record)[key] === srcKeyValue, + ); + + if (existingIndex !== -1) { + // Merge existing object with source object + result[existingIndex] = deepMerge( + result[existingIndex] as Record, + srcItem as Record, + mergeOptions, + ); + } else { + // Append new object + result.push(srcItem); + } + } else { + // Non-object or missing key, append as-is + result.push(srcItem); + } + } + + return result; +} + /** * Deeply merges two YAML-compatible objects. - * Arrays are replaced (not concatenated) — this mimics Kustomize-style merging. + * Array handling is controlled by the arrayMergeStrategy option. */ function deepMerge( target: Record, source: Record, + options: MergeOptions = {}, ): Record { - return mergeWith(target, source, (objValue: unknown, srcValue: unknown) => { - if (Array.isArray(objValue) && Array.isArray(srcValue)) { - return srcValue; // Replace arrays instead of merging - } - }); + const strategy = options.arrayMergeStrategy ?? "replace"; + + return mergeWith( + { ...target }, + source, + (objValue: unknown, srcValue: unknown) => { + if (Array.isArray(objValue) && Array.isArray(srcValue)) { + if (strategy === "replace") { + return srcValue; + } else if (strategy === "concat") { + return [...objValue, ...srcValue]; + } else if (typeof strategy === "object" && "byKey" in strategy) { + return mergeArraysByKey(objValue, srcValue, strategy.byKey, options); + } + } + }, + ); } /** * Merge multiple YAML files into one object. * * @param paths List of YAML file paths (base first, overlays last) + * @param options Optional merge options (e.g., arrayMergeStrategy) * @returns Merged YAML object */ export async function mergeYamlFiles( paths: string[], + options: MergeOptions = {}, ): Promise> { let merged: Record = {}; for (const path of paths) { const content = await fs.readFile(path, "utf8"); const parsed = (yaml.load(content) || {}) as Record; - merged = deepMerge(merged, parsed); + merged = deepMerge(merged, parsed, options); } return merged; @@ -41,10 +119,12 @@ export async function mergeYamlFiles( * Merge multiple YAML files if they exist. * * @param paths List of YAML file paths + * @param options Optional merge options (e.g., arrayMergeStrategy) * @returns Merged YAML object */ export async function mergeYamlFilesIfExists( paths: string[], + options: MergeOptions = {}, ): Promise> { return await mergeYamlFiles( paths.filter((path) => { @@ -52,6 +132,7 @@ export async function mergeYamlFilesIfExists( if (!exists) console.log(`YAML file ${path} does not exist`); return exists; }), + options, ); } @@ -60,15 +141,17 @@ export async function mergeYamlFilesIfExists( * * @param inputPaths List of input YAML files * @param outputPath Output YAML file path - * @param options Optional dump formatting + * @param dumpOptions Optional dump formatting + * @param mergeOptions Optional merge options (e.g., arrayMergeStrategy) */ export async function mergeYamlFilesToFile( inputPaths: string[], outputPath: string, - options: yaml.DumpOptions = { lineWidth: -1 }, + dumpOptions: yaml.DumpOptions = { lineWidth: -1 }, + mergeOptions: MergeOptions = {}, ): Promise { - const merged = await mergeYamlFiles(inputPaths); - const yamlString = yaml.dump(merged, options); + const merged = await mergeYamlFiles(inputPaths, mergeOptions); + const yamlString = yaml.dump(merged, dumpOptions); await fs.outputFile(outputPath, yamlString); console.log(`Merged ${inputPaths.length} YAML files into ${outputPath}`); } diff --git a/yarn.lock b/yarn.lock index 8017201..ac280ba 100644 --- a/yarn.lock +++ b/yarn.lock @@ -109,10 +109,10 @@ __metadata: languageName: node linkType: hard -"@eslint/js@npm:9.39.1, @eslint/js@npm:^9.39.1": - version: 9.39.1 - resolution: "@eslint/js@npm:9.39.1" - checksum: b651930aec03a5aef97bc144627aebb05070afec5364cd3c5fd7c5dbb97f4fd82faf1b200b3be17572d5ebb7f8805211b655f463be96f2b02202ec7250868048 +"@eslint/js@npm:9.39.2, @eslint/js@npm:^9.39.1": + version: 9.39.2 + resolution: "@eslint/js@npm:9.39.2" + checksum: 362aa447266fa5717e762b2b252f177345cb0d7b2954113db9773b3a28898f7cbbc807e07f8078995e6da3f62791f7c5fa2c03517b7170a8e76613cf7fd83c92 languageName: node linkType: hard @@ -207,6 +207,16 @@ __metadata: languageName: node linkType: hard +"@keycloak/keycloak-admin-client@npm:^26.0.0": + version: 26.4.7 + resolution: "@keycloak/keycloak-admin-client@npm:26.4.7" + dependencies: + camelize-ts: ^3.0.0 + url-template: ^3.1.1 + checksum: 973af8c11fb61d648fac7e7b767d52849dd409d1fcee33b36e72c7096e266943cbd2f8f00ebcb47bca59b2a3c106e7853ba7179c730c3eb429cbea39f7c53171 + languageName: node + linkType: hard + "@kubernetes/client-node@npm:^1.4.0": version: 1.4.0 resolution: "@kubernetes/client-node@npm:1.4.0" @@ -379,20 +389,20 @@ __metadata: linkType: hard "@types/node@npm:*": - version: 25.0.1 - resolution: "@types/node@npm:25.0.1" + version: 25.0.3 + resolution: "@types/node@npm:25.0.3" dependencies: undici-types: ~7.16.0 - checksum: f9dc7f9b083c1044b5f687db4e577417b670701625d2b6743e6be212d73fda2660073226339bdf55dee519256dafa91116f3d29deb59f9341147640c76465d5f + checksum: b5e0146eafe208e2f1c1167fd6078a460ace823ad1da61967ec70b8d7521bd6dd26f3cd945796effac48ef4b3df4d8d57d03e9eefd5f2903f6c1d6daf84a9a79 languageName: node linkType: hard "@types/node@npm:^24.0.0, @types/node@npm:^24.10.1": - version: 24.10.3 - resolution: "@types/node@npm:24.10.3" + version: 24.10.4 + resolution: "@types/node@npm:24.10.4" dependencies: undici-types: ~7.16.0 - checksum: 823345eafe5d38a98389a76481bdcfe277286b6fdb4c82c12dc549822f11159956061b75caa4d689e9164c641068a44c1237b190f42da7ed40f62af046e67852 + checksum: 27db63085116aec2b92a36405ab4e8838eafd361ab05ba043e16b70e58b41572145b8078244aa5fd51b1f80076b2e7422c848c31c5a0df0dc5e20053e24720d3 languageName: node linkType: hard @@ -405,105 +415,105 @@ __metadata: languageName: node linkType: hard -"@typescript-eslint/eslint-plugin@npm:8.49.0": - version: 8.49.0 - resolution: "@typescript-eslint/eslint-plugin@npm:8.49.0" +"@typescript-eslint/eslint-plugin@npm:8.50.0": + version: 8.50.0 + resolution: "@typescript-eslint/eslint-plugin@npm:8.50.0" dependencies: "@eslint-community/regexpp": ^4.10.0 - "@typescript-eslint/scope-manager": 8.49.0 - "@typescript-eslint/type-utils": 8.49.0 - "@typescript-eslint/utils": 8.49.0 - "@typescript-eslint/visitor-keys": 8.49.0 + "@typescript-eslint/scope-manager": 8.50.0 + "@typescript-eslint/type-utils": 8.50.0 + "@typescript-eslint/utils": 8.50.0 + "@typescript-eslint/visitor-keys": 8.50.0 ignore: ^7.0.0 natural-compare: ^1.4.0 ts-api-utils: ^2.1.0 peerDependencies: - "@typescript-eslint/parser": ^8.49.0 + "@typescript-eslint/parser": ^8.50.0 eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 0bae18dda8e8c86d8da311c382642e4e321e708ca7bad1ae86e43981b1679e99e7d9bd4e32d4874e8016cbe2e39f5a255a71f16cc2c64ec3471b23161e51afec + checksum: c113589c8d912237f0e7e5f9a0e3b9a09697b932eb365c663a86d99e5466284d3bab58fb940aeec4c2ce16e0bd6930641d0fb5cb7365867c4a032aea97f92e12 languageName: node linkType: hard -"@typescript-eslint/parser@npm:8.49.0": - version: 8.49.0 - resolution: "@typescript-eslint/parser@npm:8.49.0" +"@typescript-eslint/parser@npm:8.50.0": + version: 8.50.0 + resolution: "@typescript-eslint/parser@npm:8.50.0" dependencies: - "@typescript-eslint/scope-manager": 8.49.0 - "@typescript-eslint/types": 8.49.0 - "@typescript-eslint/typescript-estree": 8.49.0 - "@typescript-eslint/visitor-keys": 8.49.0 + "@typescript-eslint/scope-manager": 8.50.0 + "@typescript-eslint/types": 8.50.0 + "@typescript-eslint/typescript-estree": 8.50.0 + "@typescript-eslint/visitor-keys": 8.50.0 debug: ^4.3.4 peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: 27a157372fec09d72b9d3b266ca18cc6d4db040df6d507c5c9d30f97375e0be373d5fde9d02bcd997e40f21738edcc7a2e51d5a56e3cdd600147637bc96d920b + checksum: 75ece2a5ba968672ce9125c9d72aaf4082983460aa9ef095a5fa76d779dd9f1c4114a8b3e8b6d07864aeec5833d0f1f4b86d5179132432862940ddb4b7ef5f7a languageName: node linkType: hard -"@typescript-eslint/project-service@npm:8.49.0": - version: 8.49.0 - resolution: "@typescript-eslint/project-service@npm:8.49.0" +"@typescript-eslint/project-service@npm:8.50.0": + version: 8.50.0 + resolution: "@typescript-eslint/project-service@npm:8.50.0" dependencies: - "@typescript-eslint/tsconfig-utils": ^8.49.0 - "@typescript-eslint/types": ^8.49.0 + "@typescript-eslint/tsconfig-utils": ^8.50.0 + "@typescript-eslint/types": ^8.50.0 debug: ^4.3.4 peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: 378cd7e6982820aa0bb1dfe78a8cf133dc8192ad68b4e2a3ed1615a1a1b4542a1a20da08de6f5dee2a5804192aeceabe06e6c16a0453a8aaa43e495527e6af6a + checksum: e9804c3dfa80013700d2e37b8f60e32872217af85dfce9dadefe19f86b782514ff9bec52a3c3e285974040e1885e86218857d775ff91d04ca7336f970c334bc8 languageName: node linkType: hard -"@typescript-eslint/scope-manager@npm:8.49.0": - version: 8.49.0 - resolution: "@typescript-eslint/scope-manager@npm:8.49.0" +"@typescript-eslint/scope-manager@npm:8.50.0": + version: 8.50.0 + resolution: "@typescript-eslint/scope-manager@npm:8.50.0" dependencies: - "@typescript-eslint/types": 8.49.0 - "@typescript-eslint/visitor-keys": 8.49.0 - checksum: 85aae146729547df03a2ffdb4e447a10023e7c71b426a2a5d7eb3b2a82ec1bbd8ba214d619363994c500a4cf742fbb3f3743723aa13784649e0b9e909ab4529f + "@typescript-eslint/types": 8.50.0 + "@typescript-eslint/visitor-keys": 8.50.0 + checksum: 787e636483a9b05dcb61eba6fd14e73933c8d4c19b6463d14d21c5043b42651cd0b549206da6420845daab45a8b1503290122fa3956dc63f1822f77b5bc2baa2 languageName: node linkType: hard -"@typescript-eslint/tsconfig-utils@npm:8.49.0, @typescript-eslint/tsconfig-utils@npm:^8.49.0": - version: 8.49.0 - resolution: "@typescript-eslint/tsconfig-utils@npm:8.49.0" +"@typescript-eslint/tsconfig-utils@npm:8.50.0, @typescript-eslint/tsconfig-utils@npm:^8.50.0": + version: 8.50.0 + resolution: "@typescript-eslint/tsconfig-utils@npm:8.50.0" peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: be26283df8cf05a3a8d17596ac52e51ec27017f27ec5588e2fa3b804c31758864732a24e1ab777ac3e3567dda9b55de5b18d318b6a6e56025baa4f117f371804 + checksum: 1c6e47673e0f701dca9075f1c29cb45dbdc330d9319c8dc906843899ebc22c2b6bb2037f61b5aaae129784bf285aa993a872abb43118b1a4a7f399601f11e1c7 languageName: node linkType: hard -"@typescript-eslint/type-utils@npm:8.49.0": - version: 8.49.0 - resolution: "@typescript-eslint/type-utils@npm:8.49.0" +"@typescript-eslint/type-utils@npm:8.50.0": + version: 8.50.0 + resolution: "@typescript-eslint/type-utils@npm:8.50.0" dependencies: - "@typescript-eslint/types": 8.49.0 - "@typescript-eslint/typescript-estree": 8.49.0 - "@typescript-eslint/utils": 8.49.0 + "@typescript-eslint/types": 8.50.0 + "@typescript-eslint/typescript-estree": 8.50.0 + "@typescript-eslint/utils": 8.50.0 debug: ^4.3.4 ts-api-utils: ^2.1.0 peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: ce5795464be57b0a1cf5970103547a148e8971fe7cf1aafb9a62b40251c670fd1b03535edfc4622c520112705cd6ee5efd88124a7432d2fbbcfc5be54fbf131f + checksum: 79afb8939c3fdbf2104049962731ca0fbb4d185d50b2c12bd05a27618f9111430531b059ebc0ce769afdd8620e0f23b0b960c5f3363eb7c888a60490f3145335 languageName: node linkType: hard -"@typescript-eslint/types@npm:8.49.0, @typescript-eslint/types@npm:^8.49.0": - version: 8.49.0 - resolution: "@typescript-eslint/types@npm:8.49.0" - checksum: e604e27f9ff7dd4c7ae0060db5f506338b64cc302563841e729f4da7730a1e94176db8ae1f1c4c0c0c8df5086f127408dc050f27595a36d412f60ed0e09f5a64 +"@typescript-eslint/types@npm:8.50.0, @typescript-eslint/types@npm:^8.50.0": + version: 8.50.0 + resolution: "@typescript-eslint/types@npm:8.50.0" + checksum: 1ce987036c0f5e8b5b05afa8897556680f0a07f37bb67c618057427a719444613cca60cfb34a6bc2af68d6c7d68029ce19d3ee5306ee5d311c850c1f9855250a languageName: node linkType: hard -"@typescript-eslint/typescript-estree@npm:8.49.0": - version: 8.49.0 - resolution: "@typescript-eslint/typescript-estree@npm:8.49.0" +"@typescript-eslint/typescript-estree@npm:8.50.0": + version: 8.50.0 + resolution: "@typescript-eslint/typescript-estree@npm:8.50.0" dependencies: - "@typescript-eslint/project-service": 8.49.0 - "@typescript-eslint/tsconfig-utils": 8.49.0 - "@typescript-eslint/types": 8.49.0 - "@typescript-eslint/visitor-keys": 8.49.0 + "@typescript-eslint/project-service": 8.50.0 + "@typescript-eslint/tsconfig-utils": 8.50.0 + "@typescript-eslint/types": 8.50.0 + "@typescript-eslint/visitor-keys": 8.50.0 debug: ^4.3.4 minimatch: ^9.0.4 semver: ^7.6.0 @@ -511,32 +521,32 @@ __metadata: ts-api-utils: ^2.1.0 peerDependencies: typescript: ">=4.8.4 <6.0.0" - checksum: a03545eefdf2487172602930fdd27c8810dc775bdfa4d9c3a45651c5f5465c5e1fc652f318c61ece7f4f35425231961434e96d4ffca84f10149fca111e1fc520 + checksum: 658e20d1faf91ee7db99889b9ed7033feac6b232b5b292b78b41befea2480bce197a96c05d6d9e0b1437277f1ea5818793a25a1194ecda6293bd83b2f0b1f1fd languageName: node linkType: hard -"@typescript-eslint/utils@npm:8.49.0": - version: 8.49.0 - resolution: "@typescript-eslint/utils@npm:8.49.0" +"@typescript-eslint/utils@npm:8.50.0": + version: 8.50.0 + resolution: "@typescript-eslint/utils@npm:8.50.0" dependencies: "@eslint-community/eslint-utils": ^4.7.0 - "@typescript-eslint/scope-manager": 8.49.0 - "@typescript-eslint/types": 8.49.0 - "@typescript-eslint/typescript-estree": 8.49.0 + "@typescript-eslint/scope-manager": 8.50.0 + "@typescript-eslint/types": 8.50.0 + "@typescript-eslint/typescript-estree": 8.50.0 peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: be1bdf2e4a8bb56bb0c39ba8b8a5f1fc187fb17a53af0ef4d50be95914027076dfac385b54d969fdaa2a42fa8a95f31d105457a3768875054a5507ebe6f6257a + checksum: 0ab7e8b5b25c043f33b1af7865fcb35f793f0616f57998ad419fc82c59515e1f7f82dc1bf62b28008d44b2e5e4740152148488f981b33be2edad9ba21d5eff9a languageName: node linkType: hard -"@typescript-eslint/visitor-keys@npm:8.49.0": - version: 8.49.0 - resolution: "@typescript-eslint/visitor-keys@npm:8.49.0" +"@typescript-eslint/visitor-keys@npm:8.50.0": + version: 8.50.0 + resolution: "@typescript-eslint/visitor-keys@npm:8.50.0" dependencies: - "@typescript-eslint/types": 8.49.0 + "@typescript-eslint/types": 8.50.0 eslint-visitor-keys: ^4.2.1 - checksum: 446d6345d9702bcdf8713a47561ea52657bbec1c8170b1559d9462e1d815b122adff35f1cc778ecb94f4459d51ac7aac7cafe9ec8d8319b2c7d7984a0edee6ba + checksum: ee3fbacf34051cdc5cdf3df76986d106a550e4bd92f1e4b9ec7f2e177b1eb482284fdddef5116a26827c1f7616ad9bfe8cfb48622ebc95fcf566fe6027412b96 languageName: node linkType: hard @@ -848,6 +858,13 @@ __metadata: languageName: node linkType: hard +"camelize-ts@npm:^3.0.0": + version: 3.0.0 + resolution: "camelize-ts@npm:3.0.0" + checksum: 835f7f79ddec6e6e0364c6a8294ce82586bca5d9443001f28077169181801cb126d8bc608c85504aa6c877de6fe5f7c9533f80996dc81117d865ff92c676d680 + languageName: node + linkType: hard + "chalk@npm:^4.0.0": version: 4.1.2 resolution: "chalk@npm:4.1.2" @@ -1095,8 +1112,8 @@ __metadata: linkType: hard "eslint@npm:^9.39.1": - version: 9.39.1 - resolution: "eslint@npm:9.39.1" + version: 9.39.2 + resolution: "eslint@npm:9.39.2" dependencies: "@eslint-community/eslint-utils": ^4.8.0 "@eslint-community/regexpp": ^4.12.1 @@ -1104,7 +1121,7 @@ __metadata: "@eslint/config-helpers": ^0.4.2 "@eslint/core": ^0.17.0 "@eslint/eslintrc": ^3.3.1 - "@eslint/js": 9.39.1 + "@eslint/js": 9.39.2 "@eslint/plugin-kit": ^0.4.1 "@humanfs/node": ^0.16.6 "@humanwhocodes/module-importer": ^1.0.1 @@ -1139,7 +1156,7 @@ __metadata: optional: true bin: eslint: bin/eslint.js - checksum: 35583d4d93f431ea2716e18c912e0b10980e27377a89d2c644a3a755921e42a2665dfd7367b8e9b54c7e4e9f193dea4126ce503c866f5795b170934ffd3f1dd9 + checksum: bfa288fe6b19b6e7f8868e1434d8e469603203d6259e4451b8be4e2172de3172f3b07ed8943ba3904f3545c7c546062c0d656774baa0a10a54483f3907c525e3 languageName: node linkType: hard @@ -1308,13 +1325,13 @@ __metadata: linkType: hard "fs-extra@npm:^11.3.2": - version: 11.3.2 - resolution: "fs-extra@npm:11.3.2" + version: 11.3.3 + resolution: "fs-extra@npm:11.3.3" dependencies: graceful-fs: ^4.2.0 jsonfile: ^6.0.1 universalify: ^2.0.0 - checksum: 24a7a6e09668add7f74bf6884086b860ce39c7883d94f564623d4ca5c904ff9e5e33fa6333bd3efbf3528333cdedf974e49fa0723e9debf952f0882e6553d81e + checksum: fb2acabbd1e04bcaca90eadfe98e6ffba1523b8009afbb9f4c0aae5efbca0bd0bf6c9a6831df5af5aaacb98d3e499898be848fb0c03d31ae7b9d1b053e81c151 languageName: node linkType: hard @@ -2201,6 +2218,7 @@ __metadata: "@axe-core/playwright": ^4.11.0 "@backstage/catalog-model": 1.7.5 "@eslint/js": ^9.39.1 + "@keycloak/keycloak-admin-client": ^26.0.0 "@kubernetes/client-node": ^1.4.0 "@playwright/test": ^1.57.0 "@types/fs-extra": ^11.0.4 @@ -2492,17 +2510,17 @@ __metadata: linkType: hard "typescript-eslint@npm:^8.48.1": - version: 8.49.0 - resolution: "typescript-eslint@npm:8.49.0" + version: 8.50.0 + resolution: "typescript-eslint@npm:8.50.0" dependencies: - "@typescript-eslint/eslint-plugin": 8.49.0 - "@typescript-eslint/parser": 8.49.0 - "@typescript-eslint/typescript-estree": 8.49.0 - "@typescript-eslint/utils": 8.49.0 + "@typescript-eslint/eslint-plugin": 8.50.0 + "@typescript-eslint/parser": 8.50.0 + "@typescript-eslint/typescript-estree": 8.50.0 + "@typescript-eslint/utils": 8.50.0 peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: ">=4.8.4 <6.0.0" - checksum: fd91cffcf3c5de73a9ead2253dcb8516ed664fc9179d26c019e6be53f4d4429e280dd5c783c68789a4a2db34712e569468a6c9c7613fc918a310687ca53b91b1 + checksum: 91c983ae43f917d621224c126347e263e09a0ed43f944e6c5c5ba3343412eef42d925baf07b8727697019d267ebe352026536ced0b513e4eb3376cb7267e941a languageName: node linkType: hard @@ -2567,6 +2585,13 @@ __metadata: languageName: node linkType: hard +"url-template@npm:^3.1.1": + version: 3.1.1 + resolution: "url-template@npm:3.1.1" + checksum: ac09daaeaec55a6b070b838ed161d66b050a46fc12ac251cb2db1ce356e786cfb117ee4391d943aaaa757971c509a0142b3cd83dfd8cc3d7b6d90a99d001a5f9 + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1"