Skip to content

Commit 52f9b6e

Browse files
merllsvcAPLBotferruhcihan
authored
feat: update manifests for gateway api (#943)
* feat: update manifests for gateway api * fix: namespace assignment of resources * feat: rewrite cloudtty api for being more robust * test: updated tests and added module for tty implementation * fix: load kubeconfig * fix: object deletion * fix: await on return * fix: js binding obscurities * chore: clean up unused variable * chore: removed unused code * chore: removed unused files * fix: namespaced roles on multiple team assignments * Apply suggestion from @ferruhcihan Co-authored-by: Ferruh <63190600+ferruhcihan@users.noreply.github.com> --------- Co-authored-by: svcAPLBot <174728082+svcAPLBot@users.noreply.github.com> Co-authored-by: Ferruh <63190600+ferruhcihan@users.noreply.github.com>
1 parent 192eb07 commit 52f9b6e

19 files changed

Lines changed: 721 additions & 430 deletions

src/api/v1/cloudtty.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const connectCloudtty = async (req: OpenApiRequestExt, res: Response): Pr
2323
export const deleteCloudtty = async (req: OpenApiRequestExt, res: Response): Promise<void> => {
2424
const sessionUser = req.user
2525
debug(`deleteCloudtty - ${sessionUser.email} - ${sessionUser.sub}`)
26-
await req.otomi.deleteCloudtty(sessionUser)
26+
const { teamId } = req.query as { teamId: string }
27+
await req.otomi.deleteCloudtty(teamId, sessionUser)
2728
res.json({})
2829
}

src/api/v2/cloudtty.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const connectAplCloudtty = async (req: OpenApiRequestExt, res: Response):
2323
export const deleteAplCloudtty = async (req: OpenApiRequestExt, res: Response): Promise<void> => {
2424
const sessionUser = req.user
2525
debug(`deleteCloudtty - ${sessionUser.email} - ${sessionUser.sub}`)
26-
await req.otomi.deleteCloudtty(sessionUser)
26+
const { teamId } = req.query as { teamId: string }
27+
await req.otomi.deleteCloudtty(teamId, sessionUser)
2728
res.json({})
2829
}

src/k8s_operations.ts

Lines changed: 1 addition & 114 deletions
Original file line numberDiff line numberDiff line change
@@ -1,65 +1,9 @@
1-
import {
2-
CoreV1Api,
3-
CustomObjectsApi,
4-
KubeConfig,
5-
KubernetesObject,
6-
KubernetesObjectApi,
7-
RbacAuthorizationV1Api,
8-
VersionApi,
9-
} from '@kubernetes/client-node'
1+
import { CoreV1Api, CustomObjectsApi, KubeConfig, VersionApi } from '@kubernetes/client-node'
102
import Debug from 'debug'
11-
import * as fs from 'fs'
12-
import * as yaml from 'js-yaml'
13-
import { promisify } from 'util'
143
import { AplBuildResponse, AplServiceResponse, AplWorkloadResponse, SealedSecretManifestResponse } from './otomi-models'
154

165
const debug = Debug('otomi:api:k8sOperations')
176

18-
/**
19-
* Replicate the functionality of `kubectl apply`. That is, create the resources defined in the `specFile` if they do
20-
* not exist, patch them if they do exist.
21-
*
22-
* @param specPath File system path to a YAML Kubernetes spec.
23-
* @return Array of resources created
24-
*/
25-
export async function apply(specPath: string): Promise<KubernetesObject[]> {
26-
const kc = new KubeConfig()
27-
kc.loadFromDefault()
28-
const client = KubernetesObjectApi.makeApiClient(kc) as any
29-
const fsReadFileP = promisify(fs.readFile)
30-
const specString = await fsReadFileP(specPath, 'utf8')
31-
const specs: any = yaml.loadAll(specString)
32-
const validSpecs = specs.filter((s) => s && s.kind && s.metadata)
33-
const created: KubernetesObject[] = []
34-
for (const spec of validSpecs) {
35-
// this is to convince the old version of TypeScript that metadata exists even though we already filtered specs
36-
// without metadata out
37-
spec.metadata = spec.metadata || {}
38-
spec.metadata.annotations = spec.metadata.annotations || {}
39-
delete spec.metadata.annotations['kubectl.kubernetes.io/last-applied-configuration']
40-
spec.metadata.annotations['kubectl.kubernetes.io/last-applied-configuration'] = JSON.stringify(spec)
41-
try {
42-
// try to get the resource, if it does not exist an error will be thrown and we will end up in the catch
43-
// block.
44-
await client.read(spec)
45-
// we got the resource, so it exists, so patch it
46-
//
47-
// Note that this could fail if the spec refers to a custom resource. For custom resources you may need
48-
// to specify a different patch merge strategy in the content-type header.
49-
//
50-
// See: https://github.com/kubernetes/kubernetes/issues/97423
51-
const response = await client.patch(spec)
52-
created.push(response.body)
53-
} catch (e) {
54-
// we did not get the resource, so it does not exist, so create it
55-
const response = await client.create(spec)
56-
created.push(response.body)
57-
}
58-
}
59-
debug(`Cloudtty is created!`)
60-
return created
61-
}
62-
637
export async function watchPodUntilRunning(namespace: string, podName: string) {
648
let isRunning = false
659
const kc = new KubeConfig()
@@ -101,63 +45,6 @@ export async function checkPodExists(namespace: string, podName: string): Promis
10145
}
10246
}
10347

104-
export async function k8sdelete({
105-
sub,
106-
isPlatformAdmin,
107-
userTeams,
108-
}: {
109-
sub: string
110-
isPlatformAdmin: boolean
111-
userTeams: string[]
112-
}): Promise<void> {
113-
const kc = new KubeConfig()
114-
kc.loadFromDefault()
115-
const k8sApi = kc.makeApiClient(CoreV1Api)
116-
const customObjectsApi = kc.makeApiClient(CustomObjectsApi)
117-
const rbacAuthorizationV1Api = kc.makeApiClient(RbacAuthorizationV1Api)
118-
const resourceName = sub
119-
const namespace = 'team-admin'
120-
try {
121-
const apiVersion = 'v1beta1'
122-
const apiGroupAuthz = 'security.istio.io'
123-
const apiGroupVS = 'networking.istio.io'
124-
const pluralAuth = 'authorizationpolicies'
125-
const pluralVS = 'virtualservices'
126-
127-
await customObjectsApi.deleteNamespacedCustomObject({
128-
group: apiGroupAuthz,
129-
version: apiVersion,
130-
namespace,
131-
plural: pluralAuth,
132-
name: `tty-${resourceName}`,
133-
})
134-
135-
await k8sApi.deleteNamespacedServiceAccount({ name: `tty-${resourceName}`, namespace })
136-
await k8sApi.deleteNamespacedPod({ name: `tty-${resourceName}`, namespace })
137-
if (!isPlatformAdmin) {
138-
for (const team of userTeams!) {
139-
await rbacAuthorizationV1Api.deleteNamespacedRoleBinding({
140-
name: `tty-${team}-${resourceName}-rolebinding`,
141-
namespace: team,
142-
})
143-
}
144-
} else {
145-
await rbacAuthorizationV1Api.deleteClusterRoleBinding({ name: 'tty-admin-clusterrolebinding' })
146-
}
147-
await k8sApi.deleteNamespacedService({ name: `tty-${resourceName}`, namespace })
148-
149-
await customObjectsApi.deleteNamespacedCustomObject({
150-
group: apiGroupVS,
151-
version: apiVersion,
152-
namespace,
153-
plural: pluralVS,
154-
name: `tty-${resourceName}`,
155-
})
156-
} catch (error) {
157-
debug(`Failed to delete resources for ${resourceName} in namespace ${namespace}.`)
158-
}
159-
}
160-
16148
export async function getKubernetesVersion() {
16249
if (process.env.NODE_ENV === 'development') return 'x.x.x'
16350

src/otomi-stack.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,14 @@ import { loadSpec } from './app'
1212
import { PublicUrlExists, ValidationError } from './error'
1313
import { Git } from './git'
1414

15+
jest.mock('./tty', () => ({
16+
__esModule: true,
17+
default: jest.fn().mockImplementation(() => ({
18+
createTty: jest.fn(),
19+
deleteTty: jest.fn(),
20+
})),
21+
}))
22+
1523
jest.mock('src/middleware', () => ({
1624
...jest.requireActual('src/middleware'),
1725
getSessionStack: jest.fn(),

src/otomi-stack.ts

Lines changed: 19 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,7 @@ import Debug from 'debug'
33

44
import { getRegions, ObjectStorageKeyRegions, Region, ResourcePage } from '@linode/api-v4'
55
import { existsSync, rmSync } from 'fs'
6-
import { pathExists, unlink } from 'fs-extra'
7-
import { readdir, readFile, writeFile } from 'fs/promises'
6+
import { readFile } from 'fs/promises'
87
import { generate as generatePassword } from 'generate-password'
98
import { cloneDeep, filter, get, isEmpty, map, merge, omit, pick, set, unset } from 'lodash'
109
import { getAppList, getAppSchema, getSecretPaths } from 'src/app'
@@ -117,13 +116,11 @@ import { getAIModels } from './ai/aiModelHandler'
117116
import { DatabaseCR } from './ai/DatabaseCR'
118117
import { getResourceFilePath, getSecretFilePath } from './fileStore/file-map'
119118
import {
120-
apply,
121119
checkPodExists,
122120
getCloudttyActiveTime,
123121
getKubernetesVersion,
124122
getSecretValues,
125123
getTeamSecretsFromK8s,
126-
k8sdelete,
127124
watchPodUntilRunning,
128125
} from './k8s_operations'
129126
import {
@@ -146,6 +143,7 @@ import {
146143
sparseCloneChart,
147144
validateGitUrl,
148145
} from './utils/workloadUtils'
146+
import CloudTty from './tty'
149147

150148
interface ExcludedApp extends App {
151149
managed: boolean
@@ -208,12 +206,20 @@ export default class OtomiStack {
208206
isLoaded = false
209207
git: Git
210208
fileStore: FileStore
209+
private cloudTty: CloudTty
211210

212211
constructor(editor?: string, sessionId?: string) {
213212
this.editor = editor
214213
this.sessionId = sessionId ?? 'main'
215214
}
216215

216+
getCloudTty() {
217+
if (!this.cloudTty) {
218+
this.cloudTty = new CloudTty()
219+
}
220+
return this.cloudTty
221+
}
222+
217223
getAppList() {
218224
let apps = getAppList()
219225
apps = apps.filter((item) => item !== 'ingress-nginx')
@@ -1524,11 +1530,12 @@ export default class OtomiStack {
15241530
}
15251531

15261532
async connectCloudtty(teamId: string, sessionUser: SessionUser): Promise<Cloudtty> {
1533+
const isAdmin = sessionUser.isPlatformAdmin
1534+
const targetNamespace = isAdmin ? 'team-admin' : `team-${teamId}`
15271535
if (!sessionUser.sub) {
15281536
debug('No user sub found, cannot connect to shell.')
15291537
throw new OtomiError(500, 'No user sub found, cannot connect to shell.')
15301538
}
1531-
const userTeams = sessionUser.teams.map((teamName) => `team-${teamName}`)
15321539
const variables = {
15331540
FQDN: '',
15341541
SUB: sessionUser.sub,
@@ -1545,62 +1552,20 @@ export default class OtomiStack {
15451552
}
15461553

15471554
// if cloudtty shell does not exists then check if the pod is running and return it
1548-
if (await checkPodExists('team-admin', `tty-${sessionUser.sub}`)) {
1555+
if (await checkPodExists(targetNamespace, `tty-${sessionUser.sub}`)) {
15491556
return { iFrameUrl: `https://tty.${variables.FQDN}/${sessionUser.sub}` }
15501557
}
15511558

1552-
if (await pathExists('/tmp/ttyd.yaml')) await unlink('/tmp/ttyd.yaml')
1553-
1554-
//if user is admin then read the manifests from ./dist/src/ttyManifests/adminTtyManifests
1555-
const files = sessionUser.isPlatformAdmin
1556-
? await readdir('./dist/src/ttyManifests/adminTtyManifests', 'utf-8')
1557-
: await readdir('./dist/src/ttyManifests', 'utf-8')
1558-
const filteredFiles = files.filter((file) => file.startsWith('tty'))
1559-
const variableKeys = Object.keys(variables)
1560-
1561-
const podContentAddTargetTeam = (fileContent) => {
1562-
const regex = new RegExp(`\\$TARGET_TEAM`, 'g')
1563-
return fileContent.replace(regex, teamId)
1564-
}
1565-
1566-
// iterates over the rolebinding file and replace the $TARGET_TEAM with the team name for teams
1567-
const rolebindingContentsForUsers = (fileContent) => {
1568-
const rolebindingArray: string[] = []
1569-
userTeams?.forEach((team: string) => {
1570-
const regex = new RegExp(`\\$TARGET_TEAM`, 'g')
1571-
const rolebindingForTeam: string = fileContent.replace(regex, team)
1572-
rolebindingArray.push(rolebindingForTeam)
1573-
})
1574-
return rolebindingArray.join('\n')
1575-
}
1576-
1577-
const fileContents = await Promise.all(
1578-
filteredFiles.map(async (file) => {
1579-
let fileContent = sessionUser.isPlatformAdmin
1580-
? await readFile(`./dist/src/ttyManifests/adminTtyManifests/${file}`, 'utf-8')
1581-
: await readFile(`./dist/src/ttyManifests/${file}`, 'utf-8')
1582-
variableKeys.forEach((key) => {
1583-
const regex = new RegExp(`\\$${key}`, 'g')
1584-
fileContent = fileContent.replace(regex, variables[key] as string)
1585-
})
1586-
if (file === 'tty_02_Pod.yaml') fileContent = podContentAddTargetTeam(fileContent)
1587-
if (!sessionUser.isPlatformAdmin && file === 'tty_03_Rolebinding.yaml') {
1588-
fileContent = rolebindingContentsForUsers(fileContent)
1589-
}
1590-
return fileContent
1591-
}),
1592-
)
1593-
await writeFile('/tmp/ttyd.yaml', fileContents, 'utf-8')
1594-
await apply('/tmp/ttyd.yaml')
1595-
await watchPodUntilRunning('team-admin', `tty-${sessionUser.sub}`)
1559+
await this.getCloudTty().createTty(teamId, sessionUser, variables.FQDN)
1560+
await watchPodUntilRunning(targetNamespace, `tty-${sessionUser.sub}`)
15961561

15971562
// check the pod every 30 minutes and terminate it after 2 hours of inactivity
15981563
const ISACTIVE_INTERVAL = 30 * 60 * 1000
15991564
const TERMINATE_TIMEOUT = 2 * 60 * 60 * 1000
16001565
const intervalId = setInterval(() => {
1601-
getCloudttyActiveTime('team-admin', `tty-${sessionUser.sub}`).then((activeTime: number) => {
1566+
getCloudttyActiveTime(targetNamespace, `tty-${sessionUser.sub}`).then(async (activeTime: number) => {
16021567
if (activeTime > TERMINATE_TIMEOUT) {
1603-
this.deleteCloudtty(sessionUser)
1568+
await this.getCloudTty().deleteTty(teamId, sessionUser)
16041569
clearInterval(intervalId)
16051570
debug(`Cloudtty terminated after ${TERMINATE_TIMEOUT / (60 * 60 * 1000)} hours of inactivity`)
16061571
}
@@ -1610,16 +1575,8 @@ export default class OtomiStack {
16101575
return { iFrameUrl: `https://tty.${variables.FQDN}/${sessionUser.sub}` }
16111576
}
16121577

1613-
async deleteCloudtty(sessionUser: SessionUser): Promise<void> {
1614-
const { sub, isPlatformAdmin, teams } = sessionUser as { sub: string; isPlatformAdmin: boolean; teams: string[] }
1615-
const userTeams = teams.map((teamName) => `team-${teamName}`)
1616-
try {
1617-
if (await checkPodExists('team-admin', `tty-${sessionUser.sub}`)) {
1618-
await k8sdelete({ sub, isPlatformAdmin, userTeams })
1619-
}
1620-
} catch (error) {
1621-
debug('Failed to delete cloudtty')
1622-
}
1578+
async deleteCloudtty(teamId: string, sessionUser: SessionUser): Promise<void> {
1579+
await this.getCloudTty().deleteTty(teamId, sessionUser)
16231580
}
16241581

16251582
private async fetchCatalog(

0 commit comments

Comments
 (0)