Skip to content

Commit 11c1e9f

Browse files
feat: consolidate k8s service fetching (#966)
* feat: consolidate k8s service fetching * feat: consolidate k8s service fetching * fix: tests --------- Co-authored-by: svcAPLBot <174728082+svcAPLBot@users.noreply.github.com>
1 parent 8391b9f commit 11c1e9f

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: 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: 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

@@ -414,3 +420,44 @@ 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+
428+
const labels = item.metadata?.labels ?? {}
429+
430+
// Filter out knative private services
431+
if (labels[knativeServiceTypeLabel] === 'Private') return null
432+
// Filter out services that are knative service revision
433+
if (item.spec?.type === 'ClusterIP' && labels[knativeServiceLabel]) return null
434+
435+
let name = item.metadata?.name ?? 'unknown'
436+
let managedByKnative = false
437+
438+
if (item.spec?.type === 'ExternalName' && labels[knativeServiceLabel]) {
439+
name = labels[knativeServiceLabel]
440+
managedByKnative = true
441+
}
442+
443+
const ports = item.spec?.ports?.map((p) => p.port) ?? []
444+
445+
return { name, ports, managedByKnative }
446+
}
447+
448+
// Canary deployments produce two services: <name>-v1 and <name>-v2.
449+
// This function consolidates them into a single entry with the base name.
450+
// It works in two steps:
451+
// 1. Filter: drop -v2 when a matching -v1 exists (keeping only one representative)
452+
// 2. Map: rename the remaining -v1 to the base name when a matching -v2 exists
453+
// Services without a matching counterpart are left unchanged.
454+
export function mergeCanaryServices(services: K8sService[]): K8sService[] {
455+
const nameSet = new Set(services.map((s) => s.name))
456+
457+
return services
458+
.filter((svc) => !svc.name.endsWith('-v2') || !nameSet.has(svc.name.replace(/-v2$/, '-v1')))
459+
.map((svc) => {
460+
const baseName = svc.name.replace(/-v1$/, '')
461+
return nameSet.has(`${baseName}-v2`) ? { ...svc, name: baseName } : svc
462+
})
463+
}

src/k8s_operations.test.ts

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

src/otomi-stack.ts

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { CoreV1Api, User as k8sUser, KubeConfig, V1ObjectReference } from '@kubernetes/client-node'
1+
import { CoreV1Api, KubeConfig, User as k8sUser, V1ObjectReference } from '@kubernetes/client-node'
22
import Debug from 'debug'
33

44
import { getRegions, ObjectStorageKeyRegions, Region, ResourcePage } from '@linode/api-v4'
@@ -121,8 +121,10 @@ import {
121121
getKubernetesVersion,
122122
getSecretValues,
123123
getTeamSecretsFromK8s,
124+
mergeCanaryServices,
125+
toK8sService,
124126
watchPodUntilRunning,
125-
} from './k8s_operations'
127+
} from './k8s-operations'
126128
import CloudTty from './tty'
127129
import {
128130
getGiteaRepoUrls,
@@ -2191,30 +2193,10 @@ export default class OtomiStack {
21912193
async getK8sServices(teamId: string): Promise<Array<K8sService>> {
21922194
if (env.isDev) return []
21932195

2194-
const client = this.getApiClient()
2195-
const collection: K8sService[] = []
2196-
2197-
const svcList = await client.listNamespacedService({ namespace: `team-${teamId}` })
2198-
svcList.items.map((item) => {
2199-
let name = item.metadata!.name ?? 'unknown'
2200-
let managedByKnative = false
2201-
// Filter out knative private services
2202-
if (item.metadata?.labels?.['networking.internal.knative.dev/serviceType'] === 'Private') return
2203-
// Filter out services that are knative service revision
2204-
if (item.spec?.type === 'ClusterIP' && item.metadata?.labels?.['serving.knative.dev/service']) return
2205-
if (item.spec?.type === 'ExternalName' && item.metadata?.labels?.['serving.knative.dev/service']) {
2206-
name = item.metadata?.labels?.['serving.knative.dev/service']
2207-
managedByKnative = true
2208-
}
2209-
2210-
collection.push({
2211-
name,
2212-
ports: item.spec?.ports?.map((portItem) => portItem.port) ?? [],
2213-
managedByKnative,
2214-
})
2215-
})
2196+
const { items } = await this.getApiClient().listNamespacedService({ namespace: `team-${teamId}` })
2197+
const mapped = items.flatMap((item) => toK8sService(item) ?? [])
22162198

2217-
return collection
2199+
return mergeCanaryServices(mapped)
22182200
}
22192201

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

0 commit comments

Comments
 (0)