Skip to content
Merged
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
100 changes: 100 additions & 0 deletions src/server/lib/__tests__/namespaceMetadata.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ var mockReadNamespace: jest.Mock;
var mockCreateNamespace: jest.Mock;
var mockPatchNamespace: jest.Mock;
var mockGetAllConfigs: jest.Mock;
var mockGetLabels: jest.Mock;
var mockShellPromise: jest.Mock;

jest.mock('@kubernetes/client-node', () => {
const actual = jest.requireActual('@kubernetes/client-node');
Expand Down Expand Up @@ -49,12 +51,14 @@ jest.mock('@kubernetes/client-node', () => {

jest.mock('server/services/globalConfig', () => {
mockGetAllConfigs = jest.fn();
mockGetLabels = jest.fn();

return {
__esModule: true,
default: {
getInstance: jest.fn(() => ({
getAllConfigs: mockGetAllConfigs,
getLabels: mockGetLabels,
})),
},
};
Expand All @@ -69,6 +73,14 @@ jest.mock('server/lib/logger', () => ({
})),
}));

jest.mock('server/lib/shell', () => {
mockShellPromise = jest.fn();

return {
shellPromise: (...args: unknown[]) => mockShellPromise(...args),
};
});

import { createOrUpdateNamespace } from '../kubernetes';

describe('createOrUpdateNamespace metadata', () => {
Expand All @@ -78,12 +90,18 @@ describe('createOrUpdateNamespace metadata', () => {
mockCreateNamespace.mockReset();
mockPatchNamespace.mockReset();
mockGetAllConfigs.mockReset();
mockGetLabels.mockReset();
mockShellPromise.mockReset();
jest.useFakeTimers().setSystemTime(new Date('2026-04-16T12:00:00.000Z'));
mockGetAllConfigs.mockResolvedValue({
ttl_cleanup: {
inactivityDays: 7,
},
});
mockGetLabels.mockResolvedValue({
keep: ['keep-env'],
});
mockShellPromise.mockResolvedValue('Active');
});

afterEach(() => {
Expand Down Expand Up @@ -120,6 +138,88 @@ describe('createOrUpdateNamespace metadata', () => {
);
});

it('adds TTL labels immediately for build-backed namespaces', async () => {
mockReadNamespace.mockRejectedValueOnce({ response: { statusCode: 404 } });
mockCreateNamespace.mockResolvedValue({});

await createOrUpdateNamespace({
name: 'env-build123',
buildUUID: 'build123',
staticEnv: false,
pullRequest: {
fullName: 'example-org/example-repo',
pullRequestNumber: 42,
githubLogin: 'example-author',
labels: ['deploy-env'],
},
});

expect(mockCreateNamespace).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
name: 'env-build123',
labels: expect.objectContaining({
'lfc/uuid': 'build123',
'lfc/type': 'ephemeral',
'lfc/ttl-enable': 'true',
'lfc/ttl-createdAtUnix': '1776340800000',
'lfc/ttl-createdAt': '2026-04-16',
'lfc/ttl-expireAtUnix': '1776945600000',
'lfc/ttl-expireAt': '2026-04-23',
'lfc/org': 'example-org',
'lfc/repo': 'example-repo',
'lfc/pull-request': '42',
'lfc/author': 'example-author',
}),
}),
})
);
});

it('disables TTL for build-backed namespaces when the pull request has the keep label', async () => {
mockReadNamespace.mockRejectedValueOnce({ response: { statusCode: 404 } });
mockCreateNamespace.mockResolvedValue({});

await createOrUpdateNamespace({
name: 'env-keep123',
buildUUID: 'keep123',
staticEnv: false,
pullRequest: {
fullName: 'example-org/example-repo',
pullRequestNumber: 99,
githubLogin: 'example-author',
labels: ['keep-env'],
},
});

expect(mockCreateNamespace).toHaveBeenCalledWith(
expect.objectContaining({
metadata: expect.objectContaining({
labels: expect.objectContaining({
'lfc/uuid': 'keep123',
'lfc/type': 'ephemeral',
'lfc/ttl-enable': 'false',
}),
}),
})
);
expect(mockCreateNamespace.mock.calls[0][0].metadata.labels).not.toHaveProperty('lfc/ttl-expireAtUnix');
});

it('waits for the namespace to become active when requested', async () => {
mockReadNamespace.mockRejectedValueOnce({ response: { statusCode: 404 } });
mockCreateNamespace.mockResolvedValue({});

await createOrUpdateNamespace({
name: 'env-wait123',
buildUUID: 'wait123',
staticEnv: false,
waitForReady: true,
});

expect(mockShellPromise).toHaveBeenCalledWith("kubectl get namespace env-wait123 -o jsonpath='{.status.phase}'");
});

it('patches PR and repo labels onto an existing namespace', async () => {
mockReadNamespace
.mockResolvedValueOnce({ body: { metadata: { name: 'env-abc123', labels: {} } } })
Expand Down
22 changes: 22 additions & 0 deletions src/server/lib/__tests__/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import {
getStatusCommentLabel,
isDefaultStatusCommentsEnabled,
isControlCommentsEnabled,
parsePullRequestLabels,
} from 'server/lib/utils';
import GlobalConfigService from 'server/services/globalConfig';

Expand Down Expand Up @@ -683,3 +684,24 @@ describe('isControlCommentsEnabled', () => {
expect(result).toBe(true);
});
});

describe('parsePullRequestLabels', () => {
test('returns an empty array for missing labels', () => {
expect(parsePullRequestLabels()).toEqual([]);
expect(parsePullRequestLabels(null)).toEqual([]);
});

test('returns array labels unchanged', () => {
const labels = ['deploy-env', 'keep-env'];
expect(parsePullRequestLabels(labels)).toBe(labels);
});

test('parses JSON string labels', () => {
expect(parsePullRequestLabels('["deploy-env","keep-env"]')).toEqual(['deploy-env', 'keep-env']);
});

test('returns an empty array for malformed or non-array JSON', () => {
expect(parsePullRequestLabels('not-json')).toEqual([]);
expect(parsePullRequestLabels('{"label":"deploy-env"}')).toEqual([]);
});
});
92 changes: 73 additions & 19 deletions src/server/lib/kubernetes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import _ from 'lodash';
import { Build, Deploy, Deployable, Service } from 'server/models';
import { CLIDeployTypes, KubernetesDeployTypes, MEDIUM_TYPE, DEFAULT_TTL_INACTIVITY_DAYS } from 'shared/constants';
import { shellPromise } from './shell';
import { flattenObject, waitUntil } from 'server/lib/utils';
import { flattenObject, getKeepLabel, parsePullRequestLabels, waitUntil } from 'server/lib/utils';
import { ServiceDiskConfig } from 'server/models/yaml';
import * as k8s from '@kubernetes/client-node';
import { HttpError, V1Status, CoreV1Api, KubeConfig } from '@kubernetes/client-node';
Expand Down Expand Up @@ -82,6 +82,44 @@ type NamespaceMetadata = {
labels: Record<string, string>;
};

type NamespacePullRequestMetadata = {
fullName?: string | null;
pullRequestNumber?: number | null;
githubLogin?: string | null;
labels?: string[] | string | null;
};

export type CreateOrUpdateNamespaceOptions = {
name: string;
buildUUID: string;
staticEnv: boolean;
ttl?: boolean;
repo?: string | null;
pullRequestNumber?: number | null;
author?: string | null;
pullRequest?: NamespacePullRequestMetadata | null;
waitForReady?: boolean;
};

async function waitForNamespaceReady(namespace: string, timeout: number = 30000): Promise<void> {
const startTime = Date.now();

while (Date.now() - startTime < timeout) {
try {
const result = await shellPromise(`kubectl get namespace ${namespace} -o jsonpath='{.status.phase}'`);
if (result.trim() === 'Active') {
return;
}
} catch (error) {
// Namespace not ready yet, will retry
}

await new Promise((resolve) => setTimeout(resolve, 1000));
}

throw new Error(`Namespace ${namespace} did not become ready within ${timeout}ms`);
}

function buildNamespacePrMetadata({
repo,
pullRequestNumber,
Expand Down Expand Up @@ -114,6 +152,18 @@ function buildNamespacePrMetadata({
return { labels };
}

async function shouldEnableNamespaceTTL(
staticEnv: boolean,
pullRequest?: NamespacePullRequestMetadata | null
): Promise<boolean> {
if (staticEnv) {
return false;
}

const keepLabel = await getKeepLabel();
return !parsePullRequestLabels(pullRequest?.labels).includes(keepLabel);
}

/**
* Generates TTL-related labels for namespace creation
*/
Expand Down Expand Up @@ -262,34 +312,32 @@ export async function createOrUpdateNamespace({
name,
buildUUID,
staticEnv,
ttl = true,
ttl,
repo,
pullRequestNumber,
author,
}: {
name: string;
buildUUID: string;
staticEnv: boolean;
ttl?: boolean;
repo?: string | null;
pullRequestNumber?: number | null;
author?: string | null;
}) {
pullRequest,
waitForReady = false,
}: CreateOrUpdateNamespaceOptions) {
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const client = kc.makeApiClient(k8s.CoreV1Api);

const uuid = name.replace('env-', '');
const ttlEnabled = ttl ?? (pullRequest ? await shouldEnableNamespaceTTL(staticEnv, pullRequest) : !staticEnv);
const namespaceRepo = repo ?? pullRequest?.fullName;
const namespacePullRequestNumber = pullRequestNumber ?? pullRequest?.pullRequestNumber;
const namespaceAuthor = author ?? pullRequest?.githubLogin;

// Generate TTL labels using helper function
const { labels, logMessage } = await generateTTLLabels({
uuid,
staticEnv,
ttl,
ttl: ttlEnabled,
buildUUID,
repo,
pullRequestNumber,
author,
repo: namespaceRepo,
pullRequestNumber: namespacePullRequestNumber,
author: namespaceAuthor,
});

getLogger({ namespace: name }).info(`Deploy: creating namespace ${logMessage}`);
Expand All @@ -307,23 +355,29 @@ export async function createOrUpdateNamespace({
const { patch, logMessage: patchMessage } = await generateTTLPatch({
uuid,
staticEnv,
ttl: staticEnv ? false : ttl,
ttl: staticEnv ? false : ttlEnabled,
buildUUID,
repo,
pullRequestNumber,
author,
repo: namespaceRepo,
pullRequestNumber: namespacePullRequestNumber,
author: namespaceAuthor,
});

await client.patchNamespace(name, patch, undefined, undefined, undefined, undefined, undefined, {
headers: { 'Content-Type': 'application/json-patch+json' },
});
getLogger({ namespace: name }).info(`Deploy: updated namespace ${patchMessage}`);
if (waitForReady) {
await waitForNamespaceReady(name);
}
return;
}

try {
await client.createNamespace(namespace);
getLogger({ namespace: name }).debug('Namespace created');
if (waitForReady) {
await waitForNamespaceReady(name);
}
} catch (err) {
getLogger({ namespace: name, error: err }).error('Namespace: create failed');
throw err;
Expand Down
Loading
Loading