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
33 changes: 33 additions & 0 deletions src/server/lib/__tests__/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
deleteBuild,
deleteDeploy,
deployBuild,
isPinnedCname,
waitForCodefresh,
} from '../cli';

Expand Down Expand Up @@ -477,6 +478,18 @@ describe('generic CLI deploy lifecycle', () => {
expect(mockLoggerInfo).toHaveBeenCalledWith('CLI: deleted');
});

test('deleteBuild leaves a pinned aurora database in place', async () => {
const pinned = createCliDeploy(DeployTypes.AURORA_RESTORE, { cname: 'database-rw.example.test' });
const withGraphFetched = jest.fn().mockResolvedValue([pinned]);
const where = jest.fn(() => ({ withGraphFetched }));
mockDeployQuery.mockReturnValue({ where });

await deleteBuild({ id: 42, uuid: 'build-uuid' } as any);

expect(mockShellPromise).not.toHaveBeenCalled();
expect(mockLoggerInfo).not.toHaveBeenCalledWith('CLI: deleting');
});

test('codefresh deploy and destroy omit optional triggers and destroy preserves undefined CLI output', async () => {
const deploy = createDeploy({
env: { ENABLED: true, EMPTY: null },
Expand All @@ -499,3 +512,23 @@ describe('generic CLI deploy lifecycle', () => {
expect(mockUpdateLogContext).toHaveBeenCalledWith({ buildUuid: 'build-uuid' });
});
});

describe('isPinnedCname', () => {
test.each([
[null, false],
[undefined, false],
['', false],
[' ', false],
['app-db-env.cluster-abc123.us-west-2.rds.amazonaws.com', false],
['app-db-env.abc123.us-west-2.rds.amazonaws.com', false],
['APP-DB-ENV.CLUSTER-ABC123.US-WEST-2.RDS.AMAZONAWS.COM.', false],
[' app-db-env.cluster-abc123.us-west-2.rds.amazonaws.com ', false],
['app-db-env.cluster-abc123.cn-north-1.rds.amazonaws.com.cn', false],
['app-db-rw.example.com', true],
['app-db-rw.example.com.', true],
['rds.amazonaws.com.example.com', true],
['app-db.cluster-abc123.us-west-2.rds.amazonaws.com.example.com', true],
])('%p → %p', (cname, expected) => {
expect(isPinnedCname(cname)).toBe(expected);
});
});
15 changes: 15 additions & 0 deletions src/server/lib/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,20 @@ export async function waitForCodefresh(id: string) {
}
}

const RDS_HOST = /\.rds\.amazonaws\.com(\.cn)?$/;

/**
* Whether an aurora-restore deploy's cname was pinned by an operator rather than written by Lifecycle.
*
* Lifecycle only ever stores RDS endpoints returned by AWS, so any other host (typically a DNS record
* fronting an externally managed database) was set by hand. Pinned cnames must never be restored over
* or destroyed.
*/
export function isPinnedCname(cname: string | null | undefined): boolean {
const host = cname?.trim().toLowerCase().replace(/\.$/, '');
return !!host && !RDS_HOST.test(host);
}

/**
* Deletes CLI based services for this build
* @param build the build to delete CLI services from
Expand All @@ -223,6 +237,7 @@ export async function deleteBuild(build: Build) {
deploys
?.filter((d) => {
const serviceType: DeployTypes = d.deployable.type;
if (serviceType === DeployTypes.AURORA_RESTORE && isPinnedCname(d.cname)) return false;
return CLIDeployTypes.has(serviceType) && d.active;
})
.map(async (deploy) => {
Expand Down
53 changes: 52 additions & 1 deletion src/server/services/__tests__/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ jest.mock('server/lib/cli', () => ({
cliDeploy: (...args: any[]) => mockCliDeploy(...args),
codefreshDeploy: (...args: any[]) => mockCodefreshDeploy(...args),
waitForCodefresh: (...args: any[]) => mockWaitForCodefresh(...args),
isPinnedCname: jest.requireActual('server/lib/cli').isPinnedCname,
}));

describe('DeployService - shouldTriggerGithubDeployment', () => {
Expand Down Expand Up @@ -2051,7 +2052,7 @@ describe('DeployService uncovered public behavior', () => {
id: 1,
uuid: 'database-env',
status: DeployStatus.BUILT,
cname: 'database.example.test',
cname: 'database-env.cluster-abc.us-west-2.rds.amazonaws.com',
build: { uuid: 'env' },
deployable: { name: 'database', type: DeployTypes.AURORA_RESTORE },
reload: jest.fn().mockResolvedValue(undefined),
Expand All @@ -2064,6 +2065,56 @@ describe('DeployService uncovered public behavior', () => {
expect(mockCliDeploy).not.toHaveBeenCalled();
});

test.each([
DeployStatus.QUEUED,
DeployStatus.BUILDING,
DeployStatus.DEPLOY_FAILED,
DeployStatus.ERROR,
DeployStatus.TORN_DOWN,
])('deployAurora keeps a pinned cname from %s without consulting AWS or running restore', async (status) => {
const { service, deployPatch } = serviceHarness();
const deploy: any = {
id: 1,
uuid: 'database-env',
status,
cname: 'database-rw.example.test',
build: { uuid: 'env' },
deployable: { name: 'database', type: DeployTypes.AURORA_RESTORE },
reload: jest.fn().mockResolvedValue(undefined),
$fetchGraph: jest.fn().mockResolvedValue(undefined),
};

await expect(service.deployAurora(deploy, 'run-1')).resolves.toBe(true);

expect(mockTaggingGetResources).not.toHaveBeenCalled();
expect(mockCliDeploy).not.toHaveBeenCalled();
expect(deployPatch).toHaveBeenCalledTimes(1);
expect(deployPatch).toHaveBeenCalledWith({ status: DeployStatus.BUILT });
});

test.each([DeployStatus.BUILT, DeployStatus.READY])(
'deployAurora leaves a %s pinned cname untouched',
async (status) => {
const { service, deployPatch } = serviceHarness();
const deploy: any = {
id: 1,
uuid: 'database-env',
status,
cname: 'database-rw.example.test',
build: { uuid: 'env' },
deployable: { name: 'database', type: DeployTypes.AURORA_RESTORE },
reload: jest.fn().mockResolvedValue(undefined),
$fetchGraph: jest.fn().mockResolvedValue(undefined),
};

await expect(service.deployAurora(deploy, 'run-1')).resolves.toBe(true);

expect(mockTaggingGetResources).not.toHaveBeenCalled();
expect(mockCliDeploy).not.toHaveBeenCalled();
expect(deployPatch).not.toHaveBeenCalled();
}
);

test('deployAurora adopts an existing cluster endpoint without running restore', async () => {
const { service, deployPatch } = serviceHarness();
mockTaggingGetResources.mockResolvedValue({
Expand Down
27 changes: 27 additions & 0 deletions src/server/services/__tests__/deployCleanup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ jest.mock('server/lib/shell', () => ({
jest.mock('server/lib/cli', () => ({
codefreshDestroy: (...args: any[]) => mockCodefreshDestroy(...args),
deleteDeploy: (...args: any[]) => mockDeleteDeploy(...args),
isPinnedCname: jest.requireActual('server/lib/cli').isPinnedCname,
}));

jest.mock('server/lib/metrics', () => ({
Expand Down Expand Up @@ -442,6 +443,32 @@ describe('DeployCleanupService', () => {
);
});

test('infra cleanup keeps a pinned aurora database and its cname', async () => {
const deploy = createDeploy({
cname: 'database-rw.example.test',
deployable: { name: 'database', type: DeployTypes.AURORA_RESTORE, serviceDisksYaml: null },
});
const service = createService();

await expect(service.cleanupDeploy(deploy, { mode: 'infra' })).resolves.toBe(true);

expect(mockDeleteDeploy).not.toHaveBeenCalled();
expect(deploy.patch).toHaveBeenCalledWith(expect.objectContaining({ status: DeployStatus.TORN_DOWN }));
expect(deploy.patch).not.toHaveBeenCalledWith(expect.objectContaining({ cname: expect.anything() }));
});

test('infra cleanup destroys a Lifecycle-restored aurora database', async () => {
const deploy = createDeploy({
cname: 'database-build-1.cluster-abc.us-west-2.rds.amazonaws.com',
deployable: { name: 'database', type: DeployTypes.AURORA_RESTORE, serviceDisksYaml: null },
});
const service = createService();

await expect(service.cleanupDeploy(deploy, { mode: 'infra' })).resolves.toBe(true);

expect(mockDeleteDeploy).toHaveBeenCalledWith(deploy);
});

test('enqueueCleanup queues infra cleanup jobs', async () => {
const queueAdd = jest.fn().mockResolvedValue(undefined);
const service = createService({}, queueAdd);
Expand Down
12 changes: 11 additions & 1 deletion src/server/services/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,17 @@ export default class DeployService extends BaseService {
throw new Error('Aurora restore deployable is missing.');
}

if ((deploy.status === DeployStatus.BUILT || deploy.status === DeployStatus.READY) && deploy.cname) {
const isBuilt = deploy.status === DeployStatus.BUILT || deploy.status === DeployStatus.READY;

if (cli.isPinnedCname(deploy.cname)) {
getLogger().info('Aurora: skipped reason=pinned');
if (!isBuilt) {
await this.patchDeployForRun(deploy, runUUID, { status: DeployStatus.BUILT });
}
return true;
}

if (isBuilt && deploy.cname) {
getLogger().info('Aurora: skipped reason=alreadyBuilt');
return true;
}
Expand Down
7 changes: 6 additions & 1 deletion src/server/services/deployCleanup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import { Deploy } from 'server/models';
import { shellPromise } from 'server/lib/shell';
import { extractContextForQueue, getLogger, withLogContext } from 'server/lib/logger';
import { CLIDeployTypes, DeployStatus, DeployTypes } from 'shared/constants';
import { codefreshDestroy, deleteDeploy } from 'server/lib/cli';
import { codefreshDestroy, deleteDeploy, isPinnedCname } from 'server/lib/cli';
import Metrics from 'server/lib/metrics';
import BaseService from './_service';
import { parseSecretRefsFromEnv } from 'server/lib/secretRefs';
Expand Down Expand Up @@ -389,6 +389,11 @@ export default class DeployCleanupService extends BaseService {
return null;
}

if (deployType === DeployTypes.AURORA_RESTORE && isPinnedCname(deploy.cname)) {
getLogger().info('Deploy cleanup: cli-destroy skipped reason=pinned');
return null;
}

return {
name: 'cli-destroy',
resourceType: deployType,
Expand Down
Loading