feat: 增加发布策略校验
- projects: 为环境补充 releasePolicy 和 refPattern,区分 branch、tag、master 发布策略 - deploy-runs: 创建发布单前校验项目状态、环境和 ref 规则 - repository: 数据库读取路径补齐发布策略推断
This commit is contained in:
@@ -19,6 +19,8 @@ describe('DeployExecutionService', () => {
|
|||||||
name: 'test',
|
name: 'test',
|
||||||
displayName: '测试环境',
|
displayName: '测试环境',
|
||||||
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
|
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
|
||||||
|
releasePolicy: 'branch',
|
||||||
|
refPattern: '^(develop|feature/.+|hotfix/.+)$',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { AppError } from '../common/errors/app-error';
|
import { AppError } from '../common/errors/app-error';
|
||||||
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
|
import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
|
||||||
|
import { ProjectSummary } from '../projects/project.types';
|
||||||
import { ProjectsService } from '../projects/projects.service';
|
import { ProjectsService } from '../projects/projects.service';
|
||||||
import { DeployExecutionService } from './deploy-execution.service';
|
import { DeployExecutionService } from './deploy-execution.service';
|
||||||
import { DeployRunRepository } from './deploy-run.repository';
|
import { DeployRunRepository } from './deploy-run.repository';
|
||||||
@@ -27,8 +28,30 @@ describe('DeployRunsService', () => {
|
|||||||
steps: [],
|
steps: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
function createService(options?: { runs?: DeployRunSummary[] }) {
|
const project: ProjectSummary = {
|
||||||
|
id: 'project_001',
|
||||||
|
key: 'access-manage',
|
||||||
|
name: 'access-manage',
|
||||||
|
repositoryUrl: 'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
|
||||||
|
defaultBranch: 'develop',
|
||||||
|
status: 'active',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'test',
|
||||||
|
displayName: '测试环境',
|
||||||
|
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
|
||||||
|
releasePolicy: 'branch',
|
||||||
|
refPattern: '^(develop|feature/.+|hotfix/.+)$',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function createService(options?: {
|
||||||
|
runs?: DeployRunSummary[];
|
||||||
|
project?: ProjectSummary;
|
||||||
|
}) {
|
||||||
const runs = options?.runs ?? [baseRun];
|
const runs = options?.runs ?? [baseRun];
|
||||||
|
const resolvedProject = options?.project ?? project;
|
||||||
const repository = {
|
const repository = {
|
||||||
findMany: jest.fn(() => Promise.resolve(runs)),
|
findMany: jest.fn(() => Promise.resolve(runs)),
|
||||||
findById: jest.fn((id: string) =>
|
findById: jest.fn((id: string) =>
|
||||||
@@ -52,22 +75,7 @@ describe('DeployRunsService', () => {
|
|||||||
>;
|
>;
|
||||||
const projects = {
|
const projects = {
|
||||||
getProject: jest.fn(() =>
|
getProject: jest.fn(() =>
|
||||||
Promise.resolve({
|
Promise.resolve(resolvedProject),
|
||||||
id: 'project_001',
|
|
||||||
key: 'access-manage',
|
|
||||||
name: 'access-manage',
|
|
||||||
repositoryUrl:
|
|
||||||
'https://gitea.ops.mrzhan.top/my-project/access-manage.git',
|
|
||||||
defaultBranch: 'develop',
|
|
||||||
status: 'active' as const,
|
|
||||||
environments: [
|
|
||||||
{
|
|
||||||
name: 'test' as const,
|
|
||||||
displayName: '测试环境',
|
|
||||||
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
} satisfies Pick<ProjectsService, 'getProject'>;
|
} satisfies Pick<ProjectsService, 'getProject'>;
|
||||||
const audit = {
|
const audit = {
|
||||||
@@ -149,6 +157,111 @@ describe('DeployRunsService', () => {
|
|||||||
expect(execution.execute).not.toHaveBeenCalled();
|
expect(execution.execute).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects project environments that are not configured before creating a run', async () => {
|
||||||
|
const { audit, execution, repository, service } = createService({
|
||||||
|
project: {
|
||||||
|
...project,
|
||||||
|
key: 'devops-platform-web',
|
||||||
|
defaultBranch: 'master',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'production',
|
||||||
|
displayName: '生产环境',
|
||||||
|
jenkinsJobPath: 'devops-platform/prod-devops-platform-web-master',
|
||||||
|
releasePolicy: 'master',
|
||||||
|
refPattern: '^master$',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createRun({
|
||||||
|
projectKey: 'devops-platform-web',
|
||||||
|
environment: 'test',
|
||||||
|
ref: 'master',
|
||||||
|
operator: 'ops-admin',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(AppError);
|
||||||
|
expect(repository.create).not.toHaveBeenCalled();
|
||||||
|
expect(execution.execute).not.toHaveBeenCalled();
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'DEPLOY_REJECTED',
|
||||||
|
after: expect.objectContaining({
|
||||||
|
reason: 'Environment is not configured',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects production refs that do not match tag release policy', async () => {
|
||||||
|
const { audit, execution, repository, service } = createService({
|
||||||
|
project: {
|
||||||
|
...project,
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'production',
|
||||||
|
displayName: '生产环境',
|
||||||
|
jenkinsJobPath: 'my-project-prod/prod-access-manage-tag',
|
||||||
|
releasePolicy: 'tag',
|
||||||
|
refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.createRun({
|
||||||
|
projectKey: 'access-manage',
|
||||||
|
environment: 'production',
|
||||||
|
ref: 'master',
|
||||||
|
operator: 'ops-admin',
|
||||||
|
}),
|
||||||
|
).rejects.toBeInstanceOf(AppError);
|
||||||
|
expect(repository.create).not.toHaveBeenCalled();
|
||||||
|
expect(execution.execute).not.toHaveBeenCalled();
|
||||||
|
expect(audit.record).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
action: 'DEPLOY_REJECTED',
|
||||||
|
after: expect.objectContaining({
|
||||||
|
reason: 'Ref does not match release policy',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows master-only production releases for self-managed platform projects', async () => {
|
||||||
|
const { execution, repository, service } = createService({
|
||||||
|
project: {
|
||||||
|
...project,
|
||||||
|
key: 'devops-platform-web',
|
||||||
|
defaultBranch: 'master',
|
||||||
|
environments: [
|
||||||
|
{
|
||||||
|
name: 'production',
|
||||||
|
displayName: '生产环境',
|
||||||
|
jenkinsJobPath: 'devops-platform/prod-devops-platform-web-master',
|
||||||
|
releasePolicy: 'master',
|
||||||
|
refPattern: '^master$',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = await service.createRun({
|
||||||
|
projectKey: 'devops-platform-web',
|
||||||
|
environment: 'production',
|
||||||
|
ref: 'master',
|
||||||
|
operator: 'ops-admin',
|
||||||
|
idempotencyKey: 'self-managed-master',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(run.id).toBe('self-managed-master');
|
||||||
|
expect(repository.create).toHaveBeenCalled();
|
||||||
|
expect(execution.execute).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it('cancels an existing deploy run', async () => {
|
it('cancels an existing deploy run', async () => {
|
||||||
const { audit, service } = createService();
|
const { audit, service } = createService();
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,10 @@ import {
|
|||||||
DeployNotificationEvent,
|
DeployNotificationEvent,
|
||||||
DeployNotificationService,
|
DeployNotificationService,
|
||||||
} from '../notifications/wecom/deploy-notification.service';
|
} from '../notifications/wecom/deploy-notification.service';
|
||||||
|
import {
|
||||||
|
ProjectEnvironmentSummary,
|
||||||
|
ProjectSummary,
|
||||||
|
} from '../projects/project.types';
|
||||||
import { ProjectsService } from '../projects/projects.service';
|
import { ProjectsService } from '../projects/projects.service';
|
||||||
import { DeployExecutionService } from './deploy-execution.service';
|
import { DeployExecutionService } from './deploy-execution.service';
|
||||||
import { DeployRunRepository } from './deploy-run.repository';
|
import { DeployRunRepository } from './deploy-run.repository';
|
||||||
@@ -57,6 +61,7 @@ export class DeployRunsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = await this.projectsService.getProject(input.projectKey);
|
const project = await this.projectsService.getProject(input.projectKey);
|
||||||
|
this.validateReleaseRequest(input, project);
|
||||||
const run = this.deployRunRepository.create(input);
|
const run = this.deployRunRepository.create(input);
|
||||||
|
|
||||||
this.auditService.record({
|
this.auditService.record({
|
||||||
@@ -189,6 +194,70 @@ export class DeployRunsService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private validateReleaseRequest(
|
||||||
|
input: CreateDeployRunInput,
|
||||||
|
project: ProjectSummary,
|
||||||
|
): ProjectEnvironmentSummary {
|
||||||
|
if (project.status !== 'active') {
|
||||||
|
this.recordDeployRejected(input, 'Project is archived');
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
`Project ${project.key} is archived and cannot be deployed`,
|
||||||
|
400,
|
||||||
|
{ projectKey: project.key },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const environment = project.environments.find(
|
||||||
|
(item) => item.name === input.environment,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!environment) {
|
||||||
|
this.recordDeployRejected(input, 'Environment is not configured');
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
`Project ${project.key} does not expose ${input.environment} environment`,
|
||||||
|
400,
|
||||||
|
{ projectKey: project.key, environment: input.environment },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!new RegExp(environment.refPattern).test(input.ref)) {
|
||||||
|
this.recordDeployRejected(input, 'Ref does not match release policy');
|
||||||
|
throw new AppError(
|
||||||
|
'VALIDATION_FAILED',
|
||||||
|
`Ref ${input.ref} does not match ${environment.releasePolicy} release policy`,
|
||||||
|
400,
|
||||||
|
{
|
||||||
|
projectKey: project.key,
|
||||||
|
environment: input.environment,
|
||||||
|
ref: input.ref,
|
||||||
|
releasePolicy: environment.releasePolicy,
|
||||||
|
refPattern: environment.refPattern,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordDeployRejected(
|
||||||
|
input: CreateDeployRunInput,
|
||||||
|
reason: string,
|
||||||
|
): void {
|
||||||
|
this.auditService.record({
|
||||||
|
action: 'DEPLOY_REJECTED',
|
||||||
|
resourceType: 'deploy_run',
|
||||||
|
actorName: input.operator,
|
||||||
|
after: {
|
||||||
|
projectKey: input.projectKey,
|
||||||
|
environment: input.environment,
|
||||||
|
ref: input.ref,
|
||||||
|
reason,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private toSafeSyncError(error: unknown): string {
|
private toSafeSyncError(error: unknown): string {
|
||||||
const message =
|
const message =
|
||||||
error instanceof Error ? error.message : 'Jenkins sync failed';
|
error instanceof Error ? error.message : 'Jenkins sync failed';
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ export class ProjectRepository {
|
|||||||
name: environment.name === 'TEST' ? 'test' : 'production',
|
name: environment.name === 'TEST' ? 'test' : 'production',
|
||||||
displayName: environment.displayName,
|
displayName: environment.displayName,
|
||||||
jenkinsJobPath: environment.deployJobs[0]?.jenkinsJobPath ?? '',
|
jenkinsJobPath: environment.deployJobs[0]?.jenkinsJobPath ?? '',
|
||||||
|
...this.resolveReleasePolicy(
|
||||||
|
environment.name === 'TEST' ? 'test' : 'production',
|
||||||
|
environment.deployJobs[0]?.jenkinsJobPath ?? '',
|
||||||
|
),
|
||||||
})),
|
})),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -57,4 +61,28 @@ export class ProjectRepository {
|
|||||||
Boolean(this.config.get('DATABASE_URL', { infer: true }))
|
Boolean(this.config.get('DATABASE_URL', { infer: true }))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private resolveReleasePolicy(
|
||||||
|
environment: 'test' | 'production',
|
||||||
|
jobPath: string,
|
||||||
|
): Pick<ProjectSummary['environments'][number], 'releasePolicy' | 'refPattern'> {
|
||||||
|
if (environment === 'test') {
|
||||||
|
return {
|
||||||
|
releasePolicy: 'branch',
|
||||||
|
refPattern: '^(develop|feature/.+|hotfix/.+)$',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jobPath.endsWith('-master')) {
|
||||||
|
return {
|
||||||
|
releasePolicy: 'master',
|
||||||
|
refPattern: '^master$',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
releasePolicy: 'tag',
|
||||||
|
refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$',
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,11 +13,15 @@ export const seedProjects: ProjectSummary[] = [
|
|||||||
name: 'test',
|
name: 'test',
|
||||||
displayName: '测试环境',
|
displayName: '测试环境',
|
||||||
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
|
jenkinsJobPath: 'my-project-dev/test-access-manage-develop',
|
||||||
|
releasePolicy: 'branch',
|
||||||
|
refPattern: '^(develop|feature/.+|hotfix/.+)$',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'production',
|
name: 'production',
|
||||||
displayName: '生产环境',
|
displayName: '生产环境',
|
||||||
jenkinsJobPath: 'my-project-prod/prod-access-manage-tag',
|
jenkinsJobPath: 'my-project-prod/prod-access-manage-tag',
|
||||||
|
releasePolicy: 'tag',
|
||||||
|
refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -33,11 +37,15 @@ export const seedProjects: ProjectSummary[] = [
|
|||||||
name: 'test',
|
name: 'test',
|
||||||
displayName: '测试环境',
|
displayName: '测试环境',
|
||||||
jenkinsJobPath: 'my-project-dev/test-role-admin-develop',
|
jenkinsJobPath: 'my-project-dev/test-role-admin-develop',
|
||||||
|
releasePolicy: 'branch',
|
||||||
|
refPattern: '^(develop|feature/.+|hotfix/.+)$',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'production',
|
name: 'production',
|
||||||
displayName: '生产环境',
|
displayName: '生产环境',
|
||||||
jenkinsJobPath: 'my-project-prod/prod-role-admin-tag',
|
jenkinsJobPath: 'my-project-prod/prod-role-admin-tag',
|
||||||
|
releasePolicy: 'tag',
|
||||||
|
refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -53,11 +61,15 @@ export const seedProjects: ProjectSummary[] = [
|
|||||||
name: 'test',
|
name: 'test',
|
||||||
displayName: '测试环境',
|
displayName: '测试环境',
|
||||||
jenkinsJobPath: 'my-project-dev/test-role-user-develop',
|
jenkinsJobPath: 'my-project-dev/test-role-user-develop',
|
||||||
|
releasePolicy: 'branch',
|
||||||
|
refPattern: '^(develop|feature/.+|hotfix/.+)$',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'production',
|
name: 'production',
|
||||||
displayName: '生产环境',
|
displayName: '生产环境',
|
||||||
jenkinsJobPath: 'my-project-prod/prod-role-user-tag',
|
jenkinsJobPath: 'my-project-prod/prod-role-user-tag',
|
||||||
|
releasePolicy: 'tag',
|
||||||
|
refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -73,11 +85,15 @@ export const seedProjects: ProjectSummary[] = [
|
|||||||
name: 'test',
|
name: 'test',
|
||||||
displayName: '测试环境',
|
displayName: '测试环境',
|
||||||
jenkinsJobPath: 'my-project-dev/test-my-resume-develop',
|
jenkinsJobPath: 'my-project-dev/test-my-resume-develop',
|
||||||
|
releasePolicy: 'branch',
|
||||||
|
refPattern: '^(master|develop|feature/.+|hotfix/.+)$',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'production',
|
name: 'production',
|
||||||
displayName: '生产环境',
|
displayName: '生产环境',
|
||||||
jenkinsJobPath: 'my-project-prod/prod-my-resume-tag',
|
jenkinsJobPath: 'my-project-prod/prod-my-resume-tag',
|
||||||
|
releasePolicy: 'tag',
|
||||||
|
refPattern: '^(v\\d+\\.\\d+\\.\\d+.*|v\\d{4}\\.\\d{2}\\.\\d{2}.*)$',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -93,6 +109,8 @@ export const seedProjects: ProjectSummary[] = [
|
|||||||
name: 'production',
|
name: 'production',
|
||||||
displayName: '生产环境',
|
displayName: '生产环境',
|
||||||
jenkinsJobPath: 'devops-platform/prod-devops-platform-api-master',
|
jenkinsJobPath: 'devops-platform/prod-devops-platform-api-master',
|
||||||
|
releasePolicy: 'master',
|
||||||
|
refPattern: '^master$',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@@ -108,6 +126,8 @@ export const seedProjects: ProjectSummary[] = [
|
|||||||
name: 'production',
|
name: 'production',
|
||||||
displayName: '生产环境',
|
displayName: '生产环境',
|
||||||
jenkinsJobPath: 'devops-platform/prod-devops-platform-web-master',
|
jenkinsJobPath: 'devops-platform/prod-devops-platform-web-master',
|
||||||
|
releasePolicy: 'master',
|
||||||
|
refPattern: '^master$',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ export type ProjectEnvironmentSummary = {
|
|||||||
name: 'test' | 'production';
|
name: 'test' | 'production';
|
||||||
displayName: string;
|
displayName: string;
|
||||||
jenkinsJobPath: string;
|
jenkinsJobPath: string;
|
||||||
|
releasePolicy: 'branch' | 'tag' | 'master';
|
||||||
|
refPattern: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type ProjectSummary = {
|
export type ProjectSummary = {
|
||||||
|
|||||||
Reference in New Issue
Block a user