Skip to content
Draft
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
43 changes: 43 additions & 0 deletions src/server/lib/__tests__/deployScope.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { normalizeRepositoryId, scopedDeployableNames } from '../deployScope';

describe('deploy scope helpers', () => {
test('preserves null repository IDs instead of coercing them to zero', () => {
expect(normalizeRepositoryId(null)).toBeNull();
expect(normalizeRepositoryId(undefined)).toBeNull();
expect(normalizeRepositoryId('')).toBeNull();
expect(normalizeRepositoryId(123)).toBe(123);
expect(normalizeRepositoryId('123')).toBe(123);
});

test('includes YAML-only Docker dependencies for a scoped repository deploy', () => {
const scopedNames = scopedDeployableNames(
[
{ name: 'sponsored-benefits', repositoryId: 1154960313 },
{
name: 'sbs-localstack',
repositoryId: null,
dependsOnDeployableName: 'sponsored-benefits',
},
{ name: 'unrelated-localstack', repositoryId: null, dependsOnDeployableName: 'other-service' },
{ name: 'external-partners', repositoryId: 932333620 },
],
1154960313
);

expect(Array.from(scopedNames).sort()).toEqual(['sbs-localstack', 'sponsored-benefits']);
});

test('includes explicit deployment dependencies for a scoped repository deploy', () => {
const scopedNames = scopedDeployableNames(
[
{ name: 'api', repositoryId: 1, deploymentDependsOn: ['db', 'localstack'] },
{ name: 'db', repositoryId: null },
{ name: 'localstack', repositoryId: null },
{ name: 'worker', repositoryId: 2 },
],
1
);

expect(Array.from(scopedNames).sort()).toEqual(['api', 'db', 'localstack']);
});
});
80 changes: 80 additions & 0 deletions src/server/lib/deployScope.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export interface DeployScopeNode {
name?: string | null;
repositoryId?: number | string | null;
dependsOnDeployableName?: string | null;
deploymentDependsOn?: string[] | string | null;
}

export function normalizeRepositoryId(repositoryId: number | string | null | undefined): number | null {
if (repositoryId == null || repositoryId === '') {
return null;
}

const parsed = Number(repositoryId);
return Number.isFinite(parsed) ? parsed : null;
}

function deploymentDependencyNames(node: DeployScopeNode | undefined): string[] {
const dependencies = node?.deploymentDependsOn;
if (!dependencies) {
return [];
}

if (Array.isArray(dependencies)) {
return dependencies;
}

try {
const parsed = JSON.parse(dependencies);
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}

export function scopedDeployableNames(deployables: DeployScopeNode[], githubRepositoryId?: number | null): Set<string> {
if (!githubRepositoryId) {
return new Set(deployables.map((deployable) => deployable.name).filter(Boolean) as string[]);
}

const targetRepositoryId = normalizeRepositoryId(githubRepositoryId);
const byName = new Map<string, DeployScopeNode>();
deployables.forEach((deployable) => {
if (deployable.name) {
byName.set(deployable.name, deployable);
}
});

const included = new Set(
deployables
.filter((deployable) => normalizeRepositoryId(deployable.repositoryId) === targetRepositoryId)
.map((deployable) => deployable.name)
.filter(Boolean) as string[]
);

let changed = true;
while (changed) {
changed = false;

for (const deployable of deployables) {
const name = deployable.name;
if (!name || included.has(name)) {
continue;
}

const requiredByIncluded = deployable.dependsOnDeployableName
? included.has(deployable.dependsOnDeployableName)
: false;
const explicitlyRequiredByIncluded = Array.from(included).some((includedName) =>
deploymentDependencyNames(byName.get(includedName)).includes(name)
);

if (requiredByIncluded || explicitlyRequiredByIncluded) {
included.add(name);
changed = true;
}
}
}

return included;
}
63 changes: 63 additions & 0 deletions src/server/services/__tests__/build.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1094,3 +1094,66 @@ describe('BuildService queue fingerprinting', () => {
);
});
});

describe('BuildService running image updates', () => {
test('updates repo-less dependency deploys during a scoped repository deploy', async () => {
const appPatch = jest.fn().mockResolvedValue(undefined);
const dependencyPatch = jest.fn().mockResolvedValue(undefined);
const unrelatedPatch = jest.fn().mockResolvedValue(undefined);
const buildService = new BuildService(
{ models: {}, services: {} } as any,
{} as any,
{} as any,
{
registerQueue: jest.fn(() => ({
add: jest.fn(),
process: jest.fn(),
on: jest.fn(),
})),
} as any
);
const build = {
deploys: [
{
uuid: 'api-deploy',
githubRepositoryId: 100,
dockerImage: 'api:latest',
deployable: {
name: 'api',
repositoryId: 100,
},
$query: jest.fn(() => ({ patch: appPatch })),
},
{
uuid: 'api-localstack-deploy',
githubRepositoryId: null,
dockerImage: 'localstack:latest',
deployable: {
name: 'api-localstack',
repositoryId: null,
dependsOnDeployableName: 'api',
},
$query: jest.fn(() => ({ patch: dependencyPatch })),
},
{
uuid: 'worker-deploy',
githubRepositoryId: 200,
dockerImage: 'worker:latest',
deployable: {
name: 'worker',
repositoryId: 200,
},
$query: jest.fn(() => ({ patch: unrelatedPatch })),
},
],
$fetchGraph: jest.fn().mockResolvedValue(undefined),
};

await (buildService as any).updateDeploysImageDetails(build, 100);

expect(build.$fetchGraph).toHaveBeenCalledWith('deploys.[service, deployable]');
expect(appPatch).toHaveBeenCalledWith({ isRunningLatest: true, runningImage: 'api:latest' });
expect(dependencyPatch).toHaveBeenCalledWith({ isRunningLatest: true, runningImage: 'localstack:latest' });
expect(unrelatedPatch).not.toHaveBeenCalled();
});
});
166 changes: 166 additions & 0 deletions src/server/services/__tests__/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -777,3 +777,169 @@ describe('DeployService - shouldTriggerGithubDeployment', () => {
});
});
});

describe('DeployService - scoped deploy initialization', () => {
test('creates repo-less Docker deploys with a null githubRepositoryId', async () => {
const patch = jest.fn().mockResolvedValue(undefined);
const createdDeploy = {
id: 99,
$query: jest.fn(() => ({ patch })),
$setRelated: jest.fn(),
};
const deployQuery: any = {
where: jest.fn(() => deployQuery),
withGraphFetched: jest.fn().mockResolvedValue([]),
then: (resolve: (value: any[]) => void, reject: (reason: unknown) => void) =>
Promise.resolve([]).then(resolve, reject),
};
const build = {
id: 1449,
uuid: 'good-dev-0',
enableFullYaml: true,
deployables: [
{
id: 215190,
name: 'sponsored-benefits',
serviceId: null,
repositoryId: 1154960313,
branchName: 'main',
defaultTag: 'main',
type: DeployTypes.HELM,
active: true,
},
{
id: 215191,
name: 'sbs-localstack',
serviceId: null,
repositoryId: null,
branchName: 'main',
defaultTag: '4.10',
type: DeployTypes.DOCKER,
active: true,
dependsOnDeployableName: 'sponsored-benefits',
},
],
deploys: [{ deployableId: 215190 }, { deployableId: 215191 }],
$fetchGraph: jest.fn().mockResolvedValue(undefined),
};
const mockDb = {
models: {
Deploy: {
query: jest.fn(() => deployQuery),
findOne: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue(createdDeploy),
},
},
services: {
Deploy: {
hostForDeployableDeploy: jest.fn(() => 'sbs-localstack-good-dev-0.lifecycle.test'),
},
},
};

const deployService = new DeployService(
mockDb as any,
{} as any,
{} as any,
{
registerQueue: jest.fn().mockReturnValue({ add: jest.fn(), process: jest.fn(), on: jest.fn() }),
} as any
);

await deployService.findOrCreateDeploys({} as any, build as any, 1154960313);

expect(mockDb.models.Deploy.create).toHaveBeenCalledWith(
expect.objectContaining({
githubRepositoryId: null,
})
);
expect(patch).toHaveBeenCalledWith(
expect.objectContaining({
tag: '4.10',
})
);
});

test('patches repo-less Docker dependencies during a repo-scoped deploy', async () => {
const appPatch = jest.fn().mockResolvedValue(undefined);
const dependencyPatch = jest.fn().mockResolvedValue(undefined);
const existingDeploys = [
{
deployableId: 1,
$query: jest.fn(() => ({ patch: appPatch })),
$setRelated: jest.fn(),
},
{
deployableId: 2,
$query: jest.fn(() => ({ patch: dependencyPatch })),
$setRelated: jest.fn(),
},
];
const deployQuery: any = {
where: jest.fn(() => deployQuery),
withGraphFetched: jest.fn().mockResolvedValue(existingDeploys),
then: (resolve: (value: any[]) => void, reject: (reason: unknown) => void) =>
Promise.resolve(existingDeploys).then(resolve, reject),
};
const build = {
id: 1449,
uuid: 'good-dev-0',
enableFullYaml: true,
deployables: [
{
id: 1,
name: 'sponsored-benefits',
repositoryId: 1154960313,
branchName: 'main',
defaultTag: 'main',
type: DeployTypes.HELM,
active: true,
},
{
id: 2,
name: 'sbs-localstack',
repositoryId: null,
branchName: 'main',
defaultTag: '4.10',
type: DeployTypes.DOCKER,
active: true,
dependsOnDeployableName: 'sponsored-benefits',
},
],
deploys: existingDeploys,
$fetchGraph: jest.fn().mockResolvedValue(undefined),
};
const mockDb = {
models: {
Deploy: {
query: jest.fn(() => deployQuery),
findOne: jest.fn(),
create: jest.fn(),
},
},
services: {
Deploy: {
hostForDeployableDeploy: jest.fn((deploy: any, deployable: any) => `${deployable.name}.lifecycle.test`),
},
},
};

const deployService = new DeployService(
mockDb as any,
{} as any,
{} as any,
{
registerQueue: jest.fn().mockReturnValue({ add: jest.fn(), process: jest.fn(), on: jest.fn() }),
} as any
);

await deployService.findOrCreateDeploys({} as any, build as any, 1154960313);

expect(appPatch).toHaveBeenCalled();
expect(dependencyPatch).toHaveBeenCalledWith(
expect.objectContaining({
tag: '4.10',
})
);
});
});
Loading
Loading