Skip to content

Commit 2e2d5bf

Browse files
committed
Merge remote-tracking branch 'origin/main' into APL-1786
2 parents a27b2ac + 370873a commit 2e2d5bf

3 files changed

Lines changed: 228 additions & 42 deletions

File tree

src/app.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,6 @@ const resourceStatus = async (errorSet) => {
8383
debug('Values are not loaded yet')
8484
return
8585
}
86-
const { cluster } = await otomiStack.getSettings(['cluster'])
87-
const domainSuffix = cluster?.domainSuffix
8886
const resources: Record<string, (AplResponseObject | SealedSecretManifestResponse)[]> = {
8987
workloads: otomiStack.getAllAplWorkloads(),
9088
builds: otomiStack.getAllAplBuilds(),
@@ -103,7 +101,7 @@ const resourceStatus = async (errorSet) => {
103101
const promises = resources[resourceType].map(async (resource) => {
104102
const { name } = resource.metadata
105103
try {
106-
const res = await statusFunctions[resourceType](resource, domainSuffix)
104+
const res = await statusFunctions[resourceType](resource)
107105
return { [name]: res }
108106
} catch (error) {
109107
const errorMessage = `${resourceType}-${name}-${error.message}`

src/k8s-operations.test.ts

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
1-
import { CoreV1Api, KubernetesObjectApi, PatchStrategy, V1Service } from '@kubernetes/client-node'
1+
import { CoreV1Api, CustomObjectsApi, KubernetesObjectApi, PatchStrategy, V1Service } from '@kubernetes/client-node'
22
import {
33
getApiStatusFromConfigMap,
44
getCloudttyActiveTime,
55
getLogTime,
6+
getServiceStatus,
67
mergeCanaryServices,
8+
parseHTTPRouteStatus,
79
setApiStatusInConfigMap,
810
toK8sService,
911
} from './k8s-operations'
12+
import { AplServiceResponse } from './otomi-models'
1013

1114
// Mock the KubeConfig
1215
jest.mock('@kubernetes/client-node', () => {
@@ -22,6 +25,9 @@ jest.mock('@kubernetes/client-node', () => {
2225
if (apiClientType === actual.KubernetesObjectApi) {
2326
return new actual.KubernetesObjectApi()
2427
}
28+
if (apiClientType === actual.CustomObjectsApi) {
29+
return new actual.CustomObjectsApi()
30+
}
2531
return {}
2632
}),
2733
})),
@@ -231,3 +237,179 @@ describe('setApiStatusInConfigMap', () => {
231237
)
232238
})
233239
})
240+
241+
// ---------------------------------------------------------------------------
242+
// Helpers
243+
// ---------------------------------------------------------------------------
244+
245+
const makeServiceResponse = (name: string, teamName: string, overrides: Record<string, any> = {}): AplServiceResponse =>
246+
({
247+
kind: 'AplTeamService',
248+
metadata: { name, labels: { 'apl.io/teamId': teamName } },
249+
spec: {},
250+
...overrides,
251+
}) as unknown as AplServiceResponse
252+
253+
const makeHTTPRoute = (
254+
conditions: { type: string; status: string }[],
255+
options: { parentGatewayName?: string; visibility?: string } = {},
256+
) => ({
257+
metadata: {
258+
labels: {
259+
'networking.knative.dev/visibility': options.visibility ?? '',
260+
},
261+
},
262+
spec: {
263+
parentRefs: [{ name: options.parentGatewayName ?? 'platform' }],
264+
},
265+
status: {
266+
parents: [
267+
{
268+
conditions,
269+
controllerName: 'istio.io/gateway-controller',
270+
parentRef: {
271+
group: 'gateway.networking.k8s.io',
272+
kind: 'Gateway',
273+
name: options.parentGatewayName ?? 'platform',
274+
},
275+
},
276+
],
277+
},
278+
})
279+
280+
// ---------------------------------------------------------------------------
281+
// parseHTTPRouteStatus
282+
// ---------------------------------------------------------------------------
283+
284+
describe('parseHTTPRouteStatus', () => {
285+
test('returns true when Accepted and ResolvedRefs are both True', () => {
286+
const route = makeHTTPRoute([
287+
{ type: 'Accepted', status: 'True' },
288+
{ type: 'ResolvedRefs', status: 'True' },
289+
])
290+
expect(parseHTTPRouteStatus(route)).toBe(true)
291+
})
292+
293+
test('returns false when Accepted is False', () => {
294+
const route = makeHTTPRoute([
295+
{ type: 'Accepted', status: 'False' },
296+
{ type: 'ResolvedRefs', status: 'True' },
297+
])
298+
expect(parseHTTPRouteStatus(route)).toBe(false)
299+
})
300+
301+
test('returns false when ResolvedRefs is False', () => {
302+
const route = makeHTTPRoute([
303+
{ type: 'Accepted', status: 'True' },
304+
{ type: 'ResolvedRefs', status: 'False' },
305+
])
306+
expect(parseHTTPRouteStatus(route)).toBe(false)
307+
})
308+
309+
test('returns false when conditions are empty', () => {
310+
expect(parseHTTPRouteStatus(makeHTTPRoute([]))).toBe(false)
311+
})
312+
313+
test('returns false when parents array is missing', () => {
314+
expect(parseHTTPRouteStatus({ status: {} })).toBe(false)
315+
})
316+
})
317+
318+
// ---------------------------------------------------------------------------
319+
// getServiceStatus
320+
// ---------------------------------------------------------------------------
321+
322+
describe('getServiceStatus', () => {
323+
afterEach(() => {
324+
jest.clearAllMocks()
325+
})
326+
327+
const mockList = (items: any[]) =>
328+
jest.spyOn(CustomObjectsApi.prototype, 'listNamespacedCustomObject').mockResolvedValue({ items } as any)
329+
330+
test('returns NotFound when no HTTPRoutes exist', async () => {
331+
mockList([])
332+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
333+
expect(result).toBe('NotFound')
334+
})
335+
336+
test('returns NotFound when only local HTTPRoutes exist', async () => {
337+
mockList([
338+
makeHTTPRoute(
339+
[
340+
{ type: 'Accepted', status: 'True' },
341+
{ type: 'ResolvedRefs', status: 'True' },
342+
],
343+
{ parentGatewayName: 'knative-local-gateway', visibility: 'cluster-local' },
344+
),
345+
])
346+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
347+
expect(result).toBe('NotFound')
348+
})
349+
350+
test('returns Unknown when multiple HTTPRoutes are found', async () => {
351+
mockList([makeHTTPRoute([]), makeHTTPRoute([])])
352+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
353+
expect(result).toBe('Unknown')
354+
})
355+
356+
test('returns Succeeded when single HTTPRoute is accepted', async () => {
357+
mockList([
358+
makeHTTPRoute([
359+
{ type: 'Accepted', status: 'True' },
360+
{ type: 'ResolvedRefs', status: 'True' },
361+
]),
362+
])
363+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
364+
expect(result).toBe('Succeeded')
365+
})
366+
367+
test('returns Succeeded when local and public routes exist and public is accepted', async () => {
368+
mockList([
369+
makeHTTPRoute(
370+
[
371+
{ type: 'Accepted', status: 'True' },
372+
{ type: 'ResolvedRefs', status: 'True' },
373+
],
374+
{ parentGatewayName: 'knative-local-gateway', visibility: 'cluster-local' },
375+
),
376+
makeHTTPRoute([
377+
{ type: 'Accepted', status: 'True' },
378+
{ type: 'ResolvedRefs', status: 'True' },
379+
]),
380+
])
381+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
382+
expect(result).toBe('Succeeded')
383+
})
384+
385+
test('returns Unknown when single HTTPRoute is not accepted', async () => {
386+
mockList([
387+
makeHTTPRoute([
388+
{ type: 'Accepted', status: 'False' },
389+
{ type: 'ResolvedRefs', status: 'True' },
390+
]),
391+
])
392+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
393+
expect(result).toBe('Unknown')
394+
})
395+
396+
test('queries with the correct label selector and namespace', async () => {
397+
const spy = mockList([])
398+
await getServiceStatus(makeServiceResponse('my-app', 'dev'))
399+
expect(spy).toHaveBeenCalledWith(
400+
expect.objectContaining({
401+
group: 'gateway.networking.k8s.io',
402+
version: 'v1',
403+
namespace: 'team-dev',
404+
plural: 'httproutes',
405+
labelSelector: 'otomi.io/app=my-app',
406+
}),
407+
)
408+
})
409+
410+
test('returns NotFound when the k8s API call throws', async () => {
411+
jest.spyOn(CustomObjectsApi.prototype, 'listNamespacedCustomObject').mockRejectedValue(new Error('network error'))
412+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
413+
expect(result).toBe('NotFound')
414+
})
415+
})

src/k8s-operations.ts

Lines changed: 44 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -160,6 +160,7 @@ export async function getWorkloadStatus(workload: AplWorkloadResponse): Promise<
160160

161161
async function listNamespacedCustomObject(
162162
group: string,
163+
version: string,
163164
namespace: string,
164165
plural: string,
165166
labelSelector: string | undefined,
@@ -170,7 +171,7 @@ async function listNamespacedCustomObject(
170171
try {
171172
const res: any = await k8sApi.listNamespacedCustomObject({
172173
group,
173-
version: 'v1beta1',
174+
version,
174175
namespace,
175176
plural,
176177
labelSelector,
@@ -187,6 +188,7 @@ export async function getBuildStatus(build: AplBuildResponse): Promise<string> {
187188
const labelSelector = `tekton.dev/pipeline=${build.spec.mode?.type}-build-${name}`
188189
const resPipelineruns = await listNamespacedCustomObject(
189190
'tekton.dev',
191+
'v1beta1',
190192
`team-${teamName}`,
191193
'pipelineruns',
192194
labelSelector,
@@ -213,6 +215,7 @@ export async function getBuildStatus(build: AplBuildResponse): Promise<string> {
213215
} else {
214216
const resEventlisteners = await listNamespacedCustomObject(
215217
'triggers.tekton.dev',
218+
'v1beta1',
216219
`team-${teamName}`,
217220
'eventlisteners',
218221
labelSelector,
@@ -236,54 +239,57 @@ export async function getBuildStatus(build: AplBuildResponse): Promise<string> {
236239
}
237240
}
238241

239-
async function getNamespacedCustomObject(namespace: string, name: string) {
240-
const kc = new KubeConfig()
241-
kc.loadFromDefault()
242-
const k8sApi = kc.makeApiClient(CustomObjectsApi)
243-
try {
244-
const res: any = await k8sApi.getNamespacedCustomObject({
245-
group: 'networking.istio.io',
246-
version: 'v1beta1',
247-
namespace,
248-
plural: 'gateways',
249-
name,
250-
})
251-
const { hosts } = res.spec.servers[0]
252-
return hosts
253-
} catch (error) {
254-
return 'NotFound'
255-
}
242+
export function parseHTTPRouteStatus(httpRoute: any): boolean {
243+
const parents: any[] = Array.isArray(httpRoute?.status?.parents) ? httpRoute.status.parents : []
244+
245+
const isAccepted = parents.some(
246+
(parent: any) =>
247+
Array.isArray(parent?.conditions) &&
248+
parent.conditions.some((c: any) => c.type === 'Accepted' && c.status === 'True') &&
249+
parent.conditions.some((c: any) => c.type === 'ResolvedRefs' && c.status === 'True'),
250+
)
251+
return isAccepted
256252
}
257253

258-
async function checkHostStatus(namespace: string, name: string, host: string) {
259-
const hosts = await getNamespacedCustomObject(namespace, name)
260-
return hosts.includes(host) ? 'Succeeded' : 'Unknown'
254+
function isPublicHTTPRoute(httpRoute: any): boolean {
255+
const visibility = httpRoute?.metadata?.labels?.['networking.knative.dev/visibility']
256+
if (visibility === 'cluster-local') return false
257+
258+
const parentRefs: any[] = Array.isArray(httpRoute?.spec?.parentRefs) ? httpRoute.spec.parentRefs : []
259+
const hasLocalGateway = parentRefs.some((parentRef) => parentRef?.name === 'knative-local-gateway')
260+
return !hasLocalGateway
261261
}
262262

263-
export async function getServiceStatus(service: AplServiceResponse, domainSuffix: string): Promise<string> {
264-
const isKsvc = service.spec.ksvc?.predeployed
263+
export async function getServiceStatus(service: AplServiceResponse): Promise<'Succeeded' | 'Unknown' | 'NotFound'> {
265264
const { name, labels } = service.metadata
266265
const teamName = labels['apl.io/teamId']
267266
const namespace = `team-${teamName}`
268-
const host = `team-${teamName}/${name}-${teamName}.${domainSuffix}`
269267

270-
if (isKsvc) {
271-
const res = await listNamespacedCustomObject('networking.istio.io', namespace, 'virtualservices', undefined)
272-
const virtualservices = res?.items?.map((item) => item.metadata.name) || []
273-
if (virtualservices.includes(`${name}-ingress`)) {
274-
return 'Succeeded'
275-
} else {
276-
return 'NotFound'
277-
}
278-
}
268+
const res = await listNamespacedCustomObject(
269+
'gateway.networking.k8s.io',
270+
'v1',
271+
namespace,
272+
'httproutes',
273+
`otomi.io/app=${name}`,
274+
)
279275

280-
const tlstermStatus = await checkHostStatus(namespace, `team-${teamName}-public-tlsterm`, host)
281-
if (tlstermStatus === 'Succeeded') return 'Succeeded'
276+
const httpRoutes = Array.isArray(res?.items) ? res.items : []
277+
const publicHttpRoutes = httpRoutes.filter((httpRoute) => isPublicHTTPRoute(httpRoute))
282278

283-
const tlspassStatus = await checkHostStatus(namespace, `team-${teamName}-public-tlspass`, host)
284-
return tlspassStatus
285-
}
279+
if (publicHttpRoutes.length === 0) {
280+
debug(`No public HTTPRoutes found for service ${name} in namespace ${namespace}.`)
281+
return 'NotFound'
282+
} else if (publicHttpRoutes.length > 1) {
283+
debug(
284+
`Multiple public HTTPRoutes found for service ${name} in namespace ${namespace}. This may indicate an issue with the service configuration.`,
285+
)
286+
return 'Unknown'
287+
}
286288

289+
const [httpRoute] = publicHttpRoutes
290+
const isAccepted = parseHTTPRouteStatus(httpRoute)
291+
return isAccepted ? 'Succeeded' : 'Unknown'
292+
}
287293
export async function getSecretValues(name: string, namespace: string): Promise<Record<string, string> | undefined> {
288294
const kc = new KubeConfig()
289295
kc.loadFromDefault()

0 commit comments

Comments
 (0)