Skip to content

Commit 45b3cb7

Browse files
feat: simplify the connect cloudtty flow (#747)
* feat: update cloudtty shell endpoint and schema * test: token * feat: add cloudtty v2 endpoint * fix: update session middleware for cloudtty endpoint --------- Co-authored-by: Dennis van Kekem <38350840+dennisvankekem@users.noreply.github.com>
1 parent dc2a0b2 commit 45b3cb7

20 files changed

Lines changed: 152 additions & 103 deletions

src/api/v1/cloudtty.ts

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,22 +5,23 @@ import { OpenApiRequestExt } from 'src/otomi-models'
55
const debug = Debug('otomi:api:v1:cloudtty')
66

77
export default function (): OperationHandlerArray {
8-
const post: Operation = [
9-
async ({ otomi, body }: OpenApiRequestExt, res): Promise<void> => {
10-
debug(`connectCloudtty`)
11-
const v = await otomi.connectCloudtty(body)
8+
const get: Operation = [
9+
async ({ otomi, query, user: sessionUser }: OpenApiRequestExt, res): Promise<void> => {
10+
debug(`connectCloudtty - ${sessionUser.email} - ${sessionUser.sub}`)
11+
const { teamId }: { teamId: string } = query as { teamId: string }
12+
const v = await otomi.connectCloudtty(teamId, sessionUser)
1213
res.json(v)
1314
},
1415
]
1516
const del: Operation = [
16-
async ({ otomi, body }: OpenApiRequestExt, res): Promise<void> => {
17-
debug(`deleteCloudtty`)
18-
await otomi.deleteCloudtty(body)
17+
async ({ otomi, user: sessionUser }: OpenApiRequestExt, res): Promise<void> => {
18+
debug(`deleteCloudtty - ${sessionUser.email} - ${sessionUser.sub}`)
19+
await otomi.deleteCloudtty(sessionUser)
1920
res.json({})
2021
},
2122
]
2223
const api = {
23-
post,
24+
get,
2425
delete: del,
2526
}
2627
return api

src/api/v2/cloudtty.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import Debug from 'debug'
2+
import { Operation, OperationHandlerArray } from 'express-openapi'
3+
import { OpenApiRequestExt } from 'src/otomi-models'
4+
5+
const debug = Debug('otomi:api:v2:cloudtty')
6+
7+
export default function (): OperationHandlerArray {
8+
const get: Operation = [
9+
async ({ otomi, query, user: sessionUser }: OpenApiRequestExt, res): Promise<void> => {
10+
debug(`connectCloudtty - ${sessionUser.email} - ${sessionUser.sub}`)
11+
const { teamId }: { teamId: string } = query as { teamId: string }
12+
const v = await otomi.connectCloudtty(teamId, sessionUser)
13+
res.json(v)
14+
},
15+
]
16+
const del: Operation = [
17+
async ({ otomi, user: sessionUser }: OpenApiRequestExt, res): Promise<void> => {
18+
debug(`deleteCloudtty - ${sessionUser.email} - ${sessionUser.sub}`)
19+
await otomi.deleteCloudtty(sessionUser)
20+
res.json({})
21+
},
22+
]
23+
const api = {
24+
get,
25+
delete: del,
26+
}
27+
return api
28+
}

src/k8s_operations.ts

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import Debug from 'debug'
33
import * as fs from 'fs'
44
import * as yaml from 'js-yaml'
55
import { promisify } from 'util'
6-
import { AplBuildResponse, AplSecretResponse, AplServiceResponse, AplWorkloadResponse, Cloudtty } from './otomi-models'
6+
import { AplBuildResponse, AplSecretResponse, AplServiceResponse, AplWorkloadResponse } from './otomi-models'
77

88
const debug = Debug('otomi:api:k8sOperations')
99

@@ -93,13 +93,21 @@ export async function checkPodExists(namespace: string, podName: string): Promis
9393
}
9494
}
9595

96-
export async function k8sdelete({ emailNoSymbols, isAdmin, userTeams }: Cloudtty) {
96+
export async function k8sdelete({
97+
sub,
98+
isPlatformAdmin,
99+
userTeams,
100+
}: {
101+
sub: string
102+
isPlatformAdmin: boolean
103+
userTeams: string[]
104+
}): Promise<void> {
97105
const kc = new k8s.KubeConfig()
98106
kc.loadFromDefault()
99107
const k8sApi = kc.makeApiClient(k8s.CoreV1Api)
100108
const customObjectsApi = kc.makeApiClient(k8s.CustomObjectsApi)
101109
const rbacAuthorizationV1Api = kc.makeApiClient(k8s.RbacAuthorizationV1Api)
102-
const resourceName = emailNoSymbols
110+
const resourceName = sub
103111
const namespace = 'team-admin'
104112
try {
105113
const apiVersion = 'v1beta1'
@@ -118,7 +126,7 @@ export async function k8sdelete({ emailNoSymbols, isAdmin, userTeams }: Cloudtty
118126

119127
await k8sApi.deleteNamespacedServiceAccount(`tty-${resourceName}`, namespace)
120128
await k8sApi.deleteNamespacedPod(`tty-${resourceName}`, namespace)
121-
if (!isAdmin) {
129+
if (!isPlatformAdmin) {
122130
for (const team of userTeams!) {
123131
await rbacAuthorizationV1Api.deleteNamespacedRoleBinding(`tty-${team}-${resourceName}-rolebinding`, team)
124132
}

src/middleware/jwt.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,6 @@ export function getUser(user: JWT, otomi: OtomiStack): SessionUser {
4949
}
5050

5151
export function jwtMiddleware(): RequestHandler {
52-
// eslint-disable-next-line @typescript-eslint/no-misused-promises
5352
return async function nextHandler(req: OpenApiRequestExt, res, next): Promise<any> {
5453
const token = req.header('Authorization')
5554
const otomi = await getSessionStack() // we can use the readonly version

src/middleware/session.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
/* eslint-disable @typescript-eslint/no-use-before-define */
21
import Debug from 'debug'
32
import { RequestHandler } from 'express'
43
import 'express-async-errors'
@@ -88,7 +87,7 @@ export function sessionMiddleware(server: http.Server): RequestHandler {
8887
email: socket.email,
8988
})
9089
})
91-
// eslint-disable-next-line @typescript-eslint/no-misused-promises
90+
9291
return async function nextHandler(req: OpenApiRequestExt, res, next): Promise<any> {
9392
if (!env.isTest && (!readOnlyStack || !readOnlyStack.isLoaded)) throw new ApiNotReadyError()
9493
const { email } = req.user || {}
@@ -97,9 +96,8 @@ export function sessionMiddleware(server: http.Server): RequestHandler {
9796
req.otomi = roStack
9897

9998
if (['post', 'put', 'delete'].includes(req.method.toLowerCase())) {
100-
// in the cloudtty or workloadCatalog endpoint(s), don't need to create a session
101-
if (req.path === '/v1/cloudtty' || req.path === '/v1/workloadCatalog' || req.path === '/v1/createWorkloadCatalog')
102-
return next()
99+
// in the workloadCatalog endpoint(s), don't need to create a session
100+
if (req.path === '/v1/workloadCatalog' || req.path === '/v1/createWorkloadCatalog') return next()
103101
// bootstrap session stack with unique sessionId to manipulate data
104102
const sessionId = uuidv4() as string
105103
// eslint-disable-next-line no-param-reassign

src/openapi/api.yaml

Lines changed: 35 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1432,18 +1432,44 @@ paths:
14321432
schema:
14331433
type: string
14341434

1435+
'/v2/cloudtty':
1436+
get:
1437+
operationId: connectAplCloudtty
1438+
description: Get a cloudtty iFrameUrl
1439+
x-aclSchema: Cloudtty
1440+
parameters:
1441+
- name: teamId
1442+
in: query
1443+
description: Id of the team
1444+
schema:
1445+
type: string
1446+
responses:
1447+
<<: *DefaultPostResponses
1448+
'200':
1449+
description: Successfully stored cloudtty configuration
1450+
content:
1451+
application/json:
1452+
schema:
1453+
$ref: '#/components/schemas/Cloudtty'
1454+
delete:
1455+
operationId: deleteAplCloudtty
1456+
description: Delete cloudtty url
1457+
x-aclSchema: Cloudtty
1458+
responses:
1459+
'200':
1460+
description: Successfully removed cloudtty url
1461+
14351462
'/v1/cloudtty':
1436-
post:
1463+
get:
14371464
operationId: connectCloudtty
1438-
description: Create a cloudtty url
1465+
description: Get a cloudtty iFrameUrl
14391466
x-aclSchema: Cloudtty
1440-
requestBody:
1441-
content:
1442-
application/json:
1443-
schema:
1444-
$ref: '#/components/schemas/Cloudtty'
1445-
description: Cloudtty object
1446-
required: true
1467+
parameters:
1468+
- name: teamId
1469+
in: query
1470+
description: Id of the team
1471+
schema:
1472+
type: string
14471473
responses:
14481474
<<: *DefaultPostResponses
14491475
'200':
@@ -1456,13 +1482,6 @@ paths:
14561482
operationId: deleteCloudtty
14571483
description: Delete cloudtty url
14581484
x-aclSchema: Cloudtty
1459-
requestBody:
1460-
content:
1461-
application/json:
1462-
schema:
1463-
$ref: '#/components/schemas/Cloudtty'
1464-
description: Cloudtty object
1465-
required: true
14661485
responses:
14671486
'200':
14681487
description: Successfully removed cloudtty url

src/openapi/cloudtty.yaml

Lines changed: 0 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -18,27 +18,5 @@ Cloudtty:
1818
type: object
1919
x-externalDocsPath: docs/for-devs/console/cloudtty
2020
properties:
21-
id:
22-
type: string
23-
teamId:
24-
$ref: 'definitions.yaml#/idName'
25-
domain:
26-
$ref: 'definitions.yaml#/wordCharacterPattern'
27-
emailNoSymbols:
28-
$ref: 'definitions.yaml#/wordCharacterPattern'
2921
iFrameUrl:
3022
$ref: 'definitions.yaml#/wordCharacterPattern'
31-
isAdmin:
32-
type: boolean
33-
default: false
34-
userTeams:
35-
type: array
36-
items:
37-
type: string
38-
sub:
39-
$ref: 'definitions.yaml#/wordCharacterPattern'
40-
required:
41-
- teamId
42-
- domain
43-
- emailNoSymbols
44-
- isAdmin

src/otomi-stack.ts

Lines changed: 36 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1635,32 +1635,44 @@ export default class OtomiStack {
16351635
return (await getKubernetesVersion()) as string
16361636
}
16371637

1638-
async connectCloudtty(data: Cloudtty): Promise<Cloudtty | any> {
1638+
async connectCloudtty(teamId: string, sessionUser: SessionUser): Promise<Cloudtty> {
1639+
if (!sessionUser.sub) {
1640+
debug('No user sub found, cannot connect to shell.')
1641+
throw new OtomiError(500, 'No user sub found, cannot connect to shell.')
1642+
}
1643+
const userTeams = sessionUser.teams.map((teamName) => `team-${teamName}`)
16391644
const variables = {
1640-
FQDN: data.domain,
1641-
EMAIL: data.emailNoSymbols,
1642-
SUB: data.sub,
1645+
FQDN: '',
1646+
SUB: sessionUser.sub,
1647+
}
1648+
try {
1649+
const { cluster } = this.getSettings(['cluster'])
1650+
variables.FQDN = cluster?.domainSuffix || ''
1651+
} catch (error) {
1652+
debug('Error getting cluster settings for cloudtty:', error.message)
1653+
}
1654+
if (!variables.FQDN) {
1655+
debug('No cluster domain suffix found, cannot connect to shell.')
1656+
throw new OtomiError(500, 'No cluster domain suffix found, cannot connect to shell.')
16431657
}
16441658

1645-
const { userTeams } = data
1646-
1647-
// if cloudtty does not exists then check if the pod is running and return it
1648-
if (await checkPodExists('team-admin', `tty-${data.emailNoSymbols}`)) {
1649-
return { ...data, iFrameUrl: `https://tty.${data.domain}/${data.emailNoSymbols}` }
1659+
// if cloudtty shell does not exists then check if the pod is running and return it
1660+
if (await checkPodExists('team-admin', `tty-${sessionUser.sub}`)) {
1661+
return { iFrameUrl: `https://tty.${variables.FQDN}/${sessionUser.sub}` }
16501662
}
16511663

16521664
if (await pathExists('/tmp/ttyd.yaml')) await unlink('/tmp/ttyd.yaml')
16531665

16541666
//if user is admin then read the manifests from ./dist/src/ttyManifests/adminTtyManifests
1655-
const files = data.isAdmin
1667+
const files = sessionUser.isPlatformAdmin
16561668
? await readdir('./dist/src/ttyManifests/adminTtyManifests', 'utf-8')
16571669
: await readdir('./dist/src/ttyManifests', 'utf-8')
16581670
const filteredFiles = files.filter((file) => file.startsWith('tty'))
16591671
const variableKeys = Object.keys(variables)
16601672

16611673
const podContentAddTargetTeam = (fileContent) => {
16621674
const regex = new RegExp(`\\$TARGET_TEAM`, 'g')
1663-
return fileContent.replace(regex, data.teamId)
1675+
return fileContent.replace(regex, teamId)
16641676
}
16651677

16661678
// iterates over the rolebinding file and replace the $TARGET_TEAM with the team name for teams
@@ -1676,41 +1688,45 @@ export default class OtomiStack {
16761688

16771689
const fileContents = await Promise.all(
16781690
filteredFiles.map(async (file) => {
1679-
let fileContent = data.isAdmin
1691+
let fileContent = sessionUser.isPlatformAdmin
16801692
? await readFile(`./dist/src/ttyManifests/adminTtyManifests/${file}`, 'utf-8')
16811693
: await readFile(`./dist/src/ttyManifests/${file}`, 'utf-8')
16821694
variableKeys.forEach((key) => {
16831695
const regex = new RegExp(`\\$${key}`, 'g')
1684-
fileContent = fileContent.replace(regex, variables[key])
1696+
fileContent = fileContent.replace(regex, variables[key] as string)
16851697
})
16861698
if (file === 'tty_02_Pod.yaml') fileContent = podContentAddTargetTeam(fileContent)
1687-
if (!data.isAdmin && file === 'tty_03_Rolebinding.yaml') fileContent = rolebindingContentsForUsers(fileContent)
1699+
if (!sessionUser.isPlatformAdmin && file === 'tty_03_Rolebinding.yaml')
1700+
fileContent = rolebindingContentsForUsers(fileContent)
16881701
return fileContent
16891702
}),
16901703
)
16911704
await writeFile('/tmp/ttyd.yaml', fileContents, 'utf-8')
16921705
await apply('/tmp/ttyd.yaml')
1693-
await watchPodUntilRunning('team-admin', `tty-${data.emailNoSymbols}`)
1706+
await watchPodUntilRunning('team-admin', `tty-${sessionUser.sub}`)
16941707

16951708
// check the pod every 30 minutes and terminate it after 2 hours of inactivity
16961709
const ISACTIVE_INTERVAL = 30 * 60 * 1000
16971710
const TERMINATE_TIMEOUT = 2 * 60 * 60 * 1000
16981711
const intervalId = setInterval(() => {
1699-
getCloudttyActiveTime('team-admin', `tty-${data.emailNoSymbols}`).then((activeTime: number) => {
1712+
getCloudttyActiveTime('team-admin', `tty-${sessionUser.sub}`).then((activeTime: number) => {
17001713
if (activeTime > TERMINATE_TIMEOUT) {
1701-
this.deleteCloudtty(data)
1714+
this.deleteCloudtty(sessionUser)
17021715
clearInterval(intervalId)
17031716
debug(`Cloudtty terminated after ${TERMINATE_TIMEOUT / (60 * 60 * 1000)} hours of inactivity`)
17041717
}
17051718
})
17061719
}, ISACTIVE_INTERVAL)
17071720

1708-
return { ...data, iFrameUrl: `https://tty.${data.domain}/${data.emailNoSymbols}` }
1721+
return { iFrameUrl: `https://tty.${variables.FQDN}/${sessionUser.sub}` }
17091722
}
17101723

1711-
async deleteCloudtty(data: Cloudtty) {
1724+
async deleteCloudtty(sessionUser: SessionUser): Promise<void> {
1725+
const { sub, isPlatformAdmin, teams } = sessionUser as { sub: string; isPlatformAdmin: boolean; teams: string[] }
1726+
const userTeams = teams.map((teamName) => `team-${teamName}`)
17121727
try {
1713-
if (await checkPodExists('team-admin', `tty-${data.emailNoSymbols}`)) await k8sdelete(data)
1728+
if (await checkPodExists('team-admin', `tty-${sessionUser.sub}`))
1729+
await k8sdelete({ sub, isPlatformAdmin, userTeams })
17141730
} catch (error) {
17151731
debug('Failed to delete cloudtty')
17161732
}
Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,16 @@
11
apiVersion: security.istio.io/v1beta1
22
kind: AuthorizationPolicy
33
metadata:
4-
name: tty-$EMAIL
4+
name: tty-$SUB
55
namespace: team-admin
66
spec:
77
selector:
88
matchLabels:
9-
app: tty-$EMAIL
9+
app: tty-$SUB
1010
action: ALLOW
1111
rules:
1212
- when:
1313
- key: request.auth.claims[sub]
1414
values: ['$SUB']
1515
---
16+
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
apiVersion: v1
22
kind: ServiceAccount
33
metadata:
4-
name: tty-$EMAIL
4+
name: tty-$SUB
55
namespace: team-admin
66
---
77

0 commit comments

Comments
 (0)