Skip to content

Commit 998f20e

Browse files
committed
feat: parse Team Service status
1 parent 376f37d commit 998f20e

3 files changed

Lines changed: 178 additions & 44 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: 142 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,132 @@ 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 = (conditions: { type: string; status: string }[]) => ({
188+
status: {
189+
parents: [
190+
{
191+
conditions,
192+
controllerName: 'istio.io/gateway-controller',
193+
parentRef: { group: 'gateway.networking.k8s.io', kind: 'Gateway', name: 'knative-local-gateway' },
194+
},
195+
],
196+
},
197+
})
198+
199+
// ---------------------------------------------------------------------------
200+
// parseHTTPRouteStatus
201+
// ---------------------------------------------------------------------------
202+
203+
describe('parseHTTPRouteStatus', () => {
204+
test('returns true when Accepted and ResolvedRefs are both True', () => {
205+
const route = makeHTTPRoute([
206+
{ type: 'Accepted', status: 'True' },
207+
{ type: 'ResolvedRefs', status: 'True' },
208+
])
209+
expect(parseHTTPRouteStatus(route)).toBe(true)
210+
})
211+
212+
test('returns false when Accepted is False', () => {
213+
const route = makeHTTPRoute([
214+
{ type: 'Accepted', status: 'False' },
215+
{ type: 'ResolvedRefs', status: 'True' },
216+
])
217+
expect(parseHTTPRouteStatus(route)).toBe(false)
218+
})
219+
220+
test('returns false when ResolvedRefs is False', () => {
221+
const route = makeHTTPRoute([
222+
{ type: 'Accepted', status: 'True' },
223+
{ type: 'ResolvedRefs', status: 'False' },
224+
])
225+
expect(parseHTTPRouteStatus(route)).toBe(false)
226+
})
227+
228+
test('returns false when conditions are empty', () => {
229+
expect(parseHTTPRouteStatus(makeHTTPRoute([]))).toBe(false)
230+
})
231+
232+
test('returns false when parents array is missing', () => {
233+
expect(parseHTTPRouteStatus({ status: {} })).toBe(false)
234+
})
235+
})
236+
237+
// ---------------------------------------------------------------------------
238+
// getServiceStatus
239+
// ---------------------------------------------------------------------------
240+
241+
describe('getServiceStatus', () => {
242+
afterEach(() => {
243+
jest.clearAllMocks()
244+
})
245+
246+
const mockList = (items: any[]) =>
247+
jest.spyOn(CustomObjectsApi.prototype, 'listNamespacedCustomObject').mockResolvedValue({ items } as any)
248+
249+
test('returns NotFound when no HTTPRoutes exist', async () => {
250+
mockList([])
251+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
252+
expect(result).toBe('NotFound')
253+
})
254+
255+
test('returns Unknown when multiple HTTPRoutes are found', async () => {
256+
mockList([makeHTTPRoute([]), makeHTTPRoute([])])
257+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
258+
expect(result).toBe('Unknown')
259+
})
260+
261+
test('returns Succeeded when single HTTPRoute is accepted', async () => {
262+
mockList([
263+
makeHTTPRoute([
264+
{ type: 'Accepted', status: 'True' },
265+
{ type: 'ResolvedRefs', status: 'True' },
266+
]),
267+
])
268+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
269+
expect(result).toBe('Succeeded')
270+
})
271+
272+
test('returns Unknown when single HTTPRoute is not accepted', async () => {
273+
mockList([
274+
makeHTTPRoute([
275+
{ type: 'Accepted', status: 'False' },
276+
{ type: 'ResolvedRefs', status: 'True' },
277+
]),
278+
])
279+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
280+
expect(result).toBe('Unknown')
281+
})
282+
283+
test('queries with the correct label selector and namespace', async () => {
284+
const spy = mockList([])
285+
await getServiceStatus(makeServiceResponse('my-app', 'dev'))
286+
expect(spy).toHaveBeenCalledWith(
287+
expect.objectContaining({
288+
group: 'gateway.networking.k8s.io',
289+
version: 'v1',
290+
namespace: 'team-dev',
291+
plural: 'httproutes',
292+
labelSelector: 'otomi.io/app=my-app',
293+
}),
294+
)
295+
})
296+
297+
test('returns NotFound when the k8s API call throws', async () => {
298+
jest.spyOn(CustomObjectsApi.prototype, 'listNamespacedCustomObject').mockRejectedValue(new Error('network error'))
299+
const result = await getServiceStatus(makeServiceResponse('web', 'alpha'))
300+
expect(result).toBe('NotFound')
301+
})
302+
})

src/k8s-operations.ts

Lines changed: 35 additions & 39 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,47 @@ 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-
}
248-
}
234+
export function parseHTTPRouteStatus(httpRoute: any): boolean {
235+
const parents: any[] = Array.isArray(httpRoute?.status?.parents) ? httpRoute.status.parents : []
249236

250-
async function checkHostStatus(namespace: string, name: string, host: string) {
251-
const hosts = await getNamespacedCustomObject(namespace, name)
252-
return hosts.includes(host) ? 'Succeeded' : 'Unknown'
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
253244
}
254245

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

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-
}
251+
const res = await listNamespacedCustomObject(
252+
'gateway.networking.k8s.io',
253+
'v1',
254+
namespace,
255+
'httproutes',
256+
`otomi.io/app=${name}`,
257+
)
271258

272-
const tlstermStatus = await checkHostStatus(namespace, `team-${teamName}-public-tlsterm`, host)
273-
if (tlstermStatus === 'Succeeded') return 'Succeeded'
259+
const httpRoutes = Array.isArray(res?.items) ? res.items : []
274260

275-
const tlspassStatus = await checkHostStatus(namespace, `team-${teamName}-public-tlspass`, host)
276-
return tlspassStatus
277-
}
261+
if (httpRoutes.length === 0) {
262+
debug(`No HTTPRoutes found for service ${name} in namespace ${namespace}.`)
263+
return 'NotFound'
264+
} else if (httpRoutes.length > 1) {
265+
debug(
266+
`Multiple HTTPRoutes found for service ${name} in namespace ${namespace}. This may indicate an issue with the service configuration.`,
267+
)
268+
return 'Unknown'
269+
}
278270

271+
const [httpRoute] = httpRoutes
272+
const isAccepted = parseHTTPRouteStatus(httpRoute)
273+
return isAccepted ? 'Succeeded' : 'Unknown'
274+
}
279275
export async function getSecretValues(name: string, namespace: string): Promise<Record<string, string> | undefined> {
280276
const kc = new KubeConfig()
281277
kc.loadFromDefault()

0 commit comments

Comments
 (0)