Keycloak config persistance implemented (to test)

This commit is contained in:
Joseph Garrone 2024-12-14 14:36:11 +01:00
parent 8d59fe7b67
commit 9185740d35
17 changed files with 465 additions and 228 deletions

View File

@ -0,0 +1,89 @@
import fetch from "make-fetch-happen";
import type { BuildContext } from "../shared/buildContext";
import { assert } from "tsafe/assert";
import { z } from "zod";
import { SemVer } from "../tools/SemVer";
import { exclude } from "tsafe/exclude";
import { getSupportedKeycloakMajorVersions } from "./realmConfig/defaultConfig";
export type BuildContextLike = {
fetchOptions: BuildContext["fetchOptions"];
};
assert<BuildContext extends BuildContextLike ? true : false>;
let cache: string[] | undefined = undefined;
export async function getKeycloakDockerImageLatestSemVerTagsForEveryMajors(params: {
buildContext: BuildContextLike;
}) {
if (cache !== undefined) {
return cache;
}
const { buildContext } = params;
const { tags } = await fetch(
"https://quay.io/v2/keycloak/keycloak/tags/list",
buildContext.fetchOptions
)
.then(r => r.json())
.then(j =>
z
.object({
tags: z.array(z.string())
})
.parse(j)
);
const arr = tags
.map(tag => ({
tag,
version: (() => {
if (tag.includes("-")) {
return undefined;
}
let version: SemVer;
try {
version = SemVer.parse(tag);
} catch {
return undefined;
}
return version;
})()
}))
.map(({ tag, version }) => (version === undefined ? undefined : { tag, version }))
.filter(exclude(undefined));
const versionByMajor: Record<number, SemVer | undefined> = {};
for (const { version } of arr) {
const version_current = versionByMajor[version.major];
if (
version_current === undefined ||
SemVer.compare(version_current, version) === -1
) {
versionByMajor[version.major] = version;
}
}
const supportedKeycloakMajorVersions = getSupportedKeycloakMajorVersions();
cache = Object.values(versionByMajor)
.map(version => {
assert(version !== undefined);
if (!supportedKeycloakMajorVersions.includes(version.major)) {
return undefined;
}
return SemVer.stringify(version);
})
.filter(exclude(undefined));
return cache;
}

View File

@ -3,11 +3,14 @@ import { assert, type Equals } from "tsafe/assert";
import { is } from "tsafe/is";
import { id } from "tsafe/id";
import * as fs from "fs";
import { join as pathJoin } from "path";
import { getThisCodebaseRootDirPath } from "../../tools/getThisCodebaseRootDirPath";
export type ParsedRealmJson = {
name: string;
loginTheme?: string;
accountTheme?: string;
adminTheme?: string;
emailTheme?: string;
eventsListeners: string[];
users: {
id: string;
email: string;
@ -52,6 +55,11 @@ export function readRealmJsonFile(params: {
const zTargetType = z.object({
name: z.string(),
loginTheme: z.string().optional(),
accountTheme: z.string().optional(),
adminTheme: z.string().optional(),
emailTheme: z.string().optional(),
eventsListeners: z.array(z.string()),
users: z.array(
z.object({
id: z.string(),
@ -105,19 +113,3 @@ export function readRealmJsonFile(params: {
return parsedRealmJson;
}
export function getDefaultConfig(params: {
keycloakMajorVersionNumber: number;
}): ParsedRealmJson {
const { keycloakMajorVersionNumber } = params;
const realmJsonFilePath = pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"start-keycloak",
`myrealm-realm-${keycloakMajorVersionNumber}.json`
);
return readRealmJsonFile({ realmJsonFilePath });
}

View File

@ -0,0 +1,74 @@
import { join as pathJoin, dirname as pathDirname } from "path";
import { getThisCodebaseRootDirPath } from "../../../tools/getThisCodebaseRootDirPath";
import * as fs from "fs";
import { exclude } from "tsafe/exclude";
import { assert } from "tsafe/assert";
import { type ParsedRealmJson, readRealmJsonFile } from "../ParsedRealmJson";
export function getDefaultRealmJsonFilePath(params: {
keycloakMajorVersionNumber: number;
}) {
const { keycloakMajorVersionNumber } = params;
return pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"start-keycloak",
"realmConfig",
"defaultConfig",
`realm-kc-${keycloakMajorVersionNumber}.json`
);
}
export const { getSupportedKeycloakMajorVersions } = (() => {
let cache: number[] | undefined = undefined;
function getSupportedKeycloakMajorVersions(): number[] {
if (cache !== undefined) {
return cache;
}
cache = fs
.readdirSync(
pathDirname(
getDefaultRealmJsonFilePath({ keycloakMajorVersionNumber: 0 })
)
)
.map(fileBasename => {
const match = fileBasename.match(/^realm-kc-(\d+)\.json$/);
if (match === null) {
return undefined;
}
const n = parseInt(match[1]);
assert(!isNaN(n));
return n;
})
.filter(exclude(undefined));
return cache;
}
return { getSupportedKeycloakMajorVersions };
})();
export function getDefaultConfig(params: {
keycloakMajorVersionNumber: number;
}): ParsedRealmJson {
const { keycloakMajorVersionNumber } = params;
assert(
getSupportedKeycloakMajorVersions().includes(keycloakMajorVersionNumber),
`We do not have a default config for Keycloak ${keycloakMajorVersionNumber}`
);
return readRealmJsonFile({
realmJsonFilePath: getDefaultRealmJsonFilePath({
keycloakMajorVersionNumber
})
});
}

View File

@ -0,0 +1 @@
export * from "./defaultConfig";

View File

@ -1,4 +1,3 @@
import { runPrettier, getIsPrettierAvailable } from "../../tools/runPrettier";
import { CONTAINER_NAME } from "../../shared/constants";
import child_process from "child_process";
import { join as pathJoin } from "path";
@ -6,7 +5,7 @@ import chalk from "chalk";
import { Deferred } from "evt/tools/Deferred";
import { assert, is } from "tsafe/assert";
import type { BuildContext } from "../../shared/buildContext";
import * as fs from "fs/promises";
import { type ParsedRealmJson, readRealmJsonFile } from "./ParsedRealmJson";
export type BuildContextLike = {
cacheDirPath: string;
@ -17,15 +16,9 @@ assert<BuildContext extends BuildContextLike ? true : false>();
export async function dumpContainerConfig(params: {
realmName: string;
keycloakMajorVersionNumber: number;
targetRealmConfigJsonFilePath: string;
buildContext: BuildContextLike;
}) {
const {
realmName,
keycloakMajorVersionNumber,
targetRealmConfigJsonFilePath,
buildContext
} = params;
}): Promise<ParsedRealmJson> {
const { realmName, keycloakMajorVersionNumber, buildContext } = params;
{
// https://github.com/keycloak/keycloak/issues/33800
@ -148,20 +141,7 @@ export async function dumpContainerConfig(params: {
await dCompleted.pr;
}
let sourceCode = (await fs.readFile(targetRealmConfigJsonFilePath_tmp)).toString(
"utf8"
);
run_prettier: {
if (!(await getIsPrettierAvailable())) {
break run_prettier;
}
sourceCode = await runPrettier({
filePath: targetRealmConfigJsonFilePath,
sourceCode: sourceCode
return readRealmJsonFile({
realmJsonFilePath: targetRealmConfigJsonFilePath_tmp
});
}
await fs.writeFile(targetRealmConfigJsonFilePath, Buffer.from(sourceCode, "utf8"));
}

View File

@ -0,0 +1 @@
export * from "./realmConfig";

View File

@ -1,15 +1,26 @@
import { assert } from "tsafe/assert";
import { getDefaultConfig, type ParsedRealmJson } from "./ParsedRealmJson";
import type { ParsedRealmJson } from "./ParsedRealmJson";
import { getDefaultConfig } from "./defaultConfig";
import type { BuildContext } from "../../shared/buildContext";
import { objectKeys } from "tsafe/objectKeys";
export type BuildContextLike = {
themeNames: BuildContext["themeNames"];
implementedThemeTypes: BuildContext["implementedThemeTypes"];
};
assert<BuildContext extends BuildContextLike ? true : false>;
export function prepareRealmConfig(params: {
parsedRealmJson: ParsedRealmJson;
keycloakMajorVersionNumber: number;
buildContext: BuildContextLike;
}): {
realmName: string;
clientName: string;
username: string;
} {
const { parsedRealmJson, keycloakMajorVersionNumber } = params;
const { parsedRealmJson, keycloakMajorVersionNumber, buildContext } = params;
const { username } = addOrEditTestUser({
parsedRealmJson,
@ -23,6 +34,22 @@ export function prepareRealmConfig(params: {
editAccountConsoleAndSecurityAdminConsole({ parsedRealmJson });
enableCustomThemes({
parsedRealmJson,
themeName: buildContext.themeNames[0],
implementedThemeTypes: buildContext.implementedThemeTypes
});
enable_custom_events_listeners: {
const name = "keycloakify-logging";
if (parsedRealmJson.eventsListeners.includes(name)) {
break enable_custom_events_listeners;
}
parsedRealmJson.eventsListeners.push(name);
}
return {
realmName: parsedRealmJson.name,
clientName: clientId,
@ -30,6 +57,21 @@ export function prepareRealmConfig(params: {
};
}
function enableCustomThemes(params: {
parsedRealmJson: ParsedRealmJson;
themeName: string;
implementedThemeTypes: BuildContextLike["implementedThemeTypes"];
}) {
const { parsedRealmJson, themeName, implementedThemeTypes } = params;
for (const themeType of objectKeys(implementedThemeTypes)) {
parsedRealmJson[`${themeType}Theme` as const] = implementedThemeTypes[themeType]
.isImplemented
? themeName
: "";
}
}
function addOrEditTestUser(params: {
parsedRealmJson: ParsedRealmJson;
keycloakMajorVersionNumber: number;

View File

@ -0,0 +1,108 @@
import type { BuildContext } from "../../shared/buildContext";
import { assert } from "tsafe/assert";
import { runPrettier, getIsPrettierAvailable } from "../../tools/runPrettier";
import { getDefaultConfig } from "./defaultConfig";
import {
prepareRealmConfig,
type BuildContextLike as BuildContextLike_prepareRealmConfig
} from "./prepareRealmConfig";
import * as fs from "fs";
import { join as pathJoin, dirname as pathDirname } from "path";
import { existsAsync } from "../../tools/fs.existsAsync";
import { readRealmJsonFile, type ParsedRealmJson } from "./ParsedRealmJson";
import {
dumpContainerConfig,
type BuildContextLike as BuildContextLike_dumpContainerConfig
} from "./dumpContainerConfig";
export type BuildContextLike = BuildContextLike_dumpContainerConfig &
BuildContextLike_prepareRealmConfig & {
projectDirPath: string;
};
assert<BuildContext extends BuildContextLike ? true : false>;
export async function getRealmConfig(params: {
keycloakMajorVersionNumber: number;
realmJsonFilePath_userProvided: string | undefined;
buildContext: BuildContextLike;
}): Promise<{
realmJsonFilePath: string;
clientName: string;
realmName: string;
username: string;
onRealmConfigChange: () => Promise<void>;
}> {
const { keycloakMajorVersionNumber, realmJsonFilePath_userProvided, buildContext } =
params;
const realmJsonFilePath = pathJoin(
buildContext.projectDirPath,
`realm-kc-${keycloakMajorVersionNumber}.json`
);
const parsedRealmJson = await (async () => {
if (realmJsonFilePath_userProvided !== undefined) {
return readRealmJsonFile({
realmJsonFilePath: realmJsonFilePath_userProvided
});
}
if (await existsAsync(realmJsonFilePath)) {
return readRealmJsonFile({
realmJsonFilePath
});
}
return getDefaultConfig({ keycloakMajorVersionNumber });
})();
const { clientName, realmName, username } = prepareRealmConfig({
parsedRealmJson,
buildContext,
keycloakMajorVersionNumber
});
{
const dirPath = pathDirname(realmJsonFilePath);
if (!(await existsAsync(dirPath))) {
fs.mkdirSync(dirPath, { recursive: true });
}
}
const writeRealmJsonFile = async (params: { parsedRealmJson: ParsedRealmJson }) => {
const { parsedRealmJson } = params;
let sourceCode = JSON.stringify(parsedRealmJson, null, 2);
if (await getIsPrettierAvailable()) {
sourceCode = await runPrettier({
sourceCode,
filePath: realmJsonFilePath
});
}
fs.writeFileSync(realmJsonFilePath, sourceCode);
};
await writeRealmJsonFile({ parsedRealmJson });
async function onRealmConfigChange() {
const parsedRealmJson = await dumpContainerConfig({
buildContext,
realmName,
keycloakMajorVersionNumber
});
await writeRealmJsonFile({ parsedRealmJson });
}
return {
realmJsonFilePath,
clientName,
realmName,
username,
onRealmConfigChange
};
}

View File

@ -1,6 +1,5 @@
import type { BuildContext } from "../shared/buildContext";
import { exclude } from "tsafe/exclude";
import { promptKeycloakVersion } from "../shared/promptKeycloakVersion";
import {
CONTAINER_NAME,
KEYCLOAKIFY_SPA_DEV_SERVER_PORT,
@ -13,8 +12,7 @@ import {
join as pathJoin,
relative as pathRelative,
sep as pathSep,
basename as pathBasename,
dirname as pathDirname
basename as pathBasename
} from "path";
import * as child_process from "child_process";
import chalk from "chalk";
@ -32,6 +30,9 @@ import { existsAsync } from "../tools/fs.existsAsync";
import { rm } from "../tools/fs.rm";
import { downloadAndExtractArchive } from "../tools/downloadAndExtractArchive";
import { startViteDevServer } from "./startViteDevServer";
import { getSupportedKeycloakMajorVersions } from "./realmConfig/defaultConfig";
import { getKeycloakDockerImageLatestSemVerTagsForEveryMajors } from "./getQuayIoKeycloakDockerImageTags";
import { getRealmConfig } from "./realmConfig";
export async function command(params: {
buildContext: BuildContext;
@ -95,9 +96,32 @@ export async function command(params: {
const { cliCommandOptions, buildContext } = params;
const availableTags = await getKeycloakDockerImageLatestSemVerTagsForEveryMajors({
buildContext
});
const { dockerImageTag } = await (async () => {
if (cliCommandOptions.keycloakVersion !== undefined) {
return { dockerImageTag: cliCommandOptions.keycloakVersion };
const cliCommandOptions_keycloakVersion = cliCommandOptions.keycloakVersion;
const tag = availableTags.find(tag =>
tag.startsWith(cliCommandOptions_keycloakVersion)
);
if (tag === undefined) {
console.log(
chalk.red(
[
`We could not find a Keycloak Docker image for ${cliCommandOptions_keycloakVersion}`,
`Example of valid values: --keycloak-version 26, --keycloak-version 26.0.7`
].join("\n")
)
);
process.exit(1);
}
return { dockerImageTag: tag };
}
if (buildContext.startKeycloakOptions.dockerImage !== undefined) {
@ -112,50 +136,81 @@ export async function command(params: {
"On which version of Keycloak do you want to test your theme?"
),
chalk.gray(
"You can also explicitly provide the version with `npx keycloakify start-keycloak --keycloak-version 25.0.2` (or any other version)"
"You can also explicitly provide the version with `npx keycloakify start-keycloak --keycloak-version 26` (or any other version)"
)
].join("\n")
);
const { keycloakVersion } = await promptKeycloakVersion({
startingFromMajor: 18,
excludeMajorVersions: [22],
doOmitPatch: true,
buildContext
const { value: tag } = await cliSelect<string>({
values: availableTags
}).catch(() => {
process.exit(-1);
});
console.log(`${keycloakVersion}`);
console.log(`${tag}`);
return { dockerImageTag: keycloakVersion };
return { dockerImageTag: tag };
})();
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]
const [wrap] = getSupportedKeycloakMajorVersions()
.map(majorVersionNumber => ({
majorVersionNumber,
index: tag.indexOf(`${majorVersionNumber}`)
index: dockerImageTag.indexOf(`${majorVersionNumber}`)
}))
.filter(({ index }) => index !== -1)
.sort((a, b) => a.index - b.index);
if (wrap === undefined) {
console.warn(
try {
const version = SemVer.parse(dockerImageTag);
console.error(
chalk.yellow(
`Could not determine the major Keycloak version number from the docker image tag ${tag}. Assuming 25`
`Keycloak version ${version.major} is not supported, supported versions are ${getSupportedKeycloakMajorVersions().join(", ")}`
)
);
return 25;
process.exit(1);
} catch {
console.warn(
chalk.yellow(
`Could not determine the major Keycloak version number from the docker image tag ${dockerImageTag}. Assuming 26`
)
);
return 26;
}
}
return wrap.majorVersionNumber;
})();
const { clientName, onRealmConfigChange, realmJsonFilePath, realmName, username } =
await getRealmConfig({
keycloakMajorVersionNumber,
realmJsonFilePath_userProvided: await (async () => {
if (cliCommandOptions.realmJsonFilePath !== undefined) {
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;
}
return undefined;
})(),
buildContext
});
{
const { isAppBuildSuccess } = await appBuild({
buildContext
@ -193,7 +248,15 @@ export async function command(params: {
assert(jarFilePath !== undefined);
const extensionJarFilePaths = await Promise.all(
const extensionJarFilePaths = [
pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"start-keycloak",
KEYCLOAKIFY_LOGIN_JAR_BASENAME
),
...(await Promise.all(
buildContext.startKeycloakOptions.extensionJars.map(async extensionJar => {
switch (extensionJar.type) {
case "path": {
@ -216,133 +279,8 @@ export async function command(params: {
}
assert<Equals<typeof extensionJar, never>>(false);
})
);
const thisDirPath = pathJoin(
getThisCodebaseRootDirPath(),
"src",
"bin",
"start-keycloak"
);
extensionJarFilePaths.unshift(pathJoin(thisDirPath, KEYCLOAKIFY_LOGIN_JAR_BASENAME));
const getRealmJsonFilePath_defaultForKeycloakMajor = (
keycloakMajorVersionNumber: number
) => pathJoin(thisDirPath, `myrealm-realm-${keycloakMajorVersionNumber}.json`);
const realmJsonFilePath = await (async () => {
if (cliCommandOptions.realmJsonFilePath !== undefined) {
if (cliCommandOptions.realmJsonFilePath === "none") {
return undefined;
}
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 defaultFilePath = getRealmJsonFilePath_defaultForKeycloakMajor(
keycloakMajorVersionNumber
);
if (fs.existsSync(defaultFilePath)) {
return defaultFilePath;
}
console.log(
`${chalk.yellow(
`Keycloakify do not have a realm configuration for Keycloak ${keycloakMajorVersionNumber} yet.`
)}`
);
console.log(chalk.cyan("Select what configuration to use:"));
const dirPath = pathDirname(defaultFilePath);
const { value } = await cliSelect<string>({
values: [
...fs
.readdirSync(dirPath)
.filter(fileBasename => fileBasename.endsWith(".json")),
"none"
]
}).catch(() => {
process.exit(-1);
});
if (value === "none") {
return undefined;
}
return pathJoin(dirPath, value);
})();
if (internalFilePath === undefined) {
return undefined;
}
const filePath = pathJoin(
buildContext.cacheDirPath,
pathBasename(internalFilePath)
);
fs.writeFileSync(
filePath,
Buffer.from(
fs
.readFileSync(internalFilePath)
.toString("utf8")
.replace(/keycloakify\-starter/g, buildContext.themeNames[0])
),
"utf8"
);
return filePath;
})();
add_test_user_if_missing: {
if (realmJsonFilePath === undefined) {
break add_test_user_if_missing;
}
const realm: Record<string, unknown> = JSON.parse(
fs.readFileSync(realmJsonFilePath).toString("utf8")
);
if (realm.users !== undefined) {
break add_test_user_if_missing;
}
const realmJsonFilePath_internal = (() => {
const filePath = getRealmJsonFilePath_defaultForKeycloakMajor(
keycloakMajorVersionNumber
);
if (!fs.existsSync(filePath)) {
return getRealmJsonFilePath_defaultForKeycloakMajor(25);
}
return filePath;
})();
const users = JSON.parse(
fs.readFileSync(realmJsonFilePath_internal).toString("utf8")
).users;
realm.users = users;
fs.writeFileSync(realmJsonFilePath, JSON.stringify(realm, null, 2), "utf8");
}
))
];
async function extractThemeResourcesFromJar() {
await extractArchive({
@ -382,9 +320,7 @@ export async function command(params: {
});
} catch {}
const DEFAULT_PORT = 8080;
const port =
cliCommandOptions.port ?? buildContext.startKeycloakOptions.port ?? DEFAULT_PORT;
const port = cliCommandOptions.port ?? buildContext.startKeycloakOptions.port ?? 8080;
const doStartDevServer = (() => {
const hasSpaUi =
@ -457,7 +393,7 @@ export async function command(params: {
...(realmJsonFilePath === undefined
? []
: [
`-v${SPACE_PLACEHOLDER}"${realmJsonFilePath}":/opt/keycloak/data/import/myrealm-realm.json`
`-v${SPACE_PLACEHOLDER}"${realmJsonFilePath}":/opt/keycloak/data/import/${realmName}-realm.json`
]),
`-v${SPACE_PLACEHOLDER}"${jarFilePath_cacheDir}":/opt/keycloak/providers/keycloak-theme.jar`,
...extensionJarFilePaths.map(
@ -532,7 +468,14 @@ export async function command(params: {
{ shell: true }
);
child.stdout.on("data", data => process.stdout.write(data));
child.stdout.on("data", async data => {
if (data.toString("utf8").includes("keycloakify-logging: REALM_CONFIG_CHANGED")) {
await onRealmConfigChange();
return;
}
process.stdout.write(data);
});
child.stderr.on("data", data => process.stderr.write(data));
@ -581,7 +524,7 @@ export async function command(params: {
(() => {
const url = new URL("https://my-theme.keycloakify.dev");
if (port !== DEFAULT_PORT) {
if (port !== 8080) {
url.searchParams.set("port", `${port}`);
}
if (kcHttpRelativePath !== undefined) {
@ -590,13 +533,20 @@ export async function command(params: {
kcHttpRelativePath
);
}
if (realmName !== "myrealm") {
url.searchParams.set("realm", realmName);
}
if (clientName !== "myclient") {
url.searchParams.set("client", clientName);
}
return url.href;
})()
)}`,
"",
"You can login with the following credentials:",
`- username: ${chalk.cyan.bold("testuser")}`,
`- username: ${chalk.cyan.bold(username)}`,
`- password: ${chalk.cyan.bold("password123")}`,
"",
`Watching for changes in ${chalk.bold(