feat(project): add workspace directory configuration and management

- Add projectDir field to Project model for workspace directory management
- Implement workspace directory creation, validation and Git initialization
- Add workspace status query endpoint with directory info and Git status
- Create GitManager for Git repository operations (clone, branch, commit info)
- Add PathValidator for secure path validation and traversal attack prevention
- Implement execution queue with concurrency control for build tasks

- Refactor project list UI to remove edit/delete actions from cards
- Add project settings tab in detail page with edit/delete functionality
- Add icons to all tabs (History, Code, Settings)
- Implement time formatting with dayjs in YYYY-MM-DD HH:mm:ss format
- Display all timestamps using browser's local timezone

- Update PipelineRunner to use workspace directory for command execution
- Add workspace status card showing directory path, size, Git info
- Enhance CreateProjectModal with repository URL validation
This commit is contained in:
2026-01-03 00:54:57 +08:00
parent 9897bd04c2
commit b5c550f5c5
23 changed files with 1859 additions and 229 deletions

View File

@@ -1,4 +1,5 @@
import { z } from 'zod';
import { projectDirSchema } from '../../libs/path-validator.js';
/**
* 创建项目验证架构
@@ -15,6 +16,8 @@ export const createProjectSchema = z.object({
repository: z.string({
message: '仓库地址必须是字符串',
}).url({ message: '请输入有效的仓库地址' }).min(1, { message: '仓库地址不能为空' }),
projectDir: projectDirSchema,
});
/**

View File

@@ -1,8 +1,9 @@
import type { Context } from 'koa';
import {prisma} from '../../libs/prisma.ts';
import { prisma } from '../../libs/prisma.ts';
import { log } from '../../libs/logger.ts';
import { BusinessError } from '../../middlewares/exception.ts';
import { Controller, Get, Post, Put, Delete } from '../../decorators/route.ts';
import { GitManager } from '../../libs/git-manager.ts';
import {
createProjectSchema,
updateProjectSchema,
@@ -37,7 +38,7 @@ export class ProjectController {
orderBy: {
createdAt: 'desc',
},
})
}),
]);
return {
@@ -47,7 +48,7 @@ export class ProjectController {
limit: query?.limit || 10,
total,
totalPages: Math.ceil(total / (query?.limit || 10)),
}
},
};
}
@@ -67,7 +68,48 @@ export class ProjectController {
throw new BusinessError('项目不存在', 1002, 404);
}
return project;
// 获取工作目录状态信息
let workspaceStatus = null;
if (project.projectDir) {
try {
const status = await GitManager.checkWorkspaceStatus(
project.projectDir,
);
let size = 0;
let gitInfo = null;
if (status.exists && !status.isEmpty) {
size = await GitManager.getDirectorySize(project.projectDir);
}
if (status.hasGit) {
gitInfo = await GitManager.getGitInfo(project.projectDir);
}
workspaceStatus = {
...status,
size,
gitInfo,
};
} catch (error) {
log.error(
'project',
'Failed to get workspace status for project %s: %s',
project.name,
(error as Error).message,
);
// 即使获取状态失败,也返回项目信息
workspaceStatus = {
status: 'error',
error: (error as Error).message,
};
}
}
return {
...project,
workspaceStatus,
};
}
// POST /api/projects - 创建项目
@@ -75,18 +117,36 @@ export class ProjectController {
async create(ctx: Context) {
const validatedData = createProjectSchema.parse(ctx.request.body);
// 检查工作目录是否已被其他项目使用
const existingProject = await prisma.project.findFirst({
where: {
projectDir: validatedData.projectDir,
valid: 1,
},
});
if (existingProject) {
throw new BusinessError('该工作目录已被其他项目使用', 1003, 400);
}
const project = await prisma.project.create({
data: {
name: validatedData.name,
description: validatedData.description || '',
repository: validatedData.repository,
projectDir: validatedData.projectDir,
createdBy: 'system',
updatedBy: 'system',
valid: 1,
},
});
log.info('project', 'Created new project: %s', project.name);
log.info(
'project',
'Created new project: %s with projectDir: %s',
project.name,
project.projectDir,
);
return project;
}

View File

@@ -20,7 +20,7 @@ const config: runtime.GetPrismaClientConfig = {
"clientVersion": "7.0.0",
"engineVersion": "0c19ccc313cf9911a90d99d2ac2eb0280c76c513",
"activeProvider": "sqlite",
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../generated\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\nmodel Project {\n id Int @id @default(autoincrement())\n name String\n description String?\n repository String\n // Relations\n deployments Deployment[]\n pipelines Pipeline[]\n\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n}\n\nmodel User {\n id Int @id @default(autoincrement())\n username String\n login String\n email String\n avatar_url String?\n active Boolean @default(true)\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String @default(\"system\")\n updatedBy String @default(\"system\")\n}\n\nmodel Pipeline {\n id Int @id @default(autoincrement())\n name String\n description String?\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n // Relations\n projectId Int?\n Project Project? @relation(fields: [projectId], references: [id])\n steps Step[]\n}\n\nmodel Step {\n id Int @id @default(autoincrement())\n name String\n order Int\n script String // 执行的脚本命令\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n pipelineId Int\n pipeline Pipeline @relation(fields: [pipelineId], references: [id])\n}\n\nmodel Deployment {\n id Int @id @default(autoincrement())\n branch String\n env String?\n status String // pending, running, success, failed, cancelled\n commitHash String?\n commitMessage String?\n buildLog String?\n sparseCheckoutPaths String? // 稀疏检出路径用于monorepo项目\n startedAt DateTime @default(now())\n finishedAt DateTime?\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n projectId Int\n Project Project? @relation(fields: [projectId], references: [id])\n pipelineId Int\n}\n",
"inlineSchema": "// This is your Prisma schema file,\n// learn more about it in the docs: https://pris.ly/d/prisma-schema\n\ngenerator client {\n provider = \"prisma-client\"\n output = \"../generated\"\n}\n\ndatasource db {\n provider = \"sqlite\"\n}\n\nmodel Project {\n id Int @id @default(autoincrement())\n name String\n description String?\n repository String\n projectDir String @unique // 项目工作目录路径(必填)\n // Relations\n deployments Deployment[]\n pipelines Pipeline[]\n\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n}\n\nmodel User {\n id Int @id @default(autoincrement())\n username String\n login String\n email String\n avatar_url String?\n active Boolean @default(true)\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String @default(\"system\")\n updatedBy String @default(\"system\")\n}\n\nmodel Pipeline {\n id Int @id @default(autoincrement())\n name String\n description String?\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n // Relations\n projectId Int?\n Project Project? @relation(fields: [projectId], references: [id])\n steps Step[]\n}\n\nmodel Step {\n id Int @id @default(autoincrement())\n name String\n order Int\n script String // 执行的脚本命令\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n pipelineId Int\n pipeline Pipeline @relation(fields: [pipelineId], references: [id])\n}\n\nmodel Deployment {\n id Int @id @default(autoincrement())\n branch String\n env String?\n status String // pending, running, success, failed, cancelled\n commitHash String?\n commitMessage String?\n buildLog String?\n sparseCheckoutPaths String? // 稀疏检出路径用于monorepo项目\n startedAt DateTime @default(now())\n finishedAt DateTime?\n valid Int @default(1)\n createdAt DateTime @default(now())\n updatedAt DateTime @updatedAt\n createdBy String\n updatedBy String\n\n projectId Int\n Project Project? @relation(fields: [projectId], references: [id])\n pipelineId Int\n}\n",
"runtimeDataModel": {
"models": {},
"enums": {},
@@ -28,7 +28,7 @@ const config: runtime.GetPrismaClientConfig = {
}
}
config.runtimeDataModel = JSON.parse("{\"models\":{\"Project\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"repository\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"deployments\",\"kind\":\"object\",\"type\":\"Deployment\",\"relationName\":\"DeploymentToProject\"},{\"name\":\"pipelines\",\"kind\":\"object\",\"type\":\"Pipeline\",\"relationName\":\"PipelineToProject\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"username\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"login\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"avatar_url\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"active\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Pipeline\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"Project\",\"kind\":\"object\",\"type\":\"Project\",\"relationName\":\"PipelineToProject\"},{\"name\":\"steps\",\"kind\":\"object\",\"type\":\"Step\",\"relationName\":\"PipelineToStep\"}],\"dbName\":null},\"Step\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"order\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"script\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"pipelineId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"pipeline\",\"kind\":\"object\",\"type\":\"Pipeline\",\"relationName\":\"PipelineToStep\"}],\"dbName\":null},\"Deployment\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"branch\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"env\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"commitHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"commitMessage\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"buildLog\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sparseCheckoutPaths\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"startedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"finishedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"Project\",\"kind\":\"object\",\"type\":\"Project\",\"relationName\":\"DeploymentToProject\"},{\"name\":\"pipelineId\",\"kind\":\"scalar\",\"type\":\"Int\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}")
config.runtimeDataModel = JSON.parse("{\"models\":{\"Project\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"repository\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectDir\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"deployments\",\"kind\":\"object\",\"type\":\"Deployment\",\"relationName\":\"DeploymentToProject\"},{\"name\":\"pipelines\",\"kind\":\"object\",\"type\":\"Pipeline\",\"relationName\":\"PipelineToProject\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"User\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"username\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"login\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"email\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"avatar_url\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"active\",\"kind\":\"scalar\",\"type\":\"Boolean\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"}],\"dbName\":null},\"Pipeline\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"description\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"Project\",\"kind\":\"object\",\"type\":\"Project\",\"relationName\":\"PipelineToProject\"},{\"name\":\"steps\",\"kind\":\"object\",\"type\":\"Step\",\"relationName\":\"PipelineToStep\"}],\"dbName\":null},\"Step\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"name\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"order\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"script\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"pipelineId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"pipeline\",\"kind\":\"object\",\"type\":\"Pipeline\",\"relationName\":\"PipelineToStep\"}],\"dbName\":null},\"Deployment\":{\"fields\":[{\"name\":\"id\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"branch\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"env\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"status\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"commitHash\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"commitMessage\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"buildLog\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"sparseCheckoutPaths\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"startedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"finishedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"valid\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"createdAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"updatedAt\",\"kind\":\"scalar\",\"type\":\"DateTime\"},{\"name\":\"createdBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"updatedBy\",\"kind\":\"scalar\",\"type\":\"String\"},{\"name\":\"projectId\",\"kind\":\"scalar\",\"type\":\"Int\"},{\"name\":\"Project\",\"kind\":\"object\",\"type\":\"Project\",\"relationName\":\"DeploymentToProject\"},{\"name\":\"pipelineId\",\"kind\":\"scalar\",\"type\":\"Int\"}],\"dbName\":null}},\"enums\":{},\"types\":{}}")
async function decodeBase64AsWasm(wasmBase64: string): Promise<WebAssembly.Module> {
const { Buffer } = await import('node:buffer')

View File

@@ -819,6 +819,7 @@ export const ProjectScalarFieldEnum = {
name: 'name',
description: 'description',
repository: 'repository',
projectDir: 'projectDir',
valid: 'valid',
createdAt: 'createdAt',
updatedAt: 'updatedAt',

View File

@@ -76,6 +76,7 @@ export const ProjectScalarFieldEnum = {
name: 'name',
description: 'description',
repository: 'repository',
projectDir: 'projectDir',
valid: 'valid',
createdAt: 'createdAt',
updatedAt: 'updatedAt',

View File

@@ -41,6 +41,7 @@ export type ProjectMinAggregateOutputType = {
name: string | null
description: string | null
repository: string | null
projectDir: string | null
valid: number | null
createdAt: Date | null
updatedAt: Date | null
@@ -53,6 +54,7 @@ export type ProjectMaxAggregateOutputType = {
name: string | null
description: string | null
repository: string | null
projectDir: string | null
valid: number | null
createdAt: Date | null
updatedAt: Date | null
@@ -65,6 +67,7 @@ export type ProjectCountAggregateOutputType = {
name: number
description: number
repository: number
projectDir: number
valid: number
createdAt: number
updatedAt: number
@@ -89,6 +92,7 @@ export type ProjectMinAggregateInputType = {
name?: true
description?: true
repository?: true
projectDir?: true
valid?: true
createdAt?: true
updatedAt?: true
@@ -101,6 +105,7 @@ export type ProjectMaxAggregateInputType = {
name?: true
description?: true
repository?: true
projectDir?: true
valid?: true
createdAt?: true
updatedAt?: true
@@ -113,6 +118,7 @@ export type ProjectCountAggregateInputType = {
name?: true
description?: true
repository?: true
projectDir?: true
valid?: true
createdAt?: true
updatedAt?: true
@@ -212,6 +218,7 @@ export type ProjectGroupByOutputType = {
name: string
description: string | null
repository: string
projectDir: string
valid: number
createdAt: Date
updatedAt: Date
@@ -247,6 +254,7 @@ export type ProjectWhereInput = {
name?: Prisma.StringFilter<"Project"> | string
description?: Prisma.StringNullableFilter<"Project"> | string | null
repository?: Prisma.StringFilter<"Project"> | string
projectDir?: Prisma.StringFilter<"Project"> | string
valid?: Prisma.IntFilter<"Project"> | number
createdAt?: Prisma.DateTimeFilter<"Project"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Project"> | Date | string
@@ -261,6 +269,7 @@ export type ProjectOrderByWithRelationInput = {
name?: Prisma.SortOrder
description?: Prisma.SortOrderInput | Prisma.SortOrder
repository?: Prisma.SortOrder
projectDir?: Prisma.SortOrder
valid?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -272,6 +281,7 @@ export type ProjectOrderByWithRelationInput = {
export type ProjectWhereUniqueInput = Prisma.AtLeast<{
id?: number
projectDir?: string
AND?: Prisma.ProjectWhereInput | Prisma.ProjectWhereInput[]
OR?: Prisma.ProjectWhereInput[]
NOT?: Prisma.ProjectWhereInput | Prisma.ProjectWhereInput[]
@@ -285,13 +295,14 @@ export type ProjectWhereUniqueInput = Prisma.AtLeast<{
updatedBy?: Prisma.StringFilter<"Project"> | string
deployments?: Prisma.DeploymentListRelationFilter
pipelines?: Prisma.PipelineListRelationFilter
}, "id">
}, "id" | "projectDir">
export type ProjectOrderByWithAggregationInput = {
id?: Prisma.SortOrder
name?: Prisma.SortOrder
description?: Prisma.SortOrderInput | Prisma.SortOrder
repository?: Prisma.SortOrder
projectDir?: Prisma.SortOrder
valid?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -312,6 +323,7 @@ export type ProjectScalarWhereWithAggregatesInput = {
name?: Prisma.StringWithAggregatesFilter<"Project"> | string
description?: Prisma.StringNullableWithAggregatesFilter<"Project"> | string | null
repository?: Prisma.StringWithAggregatesFilter<"Project"> | string
projectDir?: Prisma.StringWithAggregatesFilter<"Project"> | string
valid?: Prisma.IntWithAggregatesFilter<"Project"> | number
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Project"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Project"> | Date | string
@@ -323,6 +335,7 @@ export type ProjectCreateInput = {
name: string
description?: string | null
repository: string
projectDir: string
valid?: number
createdAt?: Date | string
updatedAt?: Date | string
@@ -337,6 +350,7 @@ export type ProjectUncheckedCreateInput = {
name: string
description?: string | null
repository: string
projectDir: string
valid?: number
createdAt?: Date | string
updatedAt?: Date | string
@@ -350,6 +364,7 @@ export type ProjectUpdateInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
repository?: Prisma.StringFieldUpdateOperationsInput | string
projectDir?: Prisma.StringFieldUpdateOperationsInput | string
valid?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -364,6 +379,7 @@ export type ProjectUncheckedUpdateInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
repository?: Prisma.StringFieldUpdateOperationsInput | string
projectDir?: Prisma.StringFieldUpdateOperationsInput | string
valid?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -378,6 +394,7 @@ export type ProjectCreateManyInput = {
name: string
description?: string | null
repository: string
projectDir: string
valid?: number
createdAt?: Date | string
updatedAt?: Date | string
@@ -389,6 +406,7 @@ export type ProjectUpdateManyMutationInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
repository?: Prisma.StringFieldUpdateOperationsInput | string
projectDir?: Prisma.StringFieldUpdateOperationsInput | string
valid?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -401,6 +419,7 @@ export type ProjectUncheckedUpdateManyInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
repository?: Prisma.StringFieldUpdateOperationsInput | string
projectDir?: Prisma.StringFieldUpdateOperationsInput | string
valid?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -413,6 +432,7 @@ export type ProjectCountOrderByAggregateInput = {
name?: Prisma.SortOrder
description?: Prisma.SortOrder
repository?: Prisma.SortOrder
projectDir?: Prisma.SortOrder
valid?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -430,6 +450,7 @@ export type ProjectMaxOrderByAggregateInput = {
name?: Prisma.SortOrder
description?: Prisma.SortOrder
repository?: Prisma.SortOrder
projectDir?: Prisma.SortOrder
valid?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -442,6 +463,7 @@ export type ProjectMinOrderByAggregateInput = {
name?: Prisma.SortOrder
description?: Prisma.SortOrder
repository?: Prisma.SortOrder
projectDir?: Prisma.SortOrder
valid?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
@@ -515,6 +537,7 @@ export type ProjectCreateWithoutPipelinesInput = {
name: string
description?: string | null
repository: string
projectDir: string
valid?: number
createdAt?: Date | string
updatedAt?: Date | string
@@ -528,6 +551,7 @@ export type ProjectUncheckedCreateWithoutPipelinesInput = {
name: string
description?: string | null
repository: string
projectDir: string
valid?: number
createdAt?: Date | string
updatedAt?: Date | string
@@ -556,6 +580,7 @@ export type ProjectUpdateWithoutPipelinesInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
repository?: Prisma.StringFieldUpdateOperationsInput | string
projectDir?: Prisma.StringFieldUpdateOperationsInput | string
valid?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -569,6 +594,7 @@ export type ProjectUncheckedUpdateWithoutPipelinesInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
repository?: Prisma.StringFieldUpdateOperationsInput | string
projectDir?: Prisma.StringFieldUpdateOperationsInput | string
valid?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -581,6 +607,7 @@ export type ProjectCreateWithoutDeploymentsInput = {
name: string
description?: string | null
repository: string
projectDir: string
valid?: number
createdAt?: Date | string
updatedAt?: Date | string
@@ -594,6 +621,7 @@ export type ProjectUncheckedCreateWithoutDeploymentsInput = {
name: string
description?: string | null
repository: string
projectDir: string
valid?: number
createdAt?: Date | string
updatedAt?: Date | string
@@ -622,6 +650,7 @@ export type ProjectUpdateWithoutDeploymentsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
repository?: Prisma.StringFieldUpdateOperationsInput | string
projectDir?: Prisma.StringFieldUpdateOperationsInput | string
valid?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -635,6 +664,7 @@ export type ProjectUncheckedUpdateWithoutDeploymentsInput = {
name?: Prisma.StringFieldUpdateOperationsInput | string
description?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
repository?: Prisma.StringFieldUpdateOperationsInput | string
projectDir?: Prisma.StringFieldUpdateOperationsInput | string
valid?: Prisma.IntFieldUpdateOperationsInput | number
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
@@ -688,6 +718,7 @@ export type ProjectSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
name?: boolean
description?: boolean
repository?: boolean
projectDir?: boolean
valid?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -703,6 +734,7 @@ export type ProjectSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Exten
name?: boolean
description?: boolean
repository?: boolean
projectDir?: boolean
valid?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -715,6 +747,7 @@ export type ProjectSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Exten
name?: boolean
description?: boolean
repository?: boolean
projectDir?: boolean
valid?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -727,6 +760,7 @@ export type ProjectSelectScalar = {
name?: boolean
description?: boolean
repository?: boolean
projectDir?: boolean
valid?: boolean
createdAt?: boolean
updatedAt?: boolean
@@ -734,7 +768,7 @@ export type ProjectSelectScalar = {
updatedBy?: boolean
}
export type ProjectOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "description" | "repository" | "valid" | "createdAt" | "updatedAt" | "createdBy" | "updatedBy", ExtArgs["result"]["project"]>
export type ProjectOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "description" | "repository" | "projectDir" | "valid" | "createdAt" | "updatedAt" | "createdBy" | "updatedBy", ExtArgs["result"]["project"]>
export type ProjectInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
deployments?: boolean | Prisma.Project$deploymentsArgs<ExtArgs>
pipelines?: boolean | Prisma.Project$pipelinesArgs<ExtArgs>
@@ -754,6 +788,7 @@ export type $ProjectPayload<ExtArgs extends runtime.Types.Extensions.InternalArg
name: string
description: string | null
repository: string
projectDir: string
valid: number
createdAt: Date
updatedAt: Date
@@ -1188,6 +1223,7 @@ export interface ProjectFieldRefs {
readonly name: Prisma.FieldRef<"Project", 'String'>
readonly description: Prisma.FieldRef<"Project", 'String'>
readonly repository: Prisma.FieldRef<"Project", 'String'>
readonly projectDir: Prisma.FieldRef<"Project", 'String'>
readonly valid: Prisma.FieldRef<"Project", 'Int'>
readonly createdAt: Prisma.FieldRef<"Project", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"Project", 'DateTime'>

View File

@@ -61,12 +61,12 @@ export class ExecutionQueue {
const pendingDeployments = await prisma.deployment.findMany({
where: {
status: 'pending',
valid: 1
valid: 1,
},
select: {
id: true,
pipelineId: true
}
pipelineId: true,
},
});
console.log(`Found ${pendingDeployments.length} pending deployments`);
@@ -126,21 +126,25 @@ export class ExecutionQueue {
const pendingDeployments = await prisma.deployment.findMany({
where: {
status: 'pending',
valid: 1
valid: 1,
},
select: {
id: true,
pipelineId: true
}
pipelineId: true,
},
});
console.log(`Found ${pendingDeployments.length} pending deployments in polling`);
console.log(
`Found ${pendingDeployments.length} pending deployments in polling`,
);
// 检查这些任务是否已经在队列中,如果没有则添加
for (const deployment of pendingDeployments) {
// 检查是否已经在运行队列中
if (!runningDeployments.has(deployment.id)) {
console.log(`Adding deployment ${deployment.id} to queue from polling`);
console.log(
`Adding deployment ${deployment.id} to queue from polling`,
);
await this.addTask(deployment.id, deployment.pipelineId);
}
}
@@ -154,7 +158,10 @@ export class ExecutionQueue {
* @param deploymentId 部署ID
* @param pipelineId 流水线ID
*/
public async addTask(deploymentId: number, pipelineId: number): Promise<void> {
public async addTask(
deploymentId: number,
pipelineId: number,
): Promise<void> {
// 检查是否已经在运行队列中
if (runningDeployments.has(deploymentId)) {
console.log(`Deployment ${deploymentId} is already queued or running`);
@@ -196,7 +203,7 @@ export class ExecutionQueue {
}
// 添加一个小延迟以避免过度占用资源
await new Promise(resolve => setTimeout(resolve, 100));
await new Promise((resolve) => setTimeout(resolve, 100));
}
this.isProcessing = false;
@@ -207,9 +214,35 @@ export class ExecutionQueue {
* @param deploymentId 部署ID
* @param pipelineId 流水线ID
*/
private async executePipeline(deploymentId: number, pipelineId: number): Promise<void> {
private async executePipeline(
deploymentId: number,
pipelineId: number,
): Promise<void> {
try {
const runner = new PipelineRunner(deploymentId);
// 获取部署信息以获取项目和 projectDir
const deployment = await prisma.deployment.findUnique({
where: { id: deploymentId },
include: {
Project: true,
},
});
if (!deployment || !deployment.Project) {
throw new Error(
`Deployment ${deploymentId} or associated project not found`,
);
}
if (!deployment.Project.projectDir) {
throw new Error(
`项目 "${deployment.Project.name}" 未配置工作目录,无法执行流水线`,
);
}
const runner = new PipelineRunner(
deploymentId,
deployment.Project.projectDir,
);
await runner.run(pipelineId);
} catch (error) {
console.error('执行流水线失败:', error);
@@ -227,7 +260,7 @@ export class ExecutionQueue {
} {
return {
pendingCount: pendingQueue.length,
runningCount: runningDeployments.size
runningCount: runningDeployments.size,
};
}
}

View File

@@ -0,0 +1,280 @@
/**
* Git 管理器
* 封装 Git 操作:克隆、更新、分支切换等
*/
import { $ } from 'zx';
import fs from 'node:fs/promises';
import path from 'node:path';
import { log } from './logger';
/**
* 工作目录状态
*/
export const WorkspaceDirStatus = {
NOT_CREATED: 'not_created', // 目录不存在
EMPTY: 'empty', // 目录存在但为空
NO_GIT: 'no_git', // 目录存在但不是 Git 仓库
READY: 'ready', // 目录存在且包含 Git 仓库
} as const;
export type WorkspaceDirStatus =
(typeof WorkspaceDirStatus)[keyof typeof WorkspaceDirStatus];
/**
* 工作目录状态信息
*/
export interface WorkspaceStatus {
status: WorkspaceDirStatus;
exists: boolean;
isEmpty?: boolean;
hasGit?: boolean;
}
/**
* Git仓库信息
*/
export interface GitInfo {
branch?: string;
lastCommit?: string;
lastCommitMessage?: string;
}
/**
* Git管理器类
*/
export class GitManager {
static readonly TAG = 'GitManager';
/**
* 检查工作目录状态
*/
static async checkWorkspaceStatus(dirPath: string): Promise<WorkspaceStatus> {
try {
// 检查目录是否存在
const stats = await fs.stat(dirPath);
if (!stats.isDirectory()) {
return {
status: WorkspaceDirStatus.NOT_CREATED,
exists: false,
};
}
// 检查目录是否为空
const files = await fs.readdir(dirPath);
if (files.length === 0) {
return {
status: WorkspaceDirStatus.EMPTY,
exists: true,
isEmpty: true,
};
}
// 检查是否包含 .git 目录
const gitDir = path.join(dirPath, '.git');
try {
const gitStats = await fs.stat(gitDir);
if (gitStats.isDirectory()) {
return {
status: WorkspaceDirStatus.READY,
exists: true,
isEmpty: false,
hasGit: true,
};
}
} catch {
return {
status: WorkspaceDirStatus.NO_GIT,
exists: true,
isEmpty: false,
hasGit: false,
};
}
return {
status: WorkspaceDirStatus.NO_GIT,
exists: true,
isEmpty: false,
hasGit: false,
};
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {
status: WorkspaceDirStatus.NOT_CREATED,
exists: false,
};
}
throw error;
}
}
/**
* 克隆仓库到指定目录
* @param repoUrl 仓库URL
* @param dirPath 目标目录
* @param branch 分支名
* @param token Gitea access token可选
*/
static async cloneRepository(
repoUrl: string,
dirPath: string,
branch: string,
token?: string,
): Promise<void> {
try {
log.info(
GitManager.TAG,
'Cloning repository: %s to %s (branch: %s)',
repoUrl,
dirPath,
branch,
);
// 如果提供了token嵌入到URL中
let cloneUrl = repoUrl;
if (token) {
const url = new URL(repoUrl);
url.username = token;
cloneUrl = url.toString();
}
// 使用 zx 执行 git clone浅克隆
$.verbose = false; // 禁止打印敏感信息
await $`git clone --depth 1 --branch ${branch} ${cloneUrl} ${dirPath}`;
$.verbose = true;
log.info(GitManager.TAG, 'Repository cloned successfully: %s', dirPath);
} catch (error) {
log.error(
GitManager.TAG,
'Failed to clone repository: %s to %s, error: %s',
repoUrl,
dirPath,
(error as Error).message,
);
throw new Error(`克隆仓库失败: ${(error as Error).message}`);
}
}
/**
* 更新已存在的仓库
* @param dirPath 仓库目录
* @param branch 目标分支
*/
static async updateRepository(
dirPath: string,
branch: string,
): Promise<void> {
try {
log.info(
GitManager.TAG,
'Updating repository: %s (branch: %s)',
dirPath,
branch,
);
$.verbose = false;
// 切换到仓库目录
const originalCwd = process.cwd();
process.chdir(dirPath);
try {
// 获取最新代码
await $`git fetch --depth 1 origin ${branch}`;
// 切换到目标分支
await $`git checkout ${branch}`;
// 拉取最新代码
await $`git pull origin ${branch}`;
log.info(
GitManager.TAG,
'Repository updated successfully: %s (branch: %s)',
dirPath,
branch,
);
} finally {
process.chdir(originalCwd);
$.verbose = true;
}
} catch (error) {
log.error(
GitManager.TAG,
'Failed to update repository: %s (branch: %s), error: %s',
dirPath,
branch,
(error as Error).message,
);
throw new Error(`更新仓库失败: ${(error as Error).message}`);
}
}
/**
* 获取Git仓库信息
*/
static async getGitInfo(dirPath: string): Promise<GitInfo> {
try {
const originalCwd = process.cwd();
process.chdir(dirPath);
try {
$.verbose = false;
const branchResult = await $`git branch --show-current`;
const commitResult = await $`git rev-parse --short HEAD`;
const messageResult = await $`git log -1 --pretty=%B`;
$.verbose = true;
return {
branch: branchResult.stdout.trim(),
lastCommit: commitResult.stdout.trim(),
lastCommitMessage: messageResult.stdout.trim(),
};
} finally {
process.chdir(originalCwd);
}
} catch (error) {
log.error(
GitManager.TAG,
'Failed to get git info: %s, error: %s',
dirPath,
(error as Error).message,
);
return {};
}
}
/**
* 创建目录(递归)
*/
static async ensureDirectory(dirPath: string): Promise<void> {
try {
await fs.mkdir(dirPath, { recursive: true });
log.info(GitManager.TAG, 'Directory created: %s', dirPath);
} catch (error) {
log.error(
GitManager.TAG,
'Failed to create directory: %s, error: %s',
dirPath,
(error as Error).message,
);
throw new Error(`创建目录失败: ${(error as Error).message}`);
}
}
/**
* 获取目录大小
*/
static async getDirectorySize(dirPath: string): Promise<number> {
try {
const { stdout } = await $`du -sb ${dirPath}`;
const size = Number.parseInt(stdout.split('\t')[0], 10);
return size;
} catch (error) {
log.error(
GitManager.TAG,
'Failed to get directory size: %s, error: %s',
dirPath,
(error as Error).message,
);
return 0;
}
}
}

View File

@@ -0,0 +1,67 @@
/**
* 路径验证工具
* 用于验证项目工作目录路径的合法性
*/
import path from 'node:path';
import { z } from 'zod';
/**
* 项目目录路径验证schema
*/
export const projectDirSchema = z
.string()
.min(1, '工作目录路径不能为空')
.refine(path.isAbsolute, '工作目录路径必须是绝对路径')
.refine((v) => !v.includes('..'), '不能包含路径遍历字符')
.refine((v) => !v.includes('~'), '不能包含用户目录符号')
.refine((v) => !/[<>:"|?*\x00-\x1f]/.test(v), '包含非法字符')
.refine((v) => path.normalize(v) === v, '路径格式不规范');
/**
* 验证路径格式
* @param dirPath 待验证的路径
* @returns 验证结果
*/
export function validateProjectDir(dirPath: string): {
valid: boolean;
error?: string;
} {
try {
projectDirSchema.parse(dirPath);
return { valid: true };
} catch (error) {
if (error instanceof z.ZodError) {
return { valid: false, error: error.issues[0].message };
}
return { valid: false, error: '路径验证失败' };
}
}
/**
* 检查路径是否为绝对路径
*/
export function isAbsolutePath(dirPath: string): boolean {
return path.isAbsolute(dirPath);
}
/**
* 检查路径是否包含非法字符
*/
export function hasIllegalCharacters(dirPath: string): boolean {
return /[<>:"|?*\x00-\x1f]/.test(dirPath);
}
/**
* 检查路径是否包含路径遍历
*/
export function hasPathTraversal(dirPath: string): boolean {
return dirPath.includes('..') || dirPath.includes('~');
}
/**
* 规范化路径
*/
export function normalizePath(dirPath: string): string {
return path.normalize(dirPath);
}

Binary file not shown.

View File

@@ -15,6 +15,7 @@ model Project {
name String
description String?
repository String
projectDir String @unique // 项目工作目录路径(必填)
// Relations
deployments Deployment[]
pipelines Pipeline[]

View File

@@ -1,17 +1,27 @@
import { $ } from 'zx';
import { prisma } from '../libs/prisma.ts';
import type { Step } from '../generated/client.ts';
import fs from 'node:fs';
import path from 'node:path';
import { GitManager, WorkspaceDirStatus } from '../libs/git-manager.ts';
import { log } from '../libs/logger.ts';
export class PipelineRunner {
private readonly TAG = 'PipelineRunner';
private deploymentId: number;
private workspace: string;
private projectDir: string;
constructor(deploymentId: number) {
constructor(deploymentId: number, projectDir: string) {
this.deploymentId = deploymentId;
// 从环境变量获取工作空间路径,默认为/tmp/foka-ci/workspace
this.workspace = process.env.PIPELINE_WORKSPACE || '/tmp/foka-ci/workspace';
if (!projectDir) {
throw new Error('项目工作目录未配置,无法执行流水线');
}
this.projectDir = projectDir;
log.info(
this.TAG,
'PipelineRunner initialized with projectDir: %s',
this.projectDir,
);
}
/**
@@ -24,8 +34,8 @@ export class PipelineRunner {
where: { id: pipelineId },
include: {
steps: { where: { valid: 1 }, orderBy: { order: 'asc' } },
Project: true // 同时获取关联的项目信息
}
Project: true, // 同时获取关联的项目信息
},
});
if (!pipeline) {
@@ -34,47 +44,43 @@ export class PipelineRunner {
// 获取部署信息
const deployment = await prisma.deployment.findUnique({
where: { id: this.deploymentId }
where: { id: this.deploymentId },
});
if (!deployment) {
throw new Error(`Deployment with id ${this.deploymentId} not found`);
}
// 确保工作空间目录存在
await this.ensureWorkspace();
// 创建项目目录(在工作空间内)
const projectDir = path.join(this.workspace, `project-${pipelineId}`);
await this.ensureProjectDirectory(projectDir);
// 更新部署状态为running
await prisma.deployment.update({
where: { id: this.deploymentId },
data: { status: 'running' }
});
let logs = '';
let hasError = false;
try {
// 准备工作目录(检查、克隆或更新)
logs += await this.prepareWorkspace(pipeline.Project, deployment.branch);
// 更新部署状态为running
await prisma.deployment.update({
where: { id: this.deploymentId },
data: { status: 'running', buildLog: logs },
});
// 依次执行每个步骤
for (const [index, step] of pipeline.steps.entries()) {
// 准备环境变量
const envVars = this.prepareEnvironmentVariables(pipeline, deployment, projectDir);
const envVars = this.prepareEnvironmentVariables(pipeline, deployment);
// 记录开始执行步骤的日志,包含脚本内容(合并为一行,并用括号括起脚本内容)
// 记录开始执行步骤的日志
const startLog = `[${new Date().toISOString()}] 开始执行步骤 ${index + 1}/${pipeline.steps.length}: ${step.name}\n`;
logs += startLog;
// 实时更新日志
await prisma.deployment.update({
where: { id: this.deploymentId },
data: { buildLog: logs }
data: { buildLog: logs },
});
// 执行步骤(传递环境变量和项目目录)
const stepLog = await this.executeStep(step, envVars, projectDir);
// 执行步骤
const stepLog = await this.executeStep(step, envVars);
logs += stepLog + '\n';
// 记录步骤执行完成的日志
@@ -84,35 +90,107 @@ export class PipelineRunner {
// 实时更新日志
await prisma.deployment.update({
where: { id: this.deploymentId },
data: { buildLog: logs }
data: { buildLog: logs },
});
}
} catch (error) {
hasError = true;
logs += `[${new Date().toISOString()}] Error: ${(error as Error).message}\n`;
const errorMsg = `[${new Date().toISOString()}] Error: ${(error as Error).message}\n`;
logs += errorMsg;
log.error(
this.TAG,
'Pipeline execution failed: %s',
(error as Error).message,
);
// 记录错误日志
await prisma.deployment.update({
where: { id: this.deploymentId },
data: {
buildLog: logs,
status: 'failed'
}
status: 'failed',
finishedAt: new Date(),
},
});
throw error;
} finally {
// 更新最终状态
if (!hasError) {
await prisma.deployment.update({
where: { id: this.deploymentId },
data: {
buildLog: logs,
status: 'success',
finishedAt: new Date()
}
});
}
// 更新最终状态
if (!hasError) {
await prisma.deployment.update({
where: { id: this.deploymentId },
data: {
buildLog: logs,
status: 'success',
finishedAt: new Date(),
},
});
}
}
/**
* 准备工作目录:检查状态、克隆或更新代码
* @param project 项目信息
* @param branch 目标分支
* @returns 准备过程的日志
*/
private async prepareWorkspace(
project: any,
branch: string,
): Promise<string> {
let logs = '';
const timestamp = new Date().toISOString();
try {
logs += `[${timestamp}] 检查工作目录状态: ${this.projectDir}\n`;
// 检查工作目录状态
const status = await GitManager.checkWorkspaceStatus(this.projectDir);
logs += `[${new Date().toISOString()}] 工作目录状态: ${status.status}\n`;
if (
status.status === WorkspaceDirStatus.NOT_CREATED ||
status.status === WorkspaceDirStatus.EMPTY
) {
// 目录不存在或为空,需要克隆
logs += `[${new Date().toISOString()}] 工作目录不存在或为空,开始克隆仓库\n`;
// 确保父目录存在
await GitManager.ensureDirectory(this.projectDir);
// 克隆仓库注意如果需要认证token 应该从环境变量或配置中获取)
await GitManager.cloneRepository(
project.repository,
this.projectDir,
branch,
// TODO: 添加 token 支持
);
logs += `[${new Date().toISOString()}] 仓库克隆成功\n`;
} else if (status.status === WorkspaceDirStatus.NO_GIT) {
// 目录存在但不是 Git 仓库
throw new Error(
`工作目录 ${this.projectDir} 已存在但不是 Git 仓库,请检查配置`,
);
} else if (status.status === WorkspaceDirStatus.READY) {
// 已存在 Git 仓库,更新代码
logs += `[${new Date().toISOString()}] 工作目录已存在 Git 仓库,开始更新代码\n`;
await GitManager.updateRepository(this.projectDir, branch);
logs += `[${new Date().toISOString()}] 代码更新成功\n`;
}
return logs;
} catch (error) {
const errorLog = `[${new Date().toISOString()}] 准备工作目录失败: ${(error as Error).message}\n`;
logs += errorLog;
log.error(
this.TAG,
'Failed to prepare workspace: %s',
(error as Error).message,
);
throw new Error(`准备工作目录失败: ${(error as Error).message}`);
}
}
@@ -120,9 +198,11 @@ export class PipelineRunner {
* 准备环境变量
* @param pipeline 流水线信息
* @param deployment 部署信息
* @param projectDir 项目目录路径
*/
private prepareEnvironmentVariables(pipeline: any, deployment: any, projectDir: string): Record<string, string> {
private prepareEnvironmentVariables(
pipeline: any,
deployment: any,
): Record<string, string> {
const envVars: Record<string, string> = {};
// 项目相关信息
@@ -138,9 +218,9 @@ export class PipelineRunner {
// 稀疏检出路径(如果有配置的话)
envVars.SPARSE_CHECKOUT_PATHS = deployment.sparseCheckoutPaths || '';
// 工作空间路径和项目路径
envVars.WORKSPACE = this.workspace;
envVars.PROJECT_DIR = projectDir;
// 工作空间路径(使用配置的项目目录)
envVars.WORKSPACE = this.projectDir;
envVars.PROJECT_DIR = this.projectDir;
return envVars;
}
@@ -168,68 +248,37 @@ export class PipelineRunner {
private addTimestampToLines(content: string, isError = false): string {
if (!content) return '';
return content.split('\n')
.filter(line => line.trim() !== '')
.map(line => this.addTimestamp(line, isError))
.join('\n') + '\n';
}
/**
* 确保工作空间目录存在
*/
private async ensureWorkspace(): Promise<void> {
try {
// 检查目录是否存在,如果不存在则创建
if (!fs.existsSync(this.workspace)) {
// 创建目录包括所有必要的父目录
fs.mkdirSync(this.workspace, { recursive: true });
}
// 检查目录是否可写
fs.accessSync(this.workspace, fs.constants.W_OK);
} catch (error) {
throw new Error(`无法访问或创建工作空间目录 "${this.workspace}": ${(error as Error).message}`);
}
}
/**
* 确保项目目录存在
* @param projectDir 项目目录路径
*/
private async ensureProjectDirectory(projectDir: string): Promise<void> {
try {
// 检查目录是否存在,如果不存在则创建
if (!fs.existsSync(projectDir)) {
fs.mkdirSync(projectDir, { recursive: true });
}
// 检查目录是否可写
fs.accessSync(projectDir, fs.constants.W_OK);
} catch (error) {
throw new Error(`无法访问或创建项目目录 "${projectDir}": ${(error as Error).message}`);
}
return (
content
.split('\n')
.filter((line) => line.trim() !== '')
.map((line) => this.addTimestamp(line, isError))
.join('\n') + '\n'
);
}
/**
* 执行单个步骤
* @param step 步骤对象
* @param envVars 环境变量
* @param projectDir 项目目录路径
*/
private async executeStep(step: Step, envVars: Record<string, string>, projectDir: string): Promise<string> {
private async executeStep(
step: Step,
envVars: Record<string, string>,
): Promise<string> {
let logs = '';
try {
// 添加步骤开始执行的时间戳
logs += this.addTimestamp(`开始执行步骤 "${step.name}"`) + '\n';
logs += this.addTimestamp(`执行脚本: ${step.script}`) + '\n';
// 使用zx执行脚本设置项目目录为工作目录和环境变量
const script = step.script;
// 通过bash -c执行脚本确保环境变量能被正确解析
const result = await $({
cwd: projectDir,
env: { ...process.env, ...envVars }
cwd: this.projectDir,
env: { ...process.env, ...envVars },
})`bash -c ${script}`;
if (result.stdout) {
@@ -242,10 +291,11 @@ export class PipelineRunner {
logs += this.addTimestampToLines(result.stderr, true);
}
// 添加步骤执行完成的时间戳
logs += this.addTimestamp(`步骤 "${step.name}" 执行完成`) + '\n';
logs += this.addTimestamp(`步骤执行完成`) + '\n';
} catch (error) {
logs += this.addTimestamp(`Error executing step "${step.name}": ${(error as Error).message}`) + '\n';
const errorMsg = `Error executing step "${step.name}": ${(error as Error).message}`;
logs += this.addTimestamp(errorMsg, true) + '\n';
log.error(this.TAG, errorMsg);
throw error;
}