Skip to content

Commit 19f4481

Browse files
feat: [APL-898] - Network Policies Page (#772)
* feat: podlabel fetch functions * fix: k8s fetch functions rework * fix: return env check * feat: tests * fix: removed logs --------- Co-authored-by: svcAPLBot <[email protected]>
1 parent 406100e commit 19f4481

File tree

5 files changed

+309
-1
lines changed

5 files changed

+309
-1
lines changed
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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:v1:teams:kubernetes:fetchPodsFromLabel')
6+
7+
export default function (): OperationHandlerArray {
8+
const get: Operation = [
9+
async ({ otomi, query }: OpenApiRequestExt, res): Promise<void> => {
10+
debug('fetchPodsFromLabel')
11+
try {
12+
const { labelSelector, namespace }: { labelSelector: string; namespace: string } = query as any
13+
const v = await otomi.listUniquePodNamesByLabel(labelSelector, namespace)
14+
res.json(v)
15+
} catch (e) {
16+
debug(e)
17+
res.json([])
18+
}
19+
},
20+
]
21+
const api = {
22+
get,
23+
}
24+
return api
25+
}
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
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:v1:teams:kubernetes:networkPolicies')
6+
7+
export default function (): OperationHandlerArray {
8+
const get: Operation = [
9+
async ({ otomi, query }: OpenApiRequestExt, res): Promise<void> => {
10+
debug('getAllK8sPodLabelsForWorkload')
11+
try {
12+
const { workloadName, namespace }: { workloadName: string; namespace: string } = query as any
13+
const v = await otomi.getK8sPodLabelsForWorkload(workloadName, namespace)
14+
res.json(v)
15+
} catch (e) {
16+
debug(e)
17+
res.json([])
18+
}
19+
},
20+
]
21+
const api = {
22+
get,
23+
}
24+
return api
25+
}

src/openapi/api.yaml

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -297,7 +297,7 @@ paths:
297297
x-aclSchema: Service
298298
responses:
299299
'200':
300-
description: Successfully obtained kuberntes services
300+
description: Successfully obtained kubernetes services
301301
content:
302302
application/json:
303303
schema:
@@ -307,6 +307,65 @@ paths:
307307
'400':
308308
<<: *BadRequest
309309

310+
'/v1/teams/{teamId}/kubernetes/networkpolicies':
311+
parameters:
312+
- $ref: '#/components/parameters/teamParams'
313+
get:
314+
operationId: getK8SWorkloadPodLabels
315+
description: Get Podlabels from given workload
316+
x-aclSchema: NetworkPolicies
317+
parameters:
318+
- name: workloadName
319+
in: query
320+
description: name of the workload to get Podlabels from
321+
schema:
322+
type: string
323+
- name: namespace
324+
in: query
325+
description: namespace of the workload to get Podlabels from
326+
schema:
327+
type: string
328+
responses:
329+
'200':
330+
description: Successfully obtained Podlabels from given workload
331+
content:
332+
application/json:
333+
schema:
334+
type: object
335+
additionalProperties:
336+
type: string
337+
'400':
338+
<<: *BadRequest
339+
340+
'/v1/teams/{teamId}/kubernetes/fetchPodsFromLabel':
341+
parameters:
342+
- $ref: '#/components/parameters/teamParams'
343+
get:
344+
operationId: listUniquePodNamesByLabel
345+
description: Get pods name from label
346+
parameters:
347+
- name: labelSelector
348+
in: query
349+
description: name of the label to get pods name from
350+
schema:
351+
type: string
352+
- name: namespace
353+
in: query
354+
description: namespace of the workload to get Podlabels from
355+
schema:
356+
type: string
357+
responses:
358+
'200':
359+
description: Successfully obtained pods from given label
360+
content:
361+
application/json:
362+
schema:
363+
type: array
364+
items:
365+
type: string
366+
'400':
367+
<<: *BadRequest
368+
310369
'/v1/teams/{teamId}/services/{serviceName}':
311370
parameters:
312371
- $ref: '#/components/parameters/teamParams'

src/otomi-stack.test.ts

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -756,6 +756,126 @@ describe('Users tests', () => {
756756
})
757757
})
758758

759+
describe('PodService', () => {
760+
let otomiStack: OtomiStack
761+
let clientMock: {
762+
listNamespacedPod: jest.Mock
763+
listPodForAllNamespaces: jest.Mock
764+
}
765+
766+
beforeEach(() => {
767+
otomiStack = new OtomiStack()
768+
clientMock = {
769+
listNamespacedPod: jest.fn(),
770+
listPodForAllNamespaces: jest.fn(),
771+
}
772+
// Override the API client
773+
;(otomiStack as any).getApiClient = jest.fn(() => clientMock)
774+
})
775+
776+
describe('getK8sPodLabelsForWorkload', () => {
777+
const baseLabels = { 'app.kubernetes.io/name': 'test', custom: 'label' }
778+
779+
it('should return labels on primary Istio selector (namespaced)', async () => {
780+
clientMock.listNamespacedPod.mockResolvedValue({ items: [{ metadata: { labels: baseLabels } }] })
781+
782+
const labels = await otomiStack.getK8sPodLabelsForWorkload('test', 'default')
783+
784+
expect(clientMock.listNamespacedPod).toHaveBeenCalledWith({
785+
namespace: 'default',
786+
labelSelector: 'service.istio.io/canonical-name=test',
787+
})
788+
expect(labels).toEqual(baseLabels)
789+
})
790+
791+
it('should fallback to RabbitMQ selector when primary returns none', async () => {
792+
clientMock.listNamespacedPod
793+
.mockResolvedValueOnce({ items: [] })
794+
.mockResolvedValueOnce({ items: [{ metadata: { labels: { rabbit: 'yes' } } }] })
795+
796+
const labels = await otomiStack.getK8sPodLabelsForWorkload('test', 'default')
797+
798+
expect(clientMock.listNamespacedPod).toHaveBeenCalledTimes(2)
799+
expect(clientMock.listNamespacedPod).toHaveBeenCalledWith({
800+
namespace: 'default',
801+
labelSelector: 'service.istio.io/canonical-name=test',
802+
})
803+
expect(clientMock.listNamespacedPod).toHaveBeenCalledWith({
804+
namespace: 'default',
805+
labelSelector: 'service.istio.io/canonical-name=test-rabbitmq-cluster',
806+
})
807+
expect(labels).toEqual({ rabbit: 'yes' })
808+
})
809+
810+
it('should go through all fallback selectors and return empty object if none found', async () => {
811+
clientMock.listNamespacedPod.mockResolvedValue({ items: [] })
812+
813+
const labels = await otomiStack.getK8sPodLabelsForWorkload('foo', 'bar')
814+
815+
expect(clientMock.listNamespacedPod).toHaveBeenCalledTimes(5)
816+
expect(labels).toEqual({})
817+
})
818+
819+
it('should search across all namespaces when no namespace provided', async () => {
820+
clientMock.listPodForAllNamespaces.mockResolvedValue({ items: [{ metadata: { labels: { global: 'yes' } } }] })
821+
822+
const labels = await otomiStack.getK8sPodLabelsForWorkload('global', undefined)
823+
824+
expect(clientMock.listPodForAllNamespaces).toHaveBeenCalledWith({
825+
labelSelector: 'service.istio.io/canonical-name=global',
826+
})
827+
expect(labels).toEqual({ global: 'yes' })
828+
})
829+
})
830+
831+
describe('listUniquePodNamesByLabel', () => {
832+
it('should return empty array when no pods found (namespaced)', async () => {
833+
clientMock.listNamespacedPod.mockResolvedValue({ items: [] })
834+
835+
const names = await otomiStack.listUniquePodNamesByLabel('app=test', 'default')
836+
837+
expect(names).toEqual([])
838+
expect(clientMock.listNamespacedPod).toHaveBeenCalledWith({ namespace: 'default', labelSelector: 'app=test' })
839+
})
840+
841+
it('should return full names for unique bases', async () => {
842+
const pods = [
843+
{ metadata: { name: 'frontend-abc123' } },
844+
{ metadata: { name: 'backend-def456' } },
845+
{ metadata: { name: 'db-gh789' } },
846+
]
847+
clientMock.listPodForAllNamespaces.mockResolvedValue({ items: pods })
848+
849+
const names = await otomiStack.listUniquePodNamesByLabel('app=all')
850+
851+
expect(names).toEqual(['frontend-abc123', 'backend-def456', 'db-gh789'])
852+
})
853+
854+
it('should filter out duplicate base names, keeping first occurrence', async () => {
855+
const pods = [
856+
{ metadata: { name: 'app-1-a1' } },
857+
{ metadata: { name: 'app-1-b2' } },
858+
{ metadata: { name: 'app-2-c3' } },
859+
{ metadata: { name: 'app-2-d4' } },
860+
]
861+
clientMock.listPodForAllNamespaces.mockResolvedValue({ items: pods })
862+
863+
const names = await otomiStack.listUniquePodNamesByLabel('app=dup')
864+
865+
expect(names).toEqual(['app-1-a1', 'app-2-c3'])
866+
})
867+
868+
it('should handle pod names without dashes', async () => {
869+
const pods = [{ metadata: { name: 'singlename' } }]
870+
clientMock.listPodForAllNamespaces.mockResolvedValue({ items: pods })
871+
872+
const names = await otomiStack.listUniquePodNamesByLabel('app=uniq')
873+
874+
expect(names).toEqual(['singlename'])
875+
})
876+
})
877+
})
878+
759879
describe('Code repositories tests', () => {
760880
let otomiStack: OtomiStack
761881
let teamConfigService: TeamConfigService

src/otomi-stack.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2169,6 +2169,85 @@ export default class OtomiStack {
21692169
return this.apiClient
21702170
}
21712171

2172+
async getK8sPodLabelsForWorkload(workloadName: string, namespace?: string): Promise<Record<string, string>> {
2173+
console.log('Fetching pod labels for workload', workloadName, namespace)
2174+
const api = this.getApiClient()
2175+
const istioKey = 'service.istio.io/canonical-name'
2176+
const otomiKey = 'otomi.io/app'
2177+
const instanceKey = 'app.kubernetes.io/instance'
2178+
const nameKey = 'app.kubernetes.io/name'
2179+
2180+
// Helper to list pods by label selector
2181+
const listPods = async (labelSelector: string) =>
2182+
namespace
2183+
? await api.listNamespacedPod({ namespace, labelSelector })
2184+
: await api.listPodForAllNamespaces({ labelSelector })
2185+
2186+
// 1. Primary selector: Istio canonical name
2187+
let selector = `${istioKey}=${workloadName}`
2188+
let res = await listPods(selector)
2189+
let pods = res.items
2190+
2191+
// 2. RabbitMQ fallback: workloadName-rabbitmq-cluster
2192+
if (pods.length === 0) {
2193+
selector = `${istioKey}=${workloadName}-rabbitmq-cluster`
2194+
res = await listPods(selector)
2195+
pods = res.items
2196+
}
2197+
2198+
// 3. Otomi app label
2199+
if (pods.length === 0) {
2200+
selector = `${otomiKey}=${workloadName}`
2201+
res = await listPods(selector)
2202+
pods = res.items
2203+
}
2204+
2205+
// 4. app.kubernetes.io/instance
2206+
if (pods.length === 0) {
2207+
selector = `${instanceKey}=${workloadName}`
2208+
res = await listPods(selector)
2209+
pods = res.items
2210+
}
2211+
2212+
// 5. app.kubernetes.io/name
2213+
if (pods.length === 0) {
2214+
selector = `${nameKey}=${workloadName}`
2215+
res = await listPods(selector)
2216+
pods = res.items
2217+
}
2218+
2219+
// Return labels of the first matching pod, or empty object
2220+
return pods.length > 0 ? (pods[0].metadata?.labels ?? {}) : {}
2221+
}
2222+
2223+
async listUniquePodNamesByLabel(labelSelector: string, namespace?: string): Promise<string[]> {
2224+
const api = this.getApiClient()
2225+
2226+
// fetch pods, either namespaced or all
2227+
const res = namespace
2228+
? await api.listNamespacedPod({ namespace, labelSelector })
2229+
: await api.listPodForAllNamespaces({ labelSelector })
2230+
2231+
const allPods = res.items
2232+
if (allPods.length === 0) return []
2233+
2234+
const seenBases = new Set<string>()
2235+
const names: string[] = []
2236+
2237+
for (const pod of allPods) {
2238+
const fullName = pod.metadata?.name || ''
2239+
// derive “base” by stripping off the last dash-segment
2240+
const base = fullName.includes('-') ? fullName.substring(0, fullName.lastIndexOf('-')) : fullName
2241+
2242+
if (!seenBases.has(base)) {
2243+
seenBases.add(base)
2244+
names.push(fullName)
2245+
}
2246+
}
2247+
2248+
return names
2249+
}
2250+
21722251
async getK8sServices(teamId: string): Promise<Array<K8sService>> {
21732252
if (env.isDev) return []
21742253
// const teams = user.teams.map((name) => {

0 commit comments

Comments
 (0)