From fc8fe3a67d2604b0632854b1bb7fd3777b968010 Mon Sep 17 00:00:00 2001 From: Trustable User Date: Sun, 12 Jul 2026 21:15:51 +0200 Subject: [PATCH] Move SSO configuration to tasks --- config/docopts.md | 4 +- config/sso/docopts.md | 79 +++++++ config/sso/opsfile.yml | 60 ++++++ config/sso/sso.test.ts | 257 +++++++++++++++++++++++ config/sso/sso.ts | 463 +++++++++++++++++++++++++++++++++++++++++ 5 files changed, 860 insertions(+), 3 deletions(-) create mode 100644 config/sso/docopts.md create mode 100644 config/sso/opsfile.yml create mode 100644 config/sso/sso.test.ts create mode 100644 config/sso/sso.ts diff --git a/config/docopts.md b/config/docopts.md index d786128..acd882a 100644 --- a/config/docopts.md +++ b/config/docopts.md @@ -26,9 +26,7 @@ Configure OpenServerless Usage: config (enable|disable) [--all] [--redis] [--mongodb] [--minio] [--cron] [--static] [--postgres] [--prometheus] [--slack] [--mail] [--affinity] [--tolerations] [--quota] [--milvus] [--registry] [--seaweedfs] config apihost (|auto) [--tls=] [--protocol=|auto] - config sso keycloak --enable --issuer-url= --jwks-url= (--audience=|--client-id=) --required-group= [--client-secret=] [--username-claim=] [--groups-claim=] [--namespace=] [--configmap=] [--secret=] [--statefulset=] [--container=] [--no-rollout] - config sso show - config sso disable [--namespace=] [--configmap=] [--secret=] [--statefulset=] [--container=] [--no-rollout] + config sso config runtimes [] config slack [--apiurl=] [--channel=] config mail [--mailuser=] [--mailpwd=] [--mailfrom=] [--mailto=] diff --git a/config/sso/docopts.md b/config/sso/docopts.md new file mode 100644 index 0000000..d4ae69c --- /dev/null +++ b/config/sso/docopts.md @@ -0,0 +1,79 @@ + + +# Tasks `ops config sso` + +Configure OpenServerless SSO/OIDC integration for admin-api. + +## Synopsis + +```text +Usage: + sso keycloak --enable --issuer-url= --jwks-url= (--audience=|--client-id=) --required-group= [--client-secret=] [--username-claim=] [--groups-claim=] [--namespace=] [--configmap=] [--secret=] [--statefulset=] [--container=] [--no-rollout] + sso show + sso disable [--namespace=] [--configmap=] [--secret=] [--statefulset=] [--container=] [--no-rollout] +``` + +## Managed resources + +The task owns a dedicated ConfigMap, named +`openserverless-sso-config` by default, with these keys: + +- `OIDC_ISSUER_URL` +- `OIDC_JWKS_URL` +- `OIDC_AUDIENCE` +- `OIDC_CLIENT_ID` +- `OIDC_REQUIRED_GROUP` +- `OIDC_USERNAME_CLAIM` +- `OIDC_GROUPS_CLAIM` +- `SSO_AUTOPROVISION_ON_LOGIN` +- `SSO_AUTOPROVISION_TIMEOUT_SECONDS` +- `SSO_AUTOPROVISION_POLL_SECONDS` +- `SSO_AUTOPROVISION_DEFAULT_SERVICES` +- `SSO_NAMESPACE_PRESERVE_VALID` +- `SSO_NAMESPACE_HASH_LENGTH` +- `SSO_NAMESPACE_MAX_LENGTH` + +With `--client-secret`, the task also owns a dedicated Secret, named +`openserverless-sso-secret` by default, containing only +`OIDC_CLIENT_SECRET`. + +The selected admin-api container receives exact, prefix-free `envFrom` +references to those resources. The task does not manage direct `env` entries, +other `envFrom` references, volumes, volume mounts, or annotations. + +`disable` removes only the managed references and dedicated resources. Missing +resources are not errors. Repeated disable is a no-op for the StatefulSet. +Kubernetes automatically rolls out a changed pod template; `--no-rollout` +skips waiting for it and never issues an additional restart. + +## Options + +```text + --username-claim= OIDC username claim [default: preferred_username] + --groups-claim= OIDC groups claim [default: groups] + --client-id= OIDC client id; defaults to audience + --client-secret= confidential client secret stored only in Kubernetes + --namespace= Kubernetes namespace [default: nuvolaris] + --configmap= ConfigMap name [default: openserverless-sso-config] + --secret= Secret name [default: openserverless-sso-secret] + --statefulset= admin-api StatefulSet [default: nuvolaris-system-api] + --container= admin-api container [default: nuvolaris-system-api] + --no-rollout do not restart or wait for admin-api rollout +``` diff --git a/config/sso/opsfile.yml b/config/sso/opsfile.yml new file mode 100644 index 0000000..1de988f --- /dev/null +++ b/config/sso/opsfile.yml @@ -0,0 +1,60 @@ +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. + +version: "3" + +tasks: + keycloak: + desc: enable Keycloak OIDC for admin-api + silent: true + env: + OPS_SSO_ENABLE: '{{.__enable}}' + OPS_SSO_ISSUER_URL: '{{.__issuer_url}}' + OPS_SSO_JWKS_URL: '{{.__jwks_url}}' + OPS_SSO_AUDIENCE: '{{.__audience}}' + OPS_SSO_CLIENT_ID: '{{.__client_id}}' + OPS_SSO_CLIENT_SECRET: '{{.__client_secret}}' + OPS_SSO_REQUIRED_GROUP: '{{.__required_group}}' + OPS_SSO_USERNAME_CLAIM: '{{.__username_claim}}' + OPS_SSO_GROUPS_CLAIM: '{{.__groups_claim}}' + OPS_SSO_NAMESPACE: '{{.__namespace}}' + OPS_SSO_CONFIGMAP: '{{.__configmap}}' + OPS_SSO_SECRET: '{{.__secret}}' + OPS_SSO_STATEFULSET: '{{.__statefulset}}' + OPS_SSO_CONTAINER: '{{.__container}}' + OPS_SSO_NO_ROLLOUT: '{{.__no_rollout}}' + cmds: + - bun sso.ts keycloak + + show: + desc: show local SSO configuration + silent: true + cmds: + - bun sso.ts show + + disable: + desc: disable SSO/OIDC for admin-api + silent: true + env: + OPS_SSO_NAMESPACE: '{{.__namespace}}' + OPS_SSO_CONFIGMAP: '{{.__configmap}}' + OPS_SSO_SECRET: '{{.__secret}}' + OPS_SSO_STATEFULSET: '{{.__statefulset}}' + OPS_SSO_CONTAINER: '{{.__container}}' + OPS_SSO_NO_ROLLOUT: '{{.__no_rollout}}' + cmds: + - bun sso.ts disable diff --git a/config/sso/sso.test.ts b/config/sso/sso.test.ts new file mode 100644 index 0000000..9eb2306 --- /dev/null +++ b/config/sso/sso.test.ts @@ -0,0 +1,257 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { describe, expect, test } from "bun:test"; +import { + type CommandResult, + type CommandRunner, + type RunOptions, + SSOManager, +} from "./sso"; + +interface RecordedCommand { + command: string[]; + options: RunOptions; +} + +class FakeRunner implements CommandRunner { + readonly commands: RecordedCommand[] = []; + readonly local: Record = {}; + readonly resources = new Map(); + + constructor(public workload: any) {} + + async run(command: string[], options: RunOptions = {}): Promise { + this.commands.push({ command: [...command], options: { ...options } }); + if (command[0] === "test-ops" && command[1] === "-config") { + return this.runConfig(command.slice(2)); + } + if (command[0] !== "kubectl") throw new Error(`unexpected command: ${command.join(" ")}`); + + const action = command.includes("apply") + ? "apply" + : command.includes("get") + ? "get" + : command.includes("patch") + ? "patch" + : command.includes("delete") + ? "delete" + : command.includes("rollout") + ? "rollout" + : "unknown"; + switch (action) { + case "apply": { + const object = JSON.parse(options.stdin || "{}"); + this.resources.set(`${object.kind}/${object.metadata.name}`, object); + return ok(`${object.kind.toLowerCase()}/${object.metadata.name} configured\n`); + } + case "get": + return ok(JSON.stringify(this.workload)); + case "patch": { + const payload = command[command.indexOf("-p") + 1]; + this.applyPatch(JSON.parse(payload)); + return ok("statefulset.apps/nuvolaris-system-api patched\n"); + } + case "delete": { + const kindIndex = command.findIndex((value) => value === "configmap" || value === "secret"); + const key = `${command[kindIndex] === "configmap" ? "ConfigMap" : "Secret"}/${command[kindIndex + 1]}`; + this.resources.delete(key); + return ok(); + } + case "rollout": + return ok(); + default: + throw new Error(`unexpected kubectl command: ${command.join(" ")}`); + } + } + + private runConfig(args: string[]): CommandResult { + if (args[0] === "-dump") { + const stdout = Object.entries(this.local) + .map(([key, value]) => `${key}=${value}`) + .join("\n"); + return ok(stdout ? `${stdout}\n` : ""); + } + if (args[0] === "--remove") { + for (const key of args.slice(1)) delete this.local[key]; + return ok(); + } + for (const pair of args) { + const separator = pair.indexOf("="); + this.local[pair.slice(0, separator)] = pair.slice(separator + 1); + } + return ok(); + } + + private applyPatch(operations: Array<{ op: string; path: string; value?: unknown }>): void { + const envFrom = this.workload.spec.template.spec.containers[0].envFrom ?? []; + for (const operation of operations) { + const finalPart = operation.path.split("/").at(-1)!; + if (operation.op === "test") { + expect(envFrom[Number(finalPart)]).toEqual(operation.value); + } else if (operation.op === "remove") { + envFrom.splice(Number(finalPart), 1); + } else if (operation.op === "add" && finalPart === "-") { + envFrom.push(operation.value); + } else if (operation.op === "add") { + this.workload.spec.template.spec.containers[0].envFrom = structuredClone(operation.value); + } + } + this.workload.spec.template.spec.containers[0].envFrom = envFrom; + } +} + +function ok(stdout = ""): CommandResult { + return { stdout, stderr: "", exitCode: 0 }; +} + +function workload(envFrom: unknown[]): any { + return { + metadata: { + name: "nuvolaris-system-api", + annotations: { "external.example/owner": "platform" }, + }, + spec: { + template: { + metadata: { annotations: { "external.example/template": "preserve" } }, + spec: { + containers: [ + { + name: "nuvolaris-system-api", + image: "example.test/admin-api:latest", + env: [{ name: "APPLICATION_MODE", value: "production" }], + envFrom, + volumeMounts: [{ name: "application-data", mountPath: "/data" }], + }, + ], + volumes: [{ name: "application-data", emptyDir: {} }], + }, + }, + }, + }; +} + +function keycloakEnvironment(overrides: Record = {}): Record { + return { + OPS: "test-ops", + OPS_SSO_ENABLE: "true", + OPS_SSO_ISSUER_URL: "https://keycloak.example.test/realms/openserverless", + OPS_SSO_JWKS_URL: "https://keycloak.example.test/realms/openserverless/protocol/openid-connect/certs", + OPS_SSO_CLIENT_ID: "openserverless-admin-api", + OPS_SSO_CLIENT_SECRET: "test-secret", + OPS_SSO_REQUIRED_GROUP: "openserverless-users", + OPS_SSO_NO_ROLLOUT: "true", + ...overrides, + }; +} + +describe("config sso task", () => { + test("preserves foreign workload fields across enable-disable-enable", async () => { + const foreignEnvFrom = [ + { configMapRef: { name: "application-config" } }, + { secretRef: { name: "database-credentials" } }, + ]; + const state = workload(structuredClone(foreignEnvFrom)); + const originalEnv = structuredClone(state.spec.template.spec.containers[0].env); + const originalMounts = structuredClone(state.spec.template.spec.containers[0].volumeMounts); + const originalVolumes = structuredClone(state.spec.template.spec.volumes); + const originalAnnotations = structuredClone(state.spec.template.metadata.annotations); + const runner = new FakeRunner(state); + const manager = new SSOManager(runner, keycloakEnvironment()); + + await manager.keycloak(); + expect(state.spec.template.spec.containers[0].envFrom).toEqual([ + ...foreignEnvFrom, + { configMapRef: { name: "openserverless-sso-config" } }, + { secretRef: { name: "openserverless-sso-secret" } }, + ]); + expect(runner.local.SSO_ENABLED).toBe("true"); + expect(JSON.stringify(runner.local)).not.toContain("test-secret"); + + await manager.disable(); + expect(state.spec.template.spec.containers[0].envFrom).toEqual(foreignEnvFrom); + expect(runner.local.SSO_ENABLED).toBeUndefined(); + + const patchesAfterFirstDisable = runner.commands.filter((entry) => entry.command.includes("patch")).length; + await manager.disable(); + expect(runner.commands.filter((entry) => entry.command.includes("patch"))).toHaveLength( + patchesAfterFirstDisable, + ); + + await manager.keycloak(); + expect(state.spec.template.spec.containers[0].envFrom).toEqual([ + ...foreignEnvFrom, + { configMapRef: { name: "openserverless-sso-config" } }, + { secretRef: { name: "openserverless-sso-secret" } }, + ]); + expect(state.spec.template.spec.containers[0].env).toEqual(originalEnv); + expect(state.spec.template.spec.containers[0].volumeMounts).toEqual(originalMounts); + expect(state.spec.template.spec.volumes).toEqual(originalVolumes); + expect(state.spec.template.metadata.annotations).toEqual(originalAnnotations); + expect(runner.commands.some((entry) => entry.command.includes("rollout"))).toBeFalse(); + }); + + test("disable removes exact managed references and only waits for the resulting rollout", async () => { + const state = workload([ + { configMapRef: { name: "application-config" } }, + { configMapRef: { name: "openserverless-sso-config" } }, + { secretRef: { name: "openserverless-sso-secret" } }, + ]); + const runner = new FakeRunner(state); + runner.local.SSO_KUBE_NAMESPACE = "nuvolaris"; + runner.local.SSO_KUBE_CONFIGMAP = "openserverless-sso-config"; + runner.local.SSO_KUBE_SECRET = "openserverless-sso-secret"; + runner.local.SSO_KUBE_STATEFULSET = "nuvolaris-system-api"; + runner.local.SSO_KUBE_CONTAINER = "nuvolaris-system-api"; + const manager = new SSOManager(runner, { OPS: "test-ops" }); + + await manager.disable(); + + expect(state.spec.template.spec.containers[0].envFrom).toEqual([ + { configMapRef: { name: "application-config" } }, + ]); + const rolloutCommands = runner.commands + .map((entry) => entry.command) + .filter((command) => command.includes("rollout")); + expect(rolloutCommands).toHaveLength(1); + expect(rolloutCommands[0]).toContain("status"); + expect(rolloutCommands[0]).not.toContain("restart"); + }); + + test("preserves similarly named references that were not created by the task", async () => { + const prefixed = { + prefix: "EXTERNAL_", + configMapRef: { name: "openserverless-sso-config" }, + }; + const optional = { + secretRef: { name: "openserverless-sso-secret", optional: false }, + }; + const state = workload([prefixed, optional]); + const runner = new FakeRunner(state); + const manager = new SSOManager(runner, { + OPS: "test-ops", + OPS_SSO_NO_ROLLOUT: "true", + }); + + await manager.disable(); + + expect(state.spec.template.spec.containers[0].envFrom).toEqual([prefixed, optional]); + expect(runner.commands.some((entry) => entry.command.includes("patch"))).toBeFalse(); + }); +}); diff --git a/config/sso/sso.ts b/config/sso/sso.ts new file mode 100644 index 0000000..891ddb9 --- /dev/null +++ b/config/sso/sso.ts @@ -0,0 +1,463 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export interface CommandResult { + stdout: string; + stderr: string; + exitCode: number; +} + +export interface RunOptions { + stdin?: string; + quiet?: boolean; +} + +export interface CommandRunner { + run(command: string[], options?: RunOptions): Promise; +} + +interface LocalObjectReference { + name: string; + optional?: boolean; +} + +interface EnvFromSource { + prefix?: string; + configMapRef?: LocalObjectReference; + secretRef?: LocalObjectReference; +} + +interface SSOOptions { + issuerURL: string; + jwksURL: string; + audience: string; + clientID: string; + clientSecret: string; + requiredGroup: string; + usernameClaim: string; + groupsClaim: string; + namespace: string; + configMapName: string; + secretName: string; + workloadName: string; + containerName: string; + noRollout: boolean; +} + +interface JSONPatchOperation { + op: "add" | "remove" | "test"; + path: string; + value?: unknown; +} + +const DEFAULTS = { + usernameClaim: "preferred_username", + groupsClaim: "groups", + namespace: "nuvolaris", + configMapName: "openserverless-sso-config", + secretName: "openserverless-sso-secret", + workloadName: "nuvolaris-system-api", + containerName: "nuvolaris-system-api", +}; + +const MANAGED_LOCAL_KEYS = [ + "SSO_ENABLED", + "SSO_PROVIDER", + "SSO_OIDC_ISSUER_URL", + "SSO_OIDC_JWKS_URL", + "SSO_OIDC_AUDIENCE", + "SSO_OIDC_CLIENT_ID", + "SSO_OIDC_REQUIRED_GROUP", + "SSO_OIDC_USERNAME_CLAIM", + "SSO_OIDC_GROUPS_CLAIM", + "SSO_OIDC_CLIENT_SECRET_CONFIGURED", + "SSO_CLIENT_MODE", + "SSO_AUTOPROVISION_ON_LOGIN", + "SSO_AUTOPROVISION_TIMEOUT_SECONDS", + "SSO_AUTOPROVISION_POLL_SECONDS", + "SSO_AUTOPROVISION_DEFAULT_SERVICES", + "SSO_NAMESPACE_PRESERVE_VALID", + "SSO_NAMESPACE_HASH_LENGTH", + "SSO_NAMESPACE_MAX_LENGTH", + "SSO_KUBE_NAMESPACE", + "SSO_KUBE_CONFIGMAP", + "SSO_KUBE_SECRET", + "SSO_KUBE_STATEFULSET", + "SSO_KUBE_CONTAINER", +] as const; + +export class ProcessCommandRunner implements CommandRunner { + async run(command: string[], options: RunOptions = {}): Promise { + const process = Bun.spawn(command, { + stdin: options.stdin === undefined ? "ignore" : "pipe", + stdout: "pipe", + stderr: "pipe", + env: Bun.env, + }); + if (options.stdin !== undefined) { + process.stdin.write(options.stdin); + process.stdin.end(); + } + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited, + ]); + if (!options.quiet) { + if (stdout) processOutput(stdout, false); + if (stderr) processOutput(stderr, true); + } + if (exitCode !== 0) { + const detail = stderr.trim() || stdout.trim(); + throw new Error( + `${command.join(" ")} failed with exit code ${exitCode}${detail ? `: ${detail}` : ""}`, + ); + } + return { stdout, stderr, exitCode }; + } +} + +function processOutput(output: string, error: boolean): void { + (error ? process.stderr : process.stdout).write(output); +} + +export class SSOManager { + constructor( + private readonly runner: CommandRunner, + private readonly env: Record = Bun.env, + ) {} + + async keycloak(): Promise { + if (this.env.OPS_SSO_ENABLE !== "true") { + throw new Error("missing --enable"); + } + const local = await this.readLocalConfig(); + const options = this.options(local, true); + this.validateKeycloak(options); + + await this.applyConfigMap(options); + if (options.clientSecret) { + await this.applySecret(options); + } else { + await this.deleteSecret(options); + } + await this.reconcileEnvFrom(options, true); + await this.saveLocalConfig(options); + + if (!options.noRollout) { + await this.rolloutRestart(options); + } + + console.log("SSO configuration applied to admin-api."); + console.log(`ConfigMap: ${options.namespace}/${options.configMapName}`); + if (options.clientSecret) { + console.log(`Secret: ${options.namespace}/${options.secretName}`); + } + } + + async show(): Promise { + const values = await this.readLocalConfig(); + for (const key of Object.keys(values).filter((key) => key.startsWith("SSO_")).sort()) { + console.log(`${key}=${values[key]}`); + } + } + + async disable(): Promise { + const local = await this.readLocalConfig(); + const options = this.options(local, false); + + const workloadChanged = await this.reconcileEnvFrom(options, false); + await this.deleteConfigMap(options); + await this.deleteSecret(options); + await this.removeLocalConfig(local); + + if (!options.noRollout && workloadChanged) { + await this.waitForRollout(options); + } + console.log("SSO configuration disabled for admin-api."); + } + + private options(local: Record, enabling: boolean): SSOOptions { + const option = (environment: string, localKey: string, fallback: string): string => + this.env[environment] || local[localKey] || fallback; + const clientID = this.env.OPS_SSO_CLIENT_ID || ""; + const audience = this.env.OPS_SSO_AUDIENCE || clientID; + + return { + issuerURL: this.env.OPS_SSO_ISSUER_URL || "", + jwksURL: this.env.OPS_SSO_JWKS_URL || "", + audience, + clientID: clientID || audience, + clientSecret: this.env.OPS_SSO_CLIENT_SECRET || "", + requiredGroup: this.env.OPS_SSO_REQUIRED_GROUP || "", + usernameClaim: this.env.OPS_SSO_USERNAME_CLAIM || DEFAULTS.usernameClaim, + groupsClaim: this.env.OPS_SSO_GROUPS_CLAIM || DEFAULTS.groupsClaim, + namespace: option("OPS_SSO_NAMESPACE", "SSO_KUBE_NAMESPACE", DEFAULTS.namespace), + configMapName: option("OPS_SSO_CONFIGMAP", "SSO_KUBE_CONFIGMAP", DEFAULTS.configMapName), + secretName: option("OPS_SSO_SECRET", "SSO_KUBE_SECRET", DEFAULTS.secretName), + workloadName: option("OPS_SSO_STATEFULSET", "SSO_KUBE_STATEFULSET", DEFAULTS.workloadName), + containerName: option("OPS_SSO_CONTAINER", "SSO_KUBE_CONTAINER", DEFAULTS.containerName), + noRollout: this.env.OPS_SSO_NO_ROLLOUT === "true", + } satisfies SSOOptions; + } + + private validateKeycloak(options: SSOOptions): void { + const required: Array<[string, string]> = [ + ["--issuer-url", options.issuerURL], + ["--jwks-url", options.jwksURL], + ["--audience or --client-id", options.audience], + ["--required-group", options.requiredGroup], + ["--username-claim", options.usernameClaim], + ["--groups-claim", options.groupsClaim], + ["--secret", options.secretName], + ]; + for (const [name, value] of required) { + if (!value) throw new Error(`missing ${name}`); + } + } + + private async readLocalConfig(): Promise> { + const result = await this.runner.run([this.ops(), "-config", "-dump"], { quiet: true }); + const values: Record = {}; + for (const line of result.stdout.split("\n")) { + const separator = line.indexOf("="); + if (separator <= 0) continue; + values[line.slice(0, separator)] = line.slice(separator + 1); + } + return values; + } + + private async saveLocalConfig(options: SSOOptions): Promise { + const values: Record = { + SSO_ENABLED: "true", + SSO_PROVIDER: "keycloak", + SSO_OIDC_ISSUER_URL: options.issuerURL, + SSO_OIDC_JWKS_URL: options.jwksURL, + SSO_OIDC_AUDIENCE: options.audience, + SSO_OIDC_CLIENT_ID: options.clientID, + SSO_OIDC_REQUIRED_GROUP: options.requiredGroup, + SSO_OIDC_USERNAME_CLAIM: options.usernameClaim, + SSO_OIDC_GROUPS_CLAIM: options.groupsClaim, + SSO_OIDC_CLIENT_SECRET_CONFIGURED: String(Boolean(options.clientSecret)), + SSO_CLIENT_MODE: options.clientSecret ? "confidential" : "public", + SSO_AUTOPROVISION_ON_LOGIN: "true", + SSO_AUTOPROVISION_TIMEOUT_SECONDS: "120", + SSO_AUTOPROVISION_POLL_SECONDS: "2", + SSO_AUTOPROVISION_DEFAULT_SERVICES: "all", + SSO_NAMESPACE_PRESERVE_VALID: "true", + SSO_NAMESPACE_HASH_LENGTH: "8", + SSO_NAMESPACE_MAX_LENGTH: "61", + SSO_KUBE_NAMESPACE: options.namespace, + SSO_KUBE_CONFIGMAP: options.configMapName, + SSO_KUBE_SECRET: options.secretName, + SSO_KUBE_STATEFULSET: options.workloadName, + SSO_KUBE_CONTAINER: options.containerName, + }; + await this.runner.run([ + this.ops(), + "-config", + ...Object.entries(values).map(([key, value]) => `${key}=${value}`), + ]); + } + + private async removeLocalConfig(local: Record): Promise { + const keys = MANAGED_LOCAL_KEYS.filter((key) => Object.hasOwn(local, key)); + if (keys.length === 0) return; + await this.runner.run([this.ops(), "-config", "--remove", ...keys]); + } + + private async applyConfigMap(options: SSOOptions): Promise { + const object = { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: options.configMapName, namespace: options.namespace }, + data: { + OIDC_ISSUER_URL: options.issuerURL, + OIDC_JWKS_URL: options.jwksURL, + OIDC_AUDIENCE: options.audience, + OIDC_CLIENT_ID: options.clientID, + OIDC_REQUIRED_GROUP: options.requiredGroup, + OIDC_USERNAME_CLAIM: options.usernameClaim, + OIDC_GROUPS_CLAIM: options.groupsClaim, + SSO_AUTOPROVISION_ON_LOGIN: "true", + SSO_AUTOPROVISION_TIMEOUT_SECONDS: "120", + SSO_AUTOPROVISION_POLL_SECONDS: "2", + SSO_AUTOPROVISION_DEFAULT_SERVICES: "all", + SSO_NAMESPACE_PRESERVE_VALID: "true", + SSO_NAMESPACE_HASH_LENGTH: "8", + SSO_NAMESPACE_MAX_LENGTH: "61", + }, + }; + await this.runner.run(["kubectl", "apply", "-f", "-"], { stdin: JSON.stringify(object) }); + } + + private async applySecret(options: SSOOptions): Promise { + const object = { + apiVersion: "v1", + kind: "Secret", + metadata: { name: options.secretName, namespace: options.namespace }, + type: "Opaque", + stringData: { OIDC_CLIENT_SECRET: options.clientSecret }, + }; + await this.runner.run(["kubectl", "apply", "-f", "-"], { stdin: JSON.stringify(object) }); + } + + private async deleteConfigMap(options: SSOOptions): Promise { + await this.runner.run([ + "kubectl", "-n", options.namespace, "delete", "configmap", options.configMapName, "--ignore-not-found", + ]); + } + + private async deleteSecret(options: SSOOptions): Promise { + await this.runner.run([ + "kubectl", "-n", options.namespace, "delete", "secret", options.secretName, "--ignore-not-found", + ]); + } + + private async reconcileEnvFrom(options: SSOOptions, enabled: boolean): Promise { + const result = await this.runner.run([ + "kubectl", "-n", options.namespace, "get", "statefulset", options.workloadName, "-o", "json", + ], { quiet: true }); + const workload = JSON.parse(result.stdout); + const containers = workload?.spec?.template?.spec?.containers; + if (!Array.isArray(containers)) { + throw new Error(`statefulset ${options.namespace}/${options.workloadName} has no containers`); + } + const containerIndex = containers.findIndex((container: { name?: string }) => + container.name === options.containerName + ); + if (containerIndex < 0) { + throw new Error( + `container ${options.containerName} not found in statefulset ${options.namespace}/${options.workloadName}`, + ); + } + + const container = containers[containerIndex]; + const current: EnvFromSource[] = Array.isArray(container.envFrom) ? container.envFrom : []; + const desired: EnvFromSource[] = enabled + ? [ + { configMapRef: { name: options.configMapName } }, + ...(options.clientSecret ? [{ secretRef: { name: options.secretName } }] : []), + ] + : []; + + const seen = new Set(); + const removeIndexes: number[] = []; + current.forEach((source, index) => { + const key = managedReferenceKey(source, options); + if (!key) return; + if (enabled && desired.some((item) => referenceKey(item) === key) && !seen.has(key)) { + seen.add(key); + } else { + removeIndexes.push(index); + } + }); + const missing = desired.filter((source) => !seen.has(referenceKey(source))); + if (removeIndexes.length === 0 && missing.length === 0) return false; + + const path = `/spec/template/spec/containers/${containerIndex}/envFrom`; + const patch: JSONPatchOperation[] = []; + for (const index of removeIndexes.toReversed()) { + patch.push({ op: "test", path: `${path}/${index}`, value: current[index] }); + patch.push({ op: "remove", path: `${path}/${index}` }); + } + if (current.length === 0) { + patch.push({ op: "add", path, value: missing }); + } else { + for (const source of missing) { + patch.push({ op: "add", path: `${path}/-`, value: source }); + } + } + + await this.runner.run([ + "kubectl", "-n", options.namespace, "patch", "statefulset", options.workloadName, + "--type=json", "-p", JSON.stringify(patch), + ]); + return true; + } + + private async rolloutRestart(options: SSOOptions): Promise { + await this.runner.run([ + "kubectl", "-n", options.namespace, "rollout", "restart", `statefulset/${options.workloadName}`, + ]); + await this.waitForRollout(options); + } + + private async waitForRollout(options: SSOOptions): Promise { + await this.runner.run([ + "kubectl", "-n", options.namespace, "rollout", "status", `statefulset/${options.workloadName}`, + "--timeout=180s", + ]); + } + + private ops(): string { + return this.env.OPS || "ops"; + } +} + +function managedReferenceKey(source: EnvFromSource, options: SSOOptions): string { + if (source.prefix) return ""; + if ( + source.configMapRef?.name === options.configMapName && + source.configMapRef.optional === undefined && + source.secretRef === undefined + ) { + return `configmap:${options.configMapName}`; + } + if ( + source.secretRef?.name === options.secretName && + source.secretRef.optional === undefined && + source.configMapRef === undefined + ) { + return `secret:${options.secretName}`; + } + return ""; +} + +function referenceKey(source: EnvFromSource): string { + if (source.configMapRef) return `configmap:${source.configMapRef.name}`; + if (source.secretRef) return `secret:${source.secretRef.name}`; + return ""; +} + +async function main(): Promise { + const command = process.argv[2]; + const manager = new SSOManager(new ProcessCommandRunner()); + switch (command) { + case "keycloak": + await manager.keycloak(); + return; + case "show": + await manager.show(); + return; + case "disable": + await manager.disable(); + return; + default: + throw new Error(`unknown SSO command: ${command || ""}`); + } +} + +if (import.meta.main) { + main().catch((error) => { + console.error(`error: ${error instanceof Error ? error.message : String(error)}`); + process.exit(1); + }); +}