Skip to content

Commit efff943

Browse files
committed
Merge branch 'main' into APL-523
2 parents a94b846 + 11c1e9f commit efff943

9 files changed

Lines changed: 223 additions & 121 deletions

src/api-v2.authz.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ function createTeamResource(kind: AplKind, spec: Record<string, any>) {
2727
}
2828
}
2929

30-
jest.mock('./k8s_operations')
30+
jest.mock('./k8s-operations')
3131
jest.mock('./utils/sealedSecretUtils')
3232
beforeAll(async () => {
3333
jest.spyOn(console, 'log').mockImplementation(() => {})

src/api.authz.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ const userToken = getToken([])
2020
const teamId = 'team1'
2121
const otherTeamId = 'team2'
2222

23-
jest.mock('./k8s_operations', () => {
24-
const original = jest.requireActual('./k8s_operations')
23+
jest.mock('./k8s-operations', () => {
24+
const original = jest.requireActual('./k8s-operations')
2525
return {
2626
...original,
2727
apply: jest.fn(),

src/api/v1/sealedsecretskeys.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import Debug from 'debug'
22
import { Request, Response } from 'express'
3-
import { getSealedSecretsKeys } from 'src/k8s_operations'
3+
import { getSealedSecretsKeys } from 'src/k8s-operations'
44
import YAML from 'yaml'
55

66
const debug = Debug('otomi:api:v1:sealedsecrets')

src/app.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ import {
3333
} from 'src/validators'
3434
import swaggerUi from 'swagger-ui-express'
3535
import getLatestRemoteCommitSha from './git/connect'
36-
import { getBuildStatus, getSealedSecretStatus, getServiceStatus, getWorkloadStatus } from './k8s_operations'
36+
import { getBuildStatus, getSealedSecretStatus, getServiceStatus, getWorkloadStatus } from './k8s-operations'
3737

3838
const env = cleanEnv({
3939
CATALOG_CACHE_REFRESH_INTERVAL_MS,

src/k8s-operations.test.ts

Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
import { CoreV1Api, V1Service } from '@kubernetes/client-node'
2+
import { getCloudttyActiveTime, getLogTime, mergeCanaryServices, toK8sService } from './k8s-operations'
3+
4+
// Mock the KubeConfig
5+
jest.mock('@kubernetes/client-node', () => {
6+
const actual = jest.requireActual('@kubernetes/client-node')
7+
return {
8+
...actual,
9+
KubeConfig: jest.fn().mockImplementation(() => ({
10+
loadFromDefault: jest.fn(),
11+
makeApiClient: jest.fn((apiClientType) => {
12+
if (apiClientType === actual.CoreV1Api) {
13+
return new actual.CoreV1Api()
14+
}
15+
return {}
16+
}),
17+
})),
18+
}
19+
})
20+
21+
const makeService = (overrides: Partial<V1Service> = {}): V1Service => ({
22+
metadata: { name: 'my-svc', labels: {} },
23+
spec: { type: 'ClusterIP', ports: [{ port: 8080 }] },
24+
...overrides,
25+
})
26+
27+
describe('toK8sService', () => {
28+
test('maps a regular service', () => {
29+
const result = toK8sService(makeService())
30+
expect(result).toEqual({ name: 'my-svc', ports: [8080], managedByKnative: false })
31+
})
32+
33+
test('returns the raw service name', () => {
34+
const svc = makeService({ metadata: { name: 'my-svc-v1', labels: {} } })
35+
expect(toK8sService(svc)?.name).toBe('my-svc-v1')
36+
})
37+
38+
test('filters out knative private services', () => {
39+
const svc = makeService({
40+
metadata: { name: 'private-svc', labels: { 'networking.internal.knative.dev/serviceType': 'Private' } },
41+
})
42+
expect(toK8sService(svc)).toBeNull()
43+
})
44+
45+
test('filters out ClusterIP knative revision services', () => {
46+
const svc = makeService({
47+
metadata: { name: 'revision-svc', labels: { 'serving.knative.dev/service': 'my-ksvc' } },
48+
spec: { type: 'ClusterIP', ports: [{ port: 80 }] },
49+
})
50+
expect(toK8sService(svc)).toBeNull()
51+
})
52+
53+
test('maps ExternalName knative service and sets managedByKnative', () => {
54+
const svc = makeService({
55+
metadata: { name: 'external-svc', labels: { 'serving.knative.dev/service': 'my-ksvc' } },
56+
spec: { type: 'ExternalName', ports: [{ port: 80 }] },
57+
})
58+
const result = toK8sService(svc)
59+
expect(result).toEqual({ name: 'my-ksvc', ports: [80], managedByKnative: true })
60+
})
61+
})
62+
63+
describe('mergeCanaryServices', () => {
64+
test('returns services unchanged when no canary variants present', () => {
65+
const services = [
66+
{ name: 'svc-a', ports: [80], managedByKnative: false },
67+
{ name: 'svc-b', ports: [8080], managedByKnative: false },
68+
]
69+
expect(mergeCanaryServices(services)).toEqual(services)
70+
})
71+
72+
test('groups -v1 and -v2 variants into a single entry with the base name', () => {
73+
const services = [
74+
{ name: 'my-svc-v1', ports: [80], managedByKnative: false },
75+
{ name: 'my-svc-v2', ports: [80], managedByKnative: false },
76+
]
77+
expect(mergeCanaryServices(services)).toEqual([{ name: 'my-svc', ports: [80], managedByKnative: false }])
78+
})
79+
80+
test('does not strip suffix when only one variant exists', () => {
81+
const services = [{ name: 'my-svc-v1', ports: [80], managedByKnative: false }]
82+
expect(mergeCanaryServices(services)).toEqual([{ name: 'my-svc-v1', ports: [80], managedByKnative: false }])
83+
})
84+
85+
test('retains the data from the -v1 variant', () => {
86+
const services = [
87+
{ name: 'my-svc-v1', ports: [80, 443], managedByKnative: true },
88+
{ name: 'my-svc-v2', ports: [8080], managedByKnative: false },
89+
]
90+
expect(mergeCanaryServices(services)).toEqual([{ name: 'my-svc', ports: [80, 443], managedByKnative: true }])
91+
})
92+
})
93+
94+
describe('getCloudttyLogTime', () => {
95+
test('should return the timestamp for a valid log timestamp', () => {
96+
const timestampMatch = ['[2023/10/10 00:00:00:0000]', '2023/10/10 00:00:00:0000']
97+
const result = getLogTime(timestampMatch)
98+
const timestamp = new Date('2023-10-10T00:00:00.000').getTime()
99+
expect(result).toBe(timestamp)
100+
})
101+
102+
test('should return NaN for an invalid log timestamp', () => {
103+
const timestampMatch = ['[invalid-timestamp]', 'invalid-date invalid-time']
104+
const result = getLogTime(timestampMatch)
105+
expect(result).toBeNaN()
106+
})
107+
})
108+
109+
describe('getCloudttyActiveTime', () => {
110+
afterEach(() => {
111+
jest.clearAllMocks()
112+
})
113+
114+
test('should return the time difference if no clients', async () => {
115+
const namespace = 'test-namespace'
116+
const podName = 'test-pod'
117+
const log = '[2023/10/10 00:00:00:0000] [INFO] clients: 0'
118+
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockResolvedValue(log)
119+
120+
const result = await getCloudttyActiveTime(namespace, podName)
121+
expect(result).toBeGreaterThan(0)
122+
})
123+
124+
test('should return 0 if clients are connected', async () => {
125+
const namespace = 'test-namespace'
126+
const podName = 'test-pod'
127+
const log = '[2023/10/10 00:00:00:0000] [INFO] clients: 1'
128+
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockResolvedValue(log)
129+
130+
const result = await getCloudttyActiveTime(namespace, podName)
131+
expect(result).toBe(0)
132+
})
133+
134+
test('should return undefined if log does not contain client count', async () => {
135+
const namespace = 'test-namespace'
136+
const podName = 'test-pod'
137+
const log = '[2023/10/10 00:00:00:0000] [INFO] some other log message'
138+
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockResolvedValue(log)
139+
140+
const result = await getCloudttyActiveTime(namespace, podName)
141+
expect(result).toBeUndefined()
142+
})
143+
144+
test('should return undefined if log is empty', async () => {
145+
const namespace = 'test-namespace'
146+
const podName = 'test-pod'
147+
const log = ''
148+
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockResolvedValue(log)
149+
150+
const result = await getCloudttyActiveTime(namespace, podName)
151+
expect(result).toBeUndefined()
152+
})
153+
154+
test('should return undefined if an error occurs', async () => {
155+
const namespace = 'test-namespace'
156+
const podName = 'test-pod'
157+
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockRejectedValue(new Error('test error'))
158+
159+
const result = await getCloudttyActiveTime(namespace, podName)
160+
expect(result).toBeUndefined()
161+
})
162+
})
Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
import { CoreV1Api, CustomObjectsApi, KubeConfig, VersionApi } from '@kubernetes/client-node'
1+
import { CoreV1Api, CustomObjectsApi, KubeConfig, V1Service, VersionApi } from '@kubernetes/client-node'
22
import Debug from 'debug'
3-
import { AplBuildResponse, AplServiceResponse, AplWorkloadResponse, SealedSecretManifestResponse } from './otomi-models'
3+
import {
4+
AplBuildResponse,
5+
AplServiceResponse,
6+
AplWorkloadResponse,
7+
K8sService,
8+
SealedSecretManifestResponse,
9+
} from './otomi-models'
410

511
const debug = Debug('otomi:api:k8sOperations')
612

@@ -498,3 +504,44 @@ export async function isK8sReachable(): Promise<boolean> {
498504
}
499505
return _k8sReachable
500506
}
507+
508+
export function toK8sService(item: V1Service): K8sService | null {
509+
const knativeServiceTypeLabel = 'networking.internal.knative.dev/serviceType'
510+
const knativeServiceLabel = 'serving.knative.dev/service'
511+
512+
const labels = item.metadata?.labels ?? {}
513+
514+
// Filter out knative private services
515+
if (labels[knativeServiceTypeLabel] === 'Private') return null
516+
// Filter out services that are knative service revision
517+
if (item.spec?.type === 'ClusterIP' && labels[knativeServiceLabel]) return null
518+
519+
let name = item.metadata?.name ?? 'unknown'
520+
let managedByKnative = false
521+
522+
if (item.spec?.type === 'ExternalName' && labels[knativeServiceLabel]) {
523+
name = labels[knativeServiceLabel]
524+
managedByKnative = true
525+
}
526+
527+
const ports = item.spec?.ports?.map((p) => p.port) ?? []
528+
529+
return { name, ports, managedByKnative }
530+
}
531+
532+
// Canary deployments produce two services: <name>-v1 and <name>-v2.
533+
// This function consolidates them into a single entry with the base name.
534+
// It works in two steps:
535+
// 1. Filter: drop -v2 when a matching -v1 exists (keeping only one representative)
536+
// 2. Map: rename the remaining -v1 to the base name when a matching -v2 exists
537+
// Services without a matching counterpart are left unchanged.
538+
export function mergeCanaryServices(services: K8sService[]): K8sService[] {
539+
const nameSet = new Set(services.map((s) => s.name))
540+
541+
return services
542+
.filter((svc) => !svc.name.endsWith('-v2') || !nameSet.has(svc.name.replace(/-v2$/, '-v1')))
543+
.map((svc) => {
544+
const baseName = svc.name.replace(/-v1$/, '')
545+
return nameSet.has(`${baseName}-v2`) ? { ...svc, name: baseName } : svc
546+
})
547+
}

src/k8s_operations.test.ts

Lines changed: 0 additions & 89 deletions
This file was deleted.

src/otomi-stack.ts

Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -123,8 +123,10 @@ import {
123123
getSecretValues,
124124
getTeamSecretsFromK8s,
125125
isK8sReachable,
126+
mergeCanaryServices,
127+
toK8sService,
126128
watchPodUntilRunning,
127-
} from './k8s_operations'
129+
} from './k8s-operations'
128130
import CloudTty from './tty'
129131
import {
130132
getGiteaRepoUrls,
@@ -2327,30 +2329,10 @@ export default class OtomiStack {
23272329
async getK8sServices(teamId: string): Promise<Array<K8sService>> {
23282330
if (env.isDev && !(await isK8sReachable())) return []
23292331

2330-
const client = this.getApiClient()
2331-
const collection: K8sService[] = []
2332-
2333-
const svcList = await client.listNamespacedService({ namespace: `team-${teamId}` })
2334-
svcList.items.map((item) => {
2335-
let name = item.metadata!.name ?? 'unknown'
2336-
let managedByKnative = false
2337-
// Filter out knative private services
2338-
if (item.metadata?.labels?.['networking.internal.knative.dev/serviceType'] === 'Private') return
2339-
// Filter out services that are knative service revision
2340-
if (item.spec?.type === 'ClusterIP' && item.metadata?.labels?.['serving.knative.dev/service']) return
2341-
if (item.spec?.type === 'ExternalName' && item.metadata?.labels?.['serving.knative.dev/service']) {
2342-
name = item.metadata?.labels?.['serving.knative.dev/service']
2343-
managedByKnative = true
2344-
}
2345-
2346-
collection.push({
2347-
name,
2348-
ports: item.spec?.ports?.map((portItem) => portItem.port) ?? [],
2349-
managedByKnative,
2350-
})
2351-
})
2332+
const { items } = await this.getApiClient().listNamespacedService({ namespace: `team-${teamId}` })
2333+
const mapped = items.flatMap((item) => toK8sService(item) ?? [])
23522334

2353-
return collection
2335+
return mergeCanaryServices(mapped)
23542336
}
23552337

23562338
async getKubecfg(teamId: string): Promise<KubeConfig> {

0 commit comments

Comments
 (0)