Skip to content

Commit 2e63167

Browse files
committed
feat: consolidate k8s service fetching
1 parent b255d95 commit 2e63167

9 files changed

Lines changed: 234 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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const userToken = getToken([])
2020
const teamId = 'team1'
2121
const otherTeamId = 'team2'
2222

23-
jest.mock('./k8s_operations')
23+
jest.mock('./k8s-operations')
2424
jest.mock('./utils/sealedSecretUtils')
2525
beforeAll(async () => {
2626
jest.spyOn(console, 'log').mockImplementation(() => {})

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: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { CoreV1Api, V1Service } from '@kubernetes/client-node'
2+
import { getCloudttyActiveTime, getLogTime, groupK8sServices, 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('uses app.kubernetes.io/name label as canonical name', () => {
34+
const svc = makeService({ metadata: { name: 'my-svc-v1', labels: { 'app.kubernetes.io/name': 'my-svc' } } })
35+
expect(toK8sService(svc)?.name).toBe('my-svc')
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('groupK8sServices', () => {
64+
test('returns services unchanged when no duplicates', () => {
65+
const services = [
66+
{ name: 'svc-a', ports: [80], managedByKnative: false },
67+
{ name: 'svc-b', ports: [8080], managedByKnative: false },
68+
]
69+
expect(groupK8sServices(services)).toEqual(services)
70+
})
71+
72+
test('merges canary variants with the same canonical name', () => {
73+
const services = [
74+
{ name: 'my-svc', ports: [80], managedByKnative: false },
75+
{ name: 'my-svc', ports: [80], managedByKnative: false },
76+
]
77+
expect(groupK8sServices(services)).toEqual([{ name: 'my-svc', ports: [80], managedByKnative: false }])
78+
})
79+
80+
test('deduplicates ports across merged services', () => {
81+
const services = [
82+
{ name: 'my-svc', ports: [80, 443], managedByKnative: false },
83+
{ name: 'my-svc', ports: [443, 8080], managedByKnative: false },
84+
]
85+
const result = groupK8sServices(services)
86+
expect(result).toHaveLength(1)
87+
expect(result[0].ports).toEqual(expect.arrayContaining([80, 443, 8080]))
88+
expect(result[0].ports).toHaveLength(3)
89+
})
90+
91+
test('propagates managedByKnative if any service in the group has it set', () => {
92+
const services = [
93+
{ name: 'my-svc', ports: [80], managedByKnative: false },
94+
{ name: 'my-svc', ports: [80], managedByKnative: true },
95+
]
96+
expect(groupK8sServices(services)[0].managedByKnative).toBe(true)
97+
})
98+
})
99+
100+
describe('getCloudttyLogTime', () => {
101+
test('should return the timestamp for a valid log timestamp', () => {
102+
const timestampMatch = ['[2023/10/10 00:00:00:0000]', '2023/10/10 00:00:00:0000']
103+
const result = getLogTime(timestampMatch)
104+
const timestamp = new Date('2023-10-10T00:00:00.000').getTime()
105+
expect(result).toBe(timestamp)
106+
})
107+
108+
test('should return NaN for an invalid log timestamp', () => {
109+
const timestampMatch = ['[invalid-timestamp]', 'invalid-date invalid-time']
110+
const result = getLogTime(timestampMatch)
111+
expect(result).toBeNaN()
112+
})
113+
})
114+
115+
describe('getCloudttyActiveTime', () => {
116+
afterEach(() => {
117+
jest.clearAllMocks()
118+
})
119+
120+
test('should return the time difference if no clients', async () => {
121+
const namespace = 'test-namespace'
122+
const podName = 'test-pod'
123+
const log = '[2023/10/10 00:00:00:0000] [INFO] clients: 0'
124+
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockResolvedValue(log)
125+
126+
const result = await getCloudttyActiveTime(namespace, podName)
127+
expect(result).toBeGreaterThan(0)
128+
})
129+
130+
test('should return 0 if clients are connected', async () => {
131+
const namespace = 'test-namespace'
132+
const podName = 'test-pod'
133+
const log = '[2023/10/10 00:00:00:0000] [INFO] clients: 1'
134+
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockResolvedValue(log)
135+
136+
const result = await getCloudttyActiveTime(namespace, podName)
137+
expect(result).toBe(0)
138+
})
139+
140+
test('should return undefined if log does not contain client count', async () => {
141+
const namespace = 'test-namespace'
142+
const podName = 'test-pod'
143+
const log = '[2023/10/10 00:00:00:0000] [INFO] some other log message'
144+
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockResolvedValue(log)
145+
146+
const result = await getCloudttyActiveTime(namespace, podName)
147+
expect(result).toBeUndefined()
148+
})
149+
150+
test('should return undefined if log is empty', async () => {
151+
const namespace = 'test-namespace'
152+
const podName = 'test-pod'
153+
const log = ''
154+
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockResolvedValue(log)
155+
156+
const result = await getCloudttyActiveTime(namespace, podName)
157+
expect(result).toBeUndefined()
158+
})
159+
160+
test('should return undefined if an error occurs', async () => {
161+
const namespace = 'test-namespace'
162+
const podName = 'test-pod'
163+
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockRejectedValue(new Error('test error'))
164+
165+
const result = await getCloudttyActiveTime(namespace, podName)
166+
expect(result).toBeUndefined()
167+
})
168+
})
Lines changed: 54 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

@@ -414,3 +420,49 @@ export async function getTeamSecretsFromK8s(namespace: string) {
414420
debug(`Failed to get team secrets from k8s for ${namespace}.`)
415421
}
416422
}
423+
424+
export function toK8sService(item: V1Service): K8sService | null {
425+
const knativeServiceTypeLabel = 'networking.internal.knative.dev/serviceType'
426+
const knativeServiceLabel = 'serving.knative.dev/service'
427+
const appNameLabel = 'app.kubernetes.io/name'
428+
429+
const labels = item.metadata?.labels ?? {}
430+
431+
// Filter out knative private services
432+
if (labels[knativeServiceTypeLabel] === 'Private') return null
433+
// Filter out services that are knative service revision
434+
if (item.spec?.type === 'ClusterIP' && labels[knativeServiceLabel]) return null
435+
436+
let name = item.metadata?.name ?? 'unknown'
437+
let managedByKnative = false
438+
439+
if (item.spec?.type === 'ExternalName' && labels[knativeServiceLabel]) {
440+
name = labels[knativeServiceLabel]
441+
managedByKnative = true
442+
}
443+
444+
// Group canary services (e.g. foo-v1 and foo-v2) by their common app.kubernetes.io/name label
445+
const canonicalName = labels[appNameLabel] ?? name
446+
const ports = item.spec?.ports?.map((p) => p.port) ?? []
447+
448+
return { name: canonicalName, ports, managedByKnative }
449+
}
450+
451+
export function groupK8sServices(services: K8sService[]): K8sService[] {
452+
const grouped = new Map<string, K8sService>()
453+
454+
for (const svc of services) {
455+
const existing = grouped.get(svc.name)
456+
if (existing) {
457+
grouped.set(svc.name, {
458+
...existing,
459+
ports: Array.from(new Set([...(existing.ports ?? []), ...(svc.ports ?? [])])),
460+
managedByKnative: existing.managedByKnative || svc.managedByKnative,
461+
})
462+
} else {
463+
grouped.set(svc.name, svc)
464+
}
465+
}
466+
467+
return Array.from(grouped.values())
468+
}

src/k8s_operations.test.ts

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

0 commit comments

Comments
 (0)