Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src/api-v2.authz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ function createTeamResource(kind: AplKind, spec: Record<string, any>) {
}
}

jest.mock('./k8s_operations')
jest.mock('./k8s-operations')
jest.mock('./utils/sealedSecretUtils')
beforeAll(async () => {
jest.spyOn(console, 'log').mockImplementation(() => {})
Expand Down
2 changes: 1 addition & 1 deletion src/api.authz.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ const userToken = getToken([])
const teamId = 'team1'
const otherTeamId = 'team2'

jest.mock('./k8s_operations')
jest.mock('./k8s-operations')
jest.mock('./utils/sealedSecretUtils')
beforeAll(async () => {
jest.spyOn(console, 'log').mockImplementation(() => {})
Expand Down
2 changes: 1 addition & 1 deletion src/api/v1/sealedsecretskeys.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Debug from 'debug'
import { Request, Response } from 'express'
import { getSealedSecretsKeys } from 'src/k8s_operations'
import { getSealedSecretsKeys } from 'src/k8s-operations'
import YAML from 'yaml'

const debug = Debug('otomi:api:v1:sealedsecrets')
Expand Down
2 changes: 1 addition & 1 deletion src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ import {
} from 'src/validators'
import swaggerUi from 'swagger-ui-express'
import getLatestRemoteCommitSha from './git/connect'
import { getBuildStatus, getSealedSecretStatus, getServiceStatus, getWorkloadStatus } from './k8s_operations'
import { getBuildStatus, getSealedSecretStatus, getServiceStatus, getWorkloadStatus } from './k8s-operations'

const env = cleanEnv({
CATALOG_CACHE_REFRESH_INTERVAL_MS,
Expand Down
162 changes: 162 additions & 0 deletions src/k8s-operations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { CoreV1Api, V1Service } from '@kubernetes/client-node'
import { getCloudttyActiveTime, getLogTime, mergeCanaryServices, toK8sService } from './k8s-operations'

// Mock the KubeConfig
jest.mock('@kubernetes/client-node', () => {
const actual = jest.requireActual('@kubernetes/client-node')
return {
...actual,
KubeConfig: jest.fn().mockImplementation(() => ({
loadFromDefault: jest.fn(),
makeApiClient: jest.fn((apiClientType) => {
if (apiClientType === actual.CoreV1Api) {
return new actual.CoreV1Api()
}
return {}
}),
})),
}
})

const makeService = (overrides: Partial<V1Service> = {}): V1Service => ({
metadata: { name: 'my-svc', labels: {} },
spec: { type: 'ClusterIP', ports: [{ port: 8080 }] },
...overrides,
})

describe('toK8sService', () => {
test('maps a regular service', () => {
const result = toK8sService(makeService())
expect(result).toEqual({ name: 'my-svc', ports: [8080], managedByKnative: false })
})

test('returns the raw service name', () => {
const svc = makeService({ metadata: { name: 'my-svc-v1', labels: {} } })
expect(toK8sService(svc)?.name).toBe('my-svc-v1')
})

test('filters out knative private services', () => {
const svc = makeService({
metadata: { name: 'private-svc', labels: { 'networking.internal.knative.dev/serviceType': 'Private' } },
})
expect(toK8sService(svc)).toBeNull()
})

test('filters out ClusterIP knative revision services', () => {
const svc = makeService({
metadata: { name: 'revision-svc', labels: { 'serving.knative.dev/service': 'my-ksvc' } },
spec: { type: 'ClusterIP', ports: [{ port: 80 }] },
})
expect(toK8sService(svc)).toBeNull()
})

test('maps ExternalName knative service and sets managedByKnative', () => {
const svc = makeService({
metadata: { name: 'external-svc', labels: { 'serving.knative.dev/service': 'my-ksvc' } },
spec: { type: 'ExternalName', ports: [{ port: 80 }] },
})
const result = toK8sService(svc)
expect(result).toEqual({ name: 'my-ksvc', ports: [80], managedByKnative: true })
})
})

describe('mergeCanaryServices', () => {
test('returns services unchanged when no canary variants present', () => {
const services = [
{ name: 'svc-a', ports: [80], managedByKnative: false },
{ name: 'svc-b', ports: [8080], managedByKnative: false },
]
expect(mergeCanaryServices(services)).toEqual(services)
})

test('groups -v1 and -v2 variants into a single entry with the base name', () => {
const services = [
{ name: 'my-svc-v1', ports: [80], managedByKnative: false },
{ name: 'my-svc-v2', ports: [80], managedByKnative: false },
]
expect(mergeCanaryServices(services)).toEqual([{ name: 'my-svc', ports: [80], managedByKnative: false }])
})

test('does not strip suffix when only one variant exists', () => {
const services = [{ name: 'my-svc-v1', ports: [80], managedByKnative: false }]
expect(mergeCanaryServices(services)).toEqual([{ name: 'my-svc-v1', ports: [80], managedByKnative: false }])
})

test('retains the data from the -v1 variant', () => {
const services = [
{ name: 'my-svc-v1', ports: [80, 443], managedByKnative: true },
{ name: 'my-svc-v2', ports: [8080], managedByKnative: false },
]
expect(mergeCanaryServices(services)).toEqual([{ name: 'my-svc', ports: [80, 443], managedByKnative: true }])
})
})

describe('getCloudttyLogTime', () => {
test('should return the timestamp for a valid log timestamp', () => {
const timestampMatch = ['[2023/10/10 00:00:00:0000]', '2023/10/10 00:00:00:0000']
const result = getLogTime(timestampMatch)
const timestamp = new Date('2023-10-10T00:00:00.000').getTime()
expect(result).toBe(timestamp)
})

test('should return NaN for an invalid log timestamp', () => {
const timestampMatch = ['[invalid-timestamp]', 'invalid-date invalid-time']
const result = getLogTime(timestampMatch)
expect(result).toBeNaN()
})
})

describe('getCloudttyActiveTime', () => {
afterEach(() => {
jest.clearAllMocks()
})

test('should return the time difference if no clients', async () => {
const namespace = 'test-namespace'
const podName = 'test-pod'
const log = '[2023/10/10 00:00:00:0000] [INFO] clients: 0'
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockResolvedValue(log)

const result = await getCloudttyActiveTime(namespace, podName)
expect(result).toBeGreaterThan(0)
})

test('should return 0 if clients are connected', async () => {
const namespace = 'test-namespace'
const podName = 'test-pod'
const log = '[2023/10/10 00:00:00:0000] [INFO] clients: 1'
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockResolvedValue(log)

const result = await getCloudttyActiveTime(namespace, podName)
expect(result).toBe(0)
})

test('should return undefined if log does not contain client count', async () => {
const namespace = 'test-namespace'
const podName = 'test-pod'
const log = '[2023/10/10 00:00:00:0000] [INFO] some other log message'
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockResolvedValue(log)

const result = await getCloudttyActiveTime(namespace, podName)
expect(result).toBeUndefined()
})

test('should return undefined if log is empty', async () => {
const namespace = 'test-namespace'
const podName = 'test-pod'
const log = ''
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockResolvedValue(log)

const result = await getCloudttyActiveTime(namespace, podName)
expect(result).toBeUndefined()
})

test('should return undefined if an error occurs', async () => {
const namespace = 'test-namespace'
const podName = 'test-pod'
jest.spyOn(CoreV1Api.prototype, 'readNamespacedPodLog').mockRejectedValue(new Error('test error'))

const result = await getCloudttyActiveTime(namespace, podName)
expect(result).toBeUndefined()
})
})
51 changes: 49 additions & 2 deletions src/k8s_operations.ts → src/k8s-operations.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { CoreV1Api, CustomObjectsApi, KubeConfig, VersionApi } from '@kubernetes/client-node'
import { CoreV1Api, CustomObjectsApi, KubeConfig, V1Service, VersionApi } from '@kubernetes/client-node'
import Debug from 'debug'
import { AplBuildResponse, AplServiceResponse, AplWorkloadResponse, SealedSecretManifestResponse } from './otomi-models'
import {
AplBuildResponse,
AplServiceResponse,
AplWorkloadResponse,
K8sService,
SealedSecretManifestResponse,
} from './otomi-models'

const debug = Debug('otomi:api:k8sOperations')

Expand Down Expand Up @@ -414,3 +420,44 @@ export async function getTeamSecretsFromK8s(namespace: string) {
debug(`Failed to get team secrets from k8s for ${namespace}.`)
}
}

export function toK8sService(item: V1Service): K8sService | null {
const knativeServiceTypeLabel = 'networking.internal.knative.dev/serviceType'
const knativeServiceLabel = 'serving.knative.dev/service'

const labels = item.metadata?.labels ?? {}

// Filter out knative private services
if (labels[knativeServiceTypeLabel] === 'Private') return null
// Filter out services that are knative service revision
if (item.spec?.type === 'ClusterIP' && labels[knativeServiceLabel]) return null

let name = item.metadata?.name ?? 'unknown'
let managedByKnative = false

if (item.spec?.type === 'ExternalName' && labels[knativeServiceLabel]) {
name = labels[knativeServiceLabel]
managedByKnative = true
}

const ports = item.spec?.ports?.map((p) => p.port) ?? []

return { name, ports, managedByKnative }
}

// Canary deployments produce two services: <name>-v1 and <name>-v2.
// This function consolidates them into a single entry with the base name.
// It works in two steps:
// 1. Filter: drop -v2 when a matching -v1 exists (keeping only one representative)
// 2. Map: rename the remaining -v1 to the base name when a matching -v2 exists
// Services without a matching counterpart are left unchanged.
export function mergeCanaryServices(services: K8sService[]): K8sService[] {
const nameSet = new Set(services.map((s) => s.name))

return services
.filter((svc) => !svc.name.endsWith('-v2') || !nameSet.has(svc.name.replace(/-v2$/, '-v1')))
.map((svc) => {
const baseName = svc.name.replace(/-v1$/, '')
return nameSet.has(`${baseName}-v2`) ? { ...svc, name: baseName } : svc
})
}
89 changes: 0 additions & 89 deletions src/k8s_operations.test.ts

This file was deleted.

32 changes: 7 additions & 25 deletions src/otomi-stack.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CoreV1Api, User as k8sUser, KubeConfig, V1ObjectReference } from '@kubernetes/client-node'
import { CoreV1Api, KubeConfig, User as k8sUser, V1ObjectReference } from '@kubernetes/client-node'
import Debug from 'debug'

import { getRegions, ObjectStorageKeyRegions, Region, ResourcePage } from '@linode/api-v4'
Expand Down Expand Up @@ -121,8 +121,10 @@ import {
getKubernetesVersion,
getSecretValues,
getTeamSecretsFromK8s,
mergeCanaryServices,
toK8sService,
watchPodUntilRunning,
} from './k8s_operations'
} from './k8s-operations'
import CloudTty from './tty'
import {
getGiteaRepoUrls,
Expand Down Expand Up @@ -2191,30 +2193,10 @@ export default class OtomiStack {
async getK8sServices(teamId: string): Promise<Array<K8sService>> {
if (env.isDev) return []

const client = this.getApiClient()
const collection: K8sService[] = []

const svcList = await client.listNamespacedService({ namespace: `team-${teamId}` })
svcList.items.map((item) => {
let name = item.metadata!.name ?? 'unknown'
let managedByKnative = false
// Filter out knative private services
if (item.metadata?.labels?.['networking.internal.knative.dev/serviceType'] === 'Private') return
// Filter out services that are knative service revision
if (item.spec?.type === 'ClusterIP' && item.metadata?.labels?.['serving.knative.dev/service']) return
if (item.spec?.type === 'ExternalName' && item.metadata?.labels?.['serving.knative.dev/service']) {
name = item.metadata?.labels?.['serving.knative.dev/service']
managedByKnative = true
}

collection.push({
name,
ports: item.spec?.ports?.map((portItem) => portItem.port) ?? [],
managedByKnative,
})
})
const { items } = await this.getApiClient().listNamespacedService({ namespace: `team-${teamId}` })
const mapped = items.flatMap((item) => toK8sService(item) ?? [])

return collection
return mergeCanaryServices(mapped)
}

async getKubecfg(teamId: string): Promise<KubeConfig> {
Expand Down
Loading
Loading