keycloak start command options support in config

This commit is contained in:
Joseph Garrone 2024-08-17 23:20:52 +02:00
parent 66623e3324
commit 02f2124126
7 changed files with 254 additions and 100 deletions

View File

@ -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",

View File

@ -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<Equals<keyof BuildContext["implementedThemeTypes"], ThemeType | "email">>();
@ -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<z.ZodType<TargetType>>(zTargetType);
})();
const zStartKeycloakOptions = (() => {
type TargetType = NonNullable<BuildOptions["startKeycloakOptions"]>;
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<Equals<z.infer<typeof zTargetType>, TargetType>>();
return id<z.ZodType<TargetType>>(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
})
}
};
}

View File

@ -21,7 +21,7 @@ export async function downloadKeycloakDefaultTheme(params: {
let kcNodeModulesKeepFilePaths_lastAccountV1: Set<string> | 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",

View File

@ -40,7 +40,7 @@ async function appBuild_vite(params: {
const dIsSuccess = new Deferred<boolean>();
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,

View File

@ -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,

View File

@ -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<Equals<typeof extensionJar, never>>(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));

View File

@ -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<void>;
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 = {