From dd46b80708d617f5f0ef26cb402151acb3f3aaba Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Mon, 9 Mar 2026 12:35:13 +0100 Subject: [PATCH 01/13] feat: update manifests for gateway api --- src/api/v1/cloudtty.ts | 3 +- src/api/v2/cloudtty.ts | 3 +- src/k8s_operations.ts | 35 +++++++------------ src/otomi-stack.ts | 10 +++--- .../adminTtyManifests/tty_02_Pod.yaml | 2 +- .../adminTtyManifests/tty_05_HttpRoute.yaml | 32 +++++++++++++++++ .../adminTtyManifests/tty_05_Vs.yaml | 34 ------------------ src/ttyManifests/tty_00_Authz.yaml | 3 +- src/ttyManifests/tty_01_Sa.yaml | 3 +- src/ttyManifests/tty_02_Pod.yaml | 4 +-- src/ttyManifests/tty_03_Rolebinding.yaml | 3 +- src/ttyManifests/tty_04_Svc.yaml | 3 +- src/ttyManifests/tty_05_HttpRoute.yaml | 32 +++++++++++++++++ src/ttyManifests/tty_05_Vs.yaml | 34 ------------------ 14 files changed, 93 insertions(+), 108 deletions(-) create mode 100644 src/ttyManifests/adminTtyManifests/tty_05_HttpRoute.yaml delete mode 100644 src/ttyManifests/adminTtyManifests/tty_05_Vs.yaml create mode 100644 src/ttyManifests/tty_05_HttpRoute.yaml delete mode 100644 src/ttyManifests/tty_05_Vs.yaml diff --git a/src/api/v1/cloudtty.ts b/src/api/v1/cloudtty.ts index 0b3f8c25a..9c2c147dd 100644 --- a/src/api/v1/cloudtty.ts +++ b/src/api/v1/cloudtty.ts @@ -23,6 +23,7 @@ export const connectCloudtty = async (req: OpenApiRequestExt, res: Response): Pr export const deleteCloudtty = async (req: OpenApiRequestExt, res: Response): Promise => { const sessionUser = req.user debug(`deleteCloudtty - ${sessionUser.email} - ${sessionUser.sub}`) - await req.otomi.deleteCloudtty(sessionUser) + const { teamId } = req.query as { teamId: string } + await req.otomi.deleteCloudtty(teamId, sessionUser) res.json({}) } diff --git a/src/api/v2/cloudtty.ts b/src/api/v2/cloudtty.ts index 1c0051cfc..7575dc48d 100644 --- a/src/api/v2/cloudtty.ts +++ b/src/api/v2/cloudtty.ts @@ -23,6 +23,7 @@ export const connectAplCloudtty = async (req: OpenApiRequestExt, res: Response): export const deleteAplCloudtty = async (req: OpenApiRequestExt, res: Response): Promise => { const sessionUser = req.user debug(`deleteCloudtty - ${sessionUser.email} - ${sessionUser.sub}`) - await req.otomi.deleteCloudtty(sessionUser) + const { teamId } = req.query as { teamId: string } + await req.otomi.deleteCloudtty(teamId, sessionUser) res.json({}) } diff --git a/src/k8s_operations.ts b/src/k8s_operations.ts index 67f2bc8f4..5f509e8f9 100644 --- a/src/k8s_operations.ts +++ b/src/k8s_operations.ts @@ -101,34 +101,25 @@ export async function checkPodExists(namespace: string, podName: string): Promis } } -export async function k8sdelete({ - sub, - isPlatformAdmin, - userTeams, -}: { - sub: string - isPlatformAdmin: boolean - userTeams: string[] -}): Promise { +export async function k8sdelete( + teamId: string, + sub: string, + isPlatformAdmin: boolean, + userTeams: string[], +): Promise { const kc = new KubeConfig() kc.loadFromDefault() const k8sApi = kc.makeApiClient(CoreV1Api) const customObjectsApi = kc.makeApiClient(CustomObjectsApi) const rbacAuthorizationV1Api = kc.makeApiClient(RbacAuthorizationV1Api) const resourceName = sub - const namespace = 'team-admin' + const namespace = `team-${teamId}` try { - const apiVersion = 'v1beta1' - const apiGroupAuthz = 'security.istio.io' - const apiGroupVS = 'networking.istio.io' - const pluralAuth = 'authorizationpolicies' - const pluralVS = 'virtualservices' - await customObjectsApi.deleteNamespacedCustomObject({ - group: apiGroupAuthz, - version: apiVersion, + group: 'security.istio.io', + version: 'v1beta1', namespace, - plural: pluralAuth, + plural: 'authorizationpolicies', name: `tty-${resourceName}`, }) @@ -147,10 +138,10 @@ export async function k8sdelete({ await k8sApi.deleteNamespacedService({ name: `tty-${resourceName}`, namespace }) await customObjectsApi.deleteNamespacedCustomObject({ - group: apiGroupVS, - version: apiVersion, + group: 'gateway.networking.k8s.io', + version: 'v1', namespace, - plural: pluralVS, + plural: 'httproutes', name: `tty-${resourceName}`, }) } catch (error) { diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index e6ed9d2dc..5c5c665be 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1,4 +1,4 @@ -import { CoreV1Api, User as k8sUser, KubeConfig, V1ObjectReference } from '@kubernetes/client-node' +import { CoreV1Api, KubeConfig, User as k8sUser, V1ObjectReference } from '@kubernetes/client-node' import Debug from 'debug' import { getRegions, ObjectStorageKeyRegions } from '@linode/api-v4' @@ -1582,7 +1582,7 @@ export default class OtomiStack { const intervalId = setInterval(() => { getCloudttyActiveTime('team-admin', `tty-${sessionUser.sub}`).then((activeTime: number) => { if (activeTime > TERMINATE_TIMEOUT) { - this.deleteCloudtty(sessionUser) + this.deleteCloudtty(teamId, sessionUser) clearInterval(intervalId) debug(`Cloudtty terminated after ${TERMINATE_TIMEOUT / (60 * 60 * 1000)} hours of inactivity`) } @@ -1592,12 +1592,12 @@ export default class OtomiStack { return { iFrameUrl: `https://tty.${variables.FQDN}/${sessionUser.sub}` } } - async deleteCloudtty(sessionUser: SessionUser): Promise { + async deleteCloudtty(teamId: string, sessionUser: SessionUser): Promise { const { sub, isPlatformAdmin, teams } = sessionUser as { sub: string; isPlatformAdmin: boolean; teams: string[] } const userTeams = teams.map((teamName) => `team-${teamName}`) try { - if (await checkPodExists('team-admin', `tty-${sessionUser.sub}`)) { - await k8sdelete({ sub, isPlatformAdmin, userTeams }) + if (await checkPodExists(`team-${teamId}`, `tty-${sessionUser.sub}`)) { + await k8sdelete(teamId, sub, isPlatformAdmin, userTeams) } } catch (error) { debug('Failed to delete cloudtty') diff --git a/src/ttyManifests/adminTtyManifests/tty_02_Pod.yaml b/src/ttyManifests/adminTtyManifests/tty_02_Pod.yaml index c2e2d2dfd..d84fe2a15 100644 --- a/src/ttyManifests/adminTtyManifests/tty_02_Pod.yaml +++ b/src/ttyManifests/adminTtyManifests/tty_02_Pod.yaml @@ -27,7 +27,7 @@ spec: cpu: '500m' env: - name: NAMESPACE - value: team-$TARGET_TEAM + value: $TARGET_TEAM securityContext: allowPrivilegeEscalation: false capabilities: diff --git a/src/ttyManifests/adminTtyManifests/tty_05_HttpRoute.yaml b/src/ttyManifests/adminTtyManifests/tty_05_HttpRoute.yaml new file mode 100644 index 000000000..68e55a8f1 --- /dev/null +++ b/src/ttyManifests/adminTtyManifests/tty_05_HttpRoute.yaml @@ -0,0 +1,32 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: tty-$SUB + namespace: team-admin +spec: + hostnames: + - tty.$FQDN + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: platform + namespace: istio-system + sectionName: https + rules: + - backendRefs: + - group: "" + kind: Service + name: tty-$SUB + port: 8080 + weight: 1 + matches: + - path: + type: PathPrefix + value: /$SUB + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplaceFullPath + replaceFullPath: / +--- diff --git a/src/ttyManifests/adminTtyManifests/tty_05_Vs.yaml b/src/ttyManifests/adminTtyManifests/tty_05_Vs.yaml deleted file mode 100644 index 9dd644a58..000000000 --- a/src/ttyManifests/adminTtyManifests/tty_05_Vs.yaml +++ /dev/null @@ -1,34 +0,0 @@ -apiVersion: networking.istio.io/v1beta1 -kind: VirtualService -metadata: - name: tty-$SUB - namespace: team-admin -spec: - gateways: - - team-admin/team-admin-public-tlsterm - hosts: - - tty.$FQDN - http: - - match: - - uri: - prefix: /platform-logout - redirect: - authority: auth.$FQDN - uri: /oauth2/sign_out?rd=https%3A%2F%2Fkeycloak.$FQDN%2Frealms%2Fotomi%2Fprotocol%2Fopenid-connect%2Flogout%3Fpost_logout_redirect_uri%3Dhttps%3A%2F%2Fconsole.$FQDN%26client_id%3Dotomi - redirectCode: 302 - - match: - - uri: - prefix: /$SUB - rewrite: - uri: / - route: - - destination: - host: tty-$SUB.team-admin.svc.cluster.local - port: - number: 8080 - headers: - request: - set: - X-Forwarded-Proto: https ---- - diff --git a/src/ttyManifests/tty_00_Authz.yaml b/src/ttyManifests/tty_00_Authz.yaml index 1d64c4038..0e022c1ce 100644 --- a/src/ttyManifests/tty_00_Authz.yaml +++ b/src/ttyManifests/tty_00_Authz.yaml @@ -2,7 +2,7 @@ apiVersion: security.istio.io/v1beta1 kind: AuthorizationPolicy metadata: name: tty-$SUB - namespace: team-admin + namespace: $TARGET_TEAM spec: selector: matchLabels: @@ -13,4 +13,3 @@ spec: - key: request.auth.claims[sub] values: ['$SUB'] --- - diff --git a/src/ttyManifests/tty_01_Sa.yaml b/src/ttyManifests/tty_01_Sa.yaml index 3c2993caa..1abeb0fd3 100644 --- a/src/ttyManifests/tty_01_Sa.yaml +++ b/src/ttyManifests/tty_01_Sa.yaml @@ -2,6 +2,5 @@ apiVersion: v1 kind: ServiceAccount metadata: name: tty-$SUB - namespace: team-admin + namespace: $TARGET_TEAM --- - diff --git a/src/ttyManifests/tty_02_Pod.yaml b/src/ttyManifests/tty_02_Pod.yaml index c2e2d2dfd..a1a17a19f 100644 --- a/src/ttyManifests/tty_02_Pod.yaml +++ b/src/ttyManifests/tty_02_Pod.yaml @@ -5,7 +5,7 @@ metadata: app: tty-$SUB otomi: tty name: tty-$SUB - namespace: team-admin + namespace: $TARGET_TEAM spec: serviceAccountName: tty-$SUB securityContext: @@ -27,7 +27,7 @@ spec: cpu: '500m' env: - name: NAMESPACE - value: team-$TARGET_TEAM + value: $TARGET_TEAM securityContext: allowPrivilegeEscalation: false capabilities: diff --git a/src/ttyManifests/tty_03_Rolebinding.yaml b/src/ttyManifests/tty_03_Rolebinding.yaml index 0f045b4f8..99d3a166f 100644 --- a/src/ttyManifests/tty_03_Rolebinding.yaml +++ b/src/ttyManifests/tty_03_Rolebinding.yaml @@ -10,6 +10,5 @@ roleRef: subjects: - kind: ServiceAccount name: tty-$SUB - namespace: team-admin + namespace: $TARGET_TEAM --- - diff --git a/src/ttyManifests/tty_04_Svc.yaml b/src/ttyManifests/tty_04_Svc.yaml index 7ad6ae4f5..5d5c9d1d2 100644 --- a/src/ttyManifests/tty_04_Svc.yaml +++ b/src/ttyManifests/tty_04_Svc.yaml @@ -4,7 +4,7 @@ metadata: labels: app: tty-$SUB name: tty-$SUB - namespace: team-admin + namespace: $TARGET_TEAM spec: ports: - name: 8080-8080 @@ -15,4 +15,3 @@ spec: app: tty-$SUB type: ClusterIP --- - diff --git a/src/ttyManifests/tty_05_HttpRoute.yaml b/src/ttyManifests/tty_05_HttpRoute.yaml new file mode 100644 index 000000000..2d9680855 --- /dev/null +++ b/src/ttyManifests/tty_05_HttpRoute.yaml @@ -0,0 +1,32 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + name: tty-$SUB + namespace: $TARGET_TEAM +spec: + hostnames: + - tty.$FQDN + parentRefs: + - group: gateway.networking.k8s.io + kind: Gateway + name: platform + namespace: istio-system + sectionName: https + rules: + - backendRefs: + - group: "" + kind: Service + name: tty-$SUB + port: 8080 + weight: 1 + matches: + - path: + type: PathPrefix + value: /$SUB + filters: + - type: URLRewrite + urlRewrite: + path: + type: ReplaceFullPath + replaceFullPath: / +--- diff --git a/src/ttyManifests/tty_05_Vs.yaml b/src/ttyManifests/tty_05_Vs.yaml deleted file mode 100644 index 9dd644a58..000000000 --- a/src/ttyManifests/tty_05_Vs.yaml +++ /dev/null @@ -1,34 +0,0 @@ -apiVersion: networking.istio.io/v1beta1 -kind: VirtualService -metadata: - name: tty-$SUB - namespace: team-admin -spec: - gateways: - - team-admin/team-admin-public-tlsterm - hosts: - - tty.$FQDN - http: - - match: - - uri: - prefix: /platform-logout - redirect: - authority: auth.$FQDN - uri: /oauth2/sign_out?rd=https%3A%2F%2Fkeycloak.$FQDN%2Frealms%2Fotomi%2Fprotocol%2Fopenid-connect%2Flogout%3Fpost_logout_redirect_uri%3Dhttps%3A%2F%2Fconsole.$FQDN%26client_id%3Dotomi - redirectCode: 302 - - match: - - uri: - prefix: /$SUB - rewrite: - uri: / - route: - - destination: - host: tty-$SUB.team-admin.svc.cluster.local - port: - number: 8080 - headers: - request: - set: - X-Forwarded-Proto: https ---- - From 680d1ca21a57777b18ce8706b010a02a6139917b Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Mon, 9 Mar 2026 13:18:15 +0100 Subject: [PATCH 02/13] fix: namespace assignment of resources --- src/k8s_operations.ts | 3 +-- src/otomi-stack.ts | 15 +++++++++------ 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/k8s_operations.ts b/src/k8s_operations.ts index 5f509e8f9..4ada7a91c 100644 --- a/src/k8s_operations.ts +++ b/src/k8s_operations.ts @@ -102,7 +102,7 @@ export async function checkPodExists(namespace: string, podName: string): Promis } export async function k8sdelete( - teamId: string, + namespace: string, sub: string, isPlatformAdmin: boolean, userTeams: string[], @@ -113,7 +113,6 @@ export async function k8sdelete( const customObjectsApi = kc.makeApiClient(CustomObjectsApi) const rbacAuthorizationV1Api = kc.makeApiClient(RbacAuthorizationV1Api) const resourceName = sub - const namespace = `team-${teamId}` try { await customObjectsApi.deleteNamespacedCustomObject({ group: 'security.istio.io', diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 5c5c665be..f70fcda58 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1506,6 +1506,8 @@ export default class OtomiStack { } async connectCloudtty(teamId: string, sessionUser: SessionUser): Promise { + const isAdmin = sessionUser.isPlatformAdmin + const targetNamespace = isAdmin ? 'team-admin' : `team-${teamId}` if (!sessionUser.sub) { debug('No user sub found, cannot connect to shell.') throw new OtomiError(500, 'No user sub found, cannot connect to shell.') @@ -1527,14 +1529,14 @@ export default class OtomiStack { } // if cloudtty shell does not exists then check if the pod is running and return it - if (await checkPodExists('team-admin', `tty-${sessionUser.sub}`)) { + if (await checkPodExists(targetNamespace, `tty-${sessionUser.sub}`)) { return { iFrameUrl: `https://tty.${variables.FQDN}/${sessionUser.sub}` } } if (await pathExists('/tmp/ttyd.yaml')) await unlink('/tmp/ttyd.yaml') //if user is admin then read the manifests from ./dist/src/ttyManifests/adminTtyManifests - const files = sessionUser.isPlatformAdmin + const files = isAdmin ? await readdir('./dist/src/ttyManifests/adminTtyManifests', 'utf-8') : await readdir('./dist/src/ttyManifests', 'utf-8') const filteredFiles = files.filter((file) => file.startsWith('tty')) @@ -1574,13 +1576,13 @@ export default class OtomiStack { ) await writeFile('/tmp/ttyd.yaml', fileContents, 'utf-8') await apply('/tmp/ttyd.yaml') - await watchPodUntilRunning('team-admin', `tty-${sessionUser.sub}`) + await watchPodUntilRunning(targetNamespace, `tty-${sessionUser.sub}`) // check the pod every 30 minutes and terminate it after 2 hours of inactivity const ISACTIVE_INTERVAL = 30 * 60 * 1000 const TERMINATE_TIMEOUT = 2 * 60 * 60 * 1000 const intervalId = setInterval(() => { - getCloudttyActiveTime('team-admin', `tty-${sessionUser.sub}`).then((activeTime: number) => { + getCloudttyActiveTime(targetNamespace, `tty-${sessionUser.sub}`).then((activeTime: number) => { if (activeTime > TERMINATE_TIMEOUT) { this.deleteCloudtty(teamId, sessionUser) clearInterval(intervalId) @@ -1594,10 +1596,11 @@ export default class OtomiStack { async deleteCloudtty(teamId: string, sessionUser: SessionUser): Promise { const { sub, isPlatformAdmin, teams } = sessionUser as { sub: string; isPlatformAdmin: boolean; teams: string[] } + const namespace = isPlatformAdmin ? 'team-admin' : `team-${teamId}` const userTeams = teams.map((teamName) => `team-${teamName}`) try { - if (await checkPodExists(`team-${teamId}`, `tty-${sessionUser.sub}`)) { - await k8sdelete(teamId, sub, isPlatformAdmin, userTeams) + if (await checkPodExists(namespace, `tty-${sessionUser.sub}`)) { + await k8sdelete(namespace, sub, isPlatformAdmin, userTeams) } } catch (error) { debug('Failed to delete cloudtty') From 2846cea6eace14a69ed8431384b6ab45abc7eedc Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Mon, 16 Mar 2026 13:28:55 +0100 Subject: [PATCH 03/13] feat: rewrite cloudtty api for being more robust --- src/otomi-stack.ts | 67 +------- src/tty.ts | 403 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 411 insertions(+), 59 deletions(-) create mode 100644 src/tty.ts diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 514c87e89..39f875d73 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -3,8 +3,7 @@ import Debug from 'debug' import { getRegions, ObjectStorageKeyRegions, Region, ResourcePage } from '@linode/api-v4' import { existsSync, rmSync } from 'fs' -import { pathExists, unlink } from 'fs-extra' -import { readdir, readFile, writeFile } from 'fs/promises' +import { readFile } from 'fs/promises' import { generate as generatePassword } from 'generate-password' import { cloneDeep, filter, get, isEmpty, map, merge, omit, pick, set, unset } from 'lodash' import { getAppList, getAppSchema, getSecretPaths } from 'src/app' @@ -116,13 +115,11 @@ import { getAIModels } from './ai/aiModelHandler' import { DatabaseCR } from './ai/DatabaseCR' import { getResourceFilePath, getSecretFilePath } from './fileStore/file-map' import { - apply, checkPodExists, getCloudttyActiveTime, getKubernetesVersion, getSecretValues, getTeamSecretsFromK8s, - k8sdelete, watchPodUntilRunning, } from './k8s_operations' import { @@ -145,6 +142,7 @@ import { sparseCloneChart, validateGitUrl, } from './utils/workloadUtils' +import CloudTty from './tty' interface ExcludedApp extends App { managed: boolean @@ -207,10 +205,12 @@ export default class OtomiStack { isLoaded = false git: Git fileStore: FileStore + cloudTty: CloudTty constructor(editor?: string, sessionId?: string) { this.editor = editor this.sessionId = sessionId ?? 'main' + this.cloudTty = new CloudTty() } getAppList() { @@ -1549,58 +1549,16 @@ export default class OtomiStack { return { iFrameUrl: `https://tty.${variables.FQDN}/${sessionUser.sub}` } } - if (await pathExists('/tmp/ttyd.yaml')) await unlink('/tmp/ttyd.yaml') - - //if user is admin then read the manifests from ./dist/src/ttyManifests/adminTtyManifests - const files = isAdmin - ? await readdir('./dist/src/ttyManifests/adminTtyManifests', 'utf-8') - : await readdir('./dist/src/ttyManifests', 'utf-8') - const filteredFiles = files.filter((file) => file.startsWith('tty')) - const variableKeys = Object.keys(variables) - - const podContentAddTargetTeam = (fileContent) => { - const regex = new RegExp(`\\$TARGET_TEAM`, 'g') - return fileContent.replace(regex, teamId) - } - - // iterates over the rolebinding file and replace the $TARGET_TEAM with the team name for teams - const rolebindingContentsForUsers = (fileContent) => { - const rolebindingArray: string[] = [] - userTeams?.forEach((team: string) => { - const regex = new RegExp(`\\$TARGET_TEAM`, 'g') - const rolebindingForTeam: string = fileContent.replace(regex, team) - rolebindingArray.push(rolebindingForTeam) - }) - return rolebindingArray.join('\n') - } - - const fileContents = await Promise.all( - filteredFiles.map(async (file) => { - let fileContent = sessionUser.isPlatformAdmin - ? await readFile(`./dist/src/ttyManifests/adminTtyManifests/${file}`, 'utf-8') - : await readFile(`./dist/src/ttyManifests/${file}`, 'utf-8') - variableKeys.forEach((key) => { - const regex = new RegExp(`\\$${key}`, 'g') - fileContent = fileContent.replace(regex, variables[key] as string) - }) - if (file === 'tty_02_Pod.yaml') fileContent = podContentAddTargetTeam(fileContent) - if (!sessionUser.isPlatformAdmin && file === 'tty_03_Rolebinding.yaml') { - fileContent = rolebindingContentsForUsers(fileContent) - } - return fileContent - }), - ) - await writeFile('/tmp/ttyd.yaml', fileContents, 'utf-8') - await apply('/tmp/ttyd.yaml') + await this.cloudTty.createTty(teamId, sessionUser, variables.FQDN) await watchPodUntilRunning(targetNamespace, `tty-${sessionUser.sub}`) // check the pod every 30 minutes and terminate it after 2 hours of inactivity const ISACTIVE_INTERVAL = 30 * 60 * 1000 const TERMINATE_TIMEOUT = 2 * 60 * 60 * 1000 const intervalId = setInterval(() => { - getCloudttyActiveTime(targetNamespace, `tty-${sessionUser.sub}`).then((activeTime: number) => { + getCloudttyActiveTime(targetNamespace, `tty-${sessionUser.sub}`).then(async (activeTime: number) => { if (activeTime > TERMINATE_TIMEOUT) { - this.deleteCloudtty(teamId, sessionUser) + await this.cloudTty.deleteTty(teamId, sessionUser) clearInterval(intervalId) debug(`Cloudtty terminated after ${TERMINATE_TIMEOUT / (60 * 60 * 1000)} hours of inactivity`) } @@ -1611,16 +1569,7 @@ export default class OtomiStack { } async deleteCloudtty(teamId: string, sessionUser: SessionUser): Promise { - const { sub, isPlatformAdmin, teams } = sessionUser as { sub: string; isPlatformAdmin: boolean; teams: string[] } - const namespace = isPlatformAdmin ? 'team-admin' : `team-${teamId}` - const userTeams = teams.map((teamName) => `team-${teamName}`) - try { - if (await checkPodExists(namespace, `tty-${sessionUser.sub}`)) { - await k8sdelete(namespace, sub, isPlatformAdmin, userTeams) - } - } catch (error) { - debug('Failed to delete cloudtty') - } + await this.cloudTty.deleteTty(teamId, sessionUser) } private async fetchCatalog( diff --git a/src/tty.ts b/src/tty.ts new file mode 100644 index 000000000..5920fd27d --- /dev/null +++ b/src/tty.ts @@ -0,0 +1,403 @@ +import { + ApiException, + CoreV1Api, + CustomObjectsApi, + CustomObjectsApiCreateNamespacedCustomObjectRequest, + KubeConfig, + KubernetesObject, + RbacAuthorizationV1Api, +} from '@kubernetes/client-node' +import Debug from 'debug' +import { SessionUser } from './otomi-models' + +export default class CloudTty { + private k8sApi: CoreV1Api + private customObjectsApi: CustomObjectsApi + private rbacAuthorizationApi: RbacAuthorizationV1Api + private debug: Debug.Debugger + + constructor() { + const kc = new KubeConfig() + this.k8sApi = kc.makeApiClient(CoreV1Api) + this.customObjectsApi = kc.makeApiClient(CustomObjectsApi) + this.rbacAuthorizationApi = kc.makeApiClient(RbacAuthorizationV1Api) + this.debug = Debug('tty') + } + + async createOrPatch( + createFunc: (params: T) => Promise, + patchFunc: (params: T) => Promise, + params: T, + ): Promise { + try { + return createFunc(params) + } catch (error) { + if (error instanceof ApiException && error.code === 409) { + return patchFunc(params) + } else { + throw error + } + } + } + + async deleteIfExists(func: (params: T) => Promise, params: T): Promise { + try { + await func(params) + } catch (error) { + if (error instanceof ApiException && error.code === 404) { + return + } else { + this.debug.log(error) + } + } + } + + async createAuthorizationPolicy(namespace: string, sub: string): Promise { + const body = { + apiVersion: 'security.istio.io/v1', + kind: 'AuthorizationPolicy', + metadata: { + name: `tty-${sub}`, + namespace, + }, + spec: { + selector: { + matchLabels: { + app: 'tty-$SUB', + }, + }, + action: 'ALLOW', + rules: [ + { + when: [ + { + key: 'request.auth.claims[sub]', + values: [sub], + }, + ], + }, + ], + }, + } + const params: CustomObjectsApiCreateNamespacedCustomObjectRequest = { + group: 'security.istio.io', + version: 'v1', + namespace, + plural: 'authorizationpolicies', + body, + } + return this.createOrPatch( + this.customObjectsApi.createNamespacedCustomObject, + this.customObjectsApi.patchNamespacedCustomObject, + params, + ) + } + + async deleteAuthorizationPolicy(namespace: string, sub: string): Promise { + await this.deleteIfExists(this.customObjectsApi.deleteNamespacedCustomObject, { + group: 'security.istio.io', + version: 'v1', + namespace, + plural: 'authorizationpolicies', + name: `tty-${sub}`, + }) + } + + async createServiceAccount(namespace: string, sub: string): Promise { + const body = { + apiVersion: 'v1', + kind: 'ServiceAccount', + metadata: { + name: `tty-${sub}`, + namespace, + }, + } + return this.createOrPatch(this.k8sApi.createNamespacedServiceAccount, this.k8sApi.patchNamespacedServiceAccount, { + namespace, + body, + }) + } + + async deleteServiceAccount(namespace: string, sub: string): Promise { + await this.deleteIfExists(this.k8sApi.deleteNamespacedServiceAccount, { namespace, name: `tty-${sub}` }) + } + + async createPod(namespace: string, sub: string): Promise { + const body = { + apiVersion: 'v1', + kind: 'Pod', + metadata: { + labels: { + app: `tty-${sub}`, + otomi: 'tty', + }, + name: `tty-${sub}`, + namespace, + }, + spec: { + serviceAccountName: `tty-${sub}`, + securityContext: { + runAsNonRoot: true, + seccompProfile: { + type: 'RuntimeDefault', + }, + runAsUser: 1001, + runAsGroup: 1001, + fsGroup: 1001, + }, + containers: [ + { + image: 'linode/apl-tty:1.2.6', + name: 'tty', + resources: { + requests: { + memory: '128Mi', + cpu: '250m', + }, + limits: { + memory: '256Mi', + cpu: '500m', + }, + }, + env: [ + { + name: 'NAMESPACE', + value: namespace, + }, + ], + securityContext: { + allowPrivilegeEscalation: false, + capabilities: { + drop: ['ALL'], + }, + }, + }, + ], + }, + } + return this.createOrPatch(this.k8sApi.createNamespacedPod, this.k8sApi.patchNamespacedPod, { + namespace, + body, + }) + } + + async deletePod(namespace: string, sub: string): Promise { + await this.deleteIfExists(this.k8sApi.deleteNamespacedPod, { namespace, name: `tty-${sub}` }) + } + + async createRoleBinding(namespace: string, sub: string): Promise { + const body = { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'RoleBinding', + metadata: { + name: `tty-${sub}-rolebinding`, + namespace, + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'Role', + name: 'tty-admin', + }, + subjects: [ + { + kind: 'ServiceAccount', + name: `tty-${sub}`, + namespace, + }, + ], + } + return this.createOrPatch( + this.rbacAuthorizationApi.createNamespacedRoleBinding, + this.rbacAuthorizationApi.patchNamespacedRoleBinding, + { + namespace, + body, + }, + ) + } + + async deleteRoleBinding(namespace: string, sub: string): Promise { + await this.deleteIfExists(this.rbacAuthorizationApi.deleteNamespacedRoleBinding, { + namespace, + name: `tty-${sub}-rolebinding`, + }) + } + + async createClusterRoleBinding(namespace: string, sub: string): Promise { + const body = { + apiVersion: 'rbac.authorization.k8s.io/v1', + kind: 'ClusterRoleBinding', + metadata: { + name: 'tty-admin-clusterrolebinding', + }, + roleRef: { + apiGroup: 'rbac.authorization.k8s.io', + kind: 'ClusterRole', + name: 'cluster-admin', + }, + subjects: [ + { + kind: 'ServiceAccount', + name: `tty-${sub}`, + namespace, + }, + ], + } + return this.createOrPatch( + this.rbacAuthorizationApi.createClusterRoleBinding, + this.rbacAuthorizationApi.patchClusterRoleBinding, + { body }, + ) + } + + async deleteClusterRoleBinding(): Promise { + await this.deleteIfExists(this.rbacAuthorizationApi.deleteClusterRoleBinding, { + name: 'tty-admin-clusterrolebinding', + }) + } + + async createService(namespace: string, sub: string): Promise { + const body = { + apiVersion: 'v1', + kind: 'Service', + metadata: { + labels: { + app: `tty-${sub}`, + }, + name: `tty-${sub}`, + namespace, + }, + spec: { + ports: [ + { + name: 'http', + port: 8080, + protocol: 'TCP', + targetPort: 8080, + }, + ], + selector: { + app: `tty-${sub}`, + }, + type: 'ClusterIP', + }, + } + return this.createOrPatch(this.k8sApi.createNamespacedService, this.k8sApi.patchNamespacedService, { + namespace, + body, + }) + } + + async deleteService(namespace: string, sub: string): Promise { + await this.deleteIfExists(this.k8sApi.deleteNamespacedService, { namespace, name: `tty-${sub}` }) + } + + async createRoute(namespace: string, sub: string, domain: string): Promise { + const body = { + apiVersion: 'gateway.networking.k8s.io/v1', + kind: 'HTTPRoute', + metadata: { + name: `tty-${sub}`, + namespace, + }, + spec: { + hostnames: [`tty.${domain}`], + parentRefs: [ + { + group: 'gateway.networking.k8s.io', + kind: 'Gateway', + name: 'platform', + namespace: 'istio-system', + sectionName: 'https', + }, + ], + rules: [ + { + backendRefs: [ + { + group: '', + kind: 'Service', + name: `tty-${sub}`, + port: 8080, + }, + ], + matches: [ + { + path: { + type: 'PathPrefix', + value: `/${sub}`, + }, + }, + ], + filters: [ + { + type: 'URLRewrite', + urlRewrite: { + path: { + type: 'ReplaceFullPath', + replaceFullPath: '/', + }, + }, + }, + ], + }, + ], + }, + } + return this.createOrPatch( + this.customObjectsApi.createNamespacedCustomObject, + this.customObjectsApi.patchNamespacedCustomObject, + { + group: 'gateway.networking.k8s.io', + version: 'v1', + plural: 'httproutes', + namespace, + body, + }, + ) + } + + async deleteRoute(namespace: string, sub: string): Promise { + await this.deleteIfExists(this.customObjectsApi.deleteNamespacedCustomObject, { + group: 'gateway.networking.k8s.io', + version: 'v1', + namespace, + plural: 'httproutes', + name: `tty-${sub}`, + }) + } + + async createTty(teamId: string, sessionUser: SessionUser, domain: string): Promise { + const { sub, isPlatformAdmin, teams } = sessionUser + const namespace = isPlatformAdmin ? 'team-admin' : `team-${teamId}` + await this.createAuthorizationPolicy(namespace, sub!) + await this.createServiceAccount(namespace, sub!) + await this.createPod(namespace, sub!) + if (isPlatformAdmin) { + await this.createClusterRoleBinding(namespace, sub!) + } else if (teams) { + for (const team of teams) { + await this.createRoleBinding(`team-${team}`, sub!) + } + } + await this.createService(namespace, sub!) + await this.createRoute(namespace, sub!, domain) + } + + async deleteTty(teamId: string, sessionUser: SessionUser): Promise { + const { sub, isPlatformAdmin, teams } = sessionUser + const namespace = isPlatformAdmin ? 'team-admin' : `team-${teamId}` + await this.deleteAuthorizationPolicy(namespace, sub!) + await this.createServiceAccount(namespace, sub!) + await this.deletePod(namespace, sub!) + if (isPlatformAdmin) { + await this.deleteClusterRoleBinding() + } else if (teams) { + for (const team of teams) { + await this.deleteRoleBinding(`team-${team}`, sub!) + } + } + await this.deleteService(namespace, sub!) + await this.deleteRoute(namespace, sub!) + } +} From dacf47aab7ea1127fdcc4d0004da534f49c1ff04 Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Mon, 16 Mar 2026 13:59:39 +0100 Subject: [PATCH 04/13] test: updated tests and added module for tty implementation --- src/otomi-stack.test.ts | 8 ++ src/otomi-stack.ts | 16 ++- src/tty.test.ts | 278 ++++++++++++++++++++++++++++++++++++++++ src/tty.ts | 2 +- 4 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 src/tty.test.ts diff --git a/src/otomi-stack.test.ts b/src/otomi-stack.test.ts index bd886589a..3925f8d1f 100644 --- a/src/otomi-stack.test.ts +++ b/src/otomi-stack.test.ts @@ -12,6 +12,14 @@ import { loadSpec } from './app' import { PublicUrlExists, ValidationError } from './error' import { Git } from './git' +jest.mock('./tty', () => ({ + __esModule: true, + default: jest.fn().mockImplementation(() => ({ + createTty: jest.fn(), + deleteTty: jest.fn(), + })), +})) + jest.mock('src/middleware', () => ({ ...jest.requireActual('src/middleware'), getSessionStack: jest.fn(), diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 39f875d73..15fddac0a 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -205,12 +205,18 @@ export default class OtomiStack { isLoaded = false git: Git fileStore: FileStore - cloudTty: CloudTty + private cloudTty: CloudTty constructor(editor?: string, sessionId?: string) { this.editor = editor this.sessionId = sessionId ?? 'main' - this.cloudTty = new CloudTty() + } + + getCloudTty() { + if (!this.cloudTty) { + this.cloudTty = new CloudTty() + } + return this.cloudTty } getAppList() { @@ -1549,7 +1555,7 @@ export default class OtomiStack { return { iFrameUrl: `https://tty.${variables.FQDN}/${sessionUser.sub}` } } - await this.cloudTty.createTty(teamId, sessionUser, variables.FQDN) + await this.getCloudTty().createTty(teamId, sessionUser, variables.FQDN) await watchPodUntilRunning(targetNamespace, `tty-${sessionUser.sub}`) // check the pod every 30 minutes and terminate it after 2 hours of inactivity @@ -1558,7 +1564,7 @@ export default class OtomiStack { const intervalId = setInterval(() => { getCloudttyActiveTime(targetNamespace, `tty-${sessionUser.sub}`).then(async (activeTime: number) => { if (activeTime > TERMINATE_TIMEOUT) { - await this.cloudTty.deleteTty(teamId, sessionUser) + await this.getCloudTty().deleteTty(teamId, sessionUser) clearInterval(intervalId) debug(`Cloudtty terminated after ${TERMINATE_TIMEOUT / (60 * 60 * 1000)} hours of inactivity`) } @@ -1569,7 +1575,7 @@ export default class OtomiStack { } async deleteCloudtty(teamId: string, sessionUser: SessionUser): Promise { - await this.cloudTty.deleteTty(teamId, sessionUser) + await this.getCloudTty().deleteTty(teamId, sessionUser) } private async fetchCatalog( diff --git a/src/tty.test.ts b/src/tty.test.ts new file mode 100644 index 000000000..0527dddf8 --- /dev/null +++ b/src/tty.test.ts @@ -0,0 +1,278 @@ +import CloudTty from './tty' +import { ApiException } from '@kubernetes/client-node' +import { SessionUser } from './otomi-models' + +const mockCoreV1Api = { + createNamespacedServiceAccount: jest.fn(), + patchNamespacedServiceAccount: jest.fn(), + deleteNamespacedServiceAccount: jest.fn(), + createNamespacedPod: jest.fn(), + patchNamespacedPod: jest.fn(), + deleteNamespacedPod: jest.fn(), + createNamespacedService: jest.fn(), + patchNamespacedService: jest.fn(), + deleteNamespacedService: jest.fn(), +} + +const mockCustomObjectsApi = { + createNamespacedCustomObject: jest.fn(), + patchNamespacedCustomObject: jest.fn(), + deleteNamespacedCustomObject: jest.fn(), +} + +const mockRbacAuthorizationApi = { + createNamespacedRoleBinding: jest.fn(), + patchNamespacedRoleBinding: jest.fn(), + deleteNamespacedRoleBinding: jest.fn(), + createClusterRoleBinding: jest.fn(), + patchClusterRoleBinding: jest.fn(), + deleteClusterRoleBinding: jest.fn(), +} + +const mockMakeApiClient = jest.fn((apiClientType) => { + if (apiClientType.name === 'CoreV1Api') { + return mockCoreV1Api + } + if (apiClientType.name === 'CustomObjectsApi') { + return mockCustomObjectsApi + } + return mockRbacAuthorizationApi +}) + +jest.mock('@kubernetes/client-node', () => { + class MockApiException extends Error { + code: number + + constructor(code: number) { + super(`api error ${code}`) + this.code = code + } + } + + class CoreV1Api {} + class CustomObjectsApi {} + class RbacAuthorizationV1Api {} + + return { + ApiException: MockApiException, + CoreV1Api, + CustomObjectsApi, + RbacAuthorizationV1Api, + KubeConfig: jest.fn().mockImplementation(() => ({ + makeApiClient: mockMakeApiClient, + })), + } +}) + +describe('CloudTty', () => { + beforeEach(() => { + jest.clearAllMocks() + mockCustomObjectsApi.createNamespacedCustomObject.mockResolvedValue({ kind: 'ok' }) + mockCustomObjectsApi.patchNamespacedCustomObject.mockResolvedValue({ kind: 'ok' }) + mockCoreV1Api.createNamespacedServiceAccount.mockResolvedValue({ kind: 'ok' }) + mockCoreV1Api.patchNamespacedServiceAccount.mockResolvedValue({ kind: 'ok' }) + mockCoreV1Api.createNamespacedPod.mockResolvedValue({ kind: 'ok' }) + mockCoreV1Api.patchNamespacedPod.mockResolvedValue({ kind: 'ok' }) + mockCoreV1Api.createNamespacedService.mockResolvedValue({ kind: 'ok' }) + mockCoreV1Api.patchNamespacedService.mockResolvedValue({ kind: 'ok' }) + mockRbacAuthorizationApi.createNamespacedRoleBinding.mockResolvedValue({ kind: 'ok' }) + mockRbacAuthorizationApi.patchNamespacedRoleBinding.mockResolvedValue({ kind: 'ok' }) + mockRbacAuthorizationApi.createClusterRoleBinding.mockResolvedValue({ kind: 'ok' }) + mockRbacAuthorizationApi.patchClusterRoleBinding.mockResolvedValue({ kind: 'ok' }) + }) + + test('createOrPatch calls create function when no conflict occurs', async () => { + const tty = new CloudTty() + const createFn = jest.fn().mockResolvedValue({ kind: 'created' }) + const patchFn = jest.fn().mockResolvedValue({ kind: 'patched' }) + + const result = await tty.createOrPatch(createFn, patchFn, { id: 'x' }) + + expect(createFn).toHaveBeenCalledWith({ id: 'x' }) + expect(patchFn).not.toHaveBeenCalled() + expect(result).toEqual({ kind: 'created' }) + }) + + test('createOrPatch calls patch function when create throws 409', async () => { + const tty = new CloudTty() + const createFn = jest.fn().mockImplementation(() => { + throw new ApiException(409, '', {}, {}) + }) + const patchFn = jest.fn().mockResolvedValue({ kind: 'patched' }) + + const result = await tty.createOrPatch(createFn, patchFn, { id: 'x' }) + + expect(createFn).toHaveBeenCalledWith({ id: 'x' }) + expect(patchFn).toHaveBeenCalledWith({ id: 'x' }) + expect(result).toEqual({ kind: 'patched' }) + }) + + test('deleteIfExists ignores 404 errors', async () => { + const tty = new CloudTty() + const deleteFn = jest.fn().mockRejectedValue(new ApiException(404, '', {}, {})) + + await expect(tty.deleteIfExists(deleteFn, { name: 'x' })).resolves.toBeUndefined() + + expect(deleteFn).toHaveBeenCalledWith({ name: 'x' }) + }) + + test('deleteIfExists logs non-404 errors and continues', async () => { + const tty = new CloudTty() + const error = new ApiException(500, '', {}, {}) + const deleteFn = jest.fn().mockRejectedValue(error) + + await expect(tty.deleteIfExists(deleteFn, { name: 'x' })).resolves.toBeUndefined() + }) + + test('createAuthorizationPolicy passes expected API parameters', async () => { + const tty = new CloudTty() + + await tty.createAuthorizationPolicy('team-a', 'user-1') + + expect(mockCustomObjectsApi.createNamespacedCustomObject).toHaveBeenCalledTimes(1) + const params = mockCustomObjectsApi.createNamespacedCustomObject.mock.calls[0][0] + + expect(params).toEqual( + expect.objectContaining({ + group: 'security.istio.io', + version: 'v1', + namespace: 'team-a', + plural: 'authorizationpolicies', + }), + ) + expect(params.body.metadata).toEqual( + expect.objectContaining({ + name: 'tty-user-1', + namespace: 'team-a', + }), + ) + expect(params.body.spec.rules[0].when[0]).toEqual( + expect.objectContaining({ + key: 'request.auth.claims[sub]', + values: ['user-1'], + }), + ) + }) + + test('createPod sends namespace and key pod configuration fields', async () => { + const tty = new CloudTty() + + await tty.createPod('team-a', 'user-1') + + expect(mockCoreV1Api.createNamespacedPod).toHaveBeenCalledTimes(1) + const params = mockCoreV1Api.createNamespacedPod.mock.calls[0][0] + + expect(params.namespace).toBe('team-a') + expect(params.body.metadata).toEqual( + expect.objectContaining({ + name: 'tty-user-1', + namespace: 'team-a', + }), + ) + expect(params.body.spec).toEqual( + expect.objectContaining({ + serviceAccountName: 'tty-user-1', + }), + ) + expect(params.body.spec.containers[0]).toEqual( + expect.objectContaining({ + name: 'tty', + image: 'linode/apl-tty:1.2.6', + }), + ) + }) + + test('createRoute sends expected route metadata and host/path settings', async () => { + const tty = new CloudTty() + + await tty.createRoute('team-a', 'user-1', 'example.org') + + expect(mockCustomObjectsApi.createNamespacedCustomObject).toHaveBeenCalledTimes(1) + const params = mockCustomObjectsApi.createNamespacedCustomObject.mock.calls[0][0] + + expect(params).toEqual( + expect.objectContaining({ + group: 'gateway.networking.k8s.io', + version: 'v1', + plural: 'httproutes', + namespace: 'team-a', + }), + ) + expect(params.body.metadata.name).toBe('tty-user-1') + expect(params.body.spec.hostnames).toEqual(['tty.example.org']) + expect(params.body.spec.rules[0].backendRefs[0]).toEqual( + expect.objectContaining({ + kind: 'Service', + name: 'tty-user-1', + port: 8080, + }), + ) + expect(params.body.spec.rules[0].matches[0].path.value).toBe('/user-1') + }) + + test('createTty for platform admin creates cluster role binding only', async () => { + const tty = new CloudTty() + const createAuthorizationPolicy = jest.spyOn(tty, 'createAuthorizationPolicy').mockResolvedValue({ kind: 'ok' }) + const createServiceAccount = jest.spyOn(tty, 'createServiceAccount').mockResolvedValue({ kind: 'ok' }) + const createPod = jest.spyOn(tty, 'createPod').mockResolvedValue({ kind: 'ok' }) + const createClusterRoleBinding = jest.spyOn(tty, 'createClusterRoleBinding').mockResolvedValue({ kind: 'ok' }) + const createRoleBinding = jest.spyOn(tty, 'createRoleBinding').mockResolvedValue({ kind: 'ok' }) + const createService = jest.spyOn(tty, 'createService').mockResolvedValue({ kind: 'ok' }) + const createRoute = jest.spyOn(tty, 'createRoute').mockResolvedValue({ kind: 'ok' }) + + await tty.createTty('team-1', { sub: 'user-1', isPlatformAdmin: true } as SessionUser, 'example.org') + + expect(createAuthorizationPolicy).toHaveBeenCalledWith('team-admin', 'user-1') + expect(createServiceAccount).toHaveBeenCalledWith('team-admin', 'user-1') + expect(createPod).toHaveBeenCalledWith('team-admin', 'user-1') + expect(createClusterRoleBinding).toHaveBeenCalledWith('team-admin', 'user-1') + expect(createRoleBinding).not.toHaveBeenCalled() + expect(createService).toHaveBeenCalledWith('team-admin', 'user-1') + expect(createRoute).toHaveBeenCalledWith('team-admin', 'user-1', 'example.org') + }) + + test('createTty for team user creates role bindings for all teams', async () => { + const tty = new CloudTty() + jest.spyOn(tty, 'createAuthorizationPolicy').mockResolvedValue({ kind: 'ok' }) + jest.spyOn(tty, 'createServiceAccount').mockResolvedValue({ kind: 'ok' }) + jest.spyOn(tty, 'createPod').mockResolvedValue({ kind: 'ok' }) + const createClusterRoleBinding = jest.spyOn(tty, 'createClusterRoleBinding').mockResolvedValue({ kind: 'ok' }) + const createRoleBinding = jest.spyOn(tty, 'createRoleBinding').mockResolvedValue({ kind: 'ok' }) + jest.spyOn(tty, 'createService').mockResolvedValue({ kind: 'ok' }) + jest.spyOn(tty, 'createRoute').mockResolvedValue({ kind: 'ok' }) + + await tty.createTty( + 'team-1', + { sub: 'user-1', isPlatformAdmin: false, teams: ['a', 'b'] } as SessionUser, + 'example.org', + ) + + expect(createClusterRoleBinding).not.toHaveBeenCalled() + expect(createRoleBinding).toHaveBeenCalledTimes(2) + expect(createRoleBinding).toHaveBeenNthCalledWith(1, 'team-a', 'user-1') + expect(createRoleBinding).toHaveBeenNthCalledWith(2, 'team-b', 'user-1') + }) + + test('deleteTty removes namespaced and team-scoped resources for team users', async () => { + const tty = new CloudTty() + const deleteAuthorizationPolicy = jest.spyOn(tty, 'deleteAuthorizationPolicy').mockResolvedValue() + const createServiceAccount = jest.spyOn(tty, 'createServiceAccount').mockResolvedValue({ kind: 'ok' }) + const deletePod = jest.spyOn(tty, 'deletePod').mockResolvedValue() + const deleteClusterRoleBinding = jest.spyOn(tty, 'deleteClusterRoleBinding').mockResolvedValue() + const deleteRoleBinding = jest.spyOn(tty, 'deleteRoleBinding').mockResolvedValue() + const deleteService = jest.spyOn(tty, 'deleteService').mockResolvedValue() + const deleteRoute = jest.spyOn(tty, 'deleteRoute').mockResolvedValue() + + await tty.deleteTty('team-1', { sub: 'user-1', isPlatformAdmin: false, teams: ['a', 'b'] } as SessionUser) + + expect(deleteAuthorizationPolicy).toHaveBeenCalledWith('team-team-1', 'user-1') + expect(createServiceAccount).toHaveBeenCalledWith('team-team-1', 'user-1') + expect(deletePod).toHaveBeenCalledWith('team-team-1', 'user-1') + expect(deleteClusterRoleBinding).not.toHaveBeenCalled() + expect(deleteRoleBinding).toHaveBeenCalledTimes(2) + expect(deleteRoleBinding).toHaveBeenNthCalledWith(1, 'team-a', 'user-1') + expect(deleteRoleBinding).toHaveBeenNthCalledWith(2, 'team-b', 'user-1') + expect(deleteService).toHaveBeenCalledWith('team-team-1', 'user-1') + expect(deleteRoute).toHaveBeenCalledWith('team-team-1', 'user-1') + }) +}) diff --git a/src/tty.ts b/src/tty.ts index 5920fd27d..a82d6b027 100644 --- a/src/tty.ts +++ b/src/tty.ts @@ -47,7 +47,7 @@ export default class CloudTty { if (error instanceof ApiException && error.code === 404) { return } else { - this.debug.log(error) + this.debug(error) } } } From 073a0bbed1cf61eae1bb67e9557e1e9fcf8bdcde Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Mon, 16 Mar 2026 14:14:12 +0100 Subject: [PATCH 05/13] fix: load kubeconfig --- src/tty.test.ts | 1 + src/tty.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/src/tty.test.ts b/src/tty.test.ts index 0527dddf8..6f399c259 100644 --- a/src/tty.test.ts +++ b/src/tty.test.ts @@ -60,6 +60,7 @@ jest.mock('@kubernetes/client-node', () => { RbacAuthorizationV1Api, KubeConfig: jest.fn().mockImplementation(() => ({ makeApiClient: mockMakeApiClient, + loadFromDefault: jest.fn(), })), } }) diff --git a/src/tty.ts b/src/tty.ts index a82d6b027..0bfdf1137 100644 --- a/src/tty.ts +++ b/src/tty.ts @@ -18,6 +18,7 @@ export default class CloudTty { constructor() { const kc = new KubeConfig() + kc.loadFromDefault() this.k8sApi = kc.makeApiClient(CoreV1Api) this.customObjectsApi = kc.makeApiClient(CustomObjectsApi) this.rbacAuthorizationApi = kc.makeApiClient(RbacAuthorizationV1Api) From 3ee115a98362b853202438552bbdbeca97e3e981 Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Mon, 16 Mar 2026 15:07:24 +0100 Subject: [PATCH 06/13] fix: object deletion --- src/tty.test.ts | 4 ++-- src/tty.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/tty.test.ts b/src/tty.test.ts index 6f399c259..6e7016347 100644 --- a/src/tty.test.ts +++ b/src/tty.test.ts @@ -257,7 +257,7 @@ describe('CloudTty', () => { test('deleteTty removes namespaced and team-scoped resources for team users', async () => { const tty = new CloudTty() const deleteAuthorizationPolicy = jest.spyOn(tty, 'deleteAuthorizationPolicy').mockResolvedValue() - const createServiceAccount = jest.spyOn(tty, 'createServiceAccount').mockResolvedValue({ kind: 'ok' }) + const deleteServiceAccount = jest.spyOn(tty, 'deleteServiceAccount').mockResolvedValue() const deletePod = jest.spyOn(tty, 'deletePod').mockResolvedValue() const deleteClusterRoleBinding = jest.spyOn(tty, 'deleteClusterRoleBinding').mockResolvedValue() const deleteRoleBinding = jest.spyOn(tty, 'deleteRoleBinding').mockResolvedValue() @@ -267,7 +267,7 @@ describe('CloudTty', () => { await tty.deleteTty('team-1', { sub: 'user-1', isPlatformAdmin: false, teams: ['a', 'b'] } as SessionUser) expect(deleteAuthorizationPolicy).toHaveBeenCalledWith('team-team-1', 'user-1') - expect(createServiceAccount).toHaveBeenCalledWith('team-team-1', 'user-1') + expect(deleteServiceAccount).toHaveBeenCalledWith('team-team-1', 'user-1') expect(deletePod).toHaveBeenCalledWith('team-team-1', 'user-1') expect(deleteClusterRoleBinding).not.toHaveBeenCalled() expect(deleteRoleBinding).toHaveBeenCalledTimes(2) diff --git a/src/tty.ts b/src/tty.ts index 0bfdf1137..e89d11103 100644 --- a/src/tty.ts +++ b/src/tty.ts @@ -80,11 +80,11 @@ export default class CloudTty { ], }, } - const params: CustomObjectsApiCreateNamespacedCustomObjectRequest = { + const params = { group: 'security.istio.io', version: 'v1', - namespace, plural: 'authorizationpolicies', + namespace, body, } return this.createOrPatch( @@ -389,7 +389,7 @@ export default class CloudTty { const { sub, isPlatformAdmin, teams } = sessionUser const namespace = isPlatformAdmin ? 'team-admin' : `team-${teamId}` await this.deleteAuthorizationPolicy(namespace, sub!) - await this.createServiceAccount(namespace, sub!) + await this.deleteServiceAccount(namespace, sub!) await this.deletePod(namespace, sub!) if (isPlatformAdmin) { await this.deleteClusterRoleBinding() From 4485cd9d18ad8b7edeb36a505a83015f78b57ebf Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Mon, 16 Mar 2026 15:07:54 +0100 Subject: [PATCH 07/13] fix: await on return --- src/tty.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/tty.ts b/src/tty.ts index e89d11103..0ef0c6916 100644 --- a/src/tty.ts +++ b/src/tty.ts @@ -31,10 +31,10 @@ export default class CloudTty { params: T, ): Promise { try { - return createFunc(params) + return await createFunc(params) } catch (error) { if (error instanceof ApiException && error.code === 409) { - return patchFunc(params) + return await patchFunc(params) } else { throw error } From c0984cf904f832157ed1ca1dc7eeb6be5df0d441 Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Mon, 16 Mar 2026 15:08:13 +0100 Subject: [PATCH 08/13] fix: js binding obscurities --- src/tty.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/tty.ts b/src/tty.ts index 0ef0c6916..0e8873b79 100644 --- a/src/tty.ts +++ b/src/tty.ts @@ -2,7 +2,6 @@ import { ApiException, CoreV1Api, CustomObjectsApi, - CustomObjectsApiCreateNamespacedCustomObjectRequest, KubeConfig, KubernetesObject, RbacAuthorizationV1Api, @@ -14,7 +13,7 @@ export default class CloudTty { private k8sApi: CoreV1Api private customObjectsApi: CustomObjectsApi private rbacAuthorizationApi: RbacAuthorizationV1Api - private debug: Debug.Debugger + private readonly debug: Debug.Debugger constructor() { const kc = new KubeConfig() @@ -22,6 +21,17 @@ export default class CloudTty { this.k8sApi = kc.makeApiClient(CoreV1Api) this.customObjectsApi = kc.makeApiClient(CustomObjectsApi) this.rbacAuthorizationApi = kc.makeApiClient(RbacAuthorizationV1Api) + + // Bind every method on each client instance + for (const client of [this.k8sApi, this.customObjectsApi, this.rbacAuthorizationApi]) { + const proto = Object.getPrototypeOf(client) + Object.getOwnPropertyNames(proto) + .filter((m) => typeof client[m] === 'function') + .forEach((m) => { + client[m] = client[m].bind(client) + }) + } + this.debug = Debug('tty') } From 626d2ccd7567e387cc568e0bba5adfd5a374ebcd Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Mon, 16 Mar 2026 15:08:24 +0100 Subject: [PATCH 09/13] chore: clean up unused variable --- src/otomi-stack.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index 15fddac0a..9a2d564b2 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -1534,7 +1534,6 @@ export default class OtomiStack { debug('No user sub found, cannot connect to shell.') throw new OtomiError(500, 'No user sub found, cannot connect to shell.') } - const userTeams = sessionUser.teams.map((teamName) => `team-${teamName}`) const variables = { FQDN: '', SUB: sessionUser.sub, From 12e1d2681066e192901dd1eb5118657d0ec91889 Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Mon, 16 Mar 2026 15:19:37 +0100 Subject: [PATCH 10/13] chore: removed unused code --- src/k8s_operations.ts | 105 +----------------------------------------- 1 file changed, 1 insertion(+), 104 deletions(-) diff --git a/src/k8s_operations.ts b/src/k8s_operations.ts index 4ada7a91c..9b59808fe 100644 --- a/src/k8s_operations.ts +++ b/src/k8s_operations.ts @@ -1,65 +1,9 @@ -import { - CoreV1Api, - CustomObjectsApi, - KubeConfig, - KubernetesObject, - KubernetesObjectApi, - RbacAuthorizationV1Api, - VersionApi, -} from '@kubernetes/client-node' +import { CoreV1Api, CustomObjectsApi, KubeConfig, VersionApi } from '@kubernetes/client-node' import Debug from 'debug' -import * as fs from 'fs' -import * as yaml from 'js-yaml' -import { promisify } from 'util' import { AplBuildResponse, AplServiceResponse, AplWorkloadResponse, SealedSecretManifestResponse } from './otomi-models' const debug = Debug('otomi:api:k8sOperations') -/** - * Replicate the functionality of `kubectl apply`. That is, create the resources defined in the `specFile` if they do - * not exist, patch them if they do exist. - * - * @param specPath File system path to a YAML Kubernetes spec. - * @return Array of resources created - */ -export async function apply(specPath: string): Promise { - const kc = new KubeConfig() - kc.loadFromDefault() - const client = KubernetesObjectApi.makeApiClient(kc) as any - const fsReadFileP = promisify(fs.readFile) - const specString = await fsReadFileP(specPath, 'utf8') - const specs: any = yaml.loadAll(specString) - const validSpecs = specs.filter((s) => s && s.kind && s.metadata) - const created: KubernetesObject[] = [] - for (const spec of validSpecs) { - // this is to convince the old version of TypeScript that metadata exists even though we already filtered specs - // without metadata out - spec.metadata = spec.metadata || {} - spec.metadata.annotations = spec.metadata.annotations || {} - delete spec.metadata.annotations['kubectl.kubernetes.io/last-applied-configuration'] - spec.metadata.annotations['kubectl.kubernetes.io/last-applied-configuration'] = JSON.stringify(spec) - try { - // try to get the resource, if it does not exist an error will be thrown and we will end up in the catch - // block. - await client.read(spec) - // we got the resource, so it exists, so patch it - // - // Note that this could fail if the spec refers to a custom resource. For custom resources you may need - // to specify a different patch merge strategy in the content-type header. - // - // See: https://github.com/kubernetes/kubernetes/issues/97423 - const response = await client.patch(spec) - created.push(response.body) - } catch (e) { - // we did not get the resource, so it does not exist, so create it - const response = await client.create(spec) - created.push(response.body) - } - } - debug(`Cloudtty is created!`) - return created -} - export async function watchPodUntilRunning(namespace: string, podName: string) { let isRunning = false const kc = new KubeConfig() @@ -101,53 +45,6 @@ export async function checkPodExists(namespace: string, podName: string): Promis } } -export async function k8sdelete( - namespace: string, - sub: string, - isPlatformAdmin: boolean, - userTeams: string[], -): Promise { - const kc = new KubeConfig() - kc.loadFromDefault() - const k8sApi = kc.makeApiClient(CoreV1Api) - const customObjectsApi = kc.makeApiClient(CustomObjectsApi) - const rbacAuthorizationV1Api = kc.makeApiClient(RbacAuthorizationV1Api) - const resourceName = sub - try { - await customObjectsApi.deleteNamespacedCustomObject({ - group: 'security.istio.io', - version: 'v1beta1', - namespace, - plural: 'authorizationpolicies', - name: `tty-${resourceName}`, - }) - - await k8sApi.deleteNamespacedServiceAccount({ name: `tty-${resourceName}`, namespace }) - await k8sApi.deleteNamespacedPod({ name: `tty-${resourceName}`, namespace }) - if (!isPlatformAdmin) { - for (const team of userTeams!) { - await rbacAuthorizationV1Api.deleteNamespacedRoleBinding({ - name: `tty-${team}-${resourceName}-rolebinding`, - namespace: team, - }) - } - } else { - await rbacAuthorizationV1Api.deleteClusterRoleBinding({ name: 'tty-admin-clusterrolebinding' }) - } - await k8sApi.deleteNamespacedService({ name: `tty-${resourceName}`, namespace }) - - await customObjectsApi.deleteNamespacedCustomObject({ - group: 'gateway.networking.k8s.io', - version: 'v1', - namespace, - plural: 'httproutes', - name: `tty-${resourceName}`, - }) - } catch (error) { - debug(`Failed to delete resources for ${resourceName} in namespace ${namespace}.`) - } -} - export async function getKubernetesVersion() { if (process.env.NODE_ENV === 'development') return 'x.x.x' From f2246f2474b62973dabc9f0cb5a96ac34d66d86d Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Tue, 17 Mar 2026 10:42:03 +0100 Subject: [PATCH 11/13] chore: removed unused files --- .../adminTtyManifests/tty_00_Authz.yaml | 16 --------- .../adminTtyManifests/tty_01_Sa.yaml | 7 ---- .../adminTtyManifests/tty_02_Pod.yaml | 36 ------------------- .../tty_03_ClusterRolebinding.yaml | 15 -------- .../adminTtyManifests/tty_04_Svc.yaml | 18 ---------- .../adminTtyManifests/tty_05_HttpRoute.yaml | 32 ----------------- src/ttyManifests/tty_00_Authz.yaml | 15 -------- src/ttyManifests/tty_01_Sa.yaml | 6 ---- src/ttyManifests/tty_02_Pod.yaml | 36 ------------------- src/ttyManifests/tty_03_Rolebinding.yaml | 14 -------- src/ttyManifests/tty_04_Svc.yaml | 17 --------- src/ttyManifests/tty_05_HttpRoute.yaml | 32 ----------------- 12 files changed, 244 deletions(-) delete mode 100644 src/ttyManifests/adminTtyManifests/tty_00_Authz.yaml delete mode 100644 src/ttyManifests/adminTtyManifests/tty_01_Sa.yaml delete mode 100644 src/ttyManifests/adminTtyManifests/tty_02_Pod.yaml delete mode 100644 src/ttyManifests/adminTtyManifests/tty_03_ClusterRolebinding.yaml delete mode 100644 src/ttyManifests/adminTtyManifests/tty_04_Svc.yaml delete mode 100644 src/ttyManifests/adminTtyManifests/tty_05_HttpRoute.yaml delete mode 100644 src/ttyManifests/tty_00_Authz.yaml delete mode 100644 src/ttyManifests/tty_01_Sa.yaml delete mode 100644 src/ttyManifests/tty_02_Pod.yaml delete mode 100644 src/ttyManifests/tty_03_Rolebinding.yaml delete mode 100644 src/ttyManifests/tty_04_Svc.yaml delete mode 100644 src/ttyManifests/tty_05_HttpRoute.yaml diff --git a/src/ttyManifests/adminTtyManifests/tty_00_Authz.yaml b/src/ttyManifests/adminTtyManifests/tty_00_Authz.yaml deleted file mode 100644 index 1d64c4038..000000000 --- a/src/ttyManifests/adminTtyManifests/tty_00_Authz.yaml +++ /dev/null @@ -1,16 +0,0 @@ -apiVersion: security.istio.io/v1beta1 -kind: AuthorizationPolicy -metadata: - name: tty-$SUB - namespace: team-admin -spec: - selector: - matchLabels: - app: tty-$SUB - action: ALLOW - rules: - - when: - - key: request.auth.claims[sub] - values: ['$SUB'] ---- - diff --git a/src/ttyManifests/adminTtyManifests/tty_01_Sa.yaml b/src/ttyManifests/adminTtyManifests/tty_01_Sa.yaml deleted file mode 100644 index 3c2993caa..000000000 --- a/src/ttyManifests/adminTtyManifests/tty_01_Sa.yaml +++ /dev/null @@ -1,7 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: tty-$SUB - namespace: team-admin ---- - diff --git a/src/ttyManifests/adminTtyManifests/tty_02_Pod.yaml b/src/ttyManifests/adminTtyManifests/tty_02_Pod.yaml deleted file mode 100644 index d84fe2a15..000000000 --- a/src/ttyManifests/adminTtyManifests/tty_02_Pod.yaml +++ /dev/null @@ -1,36 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - labels: - app: tty-$SUB - otomi: tty - name: tty-$SUB - namespace: team-admin -spec: - serviceAccountName: tty-$SUB - securityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - runAsUser: 1001 - runAsGroup: 1001 - fsGroup: 1001 - containers: - - image: linode/apl-tty:1.2.6 - name: po - resources: - requests: - memory: '128Mi' - cpu: '250m' - limits: - memory: '256Mi' - cpu: '500m' - env: - - name: NAMESPACE - value: $TARGET_TEAM - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL ---- diff --git a/src/ttyManifests/adminTtyManifests/tty_03_ClusterRolebinding.yaml b/src/ttyManifests/adminTtyManifests/tty_03_ClusterRolebinding.yaml deleted file mode 100644 index d7d8151c5..000000000 --- a/src/ttyManifests/adminTtyManifests/tty_03_ClusterRolebinding.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: tty-admin-clusterrolebinding - namespace: team-admin -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: cluster-admin -subjects: - - kind: ServiceAccount - name: tty-$SUB - namespace: team-admin ---- - diff --git a/src/ttyManifests/adminTtyManifests/tty_04_Svc.yaml b/src/ttyManifests/adminTtyManifests/tty_04_Svc.yaml deleted file mode 100644 index 7ad6ae4f5..000000000 --- a/src/ttyManifests/adminTtyManifests/tty_04_Svc.yaml +++ /dev/null @@ -1,18 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - app: tty-$SUB - name: tty-$SUB - namespace: team-admin -spec: - ports: - - name: 8080-8080 - port: 8080 - protocol: TCP - targetPort: 8080 - selector: - app: tty-$SUB - type: ClusterIP ---- - diff --git a/src/ttyManifests/adminTtyManifests/tty_05_HttpRoute.yaml b/src/ttyManifests/adminTtyManifests/tty_05_HttpRoute.yaml deleted file mode 100644 index 68e55a8f1..000000000 --- a/src/ttyManifests/adminTtyManifests/tty_05_HttpRoute.yaml +++ /dev/null @@ -1,32 +0,0 @@ -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: tty-$SUB - namespace: team-admin -spec: - hostnames: - - tty.$FQDN - parentRefs: - - group: gateway.networking.k8s.io - kind: Gateway - name: platform - namespace: istio-system - sectionName: https - rules: - - backendRefs: - - group: "" - kind: Service - name: tty-$SUB - port: 8080 - weight: 1 - matches: - - path: - type: PathPrefix - value: /$SUB - filters: - - type: URLRewrite - urlRewrite: - path: - type: ReplaceFullPath - replaceFullPath: / ---- diff --git a/src/ttyManifests/tty_00_Authz.yaml b/src/ttyManifests/tty_00_Authz.yaml deleted file mode 100644 index 0e022c1ce..000000000 --- a/src/ttyManifests/tty_00_Authz.yaml +++ /dev/null @@ -1,15 +0,0 @@ -apiVersion: security.istio.io/v1beta1 -kind: AuthorizationPolicy -metadata: - name: tty-$SUB - namespace: $TARGET_TEAM -spec: - selector: - matchLabels: - app: tty-$SUB - action: ALLOW - rules: - - when: - - key: request.auth.claims[sub] - values: ['$SUB'] ---- diff --git a/src/ttyManifests/tty_01_Sa.yaml b/src/ttyManifests/tty_01_Sa.yaml deleted file mode 100644 index 1abeb0fd3..000000000 --- a/src/ttyManifests/tty_01_Sa.yaml +++ /dev/null @@ -1,6 +0,0 @@ -apiVersion: v1 -kind: ServiceAccount -metadata: - name: tty-$SUB - namespace: $TARGET_TEAM ---- diff --git a/src/ttyManifests/tty_02_Pod.yaml b/src/ttyManifests/tty_02_Pod.yaml deleted file mode 100644 index a1a17a19f..000000000 --- a/src/ttyManifests/tty_02_Pod.yaml +++ /dev/null @@ -1,36 +0,0 @@ -apiVersion: v1 -kind: Pod -metadata: - labels: - app: tty-$SUB - otomi: tty - name: tty-$SUB - namespace: $TARGET_TEAM -spec: - serviceAccountName: tty-$SUB - securityContext: - runAsNonRoot: true - seccompProfile: - type: RuntimeDefault - runAsUser: 1001 - runAsGroup: 1001 - fsGroup: 1001 - containers: - - image: linode/apl-tty:1.2.6 - name: po - resources: - requests: - memory: '128Mi' - cpu: '250m' - limits: - memory: '256Mi' - cpu: '500m' - env: - - name: NAMESPACE - value: $TARGET_TEAM - securityContext: - allowPrivilegeEscalation: false - capabilities: - drop: - - ALL ---- diff --git a/src/ttyManifests/tty_03_Rolebinding.yaml b/src/ttyManifests/tty_03_Rolebinding.yaml deleted file mode 100644 index 99d3a166f..000000000 --- a/src/ttyManifests/tty_03_Rolebinding.yaml +++ /dev/null @@ -1,14 +0,0 @@ -apiVersion: rbac.authorization.k8s.io/v1 -kind: RoleBinding -metadata: - name: tty-$TARGET_TEAM-$SUB-rolebinding - namespace: $TARGET_TEAM -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: Role - name: tty-admin -subjects: - - kind: ServiceAccount - name: tty-$SUB - namespace: $TARGET_TEAM ---- diff --git a/src/ttyManifests/tty_04_Svc.yaml b/src/ttyManifests/tty_04_Svc.yaml deleted file mode 100644 index 5d5c9d1d2..000000000 --- a/src/ttyManifests/tty_04_Svc.yaml +++ /dev/null @@ -1,17 +0,0 @@ -apiVersion: v1 -kind: Service -metadata: - labels: - app: tty-$SUB - name: tty-$SUB - namespace: $TARGET_TEAM -spec: - ports: - - name: 8080-8080 - port: 8080 - protocol: TCP - targetPort: 8080 - selector: - app: tty-$SUB - type: ClusterIP ---- diff --git a/src/ttyManifests/tty_05_HttpRoute.yaml b/src/ttyManifests/tty_05_HttpRoute.yaml deleted file mode 100644 index 2d9680855..000000000 --- a/src/ttyManifests/tty_05_HttpRoute.yaml +++ /dev/null @@ -1,32 +0,0 @@ -apiVersion: gateway.networking.k8s.io/v1 -kind: HTTPRoute -metadata: - name: tty-$SUB - namespace: $TARGET_TEAM -spec: - hostnames: - - tty.$FQDN - parentRefs: - - group: gateway.networking.k8s.io - kind: Gateway - name: platform - namespace: istio-system - sectionName: https - rules: - - backendRefs: - - group: "" - kind: Service - name: tty-$SUB - port: 8080 - weight: 1 - matches: - - path: - type: PathPrefix - value: /$SUB - filters: - - type: URLRewrite - urlRewrite: - path: - type: ReplaceFullPath - replaceFullPath: / ---- From 9aea77fb607183679ca7835681b95834b9e5f33c Mon Sep 17 00:00:00 2001 From: Matthias Erll Date: Tue, 17 Mar 2026 10:42:27 +0100 Subject: [PATCH 12/13] fix: namespaced roles on multiple team assignments --- src/tty.test.ts | 22 +++++++++------------- src/tty.ts | 14 +++++++------- 2 files changed, 16 insertions(+), 20 deletions(-) diff --git a/src/tty.test.ts b/src/tty.test.ts index 6e7016347..a1e1b1150 100644 --- a/src/tty.test.ts +++ b/src/tty.test.ts @@ -242,16 +242,12 @@ describe('CloudTty', () => { jest.spyOn(tty, 'createService').mockResolvedValue({ kind: 'ok' }) jest.spyOn(tty, 'createRoute').mockResolvedValue({ kind: 'ok' }) - await tty.createTty( - 'team-1', - { sub: 'user-1', isPlatformAdmin: false, teams: ['a', 'b'] } as SessionUser, - 'example.org', - ) + await tty.createTty('a', { sub: 'user-1', isPlatformAdmin: false, teams: ['a', 'b'] } as SessionUser, 'example.org') expect(createClusterRoleBinding).not.toHaveBeenCalled() expect(createRoleBinding).toHaveBeenCalledTimes(2) - expect(createRoleBinding).toHaveBeenNthCalledWith(1, 'team-a', 'user-1') - expect(createRoleBinding).toHaveBeenNthCalledWith(2, 'team-b', 'user-1') + expect(createRoleBinding).toHaveBeenNthCalledWith(1, 'team-a', 'team-a', 'user-1') + expect(createRoleBinding).toHaveBeenNthCalledWith(2, 'team-a', 'team-b', 'user-1') }) test('deleteTty removes namespaced and team-scoped resources for team users', async () => { @@ -264,16 +260,16 @@ describe('CloudTty', () => { const deleteService = jest.spyOn(tty, 'deleteService').mockResolvedValue() const deleteRoute = jest.spyOn(tty, 'deleteRoute').mockResolvedValue() - await tty.deleteTty('team-1', { sub: 'user-1', isPlatformAdmin: false, teams: ['a', 'b'] } as SessionUser) + await tty.deleteTty('a', { sub: 'user-1', isPlatformAdmin: false, teams: ['a', 'b'] } as SessionUser) - expect(deleteAuthorizationPolicy).toHaveBeenCalledWith('team-team-1', 'user-1') - expect(deleteServiceAccount).toHaveBeenCalledWith('team-team-1', 'user-1') - expect(deletePod).toHaveBeenCalledWith('team-team-1', 'user-1') + expect(deleteAuthorizationPolicy).toHaveBeenCalledWith('team-a', 'user-1') + expect(deleteServiceAccount).toHaveBeenCalledWith('team-a', 'user-1') + expect(deletePod).toHaveBeenCalledWith('team-a', 'user-1') expect(deleteClusterRoleBinding).not.toHaveBeenCalled() expect(deleteRoleBinding).toHaveBeenCalledTimes(2) expect(deleteRoleBinding).toHaveBeenNthCalledWith(1, 'team-a', 'user-1') expect(deleteRoleBinding).toHaveBeenNthCalledWith(2, 'team-b', 'user-1') - expect(deleteService).toHaveBeenCalledWith('team-team-1', 'user-1') - expect(deleteRoute).toHaveBeenCalledWith('team-team-1', 'user-1') + expect(deleteService).toHaveBeenCalledWith('team-a', 'user-1') + expect(deleteRoute).toHaveBeenCalledWith('team-a', 'user-1') }) }) diff --git a/src/tty.ts b/src/tty.ts index 0e8873b79..acc200cfb 100644 --- a/src/tty.ts +++ b/src/tty.ts @@ -196,13 +196,13 @@ export default class CloudTty { await this.deleteIfExists(this.k8sApi.deleteNamespacedPod, { namespace, name: `tty-${sub}` }) } - async createRoleBinding(namespace: string, sub: string): Promise { + async createRoleBinding(accountNamespace: string, targetNamespace: string, sub: string): Promise { const body = { apiVersion: 'rbac.authorization.k8s.io/v1', kind: 'RoleBinding', metadata: { name: `tty-${sub}-rolebinding`, - namespace, + namespace: targetNamespace, }, roleRef: { apiGroup: 'rbac.authorization.k8s.io', @@ -213,7 +213,7 @@ export default class CloudTty { { kind: 'ServiceAccount', name: `tty-${sub}`, - namespace, + namespace: accountNamespace, }, ], } @@ -221,15 +221,15 @@ export default class CloudTty { this.rbacAuthorizationApi.createNamespacedRoleBinding, this.rbacAuthorizationApi.patchNamespacedRoleBinding, { - namespace, + namespace: targetNamespace, body, }, ) } - async deleteRoleBinding(namespace: string, sub: string): Promise { + async deleteRoleBinding(targetNamespace: string, sub: string): Promise { await this.deleteIfExists(this.rbacAuthorizationApi.deleteNamespacedRoleBinding, { - namespace, + namespace: targetNamespace, name: `tty-${sub}-rolebinding`, }) } @@ -388,7 +388,7 @@ export default class CloudTty { await this.createClusterRoleBinding(namespace, sub!) } else if (teams) { for (const team of teams) { - await this.createRoleBinding(`team-${team}`, sub!) + await this.createRoleBinding(namespace, `team-${team}`, sub!) } } await this.createService(namespace, sub!) From ff332fbc6f1d767b0245aab9868f9f0bc7f8c43c Mon Sep 17 00:00:00 2001 From: merll Date: Thu, 19 Mar 2026 08:59:41 +0100 Subject: [PATCH 13/13] Apply suggestion from @ferruhcihan Co-authored-by: Ferruh <63190600+ferruhcihan@users.noreply.github.com> --- src/tty.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tty.ts b/src/tty.ts index acc200cfb..7a9bd285c 100644 --- a/src/tty.ts +++ b/src/tty.ts @@ -74,7 +74,7 @@ export default class CloudTty { spec: { selector: { matchLabels: { - app: 'tty-$SUB', + app: `tty-${sub}`, }, }, action: 'ALLOW',