This is an automated email from the ASF dual-hosted git repository.

sciabarracom pushed a commit to branch 0.9.1
in repository https://gitbox.apache.org/repos/asf/openserverless-task.git

commit 8b15bbb7e786e741ca2883e72daa3131de6a282a
Author: Trustable User <[email protected]>
AuthorDate: Sun Jul 12 21:15:51 2026 +0200

    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(-)

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 (<apihost>|auto) [--tls=<email>] 
[--protocol=<http/https>|auto]
-  config sso keycloak --enable --issuer-url=<issuer-url> --jwks-url=<jwks-url> 
(--audience=<audience>|--client-id=<client-id>) --required-group=<group> 
[--client-secret=<client-secret>] [--username-claim=<claim>] 
[--groups-claim=<claim>] [--namespace=<namespace>] [--configmap=<name>] 
[--secret=<name>] [--statefulset=<name>] [--container=<name>] [--no-rollout]
-  config sso show
-  config sso disable [--namespace=<namespace>] [--configmap=<name>] 
[--secret=<name>] [--statefulset=<name>] [--container=<name>] [--no-rollout]
+  config sso
   config runtimes [<runtimesjson>]  
   config slack [--apiurl=<slackapiurl>] [--channel=<slackchannel>]
   config mail  [--mailuser=<mailuser>] [--mailpwd=<mailpwd>] 
[--mailfrom=<mailfrom>] [--mailto=<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 @@
+<!---
+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.
+-->
+
+# Tasks `ops config sso`
+
+Configure OpenServerless SSO/OIDC integration for admin-api.
+
+## Synopsis
+
+```text
+Usage:
+  sso keycloak --enable --issuer-url=<issuer-url> --jwks-url=<jwks-url> 
(--audience=<audience>|--client-id=<client-id>) --required-group=<group> 
[--client-secret=<client-secret>] [--username-claim=<claim>] 
[--groups-claim=<claim>] [--namespace=<namespace>] [--configmap=<name>] 
[--secret=<name>] [--statefulset=<name>] [--container=<name>] [--no-rollout]
+  sso show
+  sso disable [--namespace=<namespace>] [--configmap=<name>] [--secret=<name>] 
[--statefulset=<name>] [--container=<name>] [--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=<claim>      OIDC username claim [default: 
preferred_username]
+  --groups-claim=<claim>        OIDC groups claim [default: groups]
+  --client-id=<client-id>       OIDC client id; defaults to audience
+  --client-secret=<secret>      confidential client secret stored only in 
Kubernetes
+  --namespace=<namespace>       Kubernetes namespace [default: nuvolaris]
+  --configmap=<name>            ConfigMap name [default: 
openserverless-sso-config]
+  --secret=<name>               Secret name [default: 
openserverless-sso-secret]
+  --statefulset=<name>          admin-api StatefulSet [default: 
nuvolaris-system-api]
+  --container=<name>            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<string, string> = {};
+  readonly resources = new Map<string, unknown>();
+
+  constructor(public workload: any) {}
+
+  async run(command: string[], options: RunOptions = {}): 
Promise<CommandResult> {
+    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<string, string> = {}): 
Record<string, string> {
+  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<CommandResult>;
+}
+
+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<CommandResult> {
+    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<string, string | undefined> = Bun.env,
+  ) {}
+
+  async keycloak(): Promise<void> {
+    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<void> {
+    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<void> {
+    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<string, string>, 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<Record<string, string>> {
+    const result = await this.runner.run([this.ops(), "-config", "-dump"], { 
quiet: true });
+    const values: Record<string, string> = {};
+    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<void> {
+    const values: Record<string, string> = {
+      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<string, string>): 
Promise<void> {
+    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<void> {
+    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<void> {
+    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<void> {
+    await this.runner.run([
+      "kubectl", "-n", options.namespace, "delete", "configmap", 
options.configMapName, "--ignore-not-found",
+    ]);
+  }
+
+  private async deleteSecret(options: SSOOptions): Promise<void> {
+    await this.runner.run([
+      "kubectl", "-n", options.namespace, "delete", "secret", 
options.secretName, "--ignore-not-found",
+    ]);
+  }
+
+  private async reconcileEnvFrom(options: SSOOptions, enabled: boolean): 
Promise<boolean> {
+    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<string>();
+    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<void> {
+    await this.runner.run([
+      "kubectl", "-n", options.namespace, "rollout", "restart", 
`statefulset/${options.workloadName}`,
+    ]);
+    await this.waitForRollout(options);
+  }
+
+  private async waitForRollout(options: SSOOptions): Promise<void> {
+    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<void> {
+  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 || "<empty>"}`);
+  }
+}
+
+if (import.meta.main) {
+  main().catch((error) => {
+    console.error(`error: ${error instanceof Error ? error.message : 
String(error)}`);
+    process.exit(1);
+  });
+}

Reply via email to