diff --git a/src/api-v2.authz.test.ts b/src/api-v2.authz.test.ts index 8db1cb5db..af191a2a6 100644 --- a/src/api-v2.authz.test.ts +++ b/src/api-v2.authz.test.ts @@ -27,7 +27,7 @@ function createTeamResource(kind: AplKind, spec: Record) { } } -jest.mock('./k8s_operations') +jest.mock('./k8s-operations') jest.mock('./utils/sealedSecretUtils') beforeAll(async () => { jest.spyOn(console, 'log').mockImplementation(() => {}) diff --git a/src/api.authz.test.ts b/src/api.authz.test.ts index 452416477..8cd5f1e62 100644 --- a/src/api.authz.test.ts +++ b/src/api.authz.test.ts @@ -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(() => {}) diff --git a/src/api/v1/sealedsecretskeys.ts b/src/api/v1/sealedsecretskeys.ts index baed8bacb..f6b0a3a93 100644 --- a/src/api/v1/sealedsecretskeys.ts +++ b/src/api/v1/sealedsecretskeys.ts @@ -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') diff --git a/src/app.ts b/src/app.ts index 5ef54c280..a245656d2 100644 --- a/src/app.ts +++ b/src/app.ts @@ -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, diff --git a/src/k8s-operations.test.ts b/src/k8s-operations.test.ts new file mode 100644 index 000000000..9507d27b1 --- /dev/null +++ b/src/k8s-operations.test.ts @@ -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 => ({ + 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() + }) +}) diff --git a/src/k8s_operations.ts b/src/k8s-operations.ts similarity index 87% rename from src/k8s_operations.ts rename to src/k8s-operations.ts index 9b59808fe..3bc9ce49b 100644 --- a/src/k8s_operations.ts +++ b/src/k8s-operations.ts @@ -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') @@ -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: -v1 and -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 + }) +} diff --git a/src/k8s_operations.test.ts b/src/k8s_operations.test.ts deleted file mode 100644 index 9c4cb8804..000000000 --- a/src/k8s_operations.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { CoreV1Api } from '@kubernetes/client-node' -import { getCloudttyActiveTime, getLogTime } 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 {} - }), - })), - } -}) - -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() - }) -}) diff --git a/src/otomi-stack.ts b/src/otomi-stack.ts index f37795024..b968f27dd 100644 --- a/src/otomi-stack.ts +++ b/src/otomi-stack.ts @@ -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' @@ -121,8 +121,10 @@ import { getKubernetesVersion, getSecretValues, getTeamSecretsFromK8s, + mergeCanaryServices, + toK8sService, watchPodUntilRunning, -} from './k8s_operations' +} from './k8s-operations' import CloudTty from './tty' import { getGiteaRepoUrls, @@ -2191,30 +2193,10 @@ export default class OtomiStack { async getK8sServices(teamId: string): Promise> { 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 { diff --git a/src/utils/sealedSecretUtils.ts b/src/utils/sealedSecretUtils.ts index 7e552d098..c31765182 100644 --- a/src/utils/sealedSecretUtils.ts +++ b/src/utils/sealedSecretUtils.ts @@ -3,7 +3,7 @@ import { isEmpty } from 'lodash' import { SealedSecretManifestRequest, SealedSecretManifestResponse } from 'src/otomi-models' import { cleanEnv } from 'src/validators' import { ValidationError } from '../error' -import { getSealedSecretsCertificate } from '../k8s_operations' +import { getSealedSecretsCertificate } from '../k8s-operations' const env = cleanEnv({}) export function sealedSecretManifest(