Skip to content

Commit 0193828

Browse files
committed
Merge remote-tracking branch 'origin/main' into APL-1756
2 parents f5330a2 + 370873a commit 0193828

3 files changed

Lines changed: 234 additions & 43 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: 189 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,13 @@
1-
import { CoreV1Api, V1Service } from '@kubernetes/client-node'
2-
import { getCloudttyActiveTime, getLogTime, mergeCanaryServices, toK8sService } from './k8s-operations'
1+
import { CoreV1Api, CustomObjectsApi, V1Service } from '@kubernetes/client-node'
2+
import {
3+
getCloudttyActiveTime,
4+
getLogTime,
5+
getServiceStatus,
6+
mergeCanaryServices,
7+
parseHTTPRouteStatus,
8+
toK8sService,
9+
} from './k8s-operations'
10+
import { AplServiceResponse } from './otomi-models'
311

412
// Mock the KubeConfig
513
jest.mock('@kubernetes/client-node', () => {
@@ -12,6 +20,9 @@ jest.mock('@kubernetes/client-node', () => {
1220
if (apiClientType === actual.CoreV1Api) {
1321
return new actual.CoreV1Api()
1422
}
23+
if (apiClientType === actual.CustomObjectsApi) {
24+
return new actual.CustomObjectsApi()
25+
}
1526
return {}
1627
}),
1728
})),
@@ -160,3 +171,179 @@ describe('getCloudttyActiveTime', () => {
160171
expect(result).toBeUndefined()
161172
})
162173
})
174+
175+
// ---------------------------------------------------------------------------
176+
// Helpers
177+
// ---------------------------------------------------------------------------
178+
179+
const makeServiceResponse = (name: string, teamName: string, overrides: Record<string, any> = {}): AplServiceResponse =>
180+
({
181+
kind: 'AplTeamService',
182+
metadata: { name, labels: { 'apl.io/teamId': teamName } },
183+
spec: {},
184+
...overrides,
185+
}) as unknown as AplServiceResponse
186+
187+
const makeHTTPRoute = (
188+
conditions: { type: string; status: string }[],
189+
options: { parentGatewayName?: string; visibility?: string } = {},
190+
) => ({
191+
metadata: {
192+
labels: {
193+
'networking.knative.dev/visibility': options.visibility ?? '',
194+
},
195+
},
196+
spec: {
197+
parentRefs: [{ name: options.parentGatewayName ?? 'platform' }],
198+
},
199+
status: {
200+
parents: [
201+
{
202+
conditions,
203+
controllerName: 'istio.io/gateway-controller',
204+
parentRef: {
205+
group: 'gateway.networking.k8s.io',
206+
kind: 'Gateway',
207+
name: options.parentGatewayName ?? 'platform',
208+
},
209+
},
210+
],
211+
},
212+
})
213+
214+
// ---------------------------------------------------------------------------
215+
// parseHTTPRouteStatus
216+
// ---------------------------------------------------------------------------
217+
218+
describe('parseHTTPRouteStatus', () => {
219+
test('returns true when Accepted and ResolvedRefs are both True', () => {
220+
const route = makeHTTPRoute([
221+
{ type: 'Accepted', status: 'True' },
222+
{ type: 'ResolvedRefs', status: 'True' },
223+
])
224+
expect(parseHTTPRouteStatus(route)).toBe(true)
225+
})
226+
227+
test('returns false when Accepted is False', () => {
228+
const route = makeHTTPRoute([
229+
{ type: 'Accepted', status: 'False' },
230+
{ type: 'ResolvedRefs', status: 'True' },
231+
])
232+
expect(parseHTTPRouteStatus(route)).toBe(false)
233+
})
234+
235+
test('returns false when ResolvedRefs is False', () => {
236+
const route = makeHTTPRoute([
237+
{ type: 'Accepted', status: 'True' },
238+
{ type: 'ResolvedRefs', status: 'False' },
239+
])
240+
expect(parseHTTPRouteStatus(route)).toBe(false)
241+
})
242+
243+
test('returns false when conditions are empty', () => {
244+
expect(parseHTTPRouteStatus(makeHTTPRoute([]))).toBe(false)
245+
})
246+
247+
test('returns false when parents array is missing', () => {
248+
expect(parseHTTPRouteStatus({ status: {} })).toBe(false)
249+
})
250+
})
251+
252+
// ---------------------------------------------------------------------------
253+
// getServiceStatus
254+
// ---------------------------------------------------------------------------
255+
256+
describe('getServiceStatus', () => {
257+
afterEach(() => {
258+
jest.clearAllMocks()
259+
})
260+
261+
const mockList = (items: any[]) =>
262+
jest.spyOn(CustomObjectsApi.prototype, 'listNamespacedCustomObject').mockResolvedValue({ items } as any)
263+
264+
test('returns NotFound when no HTTPRoutes exist', async () => {
265+
mockList([])
266+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
267+
expect(result).toBe('NotFound')
268+
})
269+
270+
test('returns NotFound when only local HTTPRoutes exist', async () => {
271+
mockList([
272+
makeHTTPRoute(
273+
[
274+
{ type: 'Accepted', status: 'True' },
275+
{ type: 'ResolvedRefs', status: 'True' },
276+
],
277+
{ parentGatewayName: 'knative-local-gateway', visibility: 'cluster-local' },
278+
),
279+
])
280+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
281+
expect(result).toBe('NotFound')
282+
})
283+
284+
test('returns Unknown when multiple HTTPRoutes are found', async () => {
285+
mockList([makeHTTPRoute([]), makeHTTPRoute([])])
286+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
287+
expect(result).toBe('Unknown')
288+
})
289+
290+
test('returns Succeeded when single HTTPRoute is accepted', async () => {
291+
mockList([
292+
makeHTTPRoute([
293+
{ type: 'Accepted', status: 'True' },
294+
{ type: 'ResolvedRefs', status: 'True' },
295+
]),
296+
])
297+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
298+
expect(result).toBe('Succeeded')
299+
})
300+
301+
test('returns Succeeded when local and public routes exist and public is accepted', async () => {
302+
mockList([
303+
makeHTTPRoute(
304+
[
305+
{ type: 'Accepted', status: 'True' },
306+
{ type: 'ResolvedRefs', status: 'True' },
307+
],
308+
{ parentGatewayName: 'knative-local-gateway', visibility: 'cluster-local' },
309+
),
310+
makeHTTPRoute([
311+
{ type: 'Accepted', status: 'True' },
312+
{ type: 'ResolvedRefs', status: 'True' },
313+
]),
314+
])
315+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
316+
expect(result).toBe('Succeeded')
317+
})
318+
319+
test('returns Unknown when single HTTPRoute is not accepted', async () => {
320+
mockList([
321+
makeHTTPRoute([
322+
{ type: 'Accepted', status: 'False' },
323+
{ type: 'ResolvedRefs', status: 'True' },
324+
]),
325+
])
326+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
327+
expect(result).toBe('Unknown')
328+
})
329+
330+
test('queries with the correct label selector and namespace', async () => {
331+
const spy = mockList([])
332+
await getServiceStatus(makeServiceResponse('my-app', 'dev'))
333+
expect(spy).toHaveBeenCalledWith(
334+
expect.objectContaining({
335+
group: 'gateway.networking.k8s.io',
336+
version: 'v1',
337+
namespace: 'team-dev',
338+
plural: 'httproutes',
339+
labelSelector: 'otomi.io/app=my-app',
340+
}),
341+
)
342+
})
343+
344+
test('returns NotFound when the k8s API call throws', async () => {
345+
jest.spyOn(CustomObjectsApi.prototype, 'listNamespacedCustomObject').mockRejectedValue(new Error('network error'))
346+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
347+
expect(result).toBe('NotFound')
348+
})
349+
})

src/k8s-operations.ts

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

153153
async function listNamespacedCustomObject(
154154
group: string,
155+
version: string,
155156
namespace: string,
156157
plural: string,
157158
labelSelector: string | undefined,
@@ -162,7 +163,7 @@ async function listNamespacedCustomObject(
162163
try {
163164
const res: any = await k8sApi.listNamespacedCustomObject({
164165
group,
165-
version: 'v1beta1',
166+
version,
166167
namespace,
167168
plural,
168169
labelSelector,
@@ -179,6 +180,7 @@ export async function getBuildStatus(build: AplBuildResponse): Promise<string> {
179180
const labelSelector = `tekton.dev/pipeline=${build.spec.mode?.type}-build-${name}`
180181
const resPipelineruns = await listNamespacedCustomObject(
181182
'tekton.dev',
183+
'v1beta1',
182184
`team-${teamName}`,
183185
'pipelineruns',
184186
labelSelector,
@@ -205,6 +207,7 @@ export async function getBuildStatus(build: AplBuildResponse): Promise<string> {
205207
} else {
206208
const resEventlisteners = await listNamespacedCustomObject(
207209
'triggers.tekton.dev',
210+
'v1beta1',
208211
`team-${teamName}`,
209212
'eventlisteners',
210213
labelSelector,
@@ -228,54 +231,57 @@ export async function getBuildStatus(build: AplBuildResponse): Promise<string> {
228231
}
229232
}
230233

231-
async function getNamespacedCustomObject(namespace: string, name: string) {
232-
const kc = new KubeConfig()
233-
kc.loadFromDefault()
234-
const k8sApi = kc.makeApiClient(CustomObjectsApi)
235-
try {
236-
const res: any = await k8sApi.getNamespacedCustomObject({
237-
group: 'networking.istio.io',
238-
version: 'v1beta1',
239-
namespace,
240-
plural: 'gateways',
241-
name,
242-
})
243-
const { hosts } = res.spec.servers[0]
244-
return hosts
245-
} catch (error) {
246-
return 'NotFound'
247-
}
234+
export function parseHTTPRouteStatus(httpRoute: any): boolean {
235+
const parents: any[] = Array.isArray(httpRoute?.status?.parents) ? httpRoute.status.parents : []
236+
237+
const isAccepted = parents.some(
238+
(parent: any) =>
239+
Array.isArray(parent?.conditions) &&
240+
parent.conditions.some((c: any) => c.type === 'Accepted' && c.status === 'True') &&
241+
parent.conditions.some((c: any) => c.type === 'ResolvedRefs' && c.status === 'True'),
242+
)
243+
return isAccepted
248244
}
249245

250-
async function checkHostStatus(namespace: string, name: string, host: string) {
251-
const hosts = await getNamespacedCustomObject(namespace, name)
252-
return hosts.includes(host) ? 'Succeeded' : 'Unknown'
246+
function isPublicHTTPRoute(httpRoute: any): boolean {
247+
const visibility = httpRoute?.metadata?.labels?.['networking.knative.dev/visibility']
248+
if (visibility === 'cluster-local') return false
249+
250+
const parentRefs: any[] = Array.isArray(httpRoute?.spec?.parentRefs) ? httpRoute.spec.parentRefs : []
251+
const hasLocalGateway = parentRefs.some((parentRef) => parentRef?.name === 'knative-local-gateway')
252+
return !hasLocalGateway
253253
}
254254

255-
export async function getServiceStatus(service: AplServiceResponse, domainSuffix: string): Promise<string> {
256-
const isKsvc = service.spec.ksvc?.predeployed
255+
export async function getServiceStatus(service: AplServiceResponse): Promise<'Succeeded' | 'Unknown' | 'NotFound'> {
257256
const { name, labels } = service.metadata
258257
const teamName = labels['apl.io/teamId']
259258
const namespace = `team-${teamName}`
260-
const host = `team-${teamName}/${name}-${teamName}.${domainSuffix}`
261259

262-
if (isKsvc) {
263-
const res = await listNamespacedCustomObject('networking.istio.io', namespace, 'virtualservices', undefined)
264-
const virtualservices = res?.items?.map((item) => item.metadata.name) || []
265-
if (virtualservices.includes(`${name}-ingress`)) {
266-
return 'Succeeded'
267-
} else {
268-
return 'NotFound'
269-
}
270-
}
260+
const res = await listNamespacedCustomObject(
261+
'gateway.networking.k8s.io',
262+
'v1',
263+
namespace,
264+
'httproutes',
265+
`otomi.io/app=${name}`,
266+
)
271267

272-
const tlstermStatus = await checkHostStatus(namespace, `team-${teamName}-public-tlsterm`, host)
273-
if (tlstermStatus === 'Succeeded') return 'Succeeded'
268+
const httpRoutes = Array.isArray(res?.items) ? res.items : []
269+
const publicHttpRoutes = httpRoutes.filter((httpRoute) => isPublicHTTPRoute(httpRoute))
274270

275-
const tlspassStatus = await checkHostStatus(namespace, `team-${teamName}-public-tlspass`, host)
276-
return tlspassStatus
277-
}
271+
if (publicHttpRoutes.length === 0) {
272+
debug(`No public HTTPRoutes found for service ${name} in namespace ${namespace}.`)
273+
return 'NotFound'
274+
} else if (publicHttpRoutes.length > 1) {
275+
debug(
276+
`Multiple public HTTPRoutes found for service ${name} in namespace ${namespace}. This may indicate an issue with the service configuration.`,
277+
)
278+
return 'Unknown'
279+
}
278280

281+
const [httpRoute] = publicHttpRoutes
282+
const isAccepted = parseHTTPRouteStatus(httpRoute)
283+
return isAccepted ? 'Succeeded' : 'Unknown'
284+
}
279285
export async function getSecretValues(name: string, namespace: string): Promise<Record<string, string> | undefined> {
280286
const kc = new KubeConfig()
281287
kc.loadFromDefault()

0 commit comments

Comments
 (0)