From 8653b1879136d4b2ac71fd0d980df8a74357a2f7 Mon Sep 17 00:00:00 2001 From: miki3421 Date: Wed, 1 Jul 2026 16:37:00 +0000 Subject: [PATCH 01/12] Add SSO admin tasks and IDE login support --- admin/docopts.md | 7 +++++-- admin/opsfile.yml | 11 ++++++++++- admin/sso/docopts.md | 33 +++++++++++++++++++++++++++++++++ admin/sso/opsfile.yml | 38 ++++++++++++++++++++++++++++++++++++++ config/docopts.md | 3 +++ ide/opsfile.yml | 28 +++++++++++++++++++++++----- 6 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 admin/sso/docopts.md create mode 100644 admin/sso/opsfile.yml diff --git a/admin/docopts.md b/admin/docopts.md index 19c836f3..4ce482da 100644 --- a/admin/docopts.md +++ b/admin/docopts.md @@ -28,8 +28,9 @@ You can create namespaces and choose which services to enable. ```text Usage: admin adduser [--all] [--redis] [--mongodb] [--minio|--seaweedfs] [--postgres] [--milvus] [--storagequota=|auto] - admin deleteuser + admin deleteuser [--confirm-namespace-delete] admin listuser [] + admin sso unbind admin compact [--ttl=|10] admin usage [--debug] ``` @@ -39,6 +40,7 @@ Usage: admin adduser create a new user in OpenServerless with the username, email and password provided admin deleteuser delete a user from the OpenServerless installation via the username provided admin listuser list all the secrets of an user (default list all the users) + admin sso unbind remove SSO binding while preserving the OpenServerless namespace admin compact create a one shot job which executes couchdb compact against all available dbs admin usage calculates and displays PVC disk usage statistics for bound volumes. Shows Total, Size and Available storage per PVC ``` @@ -53,6 +55,7 @@ Usage: --postgres enable postgres --milvus enable milvus vector db --storagequota= + --confirm-namespace-delete explicitly delete a namespace on an SSO-enabled cluster --ttl= modify the job ttl after finished (defaults to 10 seconds) --debug enable debug logging -``` \ No newline at end of file +``` diff --git a/admin/opsfile.yml b/admin/opsfile.yml index 415699ce..606ea30c 100644 --- a/admin/opsfile.yml +++ b/admin/opsfile.yml @@ -44,6 +44,15 @@ tasks: silent: true cmds: - | + if test "$SSO_ENABLED" = "true" && ! {{.__confirm_namespace_delete}} + then + echo "SSO is enabled on this cluster." + echo "User administration is delegated to the remote authentication provider." + echo "This command would delete the OpenServerless namespace and its resources." + echo "Use 'ops admin sso unbind {{._username_}}' to disable SSO access while preserving the namespace." + echo "Re-run with '--confirm-namespace-delete' only when you really want to deprovision the namespace." + exit 1 + fi {{.DRY}} kubectl -n nuvolaris delete wsku/{{._username_}} {{.DRY}} $OPS -config -r {{._username_}} @@ -188,4 +197,4 @@ tasks: echo 'flag is {{.__debug}}' FLAG=--debug fi - bun usage/usage.js $FLAG \ No newline at end of file + bun usage/usage.js $FLAG diff --git a/admin/sso/docopts.md b/admin/sso/docopts.md new file mode 100644 index 00000000..7a05b965 --- /dev/null +++ b/admin/sso/docopts.md @@ -0,0 +1,33 @@ + +# Tasks `ops admin sso` + +Manage SSO bindings for OpenServerless namespaces. + +## Synopsis + +```text +Usage: + sso unbind +``` + +## Commands +``` + sso unbind disable SSO login for a namespace while preserving its resources +``` diff --git a/admin/sso/opsfile.yml b/admin/sso/opsfile.yml new file mode 100644 index 00000000..edfe7237 --- /dev/null +++ b/admin/sso/opsfile.yml @@ -0,0 +1,38 @@ +# 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" + +env: + KUBECONFIG: + sh: | + if test -e "$OPS_TMP/kubeconfig" + then echo "$OPS_TMP/kubeconfig" + else echo ~/.kube/config + fi + +tasks: + + unbind: + desc: disable SSO login for a namespace while preserving resources + silent: true + cmds: + - | + kubectl -n nuvolaris annotate wsku/{{._username_}} openserverless.apache.org/sso-disabled=true --overwrite + echo "SSO login disabled for namespace {{._username_}}." + echo "The OpenServerless namespace and its resources were preserved." + echo "To delete them explicitly, run: ops admin deleteuser {{._username_}} --confirm-namespace-delete" diff --git a/config/docopts.md b/config/docopts.md index 54b69bd7..d1cd4848 100644 --- a/config/docopts.md +++ b/config/docopts.md @@ -26,6 +26,8 @@ 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= --required-group= [--username-claim=] [--groups-claim=] [--namespace=] [--configmap=] [--statefulset=] [--container=] [--no-rollout] + config sso (show|disable) config runtimes [] config slack [--apiurl=] [--channel=] config mail [--mailuser=] [--mailpwd=] [--mailfrom=] [--mailto=] @@ -58,6 +60,7 @@ Usage: ``` config apihost configure the apihost (auto: auto assign) and enable tls + config sso configure SSO/OIDC integration for admin-api; run `ops config sso --help` for details config runtime show the current runtime.json or import the if provided config enable enable OpenServerless services to install config disable disable OpenServerless services to install diff --git a/ide/opsfile.yml b/ide/opsfile.yml index b8204590..1c15d7b1 100644 --- a/ide/opsfile.yml +++ b/ide/opsfile.yml @@ -144,7 +144,10 @@ tasks: then export OPSDEV_APIHOST="{{._apihost_}}" fi echo "*** Configuring Access to OpenServerless ***" - if test -z "{{._username_}}" + if test "$SSO_ENABLED" = "true" + then + OPSDEV_USERNAME="" + elif test -z "{{._username_}}" then if test -n "$OPS_USER" then @@ -170,15 +173,31 @@ tasks: fi else export OPSDEV_APIHOST="{{._apihost_}}" fi - echo "Logging user $OPSDEV_USERNAME in $OPSDEV_APIHOST" + if test -n "$OPSDEV_USERNAME" + then echo "Logging user $OPSDEV_USERNAME in $OPSDEV_APIHOST" + else echo "Logging in $OPSDEV_APIHOST with SSO" + fi if test -n "$OPS_PASSWORD" then export "OPS_PASSWORD" fi # TO FIX: workaround to a problem on ops that ignores the paratemer if OPS_USER is set - export OPS_USER="$OPSDEV_USERNAME" + if test -n "$OPSDEV_USERNAME" + then export OPS_USER="$OPSDEV_USERNAME" + else unset OPS_USER + fi export OPS_APIHOST="$OPSDEV_APIHOST" - if ops -login + if $OPS -login then + source ~/.wskprops + if test -z "$OPSDEV_USERNAME" + then + OPSDEV_USERNAME="$($OPS -config STATUS_LOGGED_USER)" + if test -z "$OPSDEV_USERNAME" + then + OPSDEV_USERNAME="$($OPS -wsk property get --namespace | awk '{ print $3 }')" + fi + test -n "$OPSDEV_USERNAME" || die "cannot determine OpenServerless namespace after SSO login" + fi OPSDEV_HOST_PROT="$(echo "$OPSDEV_APIHOST" | awk -F '://' '{print $1}')" OPSDEV_HOST_URL="$(echo "$OPSDEV_APIHOST" | awk -F '://' '{print $2}')" OPSDEV_STREAM="${OPSDEV_APIHOST/\/\////stream.}" @@ -193,7 +212,6 @@ tasks: config VITE_OPSDEV_S3="$OPSDEV_S3" config VITE_OPSDEV_HOST="$OPSDEV_HOST" config VITE_OPSDEV_APIHOST="$OPSDEV_APIHOST" - source ~/.wskprops fi if {{.__pin}} then From 4dfc9232f0891ca3ccfa2c05e10ca15bfc8f9e6f Mon Sep 17 00:00:00 2001 From: miki3421 Date: Thu, 9 Jul 2026 18:36:22 +0000 Subject: [PATCH 02/12] Add SSO columns to WhiskUser listing --- setup/kubernetes/crds/whisk-user-crd.yaml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/setup/kubernetes/crds/whisk-user-crd.yaml b/setup/kubernetes/crds/whisk-user-crd.yaml index 2fc7402f..5a5f133a 100644 --- a/setup/kubernetes/crds/whisk-user-crd.yaml +++ b/setup/kubernetes/crds/whisk-user-crd.yaml @@ -245,4 +245,24 @@ spec: type: string priority: 0 jsonPath: .status.whisk_user_create.storage_route - description: Route \ No newline at end of file + description: Route + - name: SSO + type: string + priority: 0 + jsonPath: .metadata.annotations.openserverless\.apache\.org/auth-mode + description: SSO authentication mode + - name: SSOProvider + type: string + priority: 1 + jsonPath: .metadata.annotations.openserverless\.apache\.org/auth-provider + description: SSO authentication provider + - name: SSOUser + type: string + priority: 1 + jsonPath: .metadata.annotations.openserverless\.apache\.org/sso-username + description: External SSO username + - name: SSODisabled + type: string + priority: 1 + jsonPath: .metadata.annotations.openserverless\.apache\.org/sso-disabled + description: SSO login disabled flag From 3a28907ae0e0e2b16220505256b4b0ecf29b6338 Mon Sep 17 00:00:00 2001 From: miki3421 Date: Thu, 9 Jul 2026 19:03:42 +0000 Subject: [PATCH 03/12] Document SSO client secret options --- config/docopts.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/config/docopts.md b/config/docopts.md index d1cd4848..d7861282 100644 --- a/config/docopts.md +++ b/config/docopts.md @@ -26,8 +26,9 @@ 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= --required-group= [--username-claim=] [--groups-claim=] [--namespace=] [--configmap=] [--statefulset=] [--container=] [--no-rollout] - config sso (show|disable) + 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 runtimes [] config slack [--apiurl=] [--channel=] config mail [--mailuser=] [--mailpwd=] [--mailfrom=] [--mailto=] From 3e6966f20d47e0db799e599df15399866e3c5282 Mon Sep 17 00:00:00 2001 From: miki3421 Date: Thu, 9 Jul 2026 21:01:03 +0000 Subject: [PATCH 04/12] Pass SSO username for password login flow --- ide/opsfile.yml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/ide/opsfile.yml b/ide/opsfile.yml index be2f8a95..9bb80834 100644 --- a/ide/opsfile.yml +++ b/ide/opsfile.yml @@ -146,7 +146,7 @@ tasks: then export OPSDEV_APIHOST="{{._apihost_}}" fi echo "*** Configuring Access to OpenServerless ***" - if test "$SSO_ENABLED" = "true" + if test "$SSO_ENABLED" = "true" && test "$OPS_SSO_LOGIN_FLOW" != "password" then OPSDEV_USERNAME="" elif test -z "{{._username_}}" @@ -387,4 +387,3 @@ tasks: nodejs: desc: nodejs subcommand - From 86b17616d375f2c7ee815802678d8a85877f8783 Mon Sep 17 00:00:00 2001 From: miki3421 <42601846+miki3421@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:03:59 +0200 Subject: [PATCH 05/12] Update systemapi version in opsroot.json SSO integration --- opsroot.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opsroot.json b/opsroot.json index 585b06cd..6f0e241d 100644 --- a/opsroot.json +++ b/opsroot.json @@ -9,7 +9,7 @@ "controller": "ghcr.io/nuvolaris/openwhisk-controller:3.1.0-mastrogpt.2402101445", "invoker": "ghcr.io/nuvolaris/openwhisk-invoker:3.1.0-mastrogpt.2402101445", "streamer": "docker.io/apache/openserverless-streamer:0.1.0-incubating.2505031325", - "systemapi": "docker.io/apache/openserverless-admin-api:0.1.0-incubating.2512101357", + "systemapi": "docker.io/apache/openserverless-admin-api:0.9.1-incubating.2607101525", "devcontainer": "apache/openserverless-devcontainer:0.1.0-incubating.2603111653", "ingress": "registry.k8s.io/ingress-nginx/controller:v1.7.0", "couchdb": "docker.io/apache/couchdb:2.3", From e2fc8e7a4c3cbe77e22d000afbdc45f8308184c9 Mon Sep 17 00:00:00 2001 From: Trustable User Date: Sat, 11 Jul 2026 18:54:56 +0200 Subject: [PATCH 06/12] Update system API image for 0.9.1 --- opsroot.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opsroot.json b/opsroot.json index 6f0e241d..24b1297d 100644 --- a/opsroot.json +++ b/opsroot.json @@ -9,7 +9,7 @@ "controller": "ghcr.io/nuvolaris/openwhisk-controller:3.1.0-mastrogpt.2402101445", "invoker": "ghcr.io/nuvolaris/openwhisk-invoker:3.1.0-mastrogpt.2402101445", "streamer": "docker.io/apache/openserverless-streamer:0.1.0-incubating.2505031325", - "systemapi": "docker.io/apache/openserverless-admin-api:0.9.1-incubating.2607101525", + "systemapi": "docker.io/apache/openserverless-admin-api:0.9.1-incubating.2607111849", "devcontainer": "apache/openserverless-devcontainer:0.1.0-incubating.2603111653", "ingress": "registry.k8s.io/ingress-nginx/controller:v1.7.0", "couchdb": "docker.io/apache/couchdb:2.3", From 1ba3a6c0aa74164de77368ef6a4252ceb8a5548d Mon Sep 17 00:00:00 2001 From: Trustable User Date: Sun, 12 Jul 2026 11:06:47 +0200 Subject: [PATCH 07/12] Preserve explicit IDE workspace in device login --- ide/docopts.md | 1 + ide/opsfile.yml | 9 ++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/ide/docopts.md b/ide/docopts.md index 85821b68..0ff7c38a 100644 --- a/ide/docopts.md +++ b/ide/docopts.md @@ -68,4 +68,5 @@ Usage: --fast Skip the initial deployment step and go in incremental update mode --pin Pin the current auth in the .env to check you are not deploying on the wrong user --mode this will load from .env. after .env + Optional workspace binding; omit it for SSO device flow to use the authenticated identity ``` diff --git a/ide/opsfile.yml b/ide/opsfile.yml index 9bb80834..02d260d5 100644 --- a/ide/opsfile.yml +++ b/ide/opsfile.yml @@ -146,7 +146,7 @@ tasks: then export OPSDEV_APIHOST="{{._apihost_}}" fi echo "*** Configuring Access to OpenServerless ***" - if test "$SSO_ENABLED" = "true" && test "$OPS_SSO_LOGIN_FLOW" != "password" + if test "$SSO_ENABLED" = "true" && test "$OPS_SSO_LOGIN_FLOW" != "password" && test -z "{{._username_}}" then OPSDEV_USERNAME="" elif test -z "{{._username_}}" @@ -188,7 +188,11 @@ tasks: else unset OPS_USER fi export OPS_APIHOST="$OPSDEV_APIHOST" - if $OPS -login + LOGIN_ARGS=() + if test -n "$OPSDEV_USERNAME" + then LOGIN_ARGS=("$OPSDEV_USERNAME") + fi + if $OPS -login "${LOGIN_ARGS[@]}" then source ~/.wskprops if test -z "$OPSDEV_USERNAME" @@ -386,4 +390,3 @@ tasks: nodejs: desc: nodejs subcommand - From 3c49fe04b6bf8b06943b90424a6bd08c63b358e0 Mon Sep 17 00:00:00 2001 From: Trustable User Date: Sun, 12 Jul 2026 12:01:36 +0200 Subject: [PATCH 08/12] Update Bun to 1.3.14 --- ide/install-dependencies.test.ts | 70 ++++++++++++++++ ide/install-dependencies.ts | 133 +++++++++++++++++++++++++++++++ ide/opsfile.yml | 26 +----- prereq.yml | 6 +- tests/opsfile.yml | 2 +- 5 files changed, 210 insertions(+), 27 deletions(-) create mode 100644 ide/install-dependencies.test.ts create mode 100644 ide/install-dependencies.ts diff --git a/ide/install-dependencies.test.ts b/ide/install-dependencies.test.ts new file mode 100644 index 00000000..0e0aa40c --- /dev/null +++ b/ide/install-dependencies.test.ts @@ -0,0 +1,70 @@ +/* + * 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 { expect, test } from "bun:test"; +import { + access, + mkdtemp, + mkdir, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { installDependencies } from "./install-dependencies"; + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +test("replaces a partial install and skips a verified install", async () => { + const projectDir = await mkdtemp(join(tmpdir(), "ops-bun-install-test-")); + const nodeModules = join(projectDir, "node_modules"); + + try { + await writeFile( + join(projectDir, "package.json"), + JSON.stringify({ name: "dependency-test", private: true }), + ); + await mkdir(nodeModules); + await writeFile(join(nodeModules, "partial-download"), "incomplete"); + + expect(await installDependencies(projectDir)).toBe(true); + expect(await exists(join(nodeModules, "partial-download"))).toBe(false); + + const state = await readFile( + join(nodeModules, ".ops-bun-install-state"), + "utf8", + ); + expect(state.trim()).toHaveLength(64); + + await writeFile(join(nodeModules, "verified-install"), "keep"); + expect(await installDependencies(projectDir)).toBe(false); + expect(await exists(join(nodeModules, "verified-install"))).toBe(true); + } finally { + await rm(projectDir, { recursive: true, force: true }); + } +}); diff --git a/ide/install-dependencies.ts b/ide/install-dependencies.ts new file mode 100644 index 00000000..7afd1470 --- /dev/null +++ b/ide/install-dependencies.ts @@ -0,0 +1,133 @@ +/* + * 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 { createHash } from "node:crypto"; +import { + access, + mkdir, + mkdtemp, + readFile, + rm, + writeFile, +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { basename, join, resolve } from "node:path"; + +const stateFileName = ".ops-bun-install-state"; +const dependencyInputs = [ + "package.json", + "bun.lock", + "bun.lockb", + "package-lock.json", + "pnpm-lock.yaml", + "yarn.lock", +]; + +async function exists(path: string): Promise { + try { + await access(path); + return true; + } catch { + return false; + } +} + +async function dependencyState(projectDir: string): Promise { + const hash = createHash("sha256"); + hash.update(`bun:${Bun.version}\n`); + + for (const name of dependencyInputs) { + const path = join(projectDir, name); + if (await exists(path)) { + hash.update(`${name}\0`); + hash.update(await readFile(path)); + } + } + + return hash.digest("hex"); +} + +export async function installDependencies(projectPath: string): Promise { + const projectDir = resolve(projectPath); + const packageJson = join(projectDir, "package.json"); + if (!(await exists(packageJson))) { + throw new Error(`package.json not found in ${projectDir}`); + } + + const nodeModules = join(projectDir, "node_modules"); + const stateFile = join(nodeModules, stateFileName); + const expectedState = await dependencyState(projectDir); + + if (await exists(stateFile)) { + const installedState = (await readFile(stateFile, "utf8")).trim(); + if (installedState === expectedState) { + return false; + } + } + + await rm(nodeModules, { recursive: true, force: true }); + const cacheDir = await mkdtemp(join(tmpdir(), "openserverless-bun-cache-")); + + try { + const install = Bun.spawn( + [ + process.execPath, + "install", + "--no-save", + "--no-cache", + "--cache-dir", + cacheDir, + "--network-concurrency=1", + ], + { + cwd: projectDir, + env: process.env, + stdout: "inherit", + stderr: "inherit", + }, + ); + const exitCode = await install.exited; + if (exitCode !== 0) { + throw new Error( + `bun install failed with exit code ${exitCode} in ${projectDir}`, + ); + } + + await mkdir(nodeModules, { recursive: true }); + await writeFile(stateFile, `${expectedState}\n`); + return true; + } finally { + await rm(cacheDir, { recursive: true, force: true }); + } +} + +if (import.meta.main) { + const projectDir = Bun.argv[2]; + if (!projectDir) { + console.error(`Usage: bun ${basename(Bun.argv[1])} `); + process.exit(2); + } + + try { + await installDependencies(projectDir); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exit(1); + } +} diff --git a/ide/opsfile.yml b/ide/opsfile.yml index 02d260d5..531153c6 100644 --- a/ide/opsfile.yml +++ b/ide/opsfile.yml @@ -23,20 +23,6 @@ vars: sh: | echo "http://localhost:80" - DEPLOY_CURRENT_HASH: - sh: | - if test -e "$OPS_ROOT/ide/deploy/bun.lockb" - then cd "$OPS_ROOT/ide/deploy" && bun "$OPS_ROOT/ide/deploy/bun.lockb" --hash - else echo "0" - fi - - DEPLOY_PREVIOUS_HASH: - sh: | - if test -e "$OPS_ROOT/ide/deploy/hash.lock" - then cat "$OPS_ROOT/ide/deploy/hash.lock" - else echo "0" - fi - tasks: prereq: @@ -49,21 +35,15 @@ tasks: You can also reopen the project with VSCode and use the command 'Reopen in Container'. cmds: - test {{OS}} != "windows" || die '{{.MSG}}' - - test "$(printf '%s\n' "$(bun -v)" "1.1.18" | sort -V | head -n 1)" = "1.1.18" || die "bun 1.1.18 or greater not available" + - test "$(printf '%s\n' "$(bun -v)" "1.3.14" | sort -V | head -n 1)" = "1.3.14" || die "bun 1.3.14 or greater not available" - test -d "$OPS_PWD/packages" || die "no packages in current directory" - test -e ~/.wskprops || die "please run 'ops ide login' first" - test -n "$OPSDEV_HOST" || die "please run 'ops ide login' first" - test "$(ops -wsk property get --namespace | awk '{ print $3 }')" = "$OPSDEV_USERNAME" || die "Repeat the login" - - | - if ! test -d "$OPS_ROOT/ide/deploy/node_modules" || [ "{{.DEPLOY_CURRENT_HASH}}" != "{{.DEPLOY_PREVIOUS_HASH}}" ] - then - cd $OPS_ROOT/ide/deploy && bun install && rm -f $OPS_ROOT/ide/deploy/hash.lock && bun $OPS_ROOT/ide/deploy/bun.lockb --hash > $OPS_ROOT/ide/deploy/hash.lock - fi + - bun "$OPS_ROOT/ide/install-dependencies.ts" "$OPS_ROOT/ide/deploy" - | if test -e "$OPS_PWD/package.json" - then if ! test -d "$OPS_PWD/node_modules" - then cd $OPS_PWD ; bun install - fi + then bun "$OPS_ROOT/ide/install-dependencies.ts" "$OPS_PWD" fi - task: kill diff --git a/prereq.yml b/prereq.yml index bd866ca0..ce70e3c1 100644 --- a/prereq.yml +++ b/prereq.yml @@ -102,14 +102,15 @@ tasks: bun: desc: download bun vars: - VERSION: "1.2.5" + VERSION: "1.3.14" ARCH2: '{{if eq .ARCH "amd64"}}x64{{else}}aarch64{{end}}' URL: 'https://github.com/oven-sh/bun/releases/download/bun-v{{.VERSION}}/bun-{{.OS}}-{{.ARCH2}}.zip' FILE: '{{base .URL}}' cmds: - echo "{{.URL}}" - - curl -sL "{{.URL}}" -o "{{.FILE}}" + - curl -fsSL "{{.URL}}" -o "{{.FILE}}" - extract "{{.FILE}}" bun{{.EXE}} + - executable bun{{.EXE}} - remove "{{.FILE}}" kubectl: @@ -385,4 +386,3 @@ tasks: - __OS=darwin __ARCH=amd64 ops -task -t prereq.yml test - __OS=darwin __ARCH=arm64 ops -task -t prereq.yml test - __OS=windows __ARCH=amd64 ops -task -t prereq.yml test - diff --git a/tests/opsfile.yml b/tests/opsfile.yml index 8549147a..2a8e2345 100644 --- a/tests/opsfile.yml +++ b/tests/opsfile.yml @@ -46,7 +46,7 @@ tasks: silent: true desc: Bun test cmds: - - bun | rg '1.1.27' + - bun --version | rg '^1.3.14$' kubectl: silent: true From 5bad53e50170785c99464ec475a4743754ae8bff Mon Sep 17 00:00:00 2001 From: Michele Sciabarra <30654959+sciabarracom@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:30:51 +0200 Subject: [PATCH 09/12] Update version in opsroot.json --- opsroot.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opsroot.json b/opsroot.json index 24b1297d..65ae190f 100644 --- a/opsroot.json +++ b/opsroot.json @@ -1,5 +1,5 @@ { - "version": "0.1.0-2409121919.dev", + "version": "v0.9.1-2607121109.dev", "config": { "ops": { "coreutils": "arch b2sum b3sum base32 basename basenc cat chgrp chmod chown chroot cksum comm cp csplit cut date dd df dir dircolors dirname du env expand expr factor fmt fold groups hashsum head hostid hostname id install join kill link ln logname ls md5sum mkdir mkfifo mknod mktemp more mv nice nl nohup nproc numfmt od paste pathchk pinky pr printenv printf ptx pwd readlink realpath rm rmdir seq sha1sum sha224sum sha256sum sha3-224sum sha3-256sum sha3-384sum sha3-512sum sha384sum sha3sum sha512sum shake128sum shake256sum shred shuf sleep sort split stat stdbuf stty sum sync tac tail tee timeout touch tr truncate tsort tty uname unexpand uniq unlink uptime users vdir wc who whoami yes" From 6f5259f493f201d1621a5119335f4b58d93c08b4 Mon Sep 17 00:00:00 2001 From: Michele Sciabarra <30654959+sciabarracom@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:34:15 +0200 Subject: [PATCH 10/12] Fix version format in opsroot.json --- opsroot.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/opsroot.json b/opsroot.json index 65ae190f..75449381 100644 --- a/opsroot.json +++ b/opsroot.json @@ -1,5 +1,5 @@ { - "version": "v0.9.1-2607121109.dev", + "version": "0.9.1-2607121109.dev", "config": { "ops": { "coreutils": "arch b2sum b3sum base32 basename basenc cat chgrp chmod chown chroot cksum comm cp csplit cut date dd df dir dircolors dirname du env expand expr factor fmt fold groups hashsum head hostid hostname id install join kill link ln logname ls md5sum mkdir mkfifo mknod mktemp more mv nice nl nohup nproc numfmt od paste pathchk pinky pr printenv printf ptx pwd readlink realpath rm rmdir seq sha1sum sha224sum sha256sum sha3-224sum sha3-256sum sha3-384sum sha3-512sum sha384sum sha3sum sha512sum shake128sum shake256sum shred shuf sleep sort split stat stdbuf stty sum sync tac tail tee timeout touch tr truncate tsort tty uname unexpand uniq unlink uptime users vdir wc who whoami yes" From fc8fe3a67d2604b0632854b1bb7fd3777b968010 Mon Sep 17 00:00:00 2001 From: Trustable User Date: Sun, 12 Jul 2026 21:15:51 +0200 Subject: [PATCH 11/12] 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 d7861282..acd882a3 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 00000000..d4ae69c7 --- /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 00000000..1de988f0 --- /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 00000000..9eb2306c --- /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 00000000..891ddb9e --- /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); + }); +} From b6512f47a459bfeddd73f1bc676d7b2522ad1d5d Mon Sep 17 00:00:00 2001 From: Trustable User Date: Mon, 13 Jul 2026 11:24:44 +0200 Subject: [PATCH 12/12] Add automatic runtime builds to IDE deploy --- cloud/k3s/docopts.md | 4 +- cloud/k3s/opsfile.yml | 12 + cloud/k3s/registry.test.ts | 33 +++ cloud/k3s/registry.ts | 97 ++++++++ ide/deploy/Builder.md | 42 ++++ ide/deploy/builder.js | 209 ++++++++++++++++++ ide/deploy/builder.test.js | 178 +++++++++++++++ ide/deploy/deploy.js | 28 ++- ide/deploy/index.js | 17 +- ide/deploy/scan.js | 7 +- ide/deploy/watch.js | 28 ++- ide/opsfile.yml | 6 +- setup/kubernetes/crds/whisk-crd.yaml | 5 +- .../roles/nuvolaris-wsku-roles.yaml | 5 + setup/kubernetes/whisk.yaml | 1 + setup/nuvolaris/system-api/api-template.yaml | 11 + setup/nuvolaris/system-api/builders.json | 7 + setup/nuvolaris/system-api/builders.test.ts | 43 ++++ setup/nuvolaris/system-api/builders.ts | 74 +++++++ setup/nuvolaris/system-api/opsfile.yml | 20 +- 20 files changed, 809 insertions(+), 18 deletions(-) create mode 100644 cloud/k3s/registry.test.ts create mode 100644 cloud/k3s/registry.ts create mode 100644 ide/deploy/Builder.md create mode 100644 ide/deploy/builder.js create mode 100644 ide/deploy/builder.test.js create mode 100644 setup/nuvolaris/system-api/builders.json create mode 100644 setup/nuvolaris/system-api/builders.test.ts create mode 100644 setup/nuvolaris/system-api/builders.ts diff --git a/cloud/k3s/docopts.md b/cloud/k3s/docopts.md index 49a4bd10..d92355b1 100644 --- a/cloud/k3s/docopts.md +++ b/cloud/k3s/docopts.md @@ -28,6 +28,7 @@ Usage: k3s delete [] k3s info k3s kubeconfig [] + k3s registry [] k3s status ``` @@ -38,5 +39,6 @@ Usage: delete uninstall k3s with ssh in using with sudo info info on the server kubeconfig recover the kubeconfig from a k3s server with user + registry configure the private registry endpoint used by K3s containerd status status of the server -``` \ No newline at end of file +``` diff --git a/cloud/k3s/opsfile.yml b/cloud/k3s/opsfile.yml index efcaa899..e837a6f4 100644 --- a/cloud/k3s/opsfile.yml +++ b/cloud/k3s/opsfile.yml @@ -65,6 +65,7 @@ tasks: k3sup install --k3s-version="{{.K3S_VERSION}}" --host="{{._server_}}" --user="{{.INSTALL_USER}}" --local-path=$OPS_TMP/kubeconfig + - bun registry.ts "{{._server_}}" "{{.INSTALL_USER}}" cert-manager: @@ -78,10 +79,21 @@ tasks: desc: create a k3s with ssh in using with sudo cmds: - config OPERATOR_CONFIG_KUBE=k3s + - config REGISTRY_CONFIG_PULL_HOSTNAME="{{._server_}}:32000" - task: install - task: cert-manager - cp "$OPS_TMP/kubeconfig" "$OPS_TMP/k3s-{{._server_}}.kubeconfig" + registry: + silent: true + desc: configure the OpenServerless registry endpoint on a K3s node + vars: + INSTALL_USER: '{{._user_ | default "root"}}' + cmds: + - test -n "{{._server_}}" || die "required ip or hostname" + - bun registry.ts "{{._server_}}" "{{.INSTALL_USER}}" + - config REGISTRY_CONFIG_PULL_HOSTNAME="{{._server_}}:32000" + delete: silent: true desc: uninstall with ssh in using with sudo diff --git a/cloud/k3s/registry.test.ts b/cloud/k3s/registry.test.ts new file mode 100644 index 00000000..6ed61f6f --- /dev/null +++ b/cloud/k3s/registry.test.ts @@ -0,0 +1,33 @@ +// 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 {expect, test} from "bun:test"; +import {renderRegistryConfig} from "./registry"; + +test("renders an explicit HTTP mirror for the K3s node registry", () => { + expect(renderRegistryConfig("192.0.2.10")).toBe( + "# Managed by Apache OpenServerless\n" + + "mirrors:\n" + + " \"192.0.2.10:32000\":\n" + + " endpoint:\n" + + " - \"http://192.0.2.10:32000\"\n", + ); +}); + +test("rejects shell syntax in a K3s host", () => { + expect(() => renderRegistryConfig("host;reboot")).toThrow("invalid K3s server"); +}); diff --git a/cloud/k3s/registry.ts b/cloud/k3s/registry.ts new file mode 100644 index 00000000..581f4512 --- /dev/null +++ b/cloud/k3s/registry.ts @@ -0,0 +1,97 @@ +// 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. + +const HOST = /^[A-Za-z0-9][A-Za-z0-9.-]*$/; +const USER = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/; + +export function renderRegistryConfig(server: string): string { + if (!HOST.test(server)) throw new Error(`invalid K3s server: ${server}`); + return `# Managed by Apache OpenServerless\nmirrors:\n "${server}:32000":\n endpoint:\n - "http://${server}:32000"\n`; +} + +async function capture(args: string[]): Promise<{code: number; stdout: string; stderr: string}> { + const process = Bun.spawn(args, {stdout: "pipe", stderr: "pipe"}); + const [stdout, stderr, code] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited, + ]); + return {code, stdout, stderr}; +} + +async function writeRemote(args: string[], content: string): Promise { + const process = Bun.spawn(args, {stdin: "pipe", stdout: "inherit", stderr: "inherit"}); + process.stdin.write(content); + process.stdin.end(); + const code = await process.exited; + if (code !== 0) throw new Error(`command failed (${code}): ${args.join(" ")}`); +} + +async function configure(server: string, user: string): Promise { + if (!USER.test(user)) throw new Error(`invalid SSH user: ${user}`); + const destination = `${user}@${server}`; + const ssh = ["ssh", "-oStrictHostKeyChecking=no", destination]; + const expected = renderRegistryConfig(server); + const current = await capture([...ssh, "sudo", "cat", "/etc/rancher/k3s/registries.yaml"]); + if (current.code === 0 && current.stdout === expected) { + console.log(`K3s registry already configured for ${server}:32000`); + return; + } + if (current.code === 0 && current.stdout.trim()) { + throw new Error( + "/etc/rancher/k3s/registries.yaml already contains unmanaged configuration; refusing to overwrite it", + ); + } + + const exists = await capture([ + ...ssh, "sudo", "test", "-e", "/etc/rancher/k3s/registries.yaml", + ]); + if (exists.code === 0) { + throw new Error("cannot read the existing /etc/rancher/k3s/registries.yaml"); + } + + const directory = await capture([...ssh, "sudo", "mkdir", "-p", "/etc/rancher/k3s"]); + if (directory.code !== 0) throw new Error(directory.stderr || "cannot create K3s config directory"); + await writeRemote( + [...ssh, "sudo", "tee", "/etc/rancher/k3s/registries.yaml"], + expected, + ); + const restart = await capture([...ssh, "sudo", "systemctl", "restart", "k3s"]); + if (restart.code !== 0) throw new Error(restart.stderr || "cannot restart K3s"); + + for (let attempt = 0; attempt < 60; attempt += 1) { + const active = await capture([...ssh, "sudo", "systemctl", "is-active", "k3s"]); + if (active.code === 0 && active.stdout.trim() === "active") { + console.log(`K3s registry configured for ${server}:32000`); + return; + } + await Bun.sleep(1000); + } + throw new Error("K3s did not become active after registry configuration"); +} + +if (import.meta.main) { + const args = Bun.argv.slice(2); + if (args[0] === "--render") { + console.log(renderRegistryConfig(args[1])); + } else { + const server = args[0]; + const user = args[1] || "root"; + if (!server) throw new Error("K3s server is required"); + await configure(server, user); + } +} diff --git a/ide/deploy/Builder.md b/ide/deploy/Builder.md new file mode 100644 index 00000000..a9843dea --- /dev/null +++ b/ide/deploy/Builder.md @@ -0,0 +1,42 @@ +# Automatic runtime image builder + +`ops ide deploy` can ensure custom runtime images before updating actions. The +build runs as a Kubernetes BuildKit Job through the OpenServerless system API; +the developer machine does not need Docker. + +Projects opt in with generic runtime profiles in their root `package.json`: + +```json +{ + "openserverless": { + "runtimeProfiles": { + "python-custom": { + "builder": "python:3.13", + "requirements": "runtime/python-custom.txt", + "actions": ["samples/python-custom"] + } + } + } +} +``` + +Each action can belong to at most one profile. Requirements paths are relative +to the project root and cannot escape it. The builder id is resolved by the +server from an allowlisted catalog; projects cannot select an arbitrary source +image, target repository, namespace, or registry. + +The system API derives a content digest from the builder contract, source +image, and requirements. It either returns an existing image or starts a Job. +The deploy task waits for a terminal state and only then adds `--docker` to the +action update. A failed or timed-out build makes `ops ide deploy` fail before +the action is changed. + +An explicit `--docker` action argument takes precedence over the generated +profile image. `--dry-run` validates and reports profiles without contacting +the builder API. + +Registry endpoints are supplied by cluster configuration: + +- the push endpoint is reachable from the BuildKit pod; +- the pull endpoint is reachable from the node container runtime; +- neither endpoint is hardcoded in the deploy task. diff --git a/ide/deploy/builder.js b/ide/deploy/builder.js new file mode 100644 index 00000000..fcb343bb --- /dev/null +++ b/ide/deploy/builder.js @@ -0,0 +1,209 @@ +// 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 fs from "fs/promises"; +import path from "path"; + +const PROFILE_NAME = /^[a-z0-9][a-z0-9._-]{0,62}$/; +const BUILDER_ID = /^[a-z0-9][a-z0-9._:-]{0,62}$/; +const ACTION_NAME = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/; +const IMAGE_REFERENCE = /^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,511}$/; + +function projectDirectory(options = {}) { + return options.projectDir || process.env.OPS_PWD || process.cwd(); +} + +export async function loadRuntimeProfiles(options = {}) { + const packagePath = path.join(projectDirectory(options), "package.json"); + let document; + try { + document = JSON.parse(await fs.readFile(packagePath, "utf8")); + } catch (error) { + if (error.code === "ENOENT") return {}; + throw new Error(`cannot read ${packagePath}: ${error.message}`); + } + + const profiles = document.openserverless?.runtimeProfiles || {}; + if (typeof profiles !== "object" || Array.isArray(profiles)) { + throw new Error("openserverless.runtimeProfiles must be an object"); + } + + const result = {}; + const assignedActions = new Set(); + for (const [name, profile] of Object.entries(profiles)) { + if (!PROFILE_NAME.test(name)) throw new Error(`invalid runtime profile name: ${name}`); + if (!profile || typeof profile !== "object" || Array.isArray(profile)) { + throw new Error(`runtime profile ${name} must be an object`); + } + if (!BUILDER_ID.test(profile.builder || "")) { + throw new Error(`runtime profile ${name} has an invalid builder`); + } + if (typeof profile.requirements !== "string" || !profile.requirements.trim()) { + throw new Error(`runtime profile ${name} requires a requirements file`); + } + if (!Array.isArray(profile.actions) || profile.actions.length === 0) { + throw new Error(`runtime profile ${name} requires at least one action`); + } + for (const action of profile.actions) { + if (typeof action !== "string" || !ACTION_NAME.test(action)) { + throw new Error(`runtime profile ${name} has invalid action ${action}`); + } + if (assignedActions.has(action)) { + throw new Error(`action ${action} is assigned to more than one runtime profile`); + } + assignedActions.add(action); + } + result[name] = { + builder: profile.builder, + requirements: profile.requirements, + actions: [...new Set(profile.actions)], + }; + } + return result; +} + +export async function runtimeProfileWatchEntries(options = {}) { + const profiles = await loadRuntimeProfiles(options); + return Object.entries(profiles).map(([name, profile]) => ({ + name, + path: path.resolve(projectDirectory(options), profile.requirements), + actions: profile.actions, + })); +} + +async function runOpsProperty(property) { + const ops = process.env.OPS || "ops"; + const proc = Bun.spawn([ops, "-wsk", "property", "get", property], { + env: process.env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, code] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + if (code !== 0) throw new Error(stderr.trim() || `cannot read ${property}`); + const line = stdout.split("\n").find((item) => item.trim()) || ""; + const value = line.replace(/^whisk\s+(?:API host|auth)\s+/i, "").trim(); + if (!value) throw new Error(`empty ${property} returned by ops`); + return value; +} + +async function defaultCredentials() { + return { + apiHost: (await runOpsProperty("--apihost")).replace(/\/$/, ""), + auth: await runOpsProperty("--auth"), + }; +} + +async function responsePayload(response) { + const text = await response.text(); + try { + return JSON.parse(text); + } catch { + throw new Error(`builder returned HTTP ${response.status}: ${text}`); + } +} + +function validateImage(image) { + if (typeof image !== "string" || !IMAGE_REFERENCE.test(image)) { + throw new Error("builder returned an invalid action image reference"); + } + return image; +} + +export async function ensureRuntimeProfiles(actionFilter = null, options = {}) { + const profiles = await loadRuntimeProfiles(options); + const selectedActions = actionFilter ? new Set(actionFilter) : null; + const selectedProfiles = Object.entries(profiles).filter(([, profile]) => + !selectedActions || profile.actions.some((action) => selectedActions.has(action)) + ); + const runtimeImages = new Map(); + if (selectedProfiles.length === 0) return runtimeImages; + + if (options.dryRun) { + for (const [name, profile] of selectedProfiles) { + console.log(`[dry-run] ensure runtime profile ${name} with ${profile.builder}`); + } + return runtimeImages; + } + + const fetchImpl = options.fetchImpl || fetch; + const sleep = options.sleep || ((milliseconds) => Bun.sleep(milliseconds)); + const credentials = options.credentials || await defaultCredentials(); + const pollInterval = options.pollInterval ?? 2000; + const timeout = options.timeout ?? 15 * 60 * 1000; + + for (const [name, profile] of selectedProfiles) { + const requirementsPath = path.resolve(projectDirectory(options), profile.requirements); + const projectRoot = path.resolve(projectDirectory(options)); + if (requirementsPath !== projectRoot && !requirementsPath.startsWith(`${projectRoot}${path.sep}`)) { + throw new Error(`runtime profile ${name} requirements must stay inside the project`); + } + let requirements; + try { + requirements = await fs.readFile(requirementsPath); + } catch (error) { + throw new Error(`cannot read runtime profile ${name} requirements: ${error.message}`); + } + + const ensureResponse = await fetchImpl(`${credentials.apiHost}/system/api/v1/build/ensure`, { + method: "POST", + headers: { + "authorization": credentials.auth, + "content-type": "application/json", + }, + body: JSON.stringify({ + builder: profile.builder, + file: requirements.toString("base64"), + }), + }); + const initial = await responsePayload(ensureResponse); + if (!ensureResponse.ok && ensureResponse.status !== 202) { + throw new Error(initial.message || `runtime profile ${name} build failed`); + } + + let state = initial; + const startedAt = Date.now(); + while (state.state === "queued" || state.state === "running") { + if (!state.id) throw new Error(`runtime profile ${name} response has no build id`); + if (Date.now() - startedAt >= timeout) { + throw new Error(`runtime profile ${name} build timed out`); + } + await sleep(pollInterval); + const statusResponse = await fetchImpl( + `${credentials.apiHost}/system/api/v1/build/${state.id}`, + {headers: {"authorization": credentials.auth}}, + ); + state = await responsePayload(statusResponse); + if (!statusResponse.ok && statusResponse.status !== 202 && state.state !== "failed") { + throw new Error(state.message || `cannot query runtime profile ${name}`); + } + } + + if (state.state !== "succeeded") { + throw new Error(state.message || `runtime profile ${name} build failed`); + } + const image = validateImage(state.image); + console.log(`Runtime profile ${name}: ${image}`); + for (const action of profile.actions) { + if (!selectedActions || selectedActions.has(action)) runtimeImages.set(action, image); + } + } + return runtimeImages; +} diff --git a/ide/deploy/builder.test.js b/ide/deploy/builder.test.js new file mode 100644 index 00000000..5343bb28 --- /dev/null +++ b/ide/deploy/builder.test.js @@ -0,0 +1,178 @@ +// 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 {afterEach, describe, expect, test} from "bun:test"; +import fs from "fs/promises"; +import os from "os"; +import path from "path"; + +import {ensureRuntimeProfiles, loadRuntimeProfiles} from "./builder.js"; +import {appendRuntimeImage} from "./deploy.js"; + +const temporaryDirectories = []; + +async function project(profile = {}) { + const directory = await fs.mkdtemp(path.join(os.tmpdir(), "ops-builder-test-")); + temporaryDirectories.push(directory); + await fs.writeFile( + path.join(directory, "package.json"), + JSON.stringify({openserverless: {runtimeProfiles: profile}}), + ); + await fs.mkdir(path.join(directory, "runtime")); + await fs.writeFile(path.join(directory, "runtime", "python.txt"), "sample-lib==1.0\n"); + return directory; +} + +function response(status, payload) { + return new Response(JSON.stringify(payload), { + status, + headers: {"content-type": "application/json"}, + }); +} + +afterEach(async () => { + await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, {recursive: true}))); +}); + +describe("runtime profile builder", () => { + test("adds a generated image without replacing an explicit docker argument", () => { + expect(appendRuntimeImage("--web true", "registry.example/sample:image")).toBe( + "--web true --docker registry.example/sample:image", + ); + expect(appendRuntimeImage("--docker explicit/image:tag --web true", "generated/image:tag")).toBe( + "--docker explicit/image:tag --web true", + ); + }); + + test("polls until the image is ready and maps it to the action", async () => { + const directory = await project({ + "python-custom": { + builder: "python:3.13", + requirements: "runtime/python.txt", + actions: ["samples/custom"], + }, + }); + const replies = [ + response(202, {state: "queued", id: "a".repeat(64)}), + response(202, {state: "running", id: "a".repeat(64)}), + response(200, { + state: "succeeded", + id: "a".repeat(64), + image: "registry.example:32000/sample:python-digest", + }), + ]; + const requests = []; + + const images = await ensureRuntimeProfiles(null, { + projectDir: directory, + credentials: {apiHost: "https://api.example", auth: "uuid:key"}, + fetchImpl: async (url, options) => { + requests.push({url, options}); + return replies.shift(); + }, + sleep: async () => {}, + pollInterval: 0, + }); + + expect(images.get("samples/custom")).toBe( + "registry.example:32000/sample:python-digest", + ); + expect(requests).toHaveLength(3); + expect(JSON.parse(requests[0].options.body).builder).toBe("python:3.13"); + }); + + test("uses an immediate server cache hit without polling", async () => { + const directory = await project({ + cached: { + builder: "python:3.13", + requirements: "runtime/python.txt", + actions: ["samples/cached"], + }, + }); + let calls = 0; + + const images = await ensureRuntimeProfiles(null, { + projectDir: directory, + credentials: {apiHost: "https://api.example", auth: "uuid:key"}, + fetchImpl: async () => { + calls += 1; + return response(200, { + state: "succeeded", + image: "registry.example:32000/sample:python-cached", + }); + }, + }); + + expect(calls).toBe(1); + expect(images.get("samples/cached")).toEndWith("python-cached"); + }); + + test("dry-run validates profiles without contacting the server", async () => { + const directory = await project({ + dry: { + builder: "python:3.13", + requirements: "runtime/python.txt", + actions: ["samples/dry"], + }, + }); + + const images = await ensureRuntimeProfiles(null, { + projectDir: directory, + dryRun: true, + fetchImpl: async () => { + throw new Error("fetch must not be called"); + }, + }); + + expect(images.size).toBe(0); + }); + + test("rejects actions assigned to multiple profiles", async () => { + const directory = await project({ + first: { + builder: "python:3.13", + requirements: "runtime/python.txt", + actions: ["samples/shared"], + }, + second: { + builder: "python:3.13", + requirements: "runtime/python.txt", + actions: ["samples/shared"], + }, + }); + + await expect(loadRuntimeProfiles({projectDir: directory})).rejects.toThrow( + "assigned to more than one runtime profile", + ); + }); + + test("propagates a failed remote build", async () => { + const directory = await project({ + broken: { + builder: "python:3.13", + requirements: "runtime/python.txt", + actions: ["samples/broken"], + }, + }); + + await expect(ensureRuntimeProfiles(null, { + projectDir: directory, + credentials: {apiHost: "https://api.example", auth: "uuid:key"}, + fetchImpl: async () => response(409, {state: "failed", message: "dependency install failed"}), + })).rejects.toThrow("dependency install failed"); + }); +}); diff --git a/ide/deploy/deploy.js b/ide/deploy/deploy.js index 4b932174..3404fcc3 100644 --- a/ide/deploy/deploy.js +++ b/ide/deploy/deploy.js @@ -23,6 +23,7 @@ const MAINS = ["__main__.py", "index.js", "index.php", "main.go"]; const queue = []; const activeDeployments = new Map(); +let runtimeImages = new Map(); let dryRun = false; @@ -30,8 +31,22 @@ export function setDryRun(b) { dryRun = b; } +export function setRuntimeImages(images) { + runtimeImages = new Map(images || []); +} + +export function mergeRuntimeImages(images) { + for (const [action, image] of images || []) runtimeImages.set(action, image); +} + +export function appendRuntimeImage(args, image) { + if (!image || /(^|\s)--docker(?:\s|=)/.test(args)) return args; + return `${args} --docker ${image}`.trim(); +} + async function exec(cmd) { console.log("$", cmd); + if (dryRun) return; cmd = expandEnv(cmd); const cmdArgs = parse(cmd).filter((arg) => typeof arg === "string"); @@ -114,9 +129,9 @@ export async function deployAction(artifact) { typ = spData[1]; pkg = sp[1]; } catch(error) { - console.log("❌ cannot deploy", artifact, "Error:", error.message); - return; + activeDeployments.delete(artifact); + throw error; } await deployPackage(pkg); @@ -129,17 +144,20 @@ export async function deployAction(artifact) { toInspect = [artifact]; } - const args = (await extractArgs(toInspect)).join(" "); + let args = (await extractArgs(toInspect)).join(" "); const actionName = `${pkg}/${name}`; + const runtimeImage = runtimeImages.get(actionName); + args = appendRuntimeImage(args, runtimeImage); try { await exec(`ops action update ${actionName} ${artifact} ${args}`); } catch(error) { console.log("❌ cannot deploy", artifact, "Error:", error.message); + throw error; + } finally { + activeDeployments.delete(artifact); } - activeDeployments.delete(artifact); - if (queue.length > 0) { const nextArtifact = queue.shift(); console.debug(`📦 deploying from queue artifact ${nextArtifact}`); diff --git a/ide/deploy/index.js b/ide/deploy/index.js index 79c7b5a3..3cd0ede8 100644 --- a/ide/deploy/index.js +++ b/ide/deploy/index.js @@ -20,10 +20,11 @@ import {createServer} from 'net'; import process from 'process'; import {program} from 'commander'; import {scan} from './scan.js'; -import {watchAndDeploy, globalWatcher} from './watch.js'; -import {setDryRun, deploy} from './deploy.js'; +import {watchAndDeploy, globalWatcher, runtimeProfileWatcher} from './watch.js'; +import {setDryRun, setRuntimeImages, deploy} from './deploy.js'; import {undeploy, setDryRun as setUndeployDryRun} from './undeploy.js'; import {build} from './client.js'; +import {ensureRuntimeProfiles} from './builder.js'; @@ -35,6 +36,11 @@ async function signalHandler() { await globalWatcher.close(); } + if (runtimeProfileWatcher) { + console.log("Stopping runtime profile watcher"); + await runtimeProfileWatcher.close(); + } + const pidPath = expanduser('~/.ops/tmp/deploy.pid'); if (existsSync(pidPath)) { unlinkSync(pidPath); @@ -125,13 +131,13 @@ async function main() { } else if (options.watch) { checkPort(); if (!options.fast) { - await scan(); + await scan({dryRun: options.dryRun}); await build(); } await watchAndDeploy(); } else if (options.deploy) { - await scan(); + await scan({dryRun: options.dryRun}); await build(); process.exit(0); } else if (options.single !== '') { @@ -143,6 +149,9 @@ async function main() { console.log(`❌ action ${action} not found: must be either a file or a directory under packages`); return; } + const actionParts = action.split('/'); + const actionName = `${actionParts[1]}/${actionParts[2].split('.')[0]}`; + setRuntimeImages(await ensureRuntimeProfiles([actionName], {dryRun: options.dryRun})); console.log(`Deploying ${action}`); await deploy(action); process.exit(0); diff --git a/ide/deploy/scan.js b/ide/deploy/scan.js index 484089d3..930ae99c 100644 --- a/ide/deploy/scan.js +++ b/ide/deploy/scan.js @@ -16,10 +16,11 @@ // under the License. import {glob} from 'glob'; -import {buildAction, buildZip, deployAction, deployPackage, deployProject} from './deploy.js'; +import {buildAction, buildZip, deployAction, deployPackage, deployProject, setRuntimeImages} from './deploy.js'; import {getOpenServerlessConfig} from './client.js'; import {config} from "dotenv"; import {syncDeployInfo} from "./syncDeployInfo"; +import {ensureRuntimeProfiles} from "./builder.js"; /** * This function will prepare and deploy the functions in `packages` directory. @@ -37,12 +38,14 @@ import {syncDeployInfo} from "./syncDeployInfo"; * ``` * @returns {Promise} */ -export async function scan() { +export async function scan(options = {}) { const deployments = new Set(); const packages = new Set(); console.log("> Scan:"); + setRuntimeImages(await ensureRuntimeProfiles(null, options)); + // => REQUIREMENTS const defaultReqsGlobs = [ "packages/*/*/requirements.txt", diff --git a/ide/deploy/watch.js b/ide/deploy/watch.js index e8af88db..3bb72bcf 100644 --- a/ide/deploy/watch.js +++ b/ide/deploy/watch.js @@ -19,12 +19,14 @@ const SKIPDIR = ["virtualenv", "node_modules", "__pycache__"]; import {watch} from 'chokidar'; import {resolve} from 'path'; -import {deploy} from './deploy.js'; +import {deploy, mergeRuntimeImages} from './deploy.js'; import {logs, serve} from './client.js'; +import {ensureRuntimeProfiles, runtimeProfileWatchEntries} from './builder.js'; import process from 'process'; export let globalWatcher; +export let runtimeProfileWatcher; /** @@ -95,6 +97,29 @@ async function redeploy() { }); } +async function watchRuntimeProfiles() { + const entries = await runtimeProfileWatchEntries(); + if (entries.length === 0) return; + runtimeProfileWatcher = watch(entries.map((entry) => entry.path), { + persistent: true, + ignoreInitial: true, + atomic: 250, + }); + runtimeProfileWatcher.on('change', async (changedPath) => { + const resolved = resolve(changedPath); + const actions = entries + .filter((entry) => entry.path === resolved) + .flatMap((entry) => entry.actions); + if (actions.length === 0) return; + try { + mergeRuntimeImages(await ensureRuntimeProfiles(actions)); + for (const action of actions) await deploy(`packages/${action}`); + } catch (error) { + console.error(`runtime profile rebuild failed: ${error.message}`); + } + }); +} + /** * This function is the entry point and is called when * the program is started with the watch flag on @@ -104,6 +129,7 @@ export async function watchAndDeploy() { await logs(); try { + await watchRuntimeProfiles(); await redeploy(); } catch(error) { diff --git a/ide/opsfile.yml b/ide/opsfile.yml index 531153c6..09d8d529 100644 --- a/ide/opsfile.yml +++ b/ide/opsfile.yml @@ -102,10 +102,12 @@ tasks: desc: setup app cmds: - | + set -e echo ">>>" - ops action list setup | awk 'NR>1{print $1}' | while read action + ACTIONS=$(ops action list) + printf '%s\n' "$ACTIONS" | awk 'NR>1 && index($1, "/setup/"){print $1}' | while read action do echo "=== $action" - ops invoke "$action" || echo "error in $action" + ops invoke "$action" done echo "<<<" diff --git a/setup/kubernetes/crds/whisk-crd.yaml b/setup/kubernetes/crds/whisk-crd.yaml index 73dc3e79..94eb4cc8 100644 --- a/setup/kubernetes/crds/whisk-crd.yaml +++ b/setup/kubernetes/crds/whisk-crd.yaml @@ -825,6 +825,9 @@ spec: hostname: description: used to configure the repo hostname (if set to auto and mode=internal it will be img.) type: string + pull-hostname: + description: node-reachable registry hostname used in action image references + type: string ingress: description: configuration option for global REGISTRY ingresses exposure, will be taken into account only if deployment=interna type: object @@ -999,4 +1002,4 @@ spec: type: string priority: 0 jsonPath: .status.whisk_create.seaweedfs - description: Seaweedfs \ No newline at end of file + description: Seaweedfs diff --git a/setup/kubernetes/roles/nuvolaris-wsku-roles.yaml b/setup/kubernetes/roles/nuvolaris-wsku-roles.yaml index 101cff05..bdcf8bbd 100644 --- a/setup/kubernetes/roles/nuvolaris-wsku-roles.yaml +++ b/setup/kubernetes/roles/nuvolaris-wsku-roles.yaml @@ -33,6 +33,11 @@ rules: - apiGroups: [""] resources: ["configmaps"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] +# allow the admin api to read registry credentials used by BuildKit +- apiGroups: [""] + resources: ["secrets"] + resourceNames: ["registry-pull-secret", "registry-pull-secret-int"] + verbs: ["get"] # assign the possibility to operate on jobs (admin api) - apiGroups: ["batch"] resources: ["jobs"] diff --git a/setup/kubernetes/whisk.yaml b/setup/kubernetes/whisk.yaml index 93beb408..5e9f475c 100644 --- a/setup/kubernetes/whisk.yaml +++ b/setup/kubernetes/whisk.yaml @@ -252,6 +252,7 @@ spec: username: ${REGISTRY_CONFIG_USERNAME:-opsuser} password: ${REGISTRY_CONFIG_SECRET_PUSH_PULL:-changeme-registry} hostname: ${REGISTRY_CONFIG_HOSTNAME:-auto} + pull-hostname: ${REGISTRY_CONFIG_PULL_HOSTNAME:-auto} ingress: enabled: ${REGISTRY_CONFIG_INGRESS_ENABLED:-false} seaweedfs: diff --git a/setup/nuvolaris/system-api/api-template.yaml b/setup/nuvolaris/system-api/api-template.yaml index 9a298b5b..6346c223 100644 --- a/setup/nuvolaris/system-api/api-template.yaml +++ b/setup/nuvolaris/system-api/api-template.yaml @@ -50,6 +50,8 @@ spec: failureThreshold: 3 successThreshold: 1 env: + - name: "BUILDER_CATALOG_FILE" + value: "/etc/openserverless/builders.json" - name: "APIHOST" value: "${SYS_API_HOSTNAME:-localhost}" - name: "COUCHDB_SERVICE_PORT" @@ -60,6 +62,15 @@ spec: value: "${SYS_API_CDB_USER}" - name: "COUCHDB_ADMIN_PASSWORD" value: "${SYS_API_CDB_PASSWORD}" + volumeMounts: + - name: builder-catalog + mountPath: /etc/openserverless/builders.json + subPath: builders.json + readOnly: true + volumes: + - name: builder-catalog + configMap: + name: nuvolaris-builder-catalog --- apiVersion: v1 kind: Service diff --git a/setup/nuvolaris/system-api/builders.json b/setup/nuvolaris/system-api/builders.json new file mode 100644 index 00000000..ac0f09a5 --- /dev/null +++ b/setup/nuvolaris/system-api/builders.json @@ -0,0 +1,7 @@ +{ + "builders": { + "python:3.13": { + "kind": "python" + } + } +} diff --git a/setup/nuvolaris/system-api/builders.test.ts b/setup/nuvolaris/system-api/builders.test.ts new file mode 100644 index 00000000..d6c1df76 --- /dev/null +++ b/setup/nuvolaris/system-api/builders.test.ts @@ -0,0 +1,43 @@ +// 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 {buildCatalog} from "./builders"; + +describe("system API builder catalog", () => { + test("resolves a builder source from the versioned runtime catalog", () => { + const result = buildCatalog( + {runtimes: {python: [{ + kind: "python:3.13", + image: {prefix: "apache", name: "openserverless-runtime-python", tag: "v3.13-test"}, + }]}}, + {builders: {"python:3.13": {kind: "python"}}}, + ); + + expect(result.builders["python:3.13"]).toEqual({ + kind: "python", + source: "docker.io/apache/openserverless-runtime-python:v3.13-test", + }); + }); + + test("fails when a declared builder is missing from runtimes.json", () => { + expect(() => buildCatalog( + {runtimes: {python: []}}, + {builders: {"python:3.13": {kind: "python"}}}, + )).toThrow("is not present"); + }); +}); diff --git a/setup/nuvolaris/system-api/builders.ts b/setup/nuvolaris/system-api/builders.ts new file mode 100644 index 00000000..ec69b01f --- /dev/null +++ b/setup/nuvolaris/system-api/builders.ts @@ -0,0 +1,74 @@ +// 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. + +const BUILDER_ID = /^[a-z0-9][a-z0-9._:-]{0,62}$/; +const IMAGE_PART = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/; +const IMAGE_TAG = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/; + +type RuntimeImage = {prefix?: string; name?: string; tag?: string}; +type Runtime = {kind?: string; image?: RuntimeImage}; +type RuntimeDocument = {runtimes?: Record}; +type BuilderDefinition = {kind?: string}; +type BuilderDocument = {builders?: Record}; + +export function buildCatalog( + runtimeDocument: RuntimeDocument, + builderDocument: BuilderDocument, +): {builders: Record} { + if (!runtimeDocument?.runtimes || typeof runtimeDocument.runtimes !== "object") { + throw new Error("runtime catalog must contain a runtimes object"); + } + if (!builderDocument?.builders || typeof builderDocument.builders !== "object") { + throw new Error("builder definition must contain a builders object"); + } + + const runtimes = Object.values(runtimeDocument.runtimes).flat(); + const builders: Record = {}; + for (const [builderId, definition] of Object.entries(builderDocument.builders)) { + if (!BUILDER_ID.test(builderId)) throw new Error(`invalid builder id: ${builderId}`); + if (!definition?.kind || !BUILDER_ID.test(definition.kind)) { + throw new Error(`invalid kind for builder ${builderId}`); + } + + const runtime = runtimes.find((entry) => entry.kind === builderId); + if (!runtime) throw new Error(`runtime ${builderId} is not present in runtimes.json`); + const image = runtime.image || {}; + if (!image.prefix || !IMAGE_PART.test(image.prefix) || + !image.name || !IMAGE_PART.test(image.name) || + !image.tag || !IMAGE_TAG.test(image.tag)) { + throw new Error(`runtime ${builderId} has an invalid image`); + } + const prefix = image.prefix.includes(".") || image.prefix.includes(":") + ? image.prefix + : `docker.io/${image.prefix}`; + builders[builderId] = { + kind: definition.kind, + source: `${prefix}/${image.name}:${image.tag}`, + }; + } + return {builders}; +} + +if (import.meta.main) { + const [runtimePath, builderPath] = Bun.argv.slice(2); + if (!runtimePath || !builderPath) { + throw new Error("usage: bun builders.ts "); + } + const runtimeDocument = await Bun.file(runtimePath).json(); + const builderDocument = await Bun.file(builderPath).json(); + console.log(JSON.stringify(buildCatalog(runtimeDocument, builderDocument), null, 2)); +} diff --git a/setup/nuvolaris/system-api/opsfile.yml b/setup/nuvolaris/system-api/opsfile.yml index f5e1767b..c53c43e0 100644 --- a/setup/nuvolaris/system-api/opsfile.yml +++ b/setup/nuvolaris/system-api/opsfile.yml @@ -55,6 +55,20 @@ env: sh: ops util ingress-type tasks: + install-builders: + desc: Install the versioned runtime builder catalog + silent: true + cmds: + - > + bun "$OPS_ROOT/setup/nuvolaris/system-api/builders.ts" + "$OPS_ROOT/runtimes.json" + "$OPS_ROOT/setup/nuvolaris/system-api/builders.json" + > "$OPS_TMP/builders.json" + - > + kubectl -n nuvolaris create configmap nuvolaris-builder-catalog + --from-file=builders.json="$OPS_TMP/builders.json" + --dry-run=client -o yaml | kubectl apply -f - + install-toml: desc: Update the buildkitd.toml file config map silent: true @@ -78,7 +92,7 @@ tasks: - task: remove-secret - | if ! kubectl -n nuvolaris get secret registry-pull-secret-int >/dev/null 2>&1; - then kubectl -n nuvolaris create secret docker-registry registry-pull-secret-int --docker-server=http://nuvolaris-registry-svc:5000 --docker-username=opsuser --docker-password=${REGISTRY_PASS} + then kubectl -n nuvolaris create secret docker-registry registry-pull-secret-int --docker-server=nuvolaris-registry-svc:5000 --docker-username=opsuser --docker-password=${REGISTRY_PASS} fi remove-secret: @@ -97,6 +111,7 @@ tasks: cmds: - test -e ${INGRESS_TYPE}-template.yaml || die "No avalable template for ingress type ${INGRESS_TYPE}." - test -n "$IMAGES_SYSTEMAPI" || die "IMAGES_SYSTEMAPI is not set. Please set it to the desired image version." + - task: install-builders - envsubst -i api-template.yaml -o _api.yaml > /dev/null 2>&1 - envsubst -i ${INGRESS_TYPE}-template.yaml -o _ingress.yaml > /dev/null 2>&1 - kubectl -n nuvolaris apply -f "$OPS_ROOT/setup/nuvolaris/system-api/_api.yaml" @@ -114,6 +129,7 @@ tasks: cmds: - task: remove-secret - task: remove-toml + - kubectl -n nuvolaris delete configmap nuvolaris-builder-catalog --ignore-not-found - kubectl -n nuvolaris delete sts/nuvolaris-system-api ing/nuvolaris-system-api-ingress svc/nuvolaris-system-api - | echo "System Admin API undeployed" @@ -131,4 +147,4 @@ tasks: env: CURRENT_API_VERSION: sh: | - echo $(kubectl -n nuvolaris get sts/nuvolaris-system-api -ojsonpath='{.spec.template.spec.containers[0].image}') \ No newline at end of file + echo $(kubectl -n nuvolaris get sts/nuvolaris-system-api -ojsonpath='{.spec.template.spec.containers[0].image}')