From 02f2124126f2c1c550dfa5ad9a515e3809a93d62 Mon Sep 17 00:00:00 2001 From: Joseph Garrone Date: Sat, 17 Aug 2024 23:20:52 +0200 Subject: [PATCH] keycloak start command options support in config --- .../generateResourcesForMainTheme.ts | 2 +- src/bin/shared/buildContext.ts | 81 ++++++- .../shared/downloadKeycloakDefaultTheme.ts | 2 +- src/bin/start-keycloak/appBuild.ts | 4 +- src/bin/start-keycloak/keycloakifyBuild.ts | 2 +- src/bin/start-keycloak/start-keycloak.ts | 226 +++++++++++------- src/bin/tools/downloadAndExtractArchive.ts | 37 ++- 7 files changed, 254 insertions(+), 100 deletions(-) diff --git a/src/bin/keycloakify/generateResources/generateResourcesForMainTheme.ts b/src/bin/keycloakify/generateResources/generateResourcesForMainTheme.ts index 382631aa..2be3053c 100644 --- a/src/bin/keycloakify/generateResources/generateResourcesForMainTheme.ts +++ b/src/bin/keycloakify/generateResources/generateResourcesForMainTheme.ts @@ -314,7 +314,7 @@ export async function generateResourcesForMainTheme(params: { } const { extractedDirPath } = await downloadAndExtractArchive({ - url: "https://repo1.maven.org/maven2/org/keycloak/keycloak-account-ui/25.0.1/keycloak-account-ui-25.0.1.jar", + urlOrPath: "https://repo1.maven.org/maven2/org/keycloak/keycloak-account-ui/25.0.1/keycloak-account-ui-25.0.1.jar", cacheDirPath: buildContext.cacheDirPath, fetchOptions: buildContext.fetchOptions, uniqueIdOfOnArchiveFile: "bring_in_account_v3_i18n_messages", diff --git a/src/bin/shared/buildContext.ts b/src/bin/shared/buildContext.ts index d5811e75..558dc216 100644 --- a/src/bin/shared/buildContext.ts +++ b/src/bin/shared/buildContext.ts @@ -61,6 +61,18 @@ export type BuildContext = { keycloakVersionRange: KeycloakVersionRange; jarFileBasename: string; }[]; + startKeycloakOptions: { + dockerImage: + | { + reference: string; + tag: string; + } + | undefined; + dockerExtraArgs: string[]; + keycloakExtraArgs: string[]; + extensionJars: ({ type: "path"; path: string } | { type: "url"; url: string })[]; + realmJsonFilePath: string | undefined; + }; }; assert>(); @@ -75,6 +87,13 @@ export type BuildOptions = { loginThemeResourcesFromKeycloakVersion?: string; keycloakifyBuildDirPath?: string; kcContextExclusionsFtl?: string; + startKeycloakOptions?: { + dockerImage?: string; + dockerExtraArgs?: string[]; + keycloakExtraArgs?: string[]; + extensionJars?: string[]; + realmJsonFilePath?: string; + }; } & BuildOptions.AccountThemeImplAndKeycloakVersionTargets; export namespace BuildOptions { @@ -301,6 +320,22 @@ export function getBuildContext(params: { return id>(zTargetType); })(); + const zStartKeycloakOptions = (() => { + type TargetType = NonNullable; + + const zTargetType = z.object({ + dockerImage: z.string().optional(), + extensionJars: z.array(z.string()).optional(), + realmJsonFilePath: z.string().optional(), + dockerExtraArgs: z.array(z.string()).optional(), + keycloakExtraArgs: z.array(z.string()).optional() + }); + + assert, TargetType>>(); + + return id>(zTargetType); + })(); + const zBuildOptions = (() => { type TargetType = BuildOptions; @@ -321,7 +356,8 @@ export function getBuildContext(params: { groupId: z.string().optional(), loginThemeResourcesFromKeycloakVersion: z.string().optional(), keycloakifyBuildDirPath: z.string().optional(), - kcContextExclusionsFtl: z.string().optional() + kcContextExclusionsFtl: z.string().optional(), + startKeycloakOptions: zStartKeycloakOptions.optional() }), zAccountThemeImplAndKeycloakVersionTargets ); @@ -891,6 +927,47 @@ export function getBuildContext(params: { } return jarTargets; - })() + })(), + startKeycloakOptions: { + dockerImage: (() => { + if (buildOptions.startKeycloakOptions?.dockerImage === undefined) { + return undefined; + } + + const [reference, tag, ...rest] = + buildOptions.startKeycloakOptions.dockerImage.split(":"); + + assert( + reference !== undefined && tag !== undefined && rest.length === 0, + `Invalid docker image: ${buildOptions.startKeycloakOptions.dockerImage}` + ); + + return { reference, tag }; + })(), + dockerExtraArgs: buildOptions.startKeycloakOptions?.dockerExtraArgs ?? [], + keycloakExtraArgs: buildOptions.startKeycloakOptions?.keycloakExtraArgs ?? [], + extensionJars: (buildOptions.startKeycloakOptions?.extensionJars ?? []).map( + urlOrPath => { + if (/^https?:\/\//.test(urlOrPath)) { + return { type: "url", url: urlOrPath }; + } + + return { + type: "path", + path: getAbsoluteAndInOsFormatPath({ + pathIsh: urlOrPath, + cwd: projectDirPath + }) + }; + } + ), + realmJsonFilePath: + buildOptions.startKeycloakOptions?.realmJsonFilePath === undefined + ? undefined + : getAbsoluteAndInOsFormatPath({ + pathIsh: buildOptions.startKeycloakOptions.realmJsonFilePath, + cwd: projectDirPath + }) + } }; } diff --git a/src/bin/shared/downloadKeycloakDefaultTheme.ts b/src/bin/shared/downloadKeycloakDefaultTheme.ts index c72e251a..c1b87979 100644 --- a/src/bin/shared/downloadKeycloakDefaultTheme.ts +++ b/src/bin/shared/downloadKeycloakDefaultTheme.ts @@ -21,7 +21,7 @@ export async function downloadKeycloakDefaultTheme(params: { let kcNodeModulesKeepFilePaths_lastAccountV1: Set | undefined = undefined; const { extractedDirPath } = await downloadAndExtractArchive({ - url: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`, + urlOrPath: `https://repo1.maven.org/maven2/org/keycloak/keycloak-themes/${keycloakVersion}/keycloak-themes-${keycloakVersion}.jar`, cacheDirPath: buildContext.cacheDirPath, fetchOptions: buildContext.fetchOptions, uniqueIdOfOnArchiveFile: "downloadKeycloakDefaultTheme", diff --git a/src/bin/start-keycloak/appBuild.ts b/src/bin/start-keycloak/appBuild.ts index 6b1c72a6..0fd32553 100644 --- a/src/bin/start-keycloak/appBuild.ts +++ b/src/bin/start-keycloak/appBuild.ts @@ -40,7 +40,7 @@ async function appBuild_vite(params: { const dIsSuccess = new Deferred(); - console.log(chalk.blue("Running: 'npx vite build'")); + console.log(chalk.blue("$ npx vite build")); const child = child_process.spawn("npx", ["vite", "build"], { cwd: buildContext.projectDirPath, @@ -145,7 +145,7 @@ async function appBuild_webpack(params: { continue; } - console.log(chalk.blue(`Running: '${subCommand}'`)); + console.log(chalk.blue(`$ ${subCommand}`)); const child = child_process.spawn(command, args, { cwd: commandCwd, diff --git a/src/bin/start-keycloak/keycloakifyBuild.ts b/src/bin/start-keycloak/keycloakifyBuild.ts index 0daaadcc..b6324ae2 100644 --- a/src/bin/start-keycloak/keycloakifyBuild.ts +++ b/src/bin/start-keycloak/keycloakifyBuild.ts @@ -20,7 +20,7 @@ export async function keycloakifyBuild(params: { const dResult = new Deferred<{ isSuccess: boolean }>(); - console.log(chalk.blue("Running: 'npx keycloakify build'")); + console.log(chalk.blue("$ npx keycloakify build")); const child = child_process.spawn("npx", ["keycloakify", "build"], { cwd: buildContext.projectDirPath, diff --git a/src/bin/start-keycloak/start-keycloak.ts b/src/bin/start-keycloak/start-keycloak.ts index da01f081..2d0b6fc3 100644 --- a/src/bin/start-keycloak/start-keycloak.ts +++ b/src/bin/start-keycloak/start-keycloak.ts @@ -4,7 +4,7 @@ import type { CliCommandOptions as CliCommandOptions_common } from "../main"; import { promptKeycloakVersion } from "../shared/promptKeycloakVersion"; import { ACCOUNT_V1_THEME_NAME, CONTAINER_NAME } from "../shared/constants"; import { SemVer } from "../tools/SemVer"; -import { assert } from "tsafe/assert"; +import { assert, type Equals } from "tsafe/assert"; import * as fs from "fs"; import { join as pathJoin, @@ -26,6 +26,7 @@ import { keycloakifyBuild } from "./keycloakifyBuild"; import { isInside } from "../tools/isInside"; import { existsAsync } from "../tools/fs.existsAsync"; import { rm } from "../tools/fs.rm"; +import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive"; export type CliCommandOptions = CliCommandOptions_common & { port: number; @@ -88,11 +89,14 @@ export async function command(params: { cliCommandOptions: CliCommandOptions }) const buildContext = getBuildContext({ cliCommandOptions }); - const { keycloakVersion } = await (async () => { + const { dockerImageTag } = await (async () => { if (cliCommandOptions.keycloakVersion !== undefined) { + return { dockerImageTag: cliCommandOptions.keycloakVersion }; + } + + if (buildContext.startKeycloakOptions.dockerImage !== undefined) { return { - keycloakVersion: cliCommandOptions.keycloakVersion, - keycloakMajorNumber: SemVer.parse(cliCommandOptions.keycloakVersion).major + dockerImageTag: buildContext.startKeycloakOptions.dockerImage.tag }; } @@ -115,10 +119,35 @@ export async function command(params: { cliCommandOptions: CliCommandOptions }) console.log(`→ ${keycloakVersion}`); - return { keycloakVersion }; + return { dockerImageTag: keycloakVersion }; })(); - const keycloakMajorVersionNumber = SemVer.parse(keycloakVersion).major; + const keycloakMajorVersionNumber = (() => { + if (buildContext.startKeycloakOptions.dockerImage === undefined) { + return SemVer.parse(dockerImageTag).major; + } + + const { tag } = buildContext.startKeycloakOptions.dockerImage; + + const [wrap] = [18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28] + .map(majorVersionNumber => ({ + majorVersionNumber, + index: tag.indexOf(`${majorVersionNumber}`) + })) + .filter(({ index }) => index !== -1) + .sort((a, b) => a.index - b.index); + + if (wrap === undefined) { + console.warn( + chalk.yellow( + `Could not determine the major Keycloak version number from the docker image tag ${tag}. Assuming 25` + ) + ); + return 25; + } + + return wrap.majorVersionNumber; + })(); { const { isAppBuildSuccess } = await appBuild({ @@ -157,26 +186,50 @@ export async function command(params: { cliCommandOptions: CliCommandOptions }) assert(jarFilePath !== undefined); - console.log(`Using ${chalk.bold(pathBasename(jarFilePath))}`); + const extensionJarFilePaths = await Promise.all( + buildContext.startKeycloakOptions.extensionJars.map(async extensionJar => { + switch (extensionJar.type) { + case "path": { + assert( + await existsAsync(extensionJar.path), + `${extensionJar.path} does not exist` + ); + return extensionJar.path; + } + case "url": { + const { archiveFilePath } = await downloadAndExtractArchive({ + cacheDirPath: buildContext.cacheDirPath, + fetchOptions: buildContext.fetchOptions, + urlOrPath: extensionJar.url, + uniqueIdOfOnArchiveFile: "no extraction", + onArchiveFile: async () => {} + }); + return archiveFilePath; + } + } + assert>(false); + }) + ); const realmJsonFilePath = await (async () => { if (cliCommandOptions.realmJsonFilePath !== undefined) { if (cliCommandOptions.realmJsonFilePath === "none") { return undefined; } - - console.log( - chalk.green( - `Using realm json file: ${cliCommandOptions.realmJsonFilePath}` - ) - ); - return getAbsoluteAndInOsFormatPath({ pathIsh: cliCommandOptions.realmJsonFilePath, cwd: process.cwd() }); } + if (buildContext.startKeycloakOptions.realmJsonFilePath !== undefined) { + assert( + await existsAsync(buildContext.startKeycloakOptions.realmJsonFilePath), + `${pathRelative(process.cwd(), buildContext.startKeycloakOptions.realmJsonFilePath)} does not exist` + ); + return buildContext.startKeycloakOptions.realmJsonFilePath; + } + const internalFilePath = await (async () => { const dirPath = pathJoin( getThisCodebaseRootDirPath(), @@ -281,77 +334,84 @@ export async function command(params: { cliCommandOptions: CliCommandOptions }) }); } catch {} - const spawnArgs = [ - "docker", - [ - "run", - ...["-p", `${cliCommandOptions.port}:8080`], - ...["--name", CONTAINER_NAME], - ...["-e", "KEYCLOAK_ADMIN=admin"], - ...["-e", "KEYCLOAK_ADMIN_PASSWORD=admin"], - ...(realmJsonFilePath === undefined - ? [] - : [ - "-v", - `"${realmJsonFilePath}":/opt/keycloak/data/import/myrealm-realm.json` - ]), - ...[ - "-v", - `"${jarFilePath_cacheDir}":/opt/keycloak/providers/keycloak-theme.jar` - ], - ...(keycloakMajorVersionNumber <= 20 - ? ["-e", "JAVA_OPTS=-Dkeycloak.profile=preview"] - : []), - ...[ - ...buildContext.themeNames, - ...(fs.existsSync( - pathJoin( - buildContext.keycloakifyBuildDirPath, - "theme", - ACCOUNT_V1_THEME_NAME - ) + const dockerRunArgs: string[] = [ + `-p ${cliCommandOptions.port}:8080`, + `--name ${CONTAINER_NAME}`, + `-e KEYCLOAK_ADMIN=admin`, + `-e KEYCLOAK_ADMIN_PASSWORD=admin`, + ...(realmJsonFilePath === undefined + ? [] + : [ + `-v ".${pathSep}${pathRelative(process.cwd(), realmJsonFilePath)}":/opt/keycloak/data/import/myrealm-realm.json` + ]), + `-v "./${pathRelative(process.cwd(), jarFilePath_cacheDir)}":/opt/keycloak/providers/keycloak-theme.jar`, + ...extensionJarFilePaths.map( + jarFilePath => + `-v ".${pathSep}${pathRelative(process.cwd(), jarFilePath)}":/opt/keycloak/providers/${pathBasename(jarFilePath)}` + ), + ...(keycloakMajorVersionNumber <= 20 + ? ["-e JAVA_OPTS=-Dkeycloak.profile=preview"] + : []), + ...[ + ...buildContext.themeNames, + ...(fs.existsSync( + pathJoin( + buildContext.keycloakifyBuildDirPath, + "theme", + ACCOUNT_V1_THEME_NAME ) - ? [ACCOUNT_V1_THEME_NAME] - : []) - ] - .map(themeName => ({ - localDirPath: pathJoin( - buildContext.keycloakifyBuildDirPath, - "theme", - themeName - ), - containerDirPath: `/opt/keycloak/themes/${themeName}` - })) - .map(({ localDirPath, containerDirPath }) => [ - "-v", - `"${localDirPath}":${containerDirPath}:rw` - ]) - .flat(), - ...buildContext.environmentVariables - .map(({ name }) => ({ name, envValue: process.env[name] })) - .map(({ name, envValue }) => - envValue === undefined ? undefined : { name, envValue } - ) - .filter(exclude(undefined)) - .map(({ name, envValue }) => [ - "--env", - `${name}='${envValue.replace(/'/g, "'\\''")}'` - ]) - .flat(), - `quay.io/keycloak/keycloak:${keycloakVersion}`, - "start-dev", - ...(21 <= keycloakMajorVersionNumber && keycloakMajorVersionNumber < 24 - ? ["--features=declarative-user-profile"] - : []), - ...(realmJsonFilePath === undefined ? [] : ["--import-realm"]) - ], - { - cwd: buildContext.keycloakifyBuildDirPath, - shell: true - } - ] as const; + ) + ? [ACCOUNT_V1_THEME_NAME] + : []) + ] + .map(themeName => ({ + localDirPath: pathJoin( + buildContext.keycloakifyBuildDirPath, + "theme", + themeName + ), + containerDirPath: `/opt/keycloak/themes/${themeName}` + })) + .map( + ({ localDirPath, containerDirPath }) => + `-v ".${pathSep}${pathRelative(process.cwd(), localDirPath)}":${containerDirPath}:rw` + ), + ...buildContext.environmentVariables + .map(({ name }) => ({ name, envValue: process.env[name] })) + .map(({ name, envValue }) => + envValue === undefined ? undefined : { name, envValue } + ) + .filter(exclude(undefined)) + .map( + ({ name, envValue }) => + `--env ${name}='${envValue.replace(/'/g, "'\\''")}'` + ), + ...buildContext.startKeycloakOptions.dockerExtraArgs, + `${buildContext.startKeycloakOptions.dockerImage?.reference ?? "quay.io/keycloak/keycloak"}:${dockerImageTag}`, + "start-dev", + ...(21 <= keycloakMajorVersionNumber && keycloakMajorVersionNumber < 24 + ? ["--features=declarative-user-profile"] + : []), + ...(realmJsonFilePath === undefined ? [] : ["--import-realm"]), + ...buildContext.startKeycloakOptions.keycloakExtraArgs + ]; - const child = child_process.spawn(...spawnArgs); + console.log( + chalk.blue( + [ + `$ docker run \\`, + ...dockerRunArgs.map( + (line, i, arr) => ` ${line}${arr.length - 1 === i ? "" : " \\"}` + ) + ].join("\n") + ) + ); + + const child = child_process.spawn( + "docker", + ["run", ...dockerRunArgs.map(line => line.split(" ")).flat()], + { shell: true } + ); child.stdout.on("data", data => process.stdout.write(data)); diff --git a/src/bin/tools/downloadAndExtractArchive.ts b/src/bin/tools/downloadAndExtractArchive.ts index a339f0ac..f46fe397 100644 --- a/src/bin/tools/downloadAndExtractArchive.ts +++ b/src/bin/tools/downloadAndExtractArchive.ts @@ -1,15 +1,15 @@ import fetch, { type FetchOptions } from "make-fetch-happen"; import { mkdir, unlink, writeFile, readdir, readFile } from "fs/promises"; -import { dirname as pathDirname, join as pathJoin } from "path"; +import { dirname as pathDirname, join as pathJoin, basename as pathBasename } from "path"; import { assert } from "tsafe/assert"; import { extractArchive } from "./extractArchive"; import { existsAsync } from "./fs.existsAsync"; - import * as crypto from "crypto"; import { rm } from "./fs.rm"; +import * as fsPr from "fs/promises"; export async function downloadAndExtractArchive(params: { - url: string; + urlOrPath: string; uniqueIdOfOnArchiveFile: string; onArchiveFile: (params: { fileRelativePath: string; @@ -21,15 +21,34 @@ export async function downloadAndExtractArchive(params: { }) => Promise; cacheDirPath: string; fetchOptions: FetchOptions | undefined; -}): Promise<{ extractedDirPath: string }> { - const { url, uniqueIdOfOnArchiveFile, onArchiveFile, cacheDirPath, fetchOptions } = - params; +}): Promise<{ extractedDirPath: string; archiveFilePath: string; }> { + const { + urlOrPath, + uniqueIdOfOnArchiveFile, + onArchiveFile, + cacheDirPath, + fetchOptions + } = params; - const archiveFileBasename = url.split("?")[0].split("/").reverse()[0]; + const isUrl = /^https?:\/\//.test(urlOrPath); + + const archiveFileBasename = isUrl + ? urlOrPath.split("?")[0].split("/").reverse()[0] + : pathBasename(urlOrPath); const archiveFilePath = pathJoin(cacheDirPath, archiveFileBasename); download: { + await mkdir(pathDirname(archiveFilePath), { recursive: true }); + + if (!isUrl) { + await fsPr.copyFile(urlOrPath, archiveFilePath); + + break download; + } + + const url = urlOrPath; + if (await existsAsync(archiveFilePath)) { const isDownloaded = await SuccessTracker.getIsDownloaded({ cacheDirPath, @@ -48,8 +67,6 @@ export async function downloadAndExtractArchive(params: { }); } - await mkdir(pathDirname(archiveFilePath), { recursive: true }); - const response = await fetch(url, fetchOptions); response.body?.setMaxListeners(Number.MAX_VALUE); @@ -136,7 +153,7 @@ export async function downloadAndExtractArchive(params: { }); } - return { extractedDirPath }; + return { extractedDirPath, archiveFilePath }; } type SuccessTracker = {