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
38 changes: 38 additions & 0 deletions src/server/lib/deploymentManager/deploymentManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import { getLogger, withLogContext } from 'server/lib/logger';
import { ensureServiceAccountForJob } from '../kubernetes/common/serviceAccount';
import { waitForDeployPodReady } from '../kubernetes';
import { buildDeployJobName } from '../kubernetes/jobNames';
import GlobalConfigService from 'server/services/globalConfig';
import { getLogArchivalService } from 'server/services/logArchival';

const generateJobId = customAlphabet('abcdefghijklmnopqrstuvwxyz0123456789', 6);

Expand Down Expand Up @@ -134,6 +136,41 @@ export class DeploymentManager {
return [DeployTypes.GITHUB, DeployTypes.DOCKER, DeployTypes.AURORA_RESTORE].includes(deployType);
}

private async archiveDeployLogs(
deploy: Deploy,
jobName: string,
result: {
success: boolean;
logs?: string;
startedAt?: string;
completedAt?: string;
duration?: number;
}
): Promise<void> {
const globalConfig = await GlobalConfigService.getInstance().getAllConfigs();
if (!globalConfig.logArchival?.enabled || !result.logs) {
return;
}

await getLogArchivalService().archiveLogs(
{
jobName,
jobType: 'deploy',
serviceName: deploy.deployable?.name || deploy.service?.name || '',
namespace: deploy.build.namespace,
status: result.success ? 'Complete' : 'Failed',
sha: deploy.sha || '',
deployUuid: deploy.uuid,
deploymentType: 'github',
startedAt: result.startedAt,
completedAt: result.completedAt,
duration: result.duration,
archivedAt: new Date().toISOString(),
},
result.logs
);
}

private async deployManifests(deploy: Deploy): Promise<void> {
return withLogContext({ deployUuid: deploy.uuid, serviceName: deploy.deployable?.name }, async () => {
const jobId = generateJobId();
Expand Down Expand Up @@ -171,6 +208,7 @@ export class DeploymentManager {
shortSha,
});
const result = await monitorKubernetesJob(jobName, deploy.build.namespace);
await this.archiveDeployLogs(deploy, jobName, result);

if (!result.success) {
throw new Error(result.message);
Expand Down
76 changes: 75 additions & 1 deletion src/server/lib/helm/__tests__/helm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
import mockRedisClient from 'server/lib/__mocks__/redisClientMock';
mockRedisClient();

import { constructHelmDeploysBuildMetaData, grpcMapping } from 'server/lib/helm';
import { constructHelmDeploysBuildMetaData, grpcMapping, helmOrgAppDeployStep } from 'server/lib/helm';
import { Deploy } from 'server/models';
import GlobalConfigService from 'server/services/globalConfig';

Expand All @@ -26,6 +26,13 @@ jest.mock('server/lib/envVariables', () => ({
}));

jest.mock('server/services/globalConfig');
jest.mock('server/lib/helm/utils', () => {
const originalModule = jest.requireActual('server/lib/helm/utils');
return {
...originalModule,
renderTemplate: jest.fn().mockImplementation(async (_build, values) => values || []),
};
});

describe('Helm tests', () => {
test('constructHelmDeploysBuildMetaData should return the correct metadata', async () => {
Expand Down Expand Up @@ -155,4 +162,71 @@ describe('Helm tests', () => {
]);
});
});

describe('helmOrgAppDeployStep', () => {
beforeEach(() => {
jest.clearAllMocks();
});

test('does not emit deployment.version when init image is present', async () => {
const mockGetAllConfigs = jest.fn().mockResolvedValue({
lifecycleDefaults: {
deployCluster: 'test-cluster',
cfStepType: 'helm',
},
'lifecycle-app': {
chart: {
values: [],
},
},
serviceDefaults: {
defaultIPWhiteList: '[1.1.1.1/32]',
},
domainDefaults: {
http: 'preview.lifecycle.com',
},
});
const mockGetOrgChartName = jest.fn().mockResolvedValue('lifecycle-app');

(GlobalConfigService.getInstance as jest.Mock).mockReturnValue({
getAllConfigs: mockGetAllConfigs,
getOrgChartName: mockGetOrgChartName,
});

const deploy = {
uuid: 'test-uuid',
dockerImage: 'repo/app:tag',
initDockerImage: 'repo/init:tag',
env: {
DB_HOST: 'postgres.internal',
},
initEnv: {
INIT_DB_HOST: 'init-postgres.internal',
},
deployable: {
buildUUID: 'build-123',
port: 8080,
helm: {
chart: { name: 'lifecycle-app', values: [] },
docker: {
app: {},
init: {},
},
},
},
build: {
namespace: 'env-test',
commentRuntimeEnv: {},
isStatic: false,
},
$fetchGraph: jest.fn(),
} as unknown as Deploy;

const result = await helmOrgAppDeployStep(deploy);
const customValues = result.arguments.custom_values as string[];

expect(customValues).toContain('deployment.initImage=repo/init:tag');
expect(customValues.some((value) => value.startsWith('deployment.version='))).toBe(false);
});
});
});
1 change: 0 additions & 1 deletion src/server/lib/helm/helm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ export async function helmOrgAppDeployStep(deploy: Deploy): Promise<Record<strin
version = constructImageVersion(deploy.initDockerImage);
customValues.push(
`${resourceType}.initImage=${deploy.initDockerImage}`,
`${resourceType}.version=${version}`,
...Object.entries(initEnvVars).map(
([key, value]) => `${resourceType}.initEnv.${key.replace(/_/g, '__')}=${value}`
)
Expand Down
20 changes: 10 additions & 10 deletions src/server/lib/kubernetes/__tests__/jobNames.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,29 @@ import { buildDeployJobName, KUBERNETES_NAME_MAX_LENGTH } from '../jobNames';
describe('buildDeployJobName', () => {
it('preserves deploy job names that already fit', () => {
const jobName = buildDeployJobName({
deployUuid: 'api-crimson-tooth-697165',
deployUuid: 'api-preview-build-123456',
jobId: 'k4hlde',
shortSha: '28e350a',
shortSha: 'abcdef1',
});

expect(jobName).toBe('api-crimson-tooth-697165-deploy-k4hlde-28e350a');
expect(jobName).toBe('api-preview-build-123456-deploy-k4hlde-abcdef1');
});

it('truncates only the prefix and preserves the full suffix', () => {
const jobName = buildDeployJobName({
deployUuid: 'cyclerx-cosmosdb-emulator-crimson-tooth-697165',
deployUuid: 'sample-cosmos-emulator-preview-build-123456',
jobId: 'k4hlde',
shortSha: '28e350a',
shortSha: 'abcdef1',
});

expect(jobName).toHaveLength(KUBERNETES_NAME_MAX_LENGTH);
expect(jobName).toBe('cyclerx-cosmosdb-emulator-crimson-tooth-6-deploy-k4hlde-28e350a');
expect(jobName.endsWith('deploy-k4hlde-28e350a')).toBe(true);
expect(jobName).toBe('sample-cosmos-emulator-preview-build-1234-deploy-k4hlde-abcdef1');
expect(jobName.endsWith('deploy-k4hlde-abcdef1')).toBe(true);
});

it('removes trailing separators after truncation', () => {
const jobName = buildDeployJobName({
deployUuid: 'service-ending-with-dash------crimson-tooth-697165',
deployUuid: 'service-ending-with-dash------preview-build-123456',
jobId: 'job123',
shortSha: 'abcdef0',
});
Expand All @@ -51,12 +51,12 @@ describe('buildDeployJobName', () => {
});

it('returns a truncated suffix when suffix length alone exceeds maxLength', () => {
// suffix = 'deploy-k4hlde-28e350a' (21 chars); maxLength=14 → maxPrefixLength = 14-21-1 = -8
// suffix = 'deploy-k4hlde-abcdef1' (21 chars); maxLength=14 → maxPrefixLength = 14-21-1 = -8
// falls back to suffix.substring(0, 14) = 'deploy-k4hlde-' → trailing dash stripped
const jobName = buildDeployJobName({
deployUuid: 'some-service',
jobId: 'k4hlde',
shortSha: '28e350a',
shortSha: 'abcdef1',
maxLength: 14,
});

Expand Down
69 changes: 26 additions & 43 deletions src/server/lib/kubernetesApply/applyManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import { Deploy } from 'server/models';
import { getLogger } from 'server/lib/logger';
import GlobalConfigService from 'server/services/globalConfig';
import { buildDeployJobName } from 'server/lib/kubernetes/jobNames';
import { JobMonitor } from 'server/lib/kubernetes/JobMonitor';

export interface KubernetesApplyJobConfig {
deploy: Deploy;
Expand Down Expand Up @@ -188,48 +189,30 @@ export async function monitorKubernetesJob(
jobName: string,
namespace: string,
maxAttempts = 120
): Promise<{ success: boolean; message: string }> {
const kc = new k8s.KubeConfig();
kc.loadFromDefault();
const batchApi = kc.makeApiClient(k8s.BatchV1Api);

let attempts = 0;

while (attempts < maxAttempts) {
try {
const job = await batchApi.readNamespacedJob(jobName, namespace);

if (job.body.status?.succeeded) {
return {
success: true,
message: 'Kubernetes resources applied successfully',
};
}

if (job.body.status?.failed) {
const conditions = job.body.status.conditions || [];
const failureReason =
conditions
.filter((c) => c.type === 'Failed')
.map((c) => c.message)
.join('; ') || 'Unknown failure reason';

return {
success: false,
message: `Kubernetes apply job failed: ${failureReason}`,
};
}

await new Promise((resolve) => setTimeout(resolve, 5000));
attempts++;
} catch (error) {
getLogger({ error }).error(`Job: monitor failed name=${jobName}`);
throw error;
}
): Promise<{
success: boolean;
message: string;
logs: string;
status?: string;
startedAt?: string;
completedAt?: string;
duration?: number;
}> {
try {
const timeoutSeconds = maxAttempts * 5;
const result = await JobMonitor.waitForJobAndGetLogs(jobName, namespace, timeoutSeconds, ['kubectl-apply']);

return {
success: result.success,
message: result.success ? 'Kubernetes resources applied successfully' : 'Kubernetes apply job failed',
logs: result.logs,
status: result.status,
startedAt: result.startedAt,
completedAt: result.completedAt,
duration: result.duration,
};
} catch (error) {
getLogger({ error }).error(`Job: monitor failed name=${jobName}`);
throw error;
}

return {
success: false,
message: 'Kubernetes apply job timed out after 10 minutes',
};
}
Loading
Loading