DevOS v1 + Phase 2: unified resource model, infra ops, website deployment, AI chat ops

- Monorepo: NestJS API (Fastify) + React web (antd/ProComponents) + shared packages
- PostgreSQL/Prisma (unified resources, relations graph, audit, events, jobs)
- Auth/RBAC/workspace isolation, credential vault (AES-256-GCM)
- Servers/SSH, Docker discovery & lifecycle, Job Center
- Websites & deployments (git provider credential clone, env, health verification)
- Domains/DNS (Cloudflare adapter), certificates (TLS probe), monitoring
- Automation (cron/event/webhook HMAC + notifications), AI Gateway, MCP (29 tools)
- AI chat window (LLM function-calling + rules fallback, SSE streaming, approvals)
- Notes: Vditor WYSIWYG markdown editor, non-blocking autosave
- Tests: 35 unit/security tests; UI audit clean (desktop+mobile, deep links)
This commit is contained in:
phptits
2026-09-10 06:35:55 +00:00
commit 0d6cd37579
209 changed files with 22783 additions and 0 deletions
+14
View File
@@ -0,0 +1,14 @@
PORT=4100
HOST=0.0.0.0
APP_SECRET=change-me-to-a-long-random-secret
ENCRYPTION_KEY=change-me-64-hex-chars-32-bytes-0000000000000000000000000000000000
DATABASE_URL=postgresql://devos:devos@127.0.0.1:15432/devos
REDIS_URL=redis://127.0.0.1:16379
SEED_ADMIN_EMAIL=admin@devos.local
SEED_ADMIN_PASSWORD=Devos12345!
# ── AI 功能(Notes AI 助手 / AI Gateway 规划器)──
# 任意 OpenAI 兼容端点;配置后重启生效
# AI_PROVIDER_BASE_URL=https://api.openai.com/v1
# AI_PROVIDER_API_KEY=sk-xxx
# AI_PROVIDER_MODEL=gpt-4o-mini
+12
View File
@@ -0,0 +1,12 @@
node_modules/
dist/
build/
*.tsbuildinfo
apps/web/dist/
.env
*.local
.DS_Store
coverage/
prisma/*.db
devos.sqlite*
logs/
+45
View File
@@ -0,0 +1,45 @@
# 00 — Gap Analysis
## 当前状态判断
现有实现已经具备优秀的基础设施骨架:NestJS/Fastify、React/Ant Design/ProComponents、PostgreSQL/Prisma、Redis、SSH、Docker、DNS、部署、监控、Automation、AI/MCP、Credential Vault、RBAC/Audit。
问题不是“没有基础”,而是大量模块停留在 MVP/adapter/demo 层,尚未形成真正的 Developer Work OS。
## 最大缺口
### P0 — 必须补齐
1. 统一 Resource Model 没有成为所有模块的真正核心。
2. Project → Repository → Website → Server → Deployment → Domain → Monitoring 关系链不完整。
3. Website 部署仍依赖 HTTPS 仓库 URL 内嵌 token,必须改成 Git Provider Credential + clone adapter。
4. Git Provider 集成需要 GitHub/GitLab/Gitea 可扩展模型,而不是只存 URL。
5. Server 管理需要从“连接/发现”升级为完整生命周期管理。
6. Docker 管理需要更多实际操作、实时日志、资源信息。
7. Domain/DNS/SSL 需要从 CRUD 升级为真实 provider abstraction。
8. Automation 需要完整 trigger/action/event/job 模型和 UI。
9. AI Agent 需要真正的 planning → approval → execution → verification,而不是 AI run demo。
10. MCP 必须复用内部 application services,不能形成第二套业务逻辑。
11. Email、Knowledge/Notes、Documents、Projects/Tasks 仍明显不足。
12. Dashboard 必须成为跨资源工作台,而非统计页。
### P1
- 通知中心
- 全局搜索
- Command Palette
- Activity Timeline
- Job Center
- Resource relation UI
- Provider connection management
- Environment/secret 管理
- Backup/restore abstraction
- Webhook HMAC
### P2
- Kubernetes
- Cloud provider
- 多租户高级策略
- 完整邮件客户端
- 高级文档协作
- DAG/parallel workflow
## 核心判断
后续开发不得继续以“增加一个页面/接口”为单位。必须以“完整业务闭环”为单位。
+45
View File
@@ -0,0 +1,45 @@
# 01 — Target Scope
DevOS 的目标不是替代每个专业软件,而是建立统一的开发者工作入口。
## 一级能力
- Dashboard
- AI
- Projects
- Infrastructure
- Websites
- Code
- Domains
- Email
- Knowledge
- Tasks
- Automation
- Credentials
- Monitoring
- Audit
- Settings
## 四个核心抽象
```text
Resource → Action → Event → Agent
```
所有业务能力都应该尽可能映射到这四个抽象。
## Developer Workspace
用户进入平台后,应能从 Project 出发看到:
- 代码仓库
- 网站
- 服务器
- 容器
- 数据库
- 域名
- SSL
- 部署
- 监控
- 任务
- 文档
- 自动化
并能从任何资源进入关联资源。
+44
View File
@@ -0,0 +1,44 @@
# 02 — Unified Resource Model
## 原则
Resource Registry 必须从“通用 CRUD”升级为平台的统一索引层。
## Resource 基础字段
```ts
interface Resource {
id: string
workspaceId: string
type: ResourceType
name: string
status: string
provider?: string
externalId?: string
metadata: Record<string, unknown>
createdAt: Date
updatedAt: Date
}
```
## 必须支持的类型
Server, Container, Website, Domain, Certificate, Repository, Deployment, Database, Project, Task, Note, Document, Credential, Automation, Job, Monitor。
## Relation
实现有方向关系并支持 relation type,例如:
- PROJECT_HAS_REPOSITORY
- PROJECT_HAS_WEBSITE
- WEBSITE_DEPLOYED_TO_SERVER
- WEBSITE_USES_DOMAIN
- WEBSITE_HAS_DEPLOYMENT
- DEPLOYMENT_FROM_REPOSITORY
- DEPLOYMENT_TO_SERVER
- SERVER_HAS_CONTAINER
- PROJECT_HAS_TASK
- PROJECT_HAS_DOCUMENT
## 关键要求
- 所有资源必须 workspace scoped。
- 资源详情页必须显示 Relations。
- 支持反向关系。
- 支持按关系跳转。
- 删除资源前检查关系影响。
- 不要让 Resource Registry 替代领域模型;它负责索引/关系/统一查询,领域模块负责业务规则。
+58
View File
@@ -0,0 +1,58 @@
# 03 — Infrastructure Completion
## Server
完善:
- create/edit/delete
- connect/disconnect
- connection health
- OS/kernel/arch
- CPU/RAM/disk/network
- uptime
- running processes摘要
- Docker availability
- last seen
- tags
- credentials reference
## SSH
必须:
- connect timeout
- command timeout
- output size limit
- stderr separation
- exit code
- audit log
- secret masking
- cancellation
## Docker
至少实现:
- list containers
- inspect
- start/stop/restart
- logs with tail/follow abstraction
- image list
- container resource usage
- ports
- environment key names(禁止返回 secret values)
- status/health
## Jobs
所有长任务统一 Job Center:
- queued/running/success/failed/cancelled
- progress
- logs
- retry
- actor
- resource relation
## Monitoring
建立 provider-neutral model:
- HTTP health check
- TCP check
- uptime state
- response time
- incident
- recovery event
V1 不需要复杂 Prometheus clone,但必须让 Website/Server/Deployment 都能进入统一 monitoring/activity 体系。
@@ -0,0 +1,61 @@
# 04 — Website & Deployment Completion
## Website 生命周期
```text
Website
→ Repository
→ Environment
→ Server
→ Build
→ Deploy
→ Health Check
→ Domain
→ SSL
→ Monitoring
```
## Repository access
禁止要求用户把 private repository token 放进 URL。
建立:
```text
GitProviderConnection
├── provider
├── credentialId
├── externalAccountId
└── metadata
```
Repository 使用 provider + externalId/clone metadata。
## Deployment
Deployment 必须成为 Job 的一种领域操作,并保存:
- commit/ref
- source repository
- target server
- environment
- build strategy
- deployment strategy
- started/finished
- logs
- result
- health check
## 部署策略
V1 支持:
1. Docker Compose / Dockerfile 项目
2. 静态网站
3. 可扩展 deployment adapter
不要把部署逻辑硬编码到 Website Controller。
## Environment
支持:
- development
- staging
- production
环境变量值使用 Credential Vault 或加密存储,UI 默认 mask。
## Health verification
部署成功不能只看 SSH exit code,必须执行 health check;失败则 Deployment 状态为 failed/verification_failed,并展示证据。
@@ -0,0 +1,60 @@
# 05 — Code / Domain / Email / Knowledge
## Git
建立 provider adapter:
- GitHub
- GitLab
- Gitea
统一能力:
- account connection
- repositories
- branches
- commits
- pull/merge requests
- issues(基础)
- webhook
V1 不需要实现 Git hosting,只需要聚合外部 provider。
## Domain/DNS
建立 provider interface:
- listZones
- listRecords
- createRecord
- updateRecord
- deleteRecord
- verify
Cloudflare 是第一个正式 adapter,Manual 是 fallback。
DNS 操作必须有 preview + audit。
## SSL
统一 Certificate 模型,provider/adaptor 可插拔。
至少支持状态:pending/active/expired/error。
## Email
V1 不做完整 Gmail clone。先实现:
- EmailAccount
- provider connection
- inbox message metadata
- send email abstraction
- search metadata
- attachments metadata
支持 SMTP/IMAP adapter;Provider OAuth 可后续加入。
## Knowledge
实现:
- Note
- Document
- folder/tree
- tags
- markdown/rich editor
- code block
- backlinks/resource links
TipTap 为默认编辑器。
重点:Note/Document 可以关联 Project/Resource/Task,成为操作上下文的一部分。
@@ -0,0 +1,50 @@
# 06 — Projects / Tasks / Automation
## Project
Project 是用户工作的顶层业务上下文,而不是简单 CRUD。
Project overview 必须汇总:
- repositories
- websites
- servers
- domains
- deployments
- tasks
- notes
- documents
- automations
- recent activity
## Task
支持:
- backlog/todo/doing/review/done
- priority
- labels
- due date
- relations
- resource links
- activity
## Automation
统一:
```text
Trigger → Condition → Action → Job → Event
```
Triggers:
- manual
- cron
- webhook
- event
Actions 至少:
- deployment.run
- server.command(受权限和风险策略限制)
- container.restart
- task.create/update
- note.create
- notification.send
Webhook 必须从明文 secret 比对升级到 HMAC 签名,支持 timestamp/replay protection。
Automation UI 必须能创建、编辑、启停、测试、查看执行历史。
+57
View File
@@ -0,0 +1,57 @@
# 07 — AI Agent / MCP Completion
## Agent architecture
```text
Intent
→ Context Discovery
→ Plan
→ Risk
→ Approval
→ Execute
→ Verify
→ Report
```
## Tool registry
每个 tool 必须包含:
- name
- description
- input schema
- output schema
- required permissions
- risk level
- idempotency behavior
- audit event
## V1 必须支持的 AI 场景
1. “列出所有服务器和当前状态。”
2. “查看 blog 项目最近一次部署。”
3. “分析为什么网站不可用。”
4. “部署 blog 到指定服务器。”
5. “重启异常容器。”
6. “创建任务记录这个问题。”
7. “把这个事件配置成自动化。”
## Approval
LOW read-only 可直接执行。
MEDIUM 根据 policy。
HIGH/CRITICAL 必须二次确认。
审批内容必须显示:
- 操作
- 资源
- 影响
- 参数摘要
- 风险
## Verification
任何 mutation tool 都应该定义 verify handler。
例如 deployment.run → deployment.status + health.check。
## Memory
V1 先做短期运行上下文 + resource references,不要过早建设复杂向量数据库。
## MCP
MCP 是 DevOS Tool Registry 的 adapter。
REST、Web UI、Agent、MCP 必须共享 application service。
禁止 MCP 直接访问 Prisma。
@@ -0,0 +1,35 @@
# 08 — Security Hardening
这是继续开发前的 P0 gate。
## 立即处理
1. 修改/禁用 README 中暴露的默认管理员账号密码。
2. 轮换 APP_SECRET。
3. 轮换 ENCRYPTION_KEY 前先设计 credential migration/clear strategy;不要盲目轮换导致现有凭据全部不可解密。
4. 检查公网注册策略;生产模式默认关闭公开注册,或增加 invite-only。
5. 检查 `/root/.ldns/config.toml`,确认 token 不进入项目、日志、git、API 响应。
6. 删除冒烟测试 credential/test-token。
## Auth
增加:
- password change
- session invalidation strategy(即使 V1 暂不实现完整 revoke list,也必须保留扩展点)
- login rate limit
- register rate limit
- optional invite-only mode
## Credentials
- AES-256-GCM 保留
- key 不存数据库
- secret access audit
- API never returns plaintext
- logs redact tokens/passwords/keys
- export disabled by default
## Command security
危险命令检测只是最后一道防线,不是授权机制。
真正权限模型必须先于 command execution。
## Production config
生产启动时必须拒绝明显开发默认 secret。
`.env.example` 可以有 placeholder,但 runtime 必须校验。
@@ -0,0 +1,50 @@
# 09 — Unified Workspace UX
## Global shell
ProLayout + workspace switcher + global search + notifications + AI entry。
## Command Palette
支持:
- search resource
- go to project
- create task
- deploy website
- open server
- run automation
- ask AI
## Global Search
搜索:
- projects
- resources
- repositories
- websites
- domains
- tasks
- notes
- deployments
结果显示 resource type + status + project。
## Activity
统一 Activity Feed,至少包括:
- deployment
- server command
- automation execution
- credential access
- task changes
- AI tool call
## Dashboard
Dashboard 不以数字 KPI 为核心,而以“今天我要处理什么”为核心:
- infrastructure incidents
- failed deployments
- pending approvals
- tasks due
- recent projects
- recent activity
- AI recommendations
## Resource detail
统一 tabs:Overview / Relations / Activity / Logs / Actions。
按资源类型隐藏不适用 tab。
@@ -0,0 +1,41 @@
# 10 — API/UI Contract
## API
统一 `/api/v1`。
所有 response/error 使用已有错误契约,不得出现模块私有错误格式。
Mutation API:
- authorization
- validation
- audit
- job if long-running
## Pagination
列表统一 cursor 或 page/limit,避免每个模块自定义。
## Filtering
Resource list 支持:
- type
- status
- tag
- project
- search
## UI
ProTable + ProForm + ProDescriptions 作为默认模式。
长任务统一 JobDrawer/JobDetail。
危险操作统一 Confirm + risk explanation。
## Loading/error/empty
所有页面必须处理:
- loading
- empty
- permission denied
- network error
- operation failure
## API client
前端不得在页面中直接拼 fetch URL;统一 api client/service。
@@ -0,0 +1,35 @@
# 11 — Test & Acceptance
## Quality gate
每个 milestone 必须:
- typecheck
- unit tests
- API integration tests
- critical E2E
- security regression
## Critical E2E
1. login → workspace → dashboard
2. add server → connect → inspect
3. server → containers → restart → audit
4. create project → connect repository
5. create website → deploy → health check
6. domain → DNS verify
7. create task → link project/resource
8. create automation → run → inspect job
9. AI read-only query
10. AI mutation → approval → execution → verification
11. MCP tool call → same permission/audit behavior
12. credential create → access → redaction
## Security tests
- cross-workspace resource access denied
- unauthorized server command denied
- secret never returned by API
- secret never appears in logs
- webhook replay rejected
- destructive AI action requires approval
- production defaults rejected
## Done definition
V1 Phase 2 不是“页面全部存在”,而是上述闭环可真实运行。
@@ -0,0 +1,95 @@
# 12 — Codex Execution Plan
## Rules
- Continue from `/home/dev/bun/devos`.
- Do NOT recreate the project.
- Before changes, inspect current source and Prisma schema.
- Preserve working functionality.
- Make incremental migrations only.
- Do not reset/drop production-like databases.
- Do not expose secrets.
## Phase A — Audit and hardening
1. Inspect auth, workspace guard, credential vault, mask utilities.
2. Remove/disable default admin credential exposure.
3. Add production secret validation.
4. Disable public registration by default in production.
5. Remove test credential.
6. Upgrade webhook authentication to HMAC + timestamp.
7. Add security regression tests.
## Phase B — Resource graph
1. Extend ResourceRelation.
2. Build relation service.
3. Add resource relation endpoints.
4. Add relation UI.
5. Add project-centric overview.
## Phase C — Infrastructure
1. Complete server lifecycle.
2. Complete Docker operations.
3. Standardize Job Center.
4. Add monitoring/incident model.
## Phase D — Website/deployment
1. Git provider connection model.
2. Repository model.
3. Credential-based private repo access.
4. Deployment adapters.
5. Environment management.
6. Health verification.
7. Deployment timeline/logs.
## Phase E — Code/domain/productivity
1. GitHub/GitLab/Gitea adapters.
2. Domain/DNS provider abstraction.
3. Certificate model.
4. Project/Task completion.
5. Note/Document completion.
6. Email account/send/search foundation.
## Phase F — Automation
1. Event catalog.
2. Trigger/action schemas.
3. Sequential workflow executor.
4. HMAC webhook.
5. Job Center integration.
6. Automation UI.
## Phase G — AI
1. Tool registry.
2. Tool permission resolver.
3. Risk engine.
4. Approval model/UI.
5. Planner.
6. Executor.
7. Verification.
8. Execution report.
## Phase H — MCP
Expose selected stable tools through MCP, reusing the exact application services and authorization pipeline.
## Phase I — Unified UX
1. Global search.
2. Command palette.
3. Activity feed.
4. Job center.
5. AI command entry.
6. Project dashboard.
7. Resource relation navigation.
## Execution protocol
For each phase:
1. inspect current implementation
2. write/update design notes
3. implement backend
4. migrate DB safely
5. implement frontend
6. add tests
7. run typecheck
8. run unit/integration/E2E
9. update docs
10. report files changed and verification
## Important
Do not mark a feature complete merely because its endpoint returns 200. Acceptance requires a complete user-facing workflow and tests.
+21
View File
@@ -0,0 +1,21 @@
# DevOS V1 Phase 2 — Completion Specification
目标:在现有 `/home/dev/bun/devos` V1 基础上继续开发,不推倒重来,补齐当前与目标产品约 80% 的差距。
## 使用方式
建议先阅读:
1. 00-GAP-ANALYSIS.md
2. 01-TARGET-SCOPE.md
3. 02-RESOURCE-MODEL.md
4. 03-INFRASTRUCTURE.md
5. 04-WEBSITE-DEPLOYMENT.md
6. 05-GIT-DOMAIN-EMAIL-KNOWLEDGE.md
7. 06-PROJECT-TASK-AUTOMATION.md
8. 07-AI-AGENT-MCP.md
9. 08-SECURITY-HARDENING.md
10. 09-UX-UNIFIED-WORKSPACE.md
11. 10-API-UI-CONTRACT.md
12. 11-TEST-ACCEPTANCE.md
13. 12-CODEX-EXECUTION.md
本文件包是“增量开发规范”,不是要求重新创建 DevOS。
+83
View File
@@ -0,0 +1,83 @@
# DevOS v1 Product Requirements Document
## 1. Product Definition
DevOS is a Developer Work OS: a unified workspace for servers, websites, domains, code, databases, email, notes, tasks, documents, monitoring and automation, with AI as an operational Agent rather than merely a chat interface.
## 2. Core Product Model
The platform is organized around four primitives:
**Resource + Action + Event + Agent**
- Resource: everything managed by DevOS.
- Action: an operation performed against a resource.
- Event: a state change or external signal.
- Agent: observes resources, plans actions, executes through tools, verifies outcomes and reports.
The central architectural principle is the **Unified Resource Model**.
## 3. Primary Users
- Individual developers
- Small development teams
## 4. V1 Scope
V1 focuses on Infrastructure OS capabilities:
- Server
- Docker / Containers
- Website
- Domain
- Deployment
- Monitoring
- Authentication, Workspace, Resource, Permission and Audit foundations
## 5. V2 Scope
Developer OS:
- GitHub / GitLab / other Git providers
- Projects
- Tasks
- Notes
- Documents
- Email
## 6. V3 Scope
Automation OS:
- Event Bus
- Workflow Engine
- Cron
- Webhook
- Notifications
## 7. V4 Scope
AI OS:
- Resource Discovery
- Planning
- Tool Calling
- Execution
- Verification
- Memory
- Approval
## 8. Representative User Journey
“Deploy my blog to the Tokyo server, use Docker, configure blog.example.com and enable HTTPS.”
The intended Agent flow:
1. Find Project and Repository
2. Find target Server
3. Check Docker
4. Clone repository
5. Build image
6. Run container
7. Configure reverse proxy
8. Configure DNS
9. Obtain SSL
10. Health check
11. Return an action log and result
## 9. Non-Goals
- Reimplementing Git
- Reimplementing SMTP
- Building a Kubernetes control plane in V1
- Building a full Notion replacement in the first release
- Starting with microservices
## 10. Product Success Criteria
A developer can manage the core infrastructure lifecycle from one workspace, while resources remain related and discoverable enough for later automation and AI operation.
+105
View File
@@ -0,0 +1,105 @@
# DevOS v1 Product Architecture
## 1. System Concept
```text
DevOS AI
Agent / Copilot / MCP
|
+-------------------+-------------------+
| | |
Infrastructure Projects Productivity
| | |
Server / Docker / DB Git / Website / API Notes / Tasks / Docs
+-------------------+-------------------+
|
Unified Resource Model
```
## 2. Core Primitives
```text
Resource
|
Action
|
Event
|
Automation / Agent
|
Resource
```
Example:
- Resource: Website/blog.com
- Action: Deploy
- Event: deployment.success
- Automation: Send notification
- Agent: Analyze deployment
## 3. Resource Graph
Resources are related rather than isolated:
```text
Project
├── Repository
├── Website
├── Server
├── Database
├── Deployment
├── Domain
├── Email
└── Monitoring
```
## 4. Architectural Style
- Frontend: React application
- Backend: modular monolith
- Persistence: PostgreSQL
- Cache/async infrastructure: Redis
- Integrations: provider adapters
- AI: Agent + Tool + MCP + Workflow layer
- Infrastructure execution: SSH, Docker API, provider APIs
## 5. Module Boundaries
Core modules:
- Identity
- Workspace
- Resource
- Permission
- Audit
Infrastructure:
- Server
- Container
- Website
- Domain
- Deployment
- Monitoring
Integration:
- Git Provider
- DNS Provider
- Cloud Provider
- Email Provider
- Docker
Automation:
- Event
- Workflow
- Trigger
- Action
- Job
AI:
- AI Gateway
- Agent
- Planner
- Executor
- Memory
- Approval
- Tools
- MCP
## 6. Key Principle
Do not turn every domain into a separate service in V1. Modules must have explicit boundaries while sharing one deployable backend.
+40
View File
@@ -0,0 +1,40 @@
# DevOS v1 Technology Stack
## Frontend
- React
- TypeScript
- Ant Design
- Ant Design ProComponents
- React Router
- TanStack Query
- Zustand
- TipTap
## Backend
- Node.js
- TypeScript
- NestJS
- Prefer Fastify adapter where appropriate
## Data
- PostgreSQL
- Prisma
- Redis
## Infrastructure
- Docker
- SSH
- Docker API
- Nginx or Caddy integration
- Linux
- External DNS / cloud / Git / email APIs
## Architecture Decision
Prefer NestJS + Prisma for the initial implementation because the source discussion prioritizes modular structure and AI-friendly code comprehension.
## Dependency Rule
Do not introduce a new dependency without documenting:
1. Why existing dependencies cannot satisfy the requirement
2. Security and maintenance implications
3. Operational impact
4. Alternative considered
+105
View File
@@ -0,0 +1,105 @@
# DevOS v1 Domain Model
## 1. Resource
```typescript
interface Resource {
id: string
type: ResourceType
name: string
workspaceId: string
provider?: string
status: ResourceStatus
metadata: Record<string, unknown>
createdAt: Date
updatedAt: Date
}
```
## 2. Resource Types
Initial model:
- Server
- Domain
- Website
- Repository
- Database
- Container
- EmailAccount
- Project
- Task
- Note
- Document
- Backup
- Deployment
- Monitor
- Credential
## 3. Core Entities
### Workspace
Tenant/security boundary for users and resources.
### Project
Business/work boundary connecting repositories, websites, servers, databases, deployments, domains, email and monitoring.
### Server
Managed compute target. Supports SSH connection and infrastructure discovery.
### Website
Deployable application resource associated with domain, repository, deployment, server/container, database, SSL, environment, logs, monitoring and backup.
### Deployment
A concrete execution of deploying a repository/version to a target.
### Credential
Encrypted provider or infrastructure secret reference. Secret material is never exposed as ordinary resource metadata.
### Task
Work item associated with a project and optionally related to resources and automations.
## 4. Relationships
```text
Workspace
├── Projects
├── Resources
├── Users
├── Credentials
└── Automations
Project
├── Repository*
├── Website*
├── Server*
├── Database*
├── Deployment*
├── Domain*
├── Task*
└── Monitoring*
```
## 5. Actions
Every mutable operation should be modeled as an auditable action:
- server.exec
- server.restart
- server.installPackage
- website.deploy
- website.logs
- domain.updateRecords
- deployment.create
- container.restart
## 6. Events
Canonical event names should use `<resource>.<verb>` or `<domain>.<verb>`:
- deployment.created
- deployment.started
- deployment.success
- deployment.failed
- server.online
- server.offline
- website.down
- website.recovered
- email.received
- github.push
- task.created
- task.completed
+119
View File
@@ -0,0 +1,119 @@
# DevOS v1 Database Schema
## 1. Persistence Strategy
PostgreSQL is the system of record. Prisma is the initial ORM.
## 2. Core Tables
Recommended initial tables:
- users
- workspaces
- workspace_members
- resources
- resource_relations
- projects
- project_resources
- credentials
- audit_logs
- events
- jobs
- deployments
- servers
- websites
- domains
- containers
- monitors
## 3. Resource Table
Conceptual schema:
```text
resources
- id
- workspace_id
- type
- name
- provider
- status
- metadata_json
- created_at
- updated_at
```
Use a typed domain table when the resource has substantial operational state; use `resources` for identity, discovery and graph-level common fields.
## 4. Resource Relations
```text
resource_relations
- id
- workspace_id
- source_resource_id
- target_resource_id
- relation_type
- metadata_json
- created_at
```
This allows the resource graph to evolve without embedding every relationship into one giant table.
## 5. Credential Storage
Store encrypted credential payloads separately from general resource metadata.
```text
credentials
- id
- workspace_id
- type
- provider
- encrypted_data
- metadata_json
- created_at
- updated_at
```
The application must never log decrypted credential material.
## 6. Audit Log
```text
audit_logs
- id
- workspace_id
- actor_type
- actor_id
- action
- resource_type
- resource_id
- risk_level
- status
- request_id
- metadata_json
- created_at
```
## 7. Event
```text
events
- id
- workspace_id
- event_type
- resource_id
- payload_json
- correlation_id
- created_at
```
## 8. Indexing
At minimum index:
- workspace_id
- resource type/status
- resource relation endpoints
- event type + created_at
- audit workspace + created_at
- deployment website/project + created_at
- jobs status + scheduled_at
Exact migrations should be generated from the implementation model and reviewed before production.
+109
View File
@@ -0,0 +1,109 @@
# DevOS v1 API Specification
## 1. API Style
Use versioned HTTP APIs under `/api/v1`.
All mutations use explicit DTOs and return structured resources/results.
## 2. Core Endpoints
### Auth
```text
POST /api/v1/auth/login
POST /api/v1/auth/logout
GET /api/v1/auth/me
```
### Workspaces
```text
GET /api/v1/workspaces
POST /api/v1/workspaces
GET /api/v1/workspaces/:workspaceId
```
### Resources
```text
GET /api/v1/resources
POST /api/v1/resources
GET /api/v1/resources/:id
PATCH /api/v1/resources/:id
DELETE /api/v1/resources/:id
GET /api/v1/resources/:id/relations
```
### Servers
```text
GET /api/v1/servers
POST /api/v1/servers
GET /api/v1/servers/:id
POST /api/v1/servers/:id/connect
GET /api/v1/servers/:id/status
GET /api/v1/servers/:id/containers
POST /api/v1/servers/:id/exec
POST /api/v1/servers/:id/restart
```
### Websites
```text
GET /api/v1/websites
POST /api/v1/websites
GET /api/v1/websites/:id
POST /api/v1/websites/:id/deploy
GET /api/v1/websites/:id/logs
GET /api/v1/websites/:id/health
```
### Deployments
```text
GET /api/v1/deployments
GET /api/v1/deployments/:id
POST /api/v1/deployments
POST /api/v1/deployments/:id/cancel
```
### Domains
```text
GET /api/v1/domains
POST /api/v1/domains
GET /api/v1/domains/:id
POST /api/v1/domains/:id/records
```
### Automation
```text
GET /api/v1/automations
POST /api/v1/automations
GET /api/v1/automations/:id
POST /api/v1/automations/:id/enable
POST /api/v1/automations/:id/disable
```
### AI
```text
POST /api/v1/ai/runs
GET /api/v1/ai/runs/:id
POST /api/v1/ai/runs/:id/approve
```
## 3. Mutation Contract
Every mutation:
- validates a DTO
- checks workspace authorization
- determines risk
- writes an audit record
- emits domain events when appropriate
- returns a request/correlation identifier
## 4. Error Contract
```json
{
"error": {
"code": "RESOURCE_NOT_FOUND",
"message": "Resource was not found",
"requestId": "..."
}
}
```
Never expose stack traces, credentials or provider secrets through API errors.
+71
View File
@@ -0,0 +1,71 @@
# DevOS v1 Frontend Architecture
## 1. Stack
React + TypeScript + Ant Design + ProComponents + TanStack Query + Zustand + React Router + TipTap.
## 2. Application Layout
```text
apps/web
├── app
├── routes
├── layouts
├── features
│ ├── auth
│ ├── dashboard
│ ├── resources
│ ├── servers
│ ├── websites
│ ├── deployments
│ ├── domains
│ ├── monitoring
│ ├── automation
│ └── ai
├── components
└── services
```
Shared packages:
```text
packages/
├── ui/
├── api-client/
├── types/
└── auth/
```
## 3. UI Principle
Use Ant Design and ProComponents instead of building common enterprise controls from scratch.
Primary components:
- ProLayout
- ProTable
- ProForm
- ProDescriptions
- ProCard
- ProList
## 4. State
- Server state: TanStack Query
- Local UI state: Zustand / React state
- Do not duplicate server state into global stores without a clear reason.
## 5. Dashboard
The home page should prioritize:
- AI command entry
- Infrastructure health
- Projects
- Recent activity
- Deployment/monitoring warnings
## 6. Resource UI
Resource detail pages should expose:
- Overview
- Relations
- Actions
- Activity/Audit
- Logs where applicable
- Related resources
Dangerous actions require explicit confirmation and risk presentation.
+73
View File
@@ -0,0 +1,73 @@
# DevOS v1 Backend Architecture
## 1. Style
Modular monolith with NestJS.
## 2. Suggested Structure
```text
apps/api/
├── src/
│ ├── modules/
│ │ ├── identity/
│ │ ├── workspace/
│ │ ├── resource/
│ │ ├── permission/
│ │ ├── audit/
│ │ ├── server/
│ │ ├── container/
│ │ ├── website/
│ │ ├── domain/
│ │ ├── deployment/
│ │ ├── monitoring/
│ │ ├── automation/
│ │ └── ai/
│ ├── integrations/
│ │ ├── ssh/
│ │ ├── docker/
│ │ ├── git/
│ │ ├── dns/
│ │ ├── cloud/
│ │ └── email/
│ ├── common/
│ └── main.ts
```
## 3. Rules
- Controllers handle transport only.
- DTOs define input/output contracts.
- Business logic belongs in application/domain services.
- Provider-specific logic belongs behind adapters.
- Credential access is centralized.
- Mutations are auditable.
- Long-running operations run asynchronously.
- External calls have timeouts, retries where safe, and idempotency where possible.
## 4. Infrastructure Adapters
DevOS does not reimplement:
- SSH
- Git
- Docker
- DNS
- SMTP
- cloud control planes
It provides unified adapters over those systems.
## 5. Async Execution
Use Redis-backed jobs for:
- deployment
- server discovery
- monitoring checks
- long-running SSH commands
- automation execution
## 6. Observability
Every operation should carry:
- request ID
- correlation ID
- actor
- workspace
- resource
- action
- outcome
+96
View File
@@ -0,0 +1,96 @@
# DevOS v1 AI Agent Architecture
## 1. Principle
AI is an operating layer, not merely a chat feature.
```text
AI Gateway
|
+----------+----------+
| |
Agent Memory
|
+------+------+------+
| | | |
Tools MCP Workflow Approval
```
## 2. Agent Loop
```text
Observe
↓
Discover Resources
↓
Plan
↓
Risk Evaluation
↓
Approval if required
↓
Execute Tools
↓
Verify
↓
Report
```
## 3. Tool Examples
- server.list
- server.exec
- server.restart
- server.getStatus
- server.getDockerContainers
- website.list
- website.deploy
- website.logs
- git.repositories
- git.commits
- domain.records
- email.search
- note.create
- task.create
## 4. Context
Agent context should be built from:
- workspace
- user permissions
- relevant resources
- resource relations
- recent events
- task/run history
- tool schemas
Do not indiscriminately inject the entire database into the model.
## 5. Risk
Risk levels:
- LOW
- MEDIUM
- HIGH
- CRITICAL
HIGH and CRITICAL operations require user approval by default.
Examples:
- production deployment
- destructive filesystem operations
- database deletion
- credential changes
## 6. Verification
An Agent action is not successful merely because a command returned zero. The Agent should perform domain-specific verification where possible:
- deployment health
- HTTP health check
- container state
- DNS propagation/result
- SSL availability
## 7. Action Log
Agent runs should expose human-readable steps such as:
- found target server
- Docker available
- repository connected
- deployment created
- container started
- health check passed
+54
View File
@@ -0,0 +1,54 @@
# DevOS v1 MCP Specification
## 1. Purpose
DevOS should expose its capabilities as an MCP Server so external Agents such as ChatGPT, Claude, Cursor, Codex and other compatible agents can operate the developer infrastructure layer.
## 2. Conceptual Interface
```text
External Agent
|
v
DevOS MCP
|
+-- Servers
+-- Websites
+-- Git
+-- Domains
+-- Email
+-- Notes
+-- Tasks
+-- Automation
```
## 3. Tool Design Rules
Tools should:
- have narrow, explicit responsibilities
- declare structured input/output schemas
- be workspace-aware
- enforce authorization server-side
- expose risk classification
- return stable machine-readable results
- never return raw secrets
## 4. Initial Tool Set
```text
devos.server.list
devos.server.status
devos.server.exec
devos.website.list
devos.website.deploy
devos.website.logs
devos.deployment.get
devos.domain.list
devos.domain.records
devos.task.create
devos.note.create
devos.automation.run
```
## 5. Safety
MCP is not a bypass around DevOS security. All requests must pass the same permission, credential, risk and audit layers as first-party UI/API actions.
## 6. Resource Exposure
Prefer references and summarized metadata. Secrets and unnecessary infrastructure internals must not be exposed by default.
+72
View File
@@ -0,0 +1,72 @@
# DevOS v1 Automation Engine
## 1. Model
```text
Trigger
↓
Condition
↓
Action
↓
Action
↓
Action
```
## 2. Triggers
Initial trigger classes:
- Manual
- Cron
- Webhook
- Event
- AI Trigger
## 3. Example
```text
WHEN github.push
IF branch = main
THEN build
THEN deploy
THEN health check
THEN notify
```
## 4. Execution Model
Automation execution should be represented as a job/run with:
- automation ID
- trigger event
- execution ID
- step index
- input/output references
- status
- retry state
- timestamps
- correlation ID
## 5. Event Bus
Canonical events include:
- deployment.created
- deployment.started
- deployment.success
- deployment.failed
- server.online
- server.offline
- website.down
- website.recovered
- email.received
- github.push
- task.created
- task.completed
## 6. Reliability
- idempotency for externally triggered workflows
- bounded retries
- timeout per action
- explicit failure states
- dead-letter handling for unrecoverable jobs
- full audit trail
## 7. AI Integration
AI may create or execute automation, but generated workflows must still pass schema validation, permissions and risk policies.
+78
View File
@@ -0,0 +1,78 @@
# DevOS v1 Security Specification
## 1. Threat Model
DevOS manages highly sensitive credentials:
- SSH keys
- API keys
- cloud tokens
- Git tokens
- SMTP credentials
- DNS API credentials
- database passwords
Therefore security is a core architecture boundary.
## 2. Credential Vault
Credentials are stored encrypted, separately from ordinary resource metadata.
```text
Application Master Key
↓
Envelope Encryption
↓
Encrypted Credential
```
Do not store plaintext passwords or tokens in PostgreSQL.
## 3. Authorization
Implement:
- authentication
- workspace isolation
- RBAC
- resource-level permission checks where required
- action-level authorization
## 4. Audit
All mutations and sensitive reads/actions should be auditable:
- actor
- workspace
- action
- resource
- risk
- outcome
- timestamp
- correlation/request ID
## 5. Secret Masking
Secrets must be masked in:
- logs
- UI
- Agent context
- error messages
- audit metadata
- tool results
## 6. AI Safety
AI execution must use the same authorization path as humans.
HIGH / CRITICAL:
```text
Plan
↓
User Approval
↓
Execute
↓
Verify
```
## 7. Infrastructure Execution
- SSH command execution must be scoped and logged.
- Command timeouts are mandatory.
- Production targets should be distinguishable.
- Destructive actions require explicit confirmation.
- Never allow prompt text alone to override security policy.
## 8. Security Non-Goals
This document does not claim a complete production security certification. Before public production deployment, perform threat modeling, dependency review, penetration testing and secret-rotation drills.
+77
View File
@@ -0,0 +1,77 @@
# DevOS v1 UI Specification
## 1. Navigation
```text
DevOS
├── Dashboard
├── AI
├── Projects
├── Infrastructure
│ ├── Servers
│ ├── Containers
│ ├── Databases
│ ├── Storage
│ └── Monitoring
├── Websites
│ ├── Websites
│ ├── Domains
│ ├── SSL
│ └── Deployments
├── Code
│ ├── Repositories
│ ├── Issues
│ ├── Pull Requests
│ └── Releases
├── Email
├── Knowledge
│ ├── Notes
│ ├── Documents
│ └── Knowledge Base
├── Tasks
├── Automation
├── Credentials
└── Settings
```
## 2. Dashboard
The dashboard is an operational command center, not a conventional analytics page.
```text
Good morning.
[ AI: What do you want to do? ]
Infrastructure
3 Servers | 24 Containers | 99.98% Uptime
Projects
Blog | DevOS | SaaS
Activity
Deployment successful
SSL renewed
Server disk 82%
```
## 3. Resource Detail
Every major resource should expose a consistent information architecture:
- Summary
- Status
- Relations
- Actions
- Activity
- Logs/metrics where relevant
## 4. Action UX
Actions should show:
- what will happen
- target resource
- risk level
- expected impact
- confirmation requirement
- progress
- final verification
## 5. Design System
Use Ant Design as the primary visual/system foundation and ProComponents for data-heavy administration.
+75
View File
@@ -0,0 +1,75 @@
# DevOS v1 Roadmap
## V0 — Foundation
- Monorepo
- React
- Ant Design
- NestJS
- PostgreSQL
- Prisma
- Redis
- Docker
- Auth
- User
- Workspace
- Resource
- Permission
- Audit
## V1 — Infrastructure OS
- Server
- SSH
- Docker/container discovery
- Website
- Domain
- Deployment
- SSL integration
- Logs
- Monitoring
Target flow:
```text
Add Server
↓
SSH
↓
Discover Docker
↓
View Containers
↓
Deploy Website
↓
Bind Domain
↓
SSL
↓
Logs
```
## V2 — Developer OS
- GitHub / GitLab
- Projects
- Tasks
- Notes
- Documents
- Email
## V3 — Automation OS
- Event Bus
- Workflow Engine
- Cron
- Webhook
- Notifications
## V4 — AI OS
- Resource discovery
- Planning
- Tool calling
- Execution
- Verification
- Memory
- Approval
- MCP
## Delivery Rule
Do not start by implementing every product category. Deliver vertical slices that prove the Resource + Action + Event model.
+56
View File
@@ -0,0 +1,56 @@
# AGENTS.md — DevOS Coding Rules
## Mission
Build DevOS as a maintainable modular monolith and preserve the Resource + Action + Event + Agent architecture.
## Mandatory Rules
1. TypeScript strict mode.
2. Never use `any` unless explicitly justified.
3. All API endpoints use DTOs.
4. Controllers contain transport concerns only.
5. Business logic belongs in services/domain/application layers.
6. Every mutation requires an audit record.
7. Every credential is encrypted at rest.
8. Secrets must never appear in logs or model context unless explicitly required and policy-approved.
9. UI uses Ant Design / ProComponents unless there is a documented exception.
10. Every feature must have an API contract.
11. Every module must have tests appropriate to its risk.
12. Do not introduce dependencies without justification.
13. Provider-specific behavior belongs behind integration adapters.
14. Long-running work must be asynchronous.
15. External operations require timeout and explicit failure handling.
16. HIGH/CRITICAL AI actions require approval.
17. Security controls cannot be bypassed by UI, API, MCP or Agent paths.
18. Preserve workspace isolation.
19. Prefer idempotent operations.
20. Update documentation when changing a domain contract.
## Development Sequence
```text
Requirement
↓
Domain Model
↓
API Contract
↓
UI / Interaction
↓
Tests
↓
Implementation
↓
Verification
↓
Documentation
```
## Definition of Done
A feature is not done until:
- type checks pass
- tests pass
- API contract is documented
- authorization is enforced
- audit behavior is implemented where applicable
- error handling exists
- UI states cover loading/empty/error/success
- documentation is updated
+240
View File
@@ -0,0 +1,240 @@
# DevOS v1 ZCODE Development Specification
## 1. Purpose
This document converts the architecture into executable work units for an AI coding agent such as ZCODE, Codex, Cursor or Claude Code.
The agent must work from specifications instead of inventing architecture during implementation.
## 2. Repository Target
```text
devos/
├── apps/
│ ├── web/
│ └── api/
├── packages/
│ ├── ui/
│ ├── api-client/
│ ├── types/
│ └── auth/
├── packages/integrations/
├── prisma/
├── docs/
├── specs/
└── AGENTS.md
```
## 3. Initial Task Queue
### Task 001 — Monorepo
Create the workspace structure for web, API and shared packages.
Acceptance:
- package manager/workspaces configured
- TypeScript shared configuration
- lint/format/test commands
- build pipeline
### Task 002 — Backend Bootstrap
Create NestJS API with versioned `/api/v1` routing.
Acceptance:
- health endpoint
- configuration management
- structured logging
- error handling
- request ID
### Task 003 — Frontend Bootstrap
Create React + Ant Design application.
Acceptance:
- ProLayout
- routing
- authentication shell
- dashboard shell
- API client
- loading/error/empty states
### Task 004 — Database
Create PostgreSQL + Prisma setup.
Acceptance:
- migrations
- core identity/workspace/resource schema
- seed strategy
- database documentation
### Task 005 — Authentication
Implement authentication and current-user endpoint.
Acceptance:
- login/logout
- session/token strategy
- password/credential handling appropriate to chosen auth design
- authorization guards
### Task 006 — Workspace
Implement workspace and membership.
Acceptance:
- create/list/read workspace
- membership
- workspace isolation in service and query layers
### Task 007 — Resource System
Implement unified Resource abstraction.
Acceptance:
- CRUD
- typed resource type
- status
- metadata
- relation graph
- workspace scope
### Task 008 — Audit
Implement audit service and mutation hooks.
Acceptance:
- audit record for every mutation
- actor/workspace/resource/action
- request/correlation ID
- sensitive data masking
### Task 009 — Server Module
Implement Server resource and provider adapter.
Acceptance:
- create server
- credential reference
- connectivity check
- status
- SSH execution
- audit
- timeout/error handling
### Task 010 — Docker
Implement Docker discovery through Server integration.
Acceptance:
- list containers
- inspect container
- restart container
- resource relation
- audit
### Task 011 — Website
Implement Website domain model and UI.
Acceptance:
- website CRUD
- repository/server/domain relations
- status
- detail page
### Task 012 — Deployment
Implement deployment job model and asynchronous execution.
Acceptance:
- create deployment
- run deployment
- progress
- logs
- cancellation where safe
- verification
- success/failure events
### Task 013 — Domain
Implement domain and DNS provider adapter.
Acceptance:
- domain CRUD
- record read/update
- authorization
- audit
### Task 014 — Monitoring
Implement initial health checks.
Acceptance:
- website health
- server status
- monitor result history
- down/recovered events
### Task 015 — Event Bus
Implement internal domain events and persistence/dispatch model.
Acceptance:
- canonical event names
- correlation IDs
- durable execution for required events
### Task 016 — Automation
Implement Trigger → Condition → Action workflow model.
Acceptance:
- manual trigger
- event trigger
- cron design
- execution history
- retries/timeouts
### Task 017 — AI Gateway
Implement an AI run abstraction without allowing unrestricted execution.
Acceptance:
- create AI run
- plan
- tool registry
- risk evaluation
- approval state
- execution history
- verification result
### Task 018 — MCP
Expose selected DevOS capabilities through MCP.
Acceptance:
- server list/status
- website list/deploy
- deployment get
- domain list/records
- task/note tools when corresponding modules exist
- all requests pass normal authorization/audit/risk controls
## 4. Agent Execution Protocol
For each task:
1. Read relevant docs.
2. Inspect existing code.
3. State implementation plan internally.
4. Implement the smallest coherent change.
5. Add/update tests.
6. Run typecheck/lint/tests.
7. Verify API and UI contracts.
8. Update documentation.
9. Do not refactor unrelated code.
## 5. Stop Conditions
The coding agent must stop and ask for a human decision when:
- architecture requires a new major dependency
- a security boundary must change
- destructive production behavior is requested
- domain terminology conflicts with existing specifications
- an API breaking change is unavoidable
- two specification documents materially conflict
## 6. Priority
When requirements conflict, use this order:
1. Security
2. Explicit user requirement
3. Domain model
4. API contract
5. Architecture
6. UI convenience
7. Implementation preference
## 7. Important Constraint
Do not convert DevOS into microservices during V1 unless a concrete operational requirement proves modular monolith insufficient.
+20
View File
@@ -0,0 +1,20 @@
# DevOS v1 Development Document Package
This package was generated from the supplied architecture discussion.
## Source basis
The source defines DevOS as a Developer Work OS centered on:
- Unified Resource Model
- Resource + Action + Event + Agent
- Modular Monolith
- React + Ant Design + ProComponents
- NestJS + TypeScript
- PostgreSQL + Prisma + Redis
- Infrastructure/provider adapters
- Event Bus / Automation
- AI Agent + MCP
- Credential Vault / RBAC / Audit / Approval
- V0 → V1 → V2 → V3 → V4 roadmap
## Important
This package preserves the source decisions and turns them into implementation-oriented specifications. Where the source did not define exact production-level details (for example exact auth protocol, complete SQL constraints, or a full MCP wire contract), this package intentionally leaves them as implementation decisions rather than pretending they were already decided.
+172
View File
@@ -0,0 +1,172 @@
# DevOS — Developer Work OS
统一管理服务器、容器、网站、域名、部署、监控与自动化的开发者工作操作系统。
架构遵循 `Docs/devos-v1/`(统一资源模型:Resource + Action + Event + Agent,模块化单体)。
## 技术栈
| 层 | 技术 |
| --- | --- |
| 后端 | NestJS 11 + Fastify 适配器 + zod DTO + Prisma 6 |
| 前端 | React 18 + Ant Design 5 + ProComponents + TanStack Query + Zustand |
| 数据 | PostgreSQL 16 + Redis 7(Docker Compose) |
| 运行时 | Bun(包管理/测试/运行;后端编译为 CommonJS 后由 Bun/Node 运行) |
| 集成 | SSH (ssh2)、Docker(CLI over SSH / 本机 socket)、Cloudflare DNS |
## 目录结构
```text
devos/
├── apps/
│ ├── api/ # NestJS 模块化单体(/api/v1 + /api/v1/mcp)
│ └── web/ # React SPA(ProLayout)
├── packages/
│ ├── types/ # 共享领域类型
│ ├── auth/ # JWT(HS256) + 密码哈希(零第三方依赖)
│ ├── api-client/ # 浏览器 API 客户端
│ └── ui/ # 共享 UI(预留)
├── prisma/ # schema + migrations + seed
├── tests/ # bun test 单元测试
├── infra/ssh-target/ # 开发用 SSH 目标机(--profile dev-target)
├── docker-compose.yml
└── Docs/devos-v1/ # 规范文档(唯一事实源)
```
## 快速开始
```bash
# 1. 基础设施(PostgreSQL 15432 / Redis 16379,避免与本机其它实例冲突)
docker compose up -d postgres redis
# 2. 安装依赖
bun install
# 3. 数据库迁移 + 种子(管理员账号来自 .env)
bunx prisma migrate deploy
bun run prisma/seed.ts
# 4. 构建
bun run build:packages
bun run build:api
bun run build:web
# 5. 启动 API(默认 :4100)
bun apps/api/dist/main.js
# 6. 开发前端(任一 dev server 端口,API 直连 :4100)
bun --hot apps/web/index.html
```
默认管理员:`admin@devos.local`,密码为部署时 `.env` 中 `SEED_ADMIN_PASSWORD` 的值(**不要使用示例值上线**;本实例已轮换为随机密码)。
## 环境变量(.env)
| 变量 | 说明 |
| --- | --- |
| `PORT` / `HOST` | API 监听(默认 4100 / 0.0.0.0) |
| `APP_SECRET` | JWT HS256 密钥(必填) |
| `ENCRYPTION_KEY` | 凭据信封加密主密钥,64 位 hex(必填) |
| `DATABASE_URL` | PostgreSQL 连接串(必填) |
| `REDIS_URL` | Redis(任务派发信号,可选降级) |
| `AI_PROVIDER_*` | OpenAI 兼容 AI Provider(可选;缺省用启发式规划器) |
## 功能地图(V1 = 15-ZCODE-DEV-SPEC 任务 001–018)
- **认证与租户**:注册/登录(HS256 JWT 12h)、工作区 + 成员 RBAC(OWNER/ADMIN/MEMBER/VIEWER)、workspace isolation 全层强制
- **统一资源**:`resources` 身份/状态 + 类型化领域表(servers/websites/domains/containers/deployments/monitors/credentials/automations/ai_runs)+ 关系图谱(resource_relations)
- **审计**:每个变更写 `audit_logs`(actor/workspace/action/risk/requestId,元数据自动脱敏)
- **服务器**:SSH 连通性探测、命令执行(危险命令 CRITICAL 标记)、重启(显式 confirm)、local provider(本机 docker socket)
- **Docker 发现**:容器 list/inspect/restart(SSH `docker` CLI 或 Engine API 双适配),容器入资源图谱(runs_container 关系)
- **网站与部署**:网站 CRUD + 关系;部署 = QUEUED→RUNNING→SUCCEEDED/FAILED 异步流水线(git 拉取 → docker build → 容器替换 → curl 健康验证),步骤级日志与进度、运行中取消(步骤间生效)
- **域名**:Cloudflare(token 凭据)与 MANUAL 双 provider;记录读取/写入/删除 + 审计
- **监控**:HTTP/TCP 检查调度器(interval),结果历史,`website.down` / `website.recovered` 事件
- **事件总线**:规范事件名(`deployment.success` 等),持久化 `events` + 进程内分发 + Redis PUBLISH
- **任务队列**:Postgres `jobs` 状态机(PENDING/RUNNING/…/DEAD)+ SKIP LOCKED 认领 + 指数退避 + 僵死回收 + Redis 派发信号
- **自动化**:Trigger(MANUAL/CRON/EVENT/WEBHOOK)→ Condition(path/op/value)→ Action(LOG/WEBHOOK/DEPLOY_WEBSITE),重试/超时/执行历史
- **AI Gateway**:objective → 计划(LLM 或启发式)→ 风险评估 →(HIGH/CRITICAL 需审批)→ 工具执行 → 领域验证 → 报告;工具层与 UI/MCP 共享同一授权与审计
- **MCP**:`POST /api/v1/mcp`(Streamable HTTP,无状态),Bearer JWT 鉴权,actorType=MCP 审计;工具 14 个(server/website/deployment/domain/automation)
## 测试
```bash
bun test # 26 个用例:JWT(alg 混淆/过期/篡改)、cron、脱敏、危险命令、docker 输出解析、条件评估
```
## MCP 接入示例
```bash
curl -X POST http://localhost:4100/api/v1/mcp \
-H "Authorization: Bearer <JWT>" \
-H "Content-Type: application/json" \
-H "Accept: application/json, text/event-stream" \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'
```
## 安全设计(11-SECURITY.md)
- 凭据 AES-256-GCM 信封加密,AAD 绑定 workspaceId;无任何明文读取端点
- 日志/审计/工具结果统一脱敏(键名+内嵌 token 模式)
- HIGH/CRITICAL 动作(部署、重启、凭据变更)需显式确认或审批
- MCP 不是绕过安全旁路:同 JWT、同授权、同审计
- API 错误契约:`{error:{code,message,requestId}}`,不暴露堆栈与机密
## AI 聊天(产品核心入口)
`/ai` 是对话式管理入口:用自然语言完成查询、创建任务/笔记、部署网站、重启容器、DNS 解析、URL 总结存笔记等**全部操作**。
- **双引擎**:配置 LLM(`AI_PROVIDER_*`)时走 OpenAI function-calling 多轮编排(工具白名单 29 个,覆盖增删改查与复合流程);未配置时内置规则引擎,核心操作开箱可用
- **安全**:HIGH/CRITICAL 操作(部署/重启容器/删除)不直接执行,聊天中出审批卡片,确认后经 AiRun 管线执行;所有工具调用与人工/MCP 走同一授权与审计
- **验证**:以 Mock LLM(脚本化 function-calling)完成端到端验证 ——
「总结 <url> 保存到 Note」→ web.fetch + note.create ✓
「把 <github url> 安装到 JP-001 并用 xx.com 在 CF 代理解析」→ repository.createFromUrl + server.find + domain.find + website.create + domain.upsertRecord(proxied) ✓ + website.deploy 转审批 → 用户确认 → 部署 job 执行 ✓
## Notes AI 助手与配置
笔记页(`/notes`)为双栏笔记应用:TipTap 富文本(标题/列表/任务列表/代码块/引用 + 占位符 + 自动保存 1.2s)+ 置顶/标签/搜索 + AI 助手。
AI 助手动作:续写、总结要点、润色、中译英、英译中、生成大纲、起标题(选中文字后可只处理选区)。
启用 AI:在 `.env` 配置任意 OpenAI 兼容端点后重启:
```bash
AI_PROVIDER_BASE_URL=http://127.0.0.1:23030/openai/v1 # 示例:本机 claude-relay
AI_PROVIDER_API_KEY=<你的访问令牌>
AI_PROVIDER_MODEL=claude-sonnet-4-5
systemctl restart devos-api
```
未配置时 AI 菜单会明确提示,不影响其它功能。
## Phase 2 增量(Docs/devos-v1-phase2)
| 阶段 | 内容 | 状态 |
| --- | --- | --- |
| A 安全加固 | 生产 secret 校验、注册开关(`ENABLE_REGISTRATION`,生产默认关)、登录/注册限流、webhook HMAC+防重放、默认密码轮换、测试凭据清理、9 个安全回归测试 | ✅ |
| B 资源图谱 | 关系类型标准化(`RELATION_TYPES` 大写规范 + 数据迁移)、删除影响检查(`GET /resources/:id/relations/impact`)、项目页(Projects 聚合关系与活动) | ✅ |
| C 基础设施 | 服务器 stats(uptime/CPU/内存/磁盘/Top 进程)、Docker start/stop/logs/stats/images、Job Center(列表/详情/重试/取消) | ✅ |
| D 网站部署 | GitProviderConnection(GitHub/GitLab/Gitea 适配器)、Repository 聚合 + 凭据克隆(token 不进 URL/不残留 .git/config)、Website environment、部署 buildStrategy/deployStrategy/verification 记录 | ✅ |
| E 生产力 | Tasks(5 状态看板字段)、Notes(markdown+预览;TipTap 留待下迭代)、Certificates(TLS 探针+到期预警);**Email 模块延后** | ✅(Email 延后) |
| F 自动化 | PATCH 编辑、HMAC webhook、新动作(CONTAINER_RESTART/TASK_CREATE/NOTE_CREATE/NOTIFICATION_SEND)+ 站内通知 | ✅ |
| G/H AI+MCP | 工具注册表扩至 18 个(task.list/create、note.create、container.restart 等)、启发式规划器新场景(故障诊断/任务记录/容器重启)、MCP 同步暴露 | ✅ |
| I 统一 UX | 全局搜索(`/search` + 头部入口 ⌘K 提示)、通知中心(铃铛+未读数)、任务中心页、项目页、主题切换(亮/暗) | ✅ |
新增 API 一览:`/git/*`、`/tasks`、`/notes`、`/certificates`、`/jobs`、`/search`、`/notifications`、`/automations/:id`(PATCH)、`/servers/:id/stats`、`/servers/:id/images`、`/containers/:id/start|stop|logs|stats`、`/resources/:id/relations/impact`。
## 生产部署(本机)
- systemd 常驻:`systemctl status devos-api`(/etc/systemd/system/devos-api.service,监听 127.0.0.1:4100,自动重启/开机自启)
- API 同进程托管 Web 静态资源与 SPA fallback(`apps/web/dist`),nginx 仅纯反代
- 域名:`ldns devos.opentest.cc 127.0.0.1:4100`(nginx 反代 + Cloudflare DNS/CDN)
- 线上地址:**https://devos.opentest.cc**
更新部署流程:
```bash
bun run build:api && bun run build:web && systemctl restart devos-api
```
## 已知限制(V1 范围内取舍)
- `bun --hot` dev server 下 ProComponents 存在 CJS 互操作问题(生产 bundle 与 nginx 部署不受影响)
- 登出为无状态(客户端丢弃 JWT);撤销列表留待 V2
- Webhook 触发的自动化以 `X-Webhook-Secret` 头校验(HMAC 签名留待 V2)
+27
View File
@@ -0,0 +1,27 @@
{
"name": "devos-api",
"version": "1.0.0",
"private": true,
"scripts": {
"build": "tsc -p tsconfig.build.json",
"start": "bun dist/main.js",
"test": "bun test ../../tests"
},
"dependencies": {
"@devos/auth": "workspace:*",
"@devos/types": "workspace:*",
"@modelcontextprotocol/sdk": "^1.12.0",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"@nestjs/platform-fastify": "^11.0.0",
"@prisma/client": "6.19.3",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"ssh2": "^1.16.0",
"zod": "^3.24.0",
"zod-to-json-schema": "^3.25.2"
},
"devDependencies": {
"@types/ssh2": "^1.15.0"
}
}
+82
View File
@@ -0,0 +1,82 @@
/**
* # DevOS API 根模块(模块化单体)
* 核心域:identity / workspace / resource / permission(守卫) / audit
* 基础设施域:server / container / website / domain / deployment / monitoring
* 自动化与智能:event / job / worker / automation / ai / mcp / credential
*/
import { Module } from "@nestjs/common";
import { APP_GUARD, APP_FILTER } from "@nestjs/core";
import { ConfigModule } from "./common/config/config.module";
import { LoggerModule } from "./common/logger/logger.module";
import { SecurityModule } from "./common/security/security.module";
import { AiProviderModule } from "./common/ai/ai-provider.module";
import { PrismaModule } from "./prisma/prisma.module";
import { JwtAuthGuard } from "./common/guards/jwt-auth.guard";
import { WorkspaceGuard } from "./common/guards/workspace.guard";
import { AllExceptionsFilter } from "./common/errors/all-exceptions.filter";
import { SystemController } from "./common/system.controller";
// 模块
import { IdentityModule } from "./modules/identity/identity.module";
import { WorkspaceModule } from "./modules/workspace/workspace.module";
import { ResourceModule } from "./modules/resource/resource.module";
import { AuditModule } from "./modules/audit/audit.module";
import { CredentialModule } from "./modules/credential/credential.module";
import { EventModule } from "./modules/event/event.module";
import { JobModule } from "./modules/job/job.module";
import { ServerModule } from "./modules/server/server.module";
import { ContainerModule } from "./modules/container/container.module";
import { WebsiteModule } from "./modules/website/website.module";
import { DeploymentModule } from "./modules/deployment/deployment.module";
import { DomainModule } from "./modules/domain/domain.module";
import { MonitoringModule } from "./modules/monitoring/monitoring.module";
import { AutomationModule } from "./modules/automation/automation.module";
import { AiModule } from "./modules/ai/ai.module";
import { McpModule } from "./modules/mcp/mcp.module";
import { WorkerModule } from "./modules/worker/worker.module";
import { NotificationModule } from "./modules/notification/notification.module";
import { TaskModule } from "./modules/task/task.module";
import { NoteModule } from "./modules/note/note.module";
import { CertificateModule } from "./modules/certificate/certificate.module";
import { GitModule } from "./modules/git/git.module";
import { SearchModule } from "./modules/search/search.module";
@Module({
imports: [
ConfigModule,
LoggerModule,
SecurityModule,
AiProviderModule,
PrismaModule,
IdentityModule,
WorkspaceModule,
ResourceModule,
AuditModule,
CredentialModule,
EventModule,
JobModule,
ServerModule,
ContainerModule,
WebsiteModule,
DeploymentModule,
DomainModule,
MonitoringModule,
AutomationModule,
AiModule,
McpModule,
WorkerModule,
NotificationModule,
TaskModule,
NoteModule,
CertificateModule,
GitModule,
SearchModule,
],
controllers: [SystemController],
providers: [
{ provide: APP_GUARD, useClass: JwtAuthGuard },
{ provide: APP_GUARD, useClass: WorkspaceGuard },
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
],
})
export class AppModule {}
@@ -0,0 +1,14 @@
/**
* # AI Provider 模块(全局共享 LLM 补全)
*/
import { Global, Module } from "@nestjs/common";
import { AiProviderService } from "./ai-provider.service";
import { ConfigModule } from "../config/config.module";
@Global()
@Module({
imports: [ConfigModule],
providers: [AiProviderService],
exports: [AiProviderService],
})
export class AiProviderModule {}
@@ -0,0 +1,62 @@
/**
* # AI Provider 服务(共享 LLM 补全)
* OpenAI 兼容 /chat/completions;未配置 provider 时可优雅降级。
* 供 AI Gateway 规划器与 Notes AI 助手等场景共用。
*/
import { Injectable } from "@nestjs/common";
import { ConfigService } from "../config/config.service";
import { ApiError } from "../errors/api-error";
@Injectable()
export class AiProviderService {
constructor(private readonly config: ConfigService) {}
/** # 是否已配置 LLM 提供商 */
get enabled(): boolean {
return this.config.aiEnabled;
}
/** # 文本补全(单轮 system+user → text)
* @param system 系统提示词
* @param user 用户内容
* @param opts 温度/最大输出
*/
async complete(
system: string,
user: string,
opts: { temperature?: number; maxTokens?: number } = {},
): Promise<string> {
if (!this.config.aiEnabled) {
throw ApiError.validation(
"AI provider is not configured (set AI_PROVIDER_BASE_URL / AI_PROVIDER_API_KEY)",
);
}
const res = await fetch(
`${this.config.aiBaseUrl.replace(/\/$/, "")}/chat/completions`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${this.config.aiApiKey}`,
},
body: JSON.stringify({
model: this.config.aiModel,
messages: [
{ role: "system", content: system },
{ role: "user", content: user },
],
temperature: opts.temperature ?? 0.7,
max_tokens: opts.maxTokens ?? 2000,
}),
signal: AbortSignal.timeout(60_000),
},
);
if (!res.ok) {
throw ApiError.upstream(`AI provider HTTP ${res.status}: ${(await res.text()).slice(0, 200)}`);
}
const body = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const content = body.choices?.[0]?.message?.content;
if (!content) throw ApiError.upstream("AI provider returned no content");
return content;
}
}
@@ -0,0 +1,12 @@
/**
* # 全局配置模块
*/
import { Global, Module } from "@nestjs/common";
import { ConfigService } from "./config.service";
@Global()
@Module({
providers: [ConfigService],
exports: [ConfigService],
})
export class ConfigModule {}
@@ -0,0 +1,52 @@
/**
* # 配置服务
* 集中读取环境变量(Bun 自动加载 .env),类型化访问,缺失必填项时快速失败。
*/
import { Injectable } from "@nestjs/common";
import { assertProductionSecrets } from "../security/production-check";
/** # 必填环境变量列表 **/
const REQUIRED_KEYS = ["APP_SECRET", "ENCRYPTION_KEY", "DATABASE_URL"] as const;
@Injectable()
export class ConfigService {
/** # API 监听端口 **/
readonly port = Number(process.env.PORT ?? 4100);
/** # 监听地址 **/
readonly host = process.env.HOST ?? "0.0.0.0";
/** # JWT/应用密钥 **/
readonly appSecret = process.env.APP_SECRET ?? "";
/** # 凭据信封加密主密钥(64 位 hex = 32 字节) **/
readonly encryptionKey = process.env.ENCRYPTION_KEY ?? "";
/** # PostgreSQL 连接串 **/
readonly databaseUrl = process.env.DATABASE_URL ?? "";
/** # Redis 连接串 **/
readonly redisUrl = process.env.REDIS_URL ?? "";
/** # 服务版本标识 **/
readonly version = process.env.APP_VERSION ?? "1.0.0";
/** # AI Provider(OpenAI 兼容)配置,可选 **/
readonly aiBaseUrl = process.env.AI_PROVIDER_BASE_URL ?? "";
readonly aiApiKey = process.env.AI_PROVIDER_API_KEY ?? "";
readonly aiModel = process.env.AI_PROVIDER_MODEL ?? "gpt-4o-mini";
constructor() {
for (const key of REQUIRED_KEYS) {
if (!process.env[key]) {
throw new Error(`Missing required env: ${key}`);
}
}
if (this.encryptionKey.length !== 64 || !/^[0-9a-fA-F]{64}$/.test(this.encryptionKey)) {
throw new Error("ENCRYPTION_KEY must be 64 hex chars (32 bytes)");
}
assertProductionSecrets({
nodeEnv: process.env.NODE_ENV,
appSecret: this.appSecret,
encryptionKey: this.encryptionKey,
});
}
/** # 是否配置了 AI Provider */
get aiEnabled(): boolean {
return Boolean(this.aiBaseUrl && this.aiApiKey);
}
}
+45
View File
@@ -0,0 +1,45 @@
/**
* # 认证与授权相关自定义装饰器
*/
import { SetMetadata, createParamDecorator, ExecutionContext } from "@nestjs/common";
import type { WorkspaceRole } from "@devos/types";
/** # 路由公开标记(跳过 JWT 守卫)的 metadata key **/
export const IS_PUBLIC_KEY = "isPublic";
/** # @Public():标记路由为公开(健康检查、登录、注册) */
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
/** # 角色要求 metadata key **/
export const ROLES_KEY = "minRoles";
/** # @Roles(...roles):要求当前工作区角色在列表内(VIEWER 只读) */
export const Roles = (...roles: WorkspaceRole[]) => SetMetadata(ROLES_KEY, roles);
/** # @NoWorkspace():跳过工作区解析(如 /auth/me) */
export const NO_WORKSPACE_KEY = "noWorkspace";
export const NoWorkspace = () => SetMetadata(NO_WORKSPACE_KEY, true);
/** # 请求上的认证用户 **/
export interface RequestUser {
id: string;
email: string;
}
/** # 请求上的工作区信息 **/
export interface RequestWorkspace {
id: string;
role: WorkspaceRole;
}
/** # @CurrentUser():注入当前认证用户 */
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.user as RequestUser | undefined;
});
/** # @CurrentWorkspace():注入当前工作区(id + role) */
export const CurrentWorkspace = createParamDecorator((_data: unknown, ctx: ExecutionContext) => {
const request = ctx.switchToHttp().getRequest();
return request.workspace as RequestWorkspace | undefined;
});
@@ -0,0 +1,66 @@
/**
* # 全局异常过滤器
* 输出 05-API-SPEC 定义的错误契约:
* { "error": { "code", "message", "requestId" } }
* 绝不向客户端暴露堆栈、凭据或内部细节。
*/
import { ArgumentsHost, Catch, ExceptionFilter, HttpException, HttpStatus } from "@nestjs/common";
import type { FastifyReply, FastifyRequest } from "fastify";
import { ZodError } from "zod";
import { ApiError } from "./api-error";
/** # 从请求对象读取 requestId(中间件在请求上挂载) */
function getRequestId(request: FastifyRequest | undefined): string {
const rid = (request as { requestId?: string } | undefined)?.requestId;
return typeof rid === "string" && rid ? rid : "unknown";
}
@Catch()
export class AllExceptionsFilter implements ExceptionFilter {
/** # 捕获所有异常并归一化输出 */
catch(exception: unknown, host: ArgumentsHost): void {
const ctx = host.switchToHttp();
const reply = ctx.getResponse<FastifyReply>();
const request = ctx.getRequest<FastifyRequest>();
const requestId = getRequestId(request);
let status = HttpStatus.INTERNAL_SERVER_ERROR;
let code = "INTERNAL_ERROR";
let message = "Internal server error";
let details: unknown;
if (exception instanceof ApiError) {
status = exception.getStatus();
code = exception.code;
message = exception.message;
details = exception.details;
} else if (exception instanceof ZodError) {
status = HttpStatus.BAD_REQUEST;
code = "VALIDATION_ERROR";
message = "Request validation failed";
details = exception.issues.map((i) => ({ path: i.path.join("."), message: i.message }));
} else if (exception instanceof HttpException) {
status = exception.getStatus();
code = status === HttpStatus.NOT_FOUND ? "RESOURCE_NOT_FOUND" : "HTTP_ERROR";
message = exception.message;
} else {
// 服务端内部错误:只在日志中记录细节
console.error(
JSON.stringify({
time: new Date().toISOString(),
level: "error",
ctx: "ExceptionsFilter",
requestId,
msg: exception instanceof Error ? `${exception.message}` : String(exception),
stack: exception instanceof Error ? exception.stack : undefined,
}),
);
}
if (!reply.sent) {
reply.code(status).send({
error: { code, message, requestId, ...(details !== undefined ? { details } : {}) },
});
}
}
}
+49
View File
@@ -0,0 +1,49 @@
/**
* # HTTP API 异常
* 业务代码抛出的统一异常:携带机器可读错误码(对齐 05-API-SPEC 错误契约)。
*/
import { HttpException } from "@nestjs/common";
import type { ErrorCode } from "@devos/types";
/** # API 业务异常 **/
export class ApiError extends HttpException {
/** # 机器可读错误码 **/
readonly code: ErrorCode | string;
/** # 附加详情(绝不包含堆栈/凭据) **/
readonly details?: unknown;
constructor(status: number, code: ErrorCode | string, message: string, details?: unknown) {
super(message, status);
this.code = code;
this.details = details;
}
/** # 快捷构造:400 校验失败 */
static validation(message: string, details?: unknown) {
return new ApiError(400, "VALIDATION_ERROR", message, details);
}
/** # 快捷构造:401 未认证 */
static unauthorized(message = "Authentication required") {
return new ApiError(401, "UNAUTHORIZED", message);
}
/** # 快捷构造:403 禁止 */
static forbidden(message = "Permission denied") {
return new ApiError(403, "FORBIDDEN", message);
}
/** # 快捷构造:404 资源不存在 */
static notFound(message = "Resource was not found") {
return new ApiError(404, "RESOURCE_NOT_FOUND", message);
}
/** # 快捷构造:409 冲突 */
static conflict(message: string) {
return new ApiError(409, "CONFLICT", message);
}
/** # 快捷构造:502 上游失败 */
static upstream(message: string, details?: unknown) {
return new ApiError(502, "UPSTREAM_FAILURE", message, details);
}
/** # 快捷构造:504 超时 */
static timeout(message: string) {
return new ApiError(504, "TIMEOUT", message);
}
}
@@ -0,0 +1,55 @@
/**
* # JWT 认证守卫(全局)
* 解析 Authorization: Bearer <jwt>,校验通过后挂载 req.user 并更新请求上下文。
* @Public() 标记的路由跳过。
*/
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { verifyJwt } from "@devos/auth";
import { ConfigService } from "../config/config.service";
import { ApiError } from "../errors/api-error";
import { IS_PUBLIC_KEY } from "../decorators";
import { getRequestContext } from "../request-context";
/** # Fastify 请求扩展(认证部分) **/
declare module "fastify" {
interface FastifyRequest {
user?: { id: string; email: string };
jwtPayload?: { wid: string | null };
}
}
@Injectable()
export class JwtAuthGuard implements CanActivate {
constructor(
private readonly config: ConfigService,
private readonly reflector: Reflector,
) {}
/** # 校验 JWT 并挂载用户 */
canActivate(context: ExecutionContext): boolean {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(),
context.getClass(),
]);
if (isPublic) return true;
const request = context.switchToHttp().getRequest();
const header = request.headers["authorization"];
if (!header || !header.startsWith("Bearer ")) {
throw ApiError.unauthorized();
}
const result = verifyJwt(header.slice(7), this.config.appSecret);
if (!result.ok) {
throw ApiError.unauthorized(result.reason === "EXPIRED" ? "Token expired" : "Invalid token");
}
request.user = { id: result.payload.sub, email: result.payload.email };
request.jwtPayload = result.payload;
const ctx = getRequestContext();
if (ctx) {
ctx.actorId = result.payload.sub;
ctx.actorType = "USER";
}
return true;
}
}
@@ -0,0 +1,114 @@
/**
* # 工作区守卫(全局)
* 解析当前工作区:X-Workspace-Id 请求头 > JWT wid 声明 > 第一个成员关系。
* 校验成员资格后挂载 req.workspace(含角色)并写入请求上下文。
* @NoWorkspace() 标记的路由跳过(但已认证用户仍可访问)。
*/
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import { PrismaService } from "../../prisma/prisma.service";
import { ApiError } from "../errors/api-error";
import { NO_WORKSPACE_KEY, ROLES_KEY } from "../decorators";
import { getRequestContext } from "../request-context";
import type { WorkspaceRole } from "@devos/types";
/** # 角色权限序(数值越大权限越高) **/
const ROLE_RANK: Record<WorkspaceRole, number> = { VIEWER: 0, MEMBER: 1, ADMIN: 2, OWNER: 3 };
/** # Fastify 请求扩展(工作区部分) **/
declare module "fastify" {
interface FastifyRequest {
workspace?: { id: string; role: WorkspaceRole };
}
}
@Injectable()
export class WorkspaceGuard implements CanActivate {
constructor(
private readonly prisma: PrismaService,
private readonly reflector: Reflector,
) {}
/** # 解析并校验当前工作区 */
async canActivate(context: ExecutionContext): Promise<boolean> {
const noWorkspace = this.reflector.getAllAndOverride<boolean>(NO_WORKSPACE_KEY, [
context.getHandler(),
context.getClass(),
]);
const request = context.switchToHttp().getRequest();
if (noWorkspace || !request.user) {
this.checkRoles(request, undefined, context);
return true;
}
const headerWid = request.headers["x-workspace-id"] as string | undefined;
const tokenWid = request.jwtPayload?.wid ?? null;
const membership = await this.resolveMembership(request.user.id, headerWid, tokenWid);
if (!membership) {
throw ApiError.forbidden("No workspace membership");
}
request.workspace = { id: membership.workspaceId, role: membership.role };
const ctx = getRequestContext();
if (ctx) ctx.workspaceId = membership.workspaceId;
// 路径中出现 :workspaceId 时,必须对其具备成员资格(防止越权访问其它工作区)
const paramWid = (request.params as Record<string, string> | undefined)?.workspaceId;
if (paramWid && paramWid !== membership.workspaceId) {
const paramMembership = await this.prisma.workspaceMember.findUnique({
where: { workspaceId_userId: { workspaceId: paramWid, userId: request.user.id } },
select: { workspaceId: true, role: true },
});
if (!paramMembership) {
throw ApiError.forbidden("Not a member of the target workspace");
}
request.workspace = paramMembership;
if (ctx) ctx.workspaceId = paramMembership.workspaceId;
}
this.checkRoles(request, request.workspace.role, context);
return true;
}
/** # 校验角色要求 */
private checkRoles(
request: { workspace?: { role: WorkspaceRole } },
role: WorkspaceRole | undefined,
context: ExecutionContext,
): void {
const required = this.reflector.getAllAndOverride<WorkspaceRole[] | undefined>(ROLES_KEY, [
context.getHandler(),
context.getClass(),
]);
if (!required || required.length === 0) return;
const current = role ?? request.workspace?.role;
if (!current || !required.some((r) => ROLE_RANK[current] >= ROLE_RANK[r])) {
throw ApiError.forbidden(`Requires role: ${required.join(" or ")}`);
}
}
/** # 解析用户在某工作区的成员关系 */
private async resolveMembership(
userId: string,
headerWid?: string,
tokenWid?: string | null,
): Promise<{ workspaceId: string; role: WorkspaceRole } | null> {
const wid = headerWid || tokenWid;
if (wid) {
const m = await this.prisma.workspaceMember.findUnique({
where: { workspaceId_userId: { workspaceId: wid, userId } },
select: { workspaceId: true, role: true },
});
if (m) return m;
if (headerWid) {
// 显式指定了不属于自己的工作区:明确拒绝
return null;
}
}
const first = await this.prisma.workspaceMember.findFirst({
where: { userId },
orderBy: { createdAt: "asc" },
select: { workspaceId: true, role: true },
});
return first;
}
}
@@ -0,0 +1,81 @@
/**
* # 日志工厂(全局模块)
* 为每个消费者提供独立 context 的结构化日志实例。
*/
import { Global, Injectable, Module } from "@nestjs/common";
@Injectable()
export class LogService {
/** # 创建带类别的日志器
* @param context 模块名(写入日志 ctx 字段)
*/
child(context: string): StructuredLoggerInstance {
const logger = new StructuredLoggerInstance();
logger.setContext(context);
return logger;
}
}
/** # 结构化日志器实例(非 DI 管理,由 LogService 工厂创建) */
export class StructuredLoggerInstance {
/** # 日志类别 **/
private context = "App";
/** # 设置类别 **/
setContext(context: string): void {
this.context = context;
}
private write(level: "debug" | "log" | "warn" | "error", message: unknown, meta?: unknown): void {
const ctx = requestContext();
const entry: Record<string, unknown> = {
time: new Date().toISOString(),
level,
ctx: this.context,
msg: typeof message === "string" ? message : JSON.stringify(message),
};
if (ctx?.requestId) entry.requestId = ctx.requestId;
if (ctx?.correlationId) entry.correlationId = ctx.correlationId;
if (ctx?.workspaceId) entry.workspaceId = ctx.workspaceId;
if (ctx?.actorId) entry.actorId = ctx.actorId;
if (meta !== undefined) {
entry.meta = meta instanceof Error ? { name: meta.name, message: meta.message } : meta;
}
const line = JSON.stringify(entry);
if (level === "error") console.error(line);
else if (level === "warn") console.warn(line);
else console.log(line);
}
/** # 调试日志 */
debug(message: unknown, meta?: unknown): void {
this.write("debug", message, meta);
}
/** # 信息日志 */
log(message: unknown, meta?: unknown): void {
this.write("log", message, meta);
}
/** # 警告日志 */
warn(message: unknown, meta?: unknown): void {
this.write("warn", message, meta);
}
/** # 错误日志 */
error(message: unknown, meta?: unknown): void {
this.write("error", message, meta);
}
}
/** # 延迟引用避免循环 import(request-context 无依赖,直接引入即可) */
import { requestContextStore } from "../request-context";
/** # 快捷读取当前请求上下文 */
function requestContext(): { requestId?: string; correlationId?: string; workspaceId?: string; actorId?: string } | undefined {
return requestContextStore.getStore();
}
@Global()
@Module({
providers: [LogService],
exports: [LogService],
})
export class LoggerModule {}
@@ -0,0 +1,28 @@
/**
* # 请求上下文 Fastify 钩子
* 在 onRequest 阶段(拿到真正的 FastifyRequest/FastifyReply):
* - 透传或生成 X-Request-Id,写响应头;
* - 用 AsyncLocalStorage 建立请求级上下文(后续 Guard 补充 actor/workspace)。
*/
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
import { randomUUID } from "node:crypto";
import { requestContextStore } from "../request-context";
/** # 请求对象扩展字段 **/
declare module "fastify" {
interface FastifyRequest {
requestId: string;
/** # 原始请求体(Nest rawBody: true 注入,webhook HMAC 验签用) **/
rawBody?: Buffer;
}
}
/** # 注册钩子(传入 Fastify 实例;必须在路由初始化前调用) */
export function registerRequestContextHook(fastify: FastifyInstance): void {
fastify.addHook("onRequest", (req: FastifyRequest, reply: FastifyReply, done: () => void) => {
const requestId = (req.headers["x-request-id"] as string) || randomUUID();
req.requestId = requestId;
reply.header("X-Request-Id", requestId);
requestContextStore.run({ requestId, ip: req.ip }, () => done());
});
}
@@ -0,0 +1,25 @@
/**
* # Zod 校验管道
* 将路由参数 DTO 与 zod schema 绑定:校验失败抛出 400 VALIDATION_ERROR,
* 校验通过返回 parse 后的强类型值(转换/默认值均已应用)。
*/
import { ArgumentMetadata, Injectable, PipeTransform } from "@nestjs/common";
import { ZodSchema } from "zod";
import { ApiError } from "../errors/api-error";
@Injectable()
export class ZodValidationPipe<T> implements PipeTransform<unknown, T> {
constructor(private readonly schema: ZodSchema<T>) {}
/** # 转换并校验输入值 */
transform(value: unknown, _metadata: ArgumentMetadata): T {
const result = this.schema.safeParse(value);
if (!result.success) {
throw ApiError.validation(
"Request validation failed",
result.error.issues.map((i) => ({ path: i.path.join("."), message: i.message })),
);
}
return result.data;
}
}
+30
View File
@@ -0,0 +1,30 @@
/**
* # 请求上下文
* 基于 AsyncLocalStorage 贯穿一次请求/一次异步任务的执行单元:
* requestId(传输层标识)、correlationId(跨资源因果链)、actor(审计主体)、workspaceId。
*/
import { AsyncLocalStorage } from "node:async_hooks";
import type { ActorType } from "@devos/types";
/** # 请求上下文内容 **/
export interface RequestContext {
requestId: string;
correlationId?: string;
actorType?: ActorType;
actorId?: string;
workspaceId?: string;
ip?: string;
}
/** # 全局 ALS 实例 **/
export const requestContextStore = new AsyncLocalStorage<RequestContext>();
/** # 读取当前上下文(可能为空,例如非 HTTP 场景) */
export function getRequestContext(): RequestContext | undefined {
return requestContextStore.getStore();
}
/** # 在指定上下文中运行异步函数(用于 worker/调度器派生的执行单元) */
export function runWithContext<T>(ctx: RequestContext, fn: () => Promise<T>): Promise<T> {
return requestContextStore.run(ctx, fn);
}
@@ -0,0 +1,23 @@
/**
* # 生产环境 secret 校验(独立模块,避免与 ConfigService 循环依赖)
*/
/** # 已知开发默认 secret(生产启动时拒绝) **/
export const KNOWN_DEV_SECRETS = new Set([
"dev-devos-app-secret-change-me-please-0123456789",
"change-me-to-a-long-random-secret",
"changeme",
"secret",
]);
/** # 校验生产 secret 配置(启动时调用;失败抛错阻止启动) */
export function assertProductionSecrets(values: {
nodeEnv: string | undefined;
appSecret: string;
encryptionKey: string;
}): void {
if (values.nodeEnv !== "production") return;
if (values.appSecret.length < 32 || KNOWN_DEV_SECRETS.has(values.appSecret)) {
throw new Error("Production refuses weak/default APP_SECRET (>=32 random chars required)");
}
}
@@ -0,0 +1,60 @@
/**
* # 安全加固(Phase A)
* - 生产环境 secret 校验:拒绝明显开发默认值/弱密钥;
* - 注册开关:ENABLE_REGISTRATION(生产默认关闭);
* - 登录/注册限流:进程内滑动窗口(多实例部署时建议换 Redis 实现)。
*/
import { Injectable } from "@nestjs/common";
import { ConfigService } from "../config/config.service";
export { assertProductionSecrets } from "./production-check";
@Injectable()
export class SecurityPolicyService {
/** # 是否允许公开注册(生产默认关闭,需显式 ENABLE_REGISTRATION=1) **/
readonly registrationEnabled: boolean;
/** # 限流窗口内的最大尝试次数 **/
private readonly limit = 10;
/** # 滑动窗口长度(毫秒) **/
private readonly windowMs = 60_000;
/** # ip → 时间戳列表 **/
private attempts = new Map<string, number[]>();
constructor(private readonly config: ConfigService) {
const flag = process.env.ENABLE_REGISTRATION;
this.registrationEnabled =
flag !== undefined ? flag === "1" || flag === "true" : process.env.NODE_ENV !== "production";
}
/** # 注册是否被允许;不允许时返回原因 */
checkRegistration(): { allowed: boolean; reason?: string } {
if (this.registrationEnabled) return { allowed: true };
return { allowed: false, reason: "Registration is disabled on this instance (invite-only)" };
}
/** # 限流检查(滑动窗口);超限返回 false
* @param key 维度键(如 `login:1.2.3.4`)
*/
checkRate(key: string): boolean {
const now = Date.now();
const list = (this.attempts.get(key) ?? []).filter((t) => now - t < this.windowMs);
if (list.length >= this.limit) {
this.attempts.set(key, list);
return false;
}
list.push(now);
this.attempts.set(key, list);
// 防内存膨胀:键过多时清理过期项
if (this.attempts.size > 10_000) {
for (const [k, v] of this.attempts) {
if (v.every((t) => now - t >= this.windowMs)) this.attempts.delete(k);
}
}
return true;
}
/** # webhook secret 生成提示(HMAC 用) */
get webhookLegacyFallback(): boolean {
return this.config.appSecret.length > 0;
}
}
@@ -0,0 +1,14 @@
/**
* # 安全策略模块(全局)
*/
import { Global, Module } from "@nestjs/common";
import { SecurityPolicyService } from "./security-policy.service";
import { ConfigModule } from "../config/config.module";
@Global()
@Module({
imports: [ConfigModule],
providers: [SecurityPolicyService],
exports: [SecurityPolicyService],
})
export class SecurityModule {}
@@ -0,0 +1,56 @@
/**
* # Webhook HMAC 签名(Phase A/F 安全升级)
* 签名格式(兼容 GitHub 风格):
* X-Signature: t=<unix秒>,v1=<hex_hmac_sha256(secret, `${t}.${rawBody}`)>
* 校验:时间戳容差 ±5 分钟(防重放),签名常量时间比较。
* 未携带签名头的旧客户端回退 X-Webhook-Secret 明文比对(legacy,计划移除)。
*/
import { createHmac, timingSafeEqual } from "node:crypto";
/** # 签名时间戳容差(毫秒) **/
export const SIGNATURE_TOLERANCE_MS = 5 * 60 * 1000;
/** # 计算 webhook 签名
* @param secret webhook secret
* @param timestamp unix 秒
* @param body 原始请求体字符串
*/
export function signWebhookPayload(secret: string, timestamp: number, body: string): string {
return createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex");
}
/** # 构造完整签名头 */
export function buildSignatureHeader(secret: string, body: string, nowSec?: number): string {
const t = nowSec ?? Math.floor(Date.now() / 1000);
return `t=${t},v1=${signWebhookPayload(secret, t, body)}`;
}
/** # 常量时间字符串比较 */
function safeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a, "utf8");
const bb = Buffer.from(b, "utf8");
if (ba.length !== bb.length) return false;
return timingSafeEqual(ba, bb);
}
/** # 校验签名头
* @returns ok + 失败原因(供审计)
*/
export function verifyWebhookSignature(
secret: string,
signatureHeader: string | undefined,
body: string,
nowMs?: number,
): { ok: boolean; reason?: string } {
if (!signatureHeader) return { ok: false, reason: "missing signature" };
const m = signatureHeader.match(/^t=(\d+),v1=([0-9a-f]{64})$/);
if (!m) return { ok: false, reason: "malformed signature" };
const t = Number(m[1]);
const now = nowMs ?? Date.now();
if (Math.abs(now - t * 1000) > SIGNATURE_TOLERANCE_MS) {
return { ok: false, reason: "timestamp outside tolerance (replay?)" };
}
const expected = signWebhookPayload(secret, t, body);
if (!safeEqual(expected, m[2])) return { ok: false, reason: "signature mismatch" };
return { ok: true };
}
+71
View File
@@ -0,0 +1,71 @@
/**
* # 系统模块:健康检查 + 工作台统计
*/
import { Controller, Get } from "@nestjs/common";
import { Public, CurrentWorkspace, NoWorkspace } from "./decorators";
import { ConfigService } from "./config/config.service";
import { PrismaService } from "../prisma/prisma.service";
import type { RequestWorkspace } from "./decorators";
@Controller()
export class SystemController {
constructor(
private readonly config: ConfigService,
private readonly prisma: PrismaService,
) {}
/** # 健康检查(公开;负载均衡/监控探针用) */
@Public()
@NoWorkspace()
@Get("health")
health() {
return { status: "ok", service: "devos-api", version: this.config.version, time: new Date().toISOString() };
}
/** # 工作台统计:资源计数 + 最近活动(Dashboard 首页) */
@Get("dashboard")
async dashboard(@CurrentWorkspace() ws: RequestWorkspace) {
if (!ws) {
return { serverCount: 0, websiteCount: 0, domainCount: 0, containerCount: 0, deploymentCount: 0, monitorsUp: 0, monitorsDown: 0, recentEvents: [], recentDeployments: [] };
}
const wid = ws.id;
const [serverCount, websiteCount, domainCount, containerCount, deploymentCount, monitorsUp, monitorsDown, recentEvents, recentDeployments] =
await this.prisma.$transaction([
this.prisma.server.count({ where: { workspaceId: wid } }),
this.prisma.website.count({ where: { workspaceId: wid } }),
this.prisma.domain.count({ where: { workspaceId: wid } }),
this.prisma.container.count({ where: { workspaceId: wid } }),
this.prisma.deployment.count({ where: { workspaceId: wid } }),
this.prisma.monitor.count({ where: { workspaceId: wid, lastStatus: "UP" } }),
this.prisma.monitor.count({ where: { workspaceId: wid, lastStatus: "DOWN" } }),
this.prisma.eventRecord.findMany({
where: { workspaceId: wid },
orderBy: { createdAt: "desc" },
take: 10,
}),
this.prisma.deployment.findMany({
where: { workspaceId: wid },
orderBy: { createdAt: "desc" },
take: 5,
include: { website: { select: { name: true } } },
}),
]);
return {
serverCount,
websiteCount,
domainCount,
containerCount,
deploymentCount,
monitorsUp,
monitorsDown,
recentEvents,
recentDeployments: recentDeployments.map((d) => ({
id: d.id,
status: d.status,
websiteName: d.website?.name,
createdAt: d.createdAt,
trigger: d.trigger,
})),
};
}
}
+75
View File
@@ -0,0 +1,75 @@
/**
* # 网页文本提取工具
* 零依赖 HTML → 可读文本,供 AI 网页总结 / 内容抓取使用。
*/
/** # 提取 <title> */
export function extractHtmlTitle(html: string): string | null {
const m = html.match(/<title[^>]*>([\s\S]*?)<\/title>/i);
if (!m) return null;
return decodeEntities(m[1]).replace(/\s+/g, " ").trim().slice(0, 200) || null;
}
/** # 提取 meta description */
export function extractMetaDescription(html: string): string | null {
const m = html.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']*)["']/i) ??
html.match(/<meta[^>]+content=["']([^"']*)["'][^>]+name=["']description["']/i);
if (!m) return null;
return decodeEntities(m[1]).replace(/\s+/g, " ").trim().slice(0, 400) || null;
}
/** # 提取正文段落(按长度过滤导航/噪音) */
export function extractMainParagraphs(html: string, max = 8): string[] {
const cleaned = html
.replace(/<script[\s\S]*?<\/script>/gi, " ")
.replace(/<style[\s\S]*?<\/style>/gi, " ")
.replace(/<nav[\s\S]*?<\/nav>/gi, " ")
.replace(/<header[\s\S]*?<\/header>/gi, " ")
.replace(/<footer[\s\S]*?<\/footer>/gi, " ")
.replace(/<!--[\s\S]*?-->/g, " ");
const paragraphs: string[] = [];
const re = /<(?:p|h1|h2|h3|li)[^>]*>([\s\S]*?)<\/(?:p|h1|h2|h3|li)>/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(cleaned)) && paragraphs.length < max * 3) {
const text = decodeEntities(m[1].replace(/<[^>]+>/g, " ")).replace(/\s+/g, " ").trim();
// 过滤太短的噪音(菜单项等)
if (text.length >= 40) paragraphs.push(text);
if (paragraphs.length >= max) break;
}
return paragraphs;
}
/** # HTML → 纯文本(含标题/正文,截断) */
export function htmlToText(html: string, maxChars = 12000): string {
const title = extractHtmlTitle(html);
const paragraphs = extractMainParagraphs(html, 40);
const body = [title ? `# ${title}` : "", ...paragraphs].filter(Boolean).join("\n\n");
return body.slice(0, maxChars);
}
/** # 抽取式摘要(无 LLM 时的兜底:标题 + meta 描述 + 前几段) */
export function extractiveSummary(html: string, url: string): { title: string; summaryText: string } {
const title = extractHtmlTitle(html) ?? url;
const meta = extractMetaDescription(html);
const paragraphs = extractMainParagraphs(html, 4);
const lines = [
meta ? `📌 ${meta}` : "",
...paragraphs.map((p) => `• ${p.slice(0, 220)}`),
].filter(Boolean);
return {
title,
summaryText: lines.join("\n") || "(页面无可提取正文)",
};
}
/** # HTML 实体解码(常用集合) */
export function decodeEntities(text: string): string {
return text
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;|&apos;/g, "'")
.replace(/&nbsp;/g, " ")
.replace(/&#(\d+);/g, (_, d: string) => String.fromCodePoint(Number(d)));
}
+52
View File
@@ -0,0 +1,52 @@
/**
* # 脱敏工具
* 对日志、审计元数据、工具结果中可能出现的敏感键值进行遮蔽。
* 键名匹配敏感词(password/token/secret/key/credential/authorization 等)时替换为 "***";
* 字符串值中内嵌的密钥样式(Bearer xxx、sk-xxx)也会被遮蔽。
*/
/** # 敏感键名匹配 **/
const SENSITIVE_KEY = /pass(word)?|secret|token|api[-_]?key|access[-_]?key|credential|authorization|private[-_]?key/i;
/** # 内嵌密钥样式 **/
const SENSITIVE_PATTERN = /(Bearer\s+)[\w.\-~+/=]+|(sk-|ghp_|github_pat_)[\w]+/g;
/** # 遮蔽字符串值 */
export function maskString(value: string): string {
return value.replace(SENSITIVE_PATTERN, (_m, p1) => (p1 ? `${p1}***` : "***"));
}
/** # 递归遮蔽对象/数组中的敏感数据;原对象不被修改
* @param input 任意值
* @returns 遮蔽后的值
*/
export function maskSecrets<T>(input: T, depth = 0): T {
if (depth > 6) return input;
if (typeof input === "string") return maskString(input) as unknown as T;
if (Array.isArray(input)) return input.map((v) => maskSecrets(v, depth + 1)) as unknown as T;
if (input && typeof input === "object") {
const out: Record<string, unknown> = {};
for (const [k, v] of Object.entries(input as Record<string, unknown>)) {
out[k] = SENSITIVE_KEY.test(k) ? "***" : maskSecrets(v, depth + 1);
}
return out as unknown as T;
}
return input;
}
/** # 危险命令检测(用于 server.exec 与 AI 工具风险升级)
* @param command 待执行的 shell 命令
* @returns 是否包含破坏性模式
*/
export function isDestructiveCommand(command: string): boolean {
const patterns = [
/rm\s+(-[a-zA-Z]*[rf][a-zA-Z]*\s+)+/,
/mkfs(\.\w+)?\s/,
/dd\s+if=/,
/>\s*\/dev\/sd[a-z]/,
/:\(\)\s*\{\s*:\|\:/,
/shutdown|reboot|init\s+0|init\s+6/,
/chmod\s+-R\s+777\s+\//,
/DROP\s+(TABLE|DATABASE)\s/i,
];
return patterns.some((p) => p.test(command));
}
@@ -0,0 +1,11 @@
/**
* # DNS 集成模块(Cloudflare / Manual 适配器)
*/
import { Module } from "@nestjs/common";
import { CloudflareDnsProvider, ManualDnsProvider } from "./dns.providers";
@Module({
providers: [CloudflareDnsProvider, ManualDnsProvider],
exports: [CloudflareDnsProvider, ManualDnsProvider],
})
export class DnsModule {}
@@ -0,0 +1,171 @@
/**
* # DNS Provider 适配器(Task 013)
* 接口 + 两种实现:
* - CloudflareDnsProvider:Cloudflare v4 API(token 凭据);
* - ManualDnsProvider:无外部 API,记录仅存本地(本地事实源)。
*/
import { Injectable } from "@nestjs/common";
/** # DNS 记录 **/
export interface DnsRecord {
id: string | null;
type: string;
name: string;
value: string;
ttl: number;
proxied: boolean;
}
/** # DNS Provider 接口 **/
export interface DnsProvider {
/** # 提供商标识 **/
readonly provider: string;
listZones(params: { token: string; name?: string }): Promise<{ id: string; name: string }[]>;
listRecords(params: { token: string; zoneName: string }): Promise<DnsRecord[]>;
upsertRecord(params: {
token: string;
zoneName: string;
record: { type: string; name: string; value: string; ttl?: number; proxied?: boolean };
}): Promise<DnsRecord>;
deleteRecord(params: { token: string; zoneName: string; recordId: string }): Promise<void>;
}
/** # Cloudflare API 响应包络 **/
interface CfEnvelope<T> {
success: boolean;
errors: { code: number; message: string }[];
result: T;
}
/** # Cloudflare 记录 **/
interface CfRecord {
id: string;
type: string;
name: string;
content: string;
ttl: number;
proxied: boolean;
}
@Injectable()
export class CloudflareDnsProvider implements DnsProvider {
/** # 提供商标识 **/
readonly provider = "CLOUDFLARE";
/** # API 基址 **/
private base = process.env.CLOUDFLARE_API_BASE ?? "https://api.cloudflare.com/client/v4";
/** # 请求 Cloudflare API */
private async call<T>(token: string, path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${this.base}${path}`, {
...init,
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
signal: AbortSignal.timeout(15_000),
});
const body = (await res.json().catch(() => null)) as CfEnvelope<T> | null;
if (!body || !body.success) {
const message = body?.errors?.[0]?.message ?? `Cloudflare API HTTP ${res.status}`;
throw new Error(message);
}
return body.result;
}
/** # 列出区域 */
async listZones(params: { token: string; name?: string }) {
const query = params.name ? `?name=${encodeURIComponent(params.name)}` : "?per_page=50";
const zones = await this.call<{ id: string; name: string }[]>(params.token, `/zones${query}`);
return zones.map((z) => ({ id: z.id, name: z.name }));
}
/** # 列出记录 */
async listRecords(params: { token: string; zoneName: string }) {
const zones = await this.listZones({ token: params.token, name: params.zoneName });
if (zones.length === 0) return [];
const records = await this.call<CfRecord[]>(
params.token,
`/zones/${zones[0].id}/dns_records?per_page=100`,
);
return records.map((r) => ({
id: r.id,
type: r.type,
name: r.name,
value: r.content,
ttl: r.ttl,
proxied: Boolean(r.proxied),
}));
}
/** # 创建或更新记录(按 type+name 查找,存在则 PATCH) */
async upsertRecord(params: {
token: string;
zoneName: string;
record: { type: string; name: string; value: string; ttl?: number; proxied?: boolean };
}) {
const zones = await this.listZones({ token: params.token, name: params.zoneName });
if (zones.length === 0) throw new Error(`Zone not found: ${params.zoneName}`);
const zoneId = zones[0].id;
const existing = await this.call<CfRecord[]>(
params.token,
`/zones/${zoneId}/dns_records?type=${params.record.type}&name=${encodeURIComponent(params.record.name)}`,
);
const payload = {
type: params.record.type,
name: params.record.name,
content: params.record.value,
ttl: params.record.ttl ?? 300,
proxied: params.record.proxied ?? false,
};
if (existing.length > 0) {
const updated = await this.call<CfRecord>(
params.token,
`/zones/${zoneId}/dns_records/${existing[0].id}`,
{ method: "PATCH", body: JSON.stringify(payload) },
);
return { id: updated.id, type: updated.type, name: updated.name, value: updated.content, ttl: updated.ttl, proxied: Boolean(updated.proxied) };
}
const created = await this.call<CfRecord>(params.token, `/zones/${zoneId}/dns_records`, {
method: "POST",
body: JSON.stringify(payload),
});
return { id: created.id, type: created.type, name: created.name, value: created.content, ttl: created.ttl, proxied: Boolean(created.proxied) };
}
/** # 删除记录 */
async deleteRecord(params: { token: string; zoneName: string; recordId: string }): Promise<void> {
const zones = await this.listZones({ token: params.token, name: params.zoneName });
if (zones.length === 0) throw new Error(`Zone not found: ${params.zoneName}`);
await this.call(params.token, `/zones/${zones[0].id}/dns_records/${params.recordId}`, { method: "DELETE" });
}
}
@Injectable()
export class ManualDnsProvider implements DnsProvider {
/** # 提供商标识 **/
readonly provider = "MANUAL";
/** # 手动模式:区域/记录由本地数据库管理,适配器返回空集合(由服务层处理) */
async listZones(): Promise<{ id: string; name: string }[]> {
return [];
}
async listRecords(): Promise<DnsRecord[]> {
return [];
}
async upsertRecord(params: {
record: { type: string; name: string; value: string; ttl?: number; proxied?: boolean };
}): Promise<DnsRecord> {
return {
id: null,
type: params.record.type,
name: params.record.name,
value: params.record.value,
ttl: params.record.ttl ?? 300,
proxied: params.record.proxied ?? false,
};
}
async deleteRecord(): Promise<void> {
/* 手动模式下删除仅操作本地库 */
}
}
@@ -0,0 +1,12 @@
/**
* # Docker 集成模块
*/
import { Global, Module } from "@nestjs/common";
import { DockerService } from "./docker.service";
@Global()
@Module({
providers: [DockerService],
exports: [DockerService],
})
export class DockerModule {}
@@ -0,0 +1,314 @@
/**
* # Docker 适配器(Task 010)
* 统一接口 + 两种实现:
* - SshDockerAdapter:通过 SSH 调用目标机 docker CLI(零额外依赖、权限模型清晰);
* - LocalSocketAdapter:通过本机 /var/run/docker.sock 调用 Docker Engine API。
* DevOS 不重新实现 Docker,仅提供统一适配层。
*/
import { Injectable } from "@nestjs/common";
import { request as httpRequest } from "node:http";
import { SshClientService, type SshTarget } from "../ssh/ssh-client.service";
import { ApiError } from "../../common/errors/api-error";
/** # 容器摘要视图 **/
export interface DockerContainerInfo {
id: string;
name: string;
image: string;
state: string;
status: string;
ports: Record<string, unknown>[];
labels: Record<string, string>;
}
/** # Docker 适配器接口 **/
export interface DockerAdapter {
/** # 是否支持该目标(例如本地 socket 仅限本机) */
supports(server: { host: string; provider?: string }): boolean;
listContainers(target: SshTarget): Promise<DockerContainerInfo[]>;
inspectContainer(target: SshTarget, containerId: string): Promise<Record<string, unknown>>;
restartContainer(target: SshTarget, containerId: string): Promise<void>;
checkDocker(target: SshTarget): Promise<{ available: boolean; version: string }>;
}
/** # docker ps --format 输出行结构(部分字段) **/
interface DockerPsRow {
ID: string;
Names: string;
Image: string;
State: string;
Status: string;
Ports?: string;
Labels?: string;
Command?: string;
CreatedAt?: string;
Networks?: string;
LocalVolumes?: string;
Mounts?: string;
RunningFor?: string;
Size?: string;
}
/** # 解析 docker ps --format '{{json .}}' 的 NDJSON 输出(纯函数,可单测) */
export function parseDockerPsOutput(output: string): DockerContainerInfo[] {
const lines = output
.split("\n")
.map((l) => l.trim())
.filter((l) => l.length > 0);
const result: DockerContainerInfo[] = [];
for (const line of lines) {
let row: DockerPsRow;
try {
row = JSON.parse(line) as DockerPsRow;
} catch {
continue; // 跳过非 JSON 噪音行
}
result.push({
id: row.ID ?? "",
name: (row.Names ?? "").split(",").filter(Boolean)[0] ?? "",
image: row.Image ?? "",
state: row.State ?? "",
status: row.Status ?? "",
ports: parseDockerPorts(row.Ports ?? ""),
labels: parseDockerLabels(row.Labels ?? ""),
});
}
return result;
}
/** # 解析 docker ps Ports 列(如 0.0.0.0:8080->80/tcp, :::8080->80/tcp) */
export function parseDockerPorts(ports: string): Record<string, unknown>[] {
if (!ports || ports === "") return [];
return ports.split(", ").filter(Boolean).map((p) => ({ raw: p }));
}
/** # 解析 docker ps Labels 列(逗号分隔 key=value) */
export function parseDockerLabels(labels: string): Record<string, string> {
const out: Record<string, string> = {};
if (!labels) return out;
for (const pair of labels.split(",")) {
const idx = pair.indexOf("=");
if (idx > 0) out[pair.slice(0, idx)] = pair.slice(idx + 1);
}
return out;
}
@Injectable()
export class DockerService implements DockerAdapter {
/** # 本机 docker socket 路径 **/
private static readonly LOCAL_SOCKET = process.env.DOCKER_SOCKET ?? "/var/run/docker.sock";
constructor(private readonly ssh: SshClientService) {}
/** # 是否支持该目标 */
supports(server: { host: string; provider?: string }): boolean {
return true; // 接口层总是可用;选择 SSH 或 socket 由 targetProvider 决定
}
/** # 目标是否走本地 socket(server.provider === 'local') */
isLocal(server: { provider?: string | null }): boolean {
return server.provider === "local";
}
/** # 列出容器(含停止的) */
async listContainers(target: SshTarget, isLocal = false): Promise<DockerContainerInfo[]> {
if (isLocal) {
const rows = await this.localApi<Record<string, unknown>[]>("/containers/json?all=1");
return rows.map(mapEngineContainer);
}
const res = await this.ssh.exec(target, "docker ps -a --format '{{json .}}'", 30_000);
if (res.code !== 0 && !res.stdout.trim()) {
throw ApiError.upstream(`docker ps failed: ${res.stderr.slice(0, 500)}`);
}
return parseDockerPsOutput(res.stdout);
}
/** # 检查容器详情 */
async inspectContainer(
target: SshTarget,
containerId: string,
isLocal = false,
): Promise<Record<string, unknown>> {
const safeId = assertSafeContainerId(containerId);
if (isLocal) {
return this.localApi<Record<string, unknown>>(`/containers/${safeId}/json`);
}
const res = await this.ssh.exec(target, `docker inspect ${safeId}`, 20_000);
if (res.code !== 0) throw ApiError.upstream(`docker inspect failed: ${res.stderr.slice(0, 300)}`);
const parsed = JSON.parse(res.stdout) as Record<string, unknown>[];
return parsed[0] ?? {};
}
/** # 重启容器 */
async restartContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> {
const safeId = assertSafeContainerId(containerId);
if (isLocal) {
await this.localApiVoid(`/containers/${safeId}/restart?t=10`, "POST");
return;
}
const res = await this.ssh.exec(target, `docker restart -t 10 ${safeId}`, 60_000);
if (res.code !== 0) throw ApiError.upstream(`docker restart failed: ${res.stderr.slice(0, 300)}`);
}
/** # 启动容器 */
async startContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> {
const safeId = assertSafeContainerId(containerId);
if (isLocal) {
await this.localApiVoid(`/containers/${safeId}/start`, "POST");
return;
}
const res = await this.ssh.exec(target, `docker start ${safeId}`, 60_000);
if (res.code !== 0) throw ApiError.upstream(`docker start failed: ${res.stderr.slice(0, 300)}`);
}
/** # 停止容器 */
async stopContainer(target: SshTarget, containerId: string, isLocal = false): Promise<void> {
const safeId = assertSafeContainerId(containerId);
if (isLocal) {
await this.localApiVoid(`/containers/${safeId}/stop?t=10`, "POST");
return;
}
const res = await this.ssh.exec(target, `docker stop -t 10 ${safeId}`, 60_000);
if (res.code !== 0) throw ApiError.upstream(`docker stop failed: ${res.stderr.slice(0, 300)}`);
}
/** # 容器日志(tail N 行) */
async containerLogs(target: SshTarget, containerId: string, tail = 200, isLocal = false): Promise<string[]> {
const safeId = assertSafeContainerId(containerId);
if (isLocal) {
const res = await this.localApi<string>(`/containers/${safeId}/logs?stdout=1&stderr=1&tail=${tail}`);
// Docker API 多路复用流:剥离 8 字节帧头
return res
.split("\n")
.map((l) => l.replace(/^[^\r\n]*\r?/, ""))
.slice(-tail);
}
const res = await this.ssh.exec(target, `docker logs --tail ${tail} ${safeId} 2>&1`, 30_000);
return res.stdout.split("\n").slice(-tail);
}
/** # 镜像列表 */
async listImages(target: SshTarget, isLocal = false): Promise<{ repository: string; tag: string; size: string; created: string }[]> {
if (isLocal) {
const rows = await this.localApi<Record<string, unknown>[]>("/images/json");
return rows.map((r) => {
const tags = (r.RepoTags as string[] | undefined) ?? ["<none>:<none>"];
return {
repository: (tags[0].split(":")[0]) ?? "<none>",
tag: tags[0].split(":").slice(1).join(":") || "<none>",
size: `${Math.round((Number(r.Size) || 0) / 1024 / 1024)}MB`,
created: new String(r.Created ?? "").toString(),
};
});
}
const res = await this.ssh.exec(target, "docker images --format '{{.Repository}}|{{.Tag}}|{{.Size}}|{{.CreatedAt}}'", 20_000);
return res.stdout
.split("\n")
.filter((l) => l.trim())
.map((l) => {
const [repository, tag, size, created] = l.split("|");
return { repository, tag, size, created };
});
}
/** # 容器资源用量(stats no-stream) */
async containerStats(
target: SshTarget,
containerId: string,
isLocal = false,
): Promise<{ cpuPct: string; memUsage: string; memPct: string }> {
const safeId = assertSafeContainerId(containerId);
if (isLocal) {
const s = await this.localApi<{ cpu_stats: Record<string, unknown>; memory_stats: Record<string, unknown> }>(
`/containers/${safeId}/stats?stream=false`,
);
return { cpuPct: "n/a", memUsage: `${Math.round((Number(s.memory_stats.usage) || 0) / 1024 / 1024)}MB`, memPct: "n/a" };
}
const res = await this.ssh.exec(target, `docker stats --no-stream --format '{{.CPUPerc}}|{{.MemUsage}}|{{.MemPerc}}' ${safeId}`, 20_000);
const [cpuPct, memUsage, memPct] = res.stdout.trim().split("|");
return { cpuPct: cpuPct ?? "n/a", memUsage: memUsage ?? "n/a", memPct: memPct ?? "n/a" };
}
/** # Docker 可用性检查 */
async checkDocker(target: SshTarget, isLocal = false): Promise<{ available: boolean; version: string }> {
try {
if (isLocal) {
const version = await this.localApi<{ Version: string }>("/version");
return { available: true, version: version.Version };
}
const res = await this.ssh.exec(target, "docker version --format '{{.Server.Version}}'", 20_000);
if (res.code === 0 && res.stdout.trim()) {
return { available: true, version: res.stdout.trim() };
}
return { available: false, version: res.stderr.trim().slice(0, 200) };
} catch (error) {
return {
available: false,
version: error instanceof Error ? error.message.slice(0, 200) : "unknown error",
};
}
}
/** # 通过本机 socket 调用 Docker Engine API */
private localApi<T>(path: string, method = "GET"): Promise<T> {
return new Promise<T>((resolve, reject) => {
const req = httpRequest(
{
socketPath: DockerService.LOCAL_SOCKET,
path: `http://localhost/v1.41${path}`,
method,
timeout: 15_000,
},
(res) => {
let body = "";
res.on("data", (c) => (body += c.toString("utf8")));
res.on("end", () => {
if ((res.statusCode ?? 500) >= 400) {
reject(ApiError.upstream(`Docker API ${path} -> ${res.statusCode}: ${body.slice(0, 200)}`));
return;
}
try {
resolve(body ? (JSON.parse(body) as T) : (undefined as T));
} catch {
resolve(undefined as T);
}
});
},
);
req.on("timeout", () => {
req.destroy();
reject(ApiError.timeout("Docker API timeout"));
});
req.on("error", (err) => reject(ApiError.upstream(`Docker API error: ${err.message}`)));
req.end();
});
}
/** # 本机 socket 无返回体调用 */
private async localApiVoid(path: string, method: string): Promise<void> {
await this.localApi<unknown>(path, method);
}
}
/** # 容器 ID 白名单校验(防注入;docker id 为 [0-9a-f]{12,64} 或 name) */
export function assertSafeContainerId(id: string): string {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/.test(id)) {
throw ApiError.validation("Invalid container id");
}
return id;
}
/** # Docker Engine API 容器对象 → 摘要视图 */
function mapEngineContainer(row: Record<string, unknown>): DockerContainerInfo {
const names = (row.Names as string[] | undefined) ?? [];
const ports = (row.Ports as Record<string, unknown>[] | undefined) ?? [];
return {
id: (row.Id as string | undefined)?.slice(0, 12) ?? "",
name: (names[0] ?? "").replace(/^\//, ""),
image: (row.Image as string | undefined) ?? "",
state: (row.State as string | undefined) ?? "",
status: (row.Status as string | undefined) ?? "",
ports,
labels: (row.Labels as Record<string, string> | undefined) ?? {},
};
}
@@ -0,0 +1,178 @@
/**
* # SSH 集成客户端
* 基于 ssh2 的统一封装:连接池化(每目标复用)、命令执行(强制超时)、连通性探测。
* 安全约束:所有命令必须带超时;凭据只在内存中短暂存在。
*/
import { Injectable, OnModuleDestroy } from "@nestjs/common";
import { Client, type ClientCallback, type ConnectConfig } from "ssh2";
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
import { ApiError } from "../../common/errors/api-error";
/** # 连接参数 **/
export interface SshTarget {
host: string;
port: number;
username: string;
/** # 密码或私钥二选一 **/
password?: string;
privateKey?: string;
/** # 私钥口令(可选) **/
passphrase?: string;
}
/** # 命令执行结果 **/
export interface SshExecResult {
code: number;
stdout: string;
stderr: string;
/** # 是否因超时被终止 **/
timedOut: boolean;
}
/** # 默认连接超时(毫秒) **/
const CONNECT_TIMEOUT_MS = 10_000;
/** # 默认命令超时(毫秒) **/
const DEFAULT_EXEC_TIMEOUT_MS = 30_000;
@Injectable()
export class SshClientService implements OnModuleDestroy {
/** # 连接缓存 key → Client **/
private connections = new Map<string, { client: Client; busy: boolean }>();
/** # 日志器 **/
private readonly logger: StructuredLoggerInstance;
constructor(loggers: LogService) {
this.logger = loggers.child("SshClient");
}
/** # 连接缓存键(不含凭据) */
private cacheKey(target: SshTarget): string {
return `${target.username}@${target.host}:${target.port}`;
}
/** # 建立或复用连接
* @returns 就绪的 ssh2 Client
*/
private async connect(target: SshTarget): Promise<Client> {
const key = this.cacheKey(target);
const cached = this.connections.get(key);
if (cached) {
// 已缓存的连接复用(ssh2 Client 支持串行 exec)
return cached.client;
}
const config: ConnectConfig = {
host: target.host,
port: target.port,
username: target.username,
readyTimeout: CONNECT_TIMEOUT_MS,
keepaliveInterval: 10_000,
tryKeyboard: false,
};
if (target.privateKey) {
config.privateKey = target.privateKey;
if (target.passphrase) config.passphrase = target.passphrase;
} else if (target.password) {
config.password = target.password;
} else {
throw ApiError.validation("SSH target requires password or privateKey");
}
return new Promise<Client>((resolve, reject) => {
const client = new Client();
const timer = setTimeout(() => {
client.end();
reject(ApiError.timeout(`SSH connect timeout after ${CONNECT_TIMEOUT_MS}ms`));
}, CONNECT_TIMEOUT_MS + 2000);
client
.on("ready", () => {
clearTimeout(timer);
this.connections.set(key, { client, busy: false });
resolve(client);
})
.on("error", (err: Error) => {
clearTimeout(timer);
this.connections.delete(key);
reject(
new ApiError(
502,
"UPSTREAM_FAILURE",
`SSH connection failed: ${err.message}`,
),
);
})
.connect(config);
});
}
/** # 在目标机上执行命令(强制超时)
* @param target SSH 目标
* @param command 待执行命令
* @param timeoutMs 命令超时(默认 30s)
*/
async exec(target: SshTarget, command: string, timeoutMs = DEFAULT_EXEC_TIMEOUT_MS): Promise<SshExecResult> {
const client = await this.connect(target);
return new Promise<SshExecResult>((resolve, reject) => {
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
// 超时后断开整条连接,避免流挂起
this.closeKey(this.cacheKey(target));
reject(ApiError.timeout(`SSH command timed out after ${timeoutMs}ms`));
}, timeoutMs);
client.exec(command, ((err: Error | undefined, stream) => {
if (err) {
clearTimeout(timer);
this.closeKey(this.cacheKey(target));
reject(new ApiError(502, "UPSTREAM_FAILURE", `SSH exec failed: ${err.message}`));
return;
}
let stdout = "";
let stderr = "";
stream.on("data", (chunk: Buffer) => {
stdout += chunk.toString("utf8");
});
stream.stderr.on("data", (chunk: Buffer) => {
stderr += chunk.toString("utf8");
});
stream.on("close", (code: number | null) => {
clearTimeout(timer);
if (timedOut) return;
resolve({ code: code ?? -1, stdout, stderr, timedOut: false });
});
}) as ClientCallback);
});
}
/** # 连通性探测:执行 echo 并测量耗时
* @returns 延迟毫秒与远端 uname 信息
*/
async check(target: SshTarget): Promise<{ latencyMs: number; uname: string }> {
const start = Date.now();
const result = await this.exec(target, "uname -srm && cat /etc/os-release 2>/dev/null | head -2", 15_000);
return { latencyMs: Date.now() - start, uname: result.stdout.trim() };
}
/** # 关闭指定目标的连接 */
private closeKey(key: string): void {
const cached = this.connections.get(key);
if (cached) {
try {
cached.client.end();
} catch {
/* ignore */
}
this.connections.delete(key);
}
}
/** # 关闭所有连接 */
closeAll(): void {
for (const [key] of this.connections) this.closeKey(key);
}
/** # 停止时清理连接 */
onModuleDestroy(): void {
this.closeAll();
}
}
@@ -0,0 +1,12 @@
/**
* # SSH 集成模块
*/
import { Global, Module } from "@nestjs/common";
import { SshClientService } from "./ssh-client.service";
@Global()
@Module({
providers: [SshClientService],
exports: [SshClientService],
})
export class SshModule {}
+96
View File
@@ -0,0 +1,96 @@
/**
* # DevOS API 入口
* NestJS + Fastify 适配器,全局前缀 /api/v1(05-API-SPEC)。
* 启动:结构化日志、全局错误契约、CORS(开发跨源)、优雅停机。
* 生产模式(WEB_DIST 存在):同进程托管 Web 静态资源 + SPA fallback(nginx 纯反代即可)。
*/
import { NestFactory } from "@nestjs/core";
import { FastifyAdapter, type NestFastifyApplication } from "@nestjs/platform-fastify";
import { Logger as NestConsoleLogger } from "@nestjs/common";
import { HttpAdapterHost } from "@nestjs/core";
import { createReadStream, existsSync, statSync } from "node:fs";
import { join, extname, resolve, sep } from "node:path";
import type { FastifyReply, FastifyRequest } from "fastify";
import { AppModule } from "./app.module";
import { LogService } from "./common/logger/logger.module";
import { registerRequestContextHook } from "./common/middleware/request-context.hook";
/** # 静态资源 MIME 映射(覆盖 SPA 所需集合) **/
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".ico": "image/x-icon",
".woff": "font/woff",
".woff2": "font/woff2",
".map": "application/json",
};
/** # 注册 Web 静态托管(生产):静态文件命中即服务;非 /api 的 GET 回退 index.html(SPA fallback) */
function registerWebStatic(app: NestFastifyApplication): void {
const webDist = resolve(process.env.WEB_DIST ?? join(process.cwd(), "apps/web/dist"));
if (!existsSync(webDist)) return;
const fastify = app.get(HttpAdapterHost).httpAdapter.getInstance();
fastify.get("/*", (req: FastifyRequest, reply: FastifyReply) => {
const url = req.raw.url ?? "/";
if (url.startsWith("/api/") || url.startsWith("/mcp")) {
return reply.code(404).send({ error: { code: "RESOURCE_NOT_FOUND", message: "Not found", requestId: (req as unknown as { requestId?: string }).requestId ?? "" } });
}
const rel = (req.params as Record<string, string>)["*"] ?? "";
const file = resolve(webDist, rel);
if (file.startsWith(webDist + sep) && existsSync(file) && statSync(file).isFile()) {
const mime = MIME[extname(file)] ?? "application/octet-stream";
const cache = rel.startsWith("assets/") || /-[\w]{8}\.\w+$/.test(rel) ? "public, max-age=31536000, immutable" : "no-cache";
return reply.header("Content-Type", mime).header("Cache-Control", cache).send(createReadStream(file));
}
// 带扩展名的未命中 = 真实静态资源缺失,返回 404(避免 fallback 返回 HTML 破坏模块加载)
if (/\.[a-zA-Z0-9]{1,6}$/.test(rel)) {
return reply.code(404).send({ error: { code: "RESOURCE_NOT_FOUND", message: "Asset not found", requestId: (req as unknown as { requestId?: string }).requestId ?? "" } });
}
// SPA fallback(仅无扩展名的页面路径)
return reply
.header("Content-Type", "text/html; charset=utf-8")
.header("Cache-Control", "no-cache")
.send(createReadStream(join(webDist, "index.html")));
});
console.log(JSON.stringify({ time: new Date().toISOString(), level: "log", ctx: "Bootstrap", msg: `web static served from ${webDist}` }));
}
/** # 引导 API 应用 */
async function bootstrap(): Promise<void> {
const adapter = new FastifyAdapter({ trustProxy: true, bodyLimit: 2 * 1024 * 1024 });
const app = await NestFactory.create<NestFastifyApplication>(AppModule, adapter, {
bufferLogs: false,
rawBody: true, // webhook HMAC 验签需要原始字节
});
// 结构化日志接管 Nest 内部日志
app.useLogger(new LogService().child("Nest"));
NestConsoleLogger.overrideLogger(false);
const fastify = app.get(HttpAdapterHost).httpAdapter.getInstance();
registerRequestContextHook(fastify);
app.setGlobalPrefix("api/v1");
app.enableCors({ origin: true, methods: ["GET", "POST", "PATCH", "DELETE", "OPTIONS"] });
app.enableShutdownHooks();
registerWebStatic(app);
const port = Number(process.env.PORT ?? 4100);
const host = process.env.HOST ?? "0.0.0.0";
await app.listen(port, host);
console.log(
JSON.stringify({ time: new Date().toISOString(), level: "log", ctx: "Bootstrap", msg: `DevOS API listening on http://${host}:${port}/api/v1` }),
);
}
void bootstrap().catch((error) => {
console.error("bootstrap failed", error);
process.exit(1);
});
@@ -0,0 +1,111 @@
/**
* # AI 聊天控制器(/api/v1/ai/chat)
* 对话式操作入口;审批复用 /ai/runs/:id/approve。
* 提供两种形态:
* - POST /ai/chat 普通 JSON 响应
* - POST /ai/chat/stream SSE 流式(status/tool/reply_token/done 事件,现代聊天体验)
*/
import { Body, Controller, Post, Res } from "@nestjs/common";
import type { FastifyReply } from "fastify";
import { z } from "zod";
import { AiChatService, type ChatTurn, type ChatResponse } from "./ai-chat.service";
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
import { CurrentUser, CurrentWorkspace, Roles } from "../../common/decorators";
import type { RequestUser, RequestWorkspace } from "../../common/decorators";
import { AuditService } from "../audit/audit.service";
const ChatSchema = z.object({
message: z.string().min(1).max(4000),
history: z
.array(z.object({ role: z.enum(["user", "assistant"]), content: z.string().max(4000) }))
.max(20)
.default([]),
});
@Controller("ai/chat")
export class AiChatController {
constructor(
private readonly chat: AiChatService,
private readonly audit: AuditService,
) {}
/** # 发送一条聊天消息(普通 JSON 响应) */
@Roles("MEMBER")
@Post()
async send(
@CurrentWorkspace() ws: RequestWorkspace,
@CurrentUser() user: RequestUser,
@Body(new ZodValidationPipe(ChatSchema)) dto: { message: string; history: ChatTurn[] },
) {
const response = await this.chat.chat(ws.id, user.id, dto.message, dto.history);
await this.auditRecord(ws.id, user.id, dto.message, response);
return response;
}
/** # 流式聊天(SSE):
* event: status 进度文本("正在执行 devos.server.list…")
* event: tool 单个工具执行结果 chip
* event: reply_token 回复文本分片(模拟逐字流式)
* event: done 完整 ChatResponse(含 approval/refs)
*/
@Roles("MEMBER")
@Post("stream")
async stream(
@CurrentWorkspace() ws: RequestWorkspace,
@CurrentUser() user: RequestUser,
@Body(new ZodValidationPipe(ChatSchema)) dto: { message: string; history: ChatTurn[] },
@Res() reply: FastifyReply,
) {
reply.raw.writeHead(200, {
"Content-Type": "text/event-stream; charset=utf-8",
"Cache-Control": "no-cache",
Connection: "keep-alive",
"X-Accel-Buffering": "no",
});
const send = (event: string, data: unknown) => {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
try {
const response: ChatResponse = await this.chat.chat(ws.id, user.id, dto.message, dto.history, (event) => {
if (event.type === "tool_start") {
send("status", { text: `正在执行 ${event.text}…` });
} else if (event.type === "tool" && event.call) {
send("tool", event.call);
}
});
// 回复分片(模拟逐字流式;真实 token 流待 provider stream 接入)
const chunks = response.reply.match(/[\s\S]{1,24}/g) ?? [response.reply];
for (const chunk of chunks) {
send("reply_token", { text: chunk });
await new Promise((r) => setTimeout(r, 18));
}
send("done", response);
await this.auditRecord(ws.id, user.id, dto.message, response);
} catch (error) {
send("done", {
reply: `出错了:${error instanceof Error ? error.message : "未知错误"}`,
engine: "rules",
toolCalls: [],
} satisfies ChatResponse);
}
reply.raw.end();
}
/** # 聊天审计 */
private async auditRecord(workspaceId: string, userId: string, message: string, response: ChatResponse) {
await this.audit.record({
workspaceId,
actorType: "USER",
actorId: userId,
action: "ai.chat",
resourceType: "AiRun",
riskLevel: response.approval ? "HIGH" : "LOW",
metadata: {
message: message.slice(0, 200),
engine: response.engine,
executed: response.toolCalls.length,
approvalRequired: Boolean(response.approval),
},
});
}
}
+657
View File
@@ -0,0 +1,657 @@
/**
* # AI 聊天服务(对话式操作入口)
* 让用户用自然语言驱动整个 DevOS,而不是手动填表单。
*
* 双引擎:
* 1. LLM 引擎(配置 AI_PROVIDER 后自动启用):OpenAI function-calling 多轮循环,
* 模型自主选择 ToolRegistry 工具并填参(工作区快照注入上下文用于名称→ID 解析);
* 2. 规则引擎(无 LLM 时兜底,保证开箱可用):意图匹配核心操作。
*
* 安全:所有执行走 ToolRegistry(与 UI/MCP 同一授权与审计);
* HIGH/CRITICAL 工具不直接执行,而是创建 AWAITING_APPROVAL 的 AiRun 返回审批卡片。
*/
import { Injectable } from "@nestjs/common";
import { zodToJsonSchema } from "zod-to-json-schema";
import type { ResourceStatus } from "@prisma/client";
import { PrismaService } from "../../prisma/prisma.service";
import { ConfigService } from "../../common/config/config.service";
import { AiProviderService } from "../../common/ai/ai-provider.service";
import { ToolRegistryService, type ToolContext } from "./tool-registry.service";
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
import { maskSecrets } from "../../common/utils/mask";
/** # 聊天允许使用的工具白名单(删除类操作刻意不开放,防误删) **/
const CHAT_TOOLS = [
"devos.server.list",
"devos.server.find",
"devos.server.status",
"devos.server.exec",
"devos.server.getDockerContainers",
"devos.website.list",
"devos.website.create",
"devos.website.deploy",
"devos.website.logs",
"devos.website.health",
"devos.deployment.list",
"devos.deployment.get",
"devos.domain.list",
"devos.domain.find",
"devos.domain.records",
"devos.domain.upsertRecord",
"devos.monitor.list",
"devos.automation.list",
"devos.automation.run",
"devos.task.list",
"devos.task.create",
"devos.task.update",
"devos.task.delete",
"devos.note.list",
"devos.note.create",
"devos.note.update",
"devos.note.delete",
"devos.web.fetch",
"devos.repository.createFromUrl",
] as const;
/** # 聊天消息(前端回传的简史) **/
export interface ChatTurn {
role: "user" | "assistant";
content: string;
}
/** # 已执行的工具步骤(回显) **/
export interface ChatToolCall {
tool: string;
args: Record<string, unknown>;
ok: boolean;
summary: string;
}
/** # 聊天响应 **/
export interface ChatResponse {
reply: string;
engine: "llm" | "rules";
toolCalls: ChatToolCall[];
/** # HIGH/CRITICAL 操作待审批卡片 **/
approval?: { runId: string; steps: { tool: string; args: Record<string, unknown>; risk: string }[] };
/** # 自动执行产生的引用(如新任务 ID) **/
refs?: { type: string; id: string; name: string }[];
}
/** # 单条快照行 **/
interface SnapshotItem {
id: string;
name: string;
status?: string;
extra?: string;
}
@Injectable()
export class AiChatService {
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly ai: AiProviderService,
private readonly tools: ToolRegistryService,
loggers: LogService,
) {
this.logger = loggers.child("AiChat");
}
/** # 日志器 **/
private readonly logger: StructuredLoggerInstance;
/** # 主入口:处理一条用户消息
* @param onEvent 可选进度回调(工具开始/完成实时上报,供流式 UI)
*/
async chat(
workspaceId: string,
userId: string,
message: string,
history: ChatTurn[],
onEvent?: (event: { type: "status" | "tool_start" | "tool"; text?: string; call?: ChatToolCall }) => void,
): Promise<ChatResponse> {
const snapshot = await this.buildSnapshot(workspaceId);
const ctx: ToolContext = { workspaceId, actorType: "USER", actorId: userId };
if (this.ai.enabled) {
try {
return await this.llmChat(workspaceId, ctx, message, history, snapshot, onEvent);
} catch (error) {
this.logger.warn(
`LLM chat failed, falling back to rules: ${error instanceof Error ? error.message : ""}`,
);
}
}
return this.rulesChat(workspaceId, ctx, message, snapshot, onEvent);
}
// ─────────────────────────── LLM 引擎 ───────────────────────────
/** # LLM function-calling 多轮循环 */
private async llmChat(
workspaceId: string,
ctx: ToolContext,
message: string,
history: ChatTurn[],
snapshot: string,
onEvent?: (event: { type: "status" | "tool_start" | "tool"; text?: string; call?: ChatToolCall }) => void,
): Promise<ChatResponse> {
const chatTools = CHAT_TOOLS.map((name) => this.tools.get(name)).filter(Boolean);
const toolsParam = chatTools.map((t) => ({
type: "function" as const,
function: {
name: t!.name,
description: `${t!.description}(风险等级 ${t!.risk})`,
parameters: zodToJsonSchema(t!.schema as never, { $refStrategy: "none" }),
},
}));
const messages: Record<string, unknown>[] = [
{
role: "system",
content: [
"你是 DevOS 的运维助手,能通过工具完成平台上的几乎所有操作。今天是 " + new Date().toISOString().slice(0, 10) + "。",
"",
"## 能力地图",
"- 查询:server/website/domain/container/task/note/deployment/monitor/automation 列表与详情",
"- 创建:任务、笔记、网站(website.create)、仓库登记(repository.createFromUrl)、DNS 记录(domain.upsertRecord)",
"- 操作:部署(website.deploy)、重启容器(container.restart)、服务器命令(server.exec)、运行自动化",
"- 编辑/删除:task/note 的 update 与 delete;website/server/domain 的 delete",
"- 网页:web.fetch 抓取 URL 正文(供总结/摘录)",
"",
"## 复合任务编排(重要)",
"用户常一次提出多步需求,请按依赖顺序组合工具完成:",
"例1「总结 <url> 存到笔记」:web.fetch → 自己提炼总结 → note.create(content 用简洁 HTML:h3+p/ul,开头附原文链接)",
"例2「把 <github url> 安装到 <服务器名> 并用 <域名> 解析(CF 代理)」:",
" 1. repository.createFromUrl {url}",
" 2. server.find {name:<服务器名>} → 取 id",
" 3. domain.find {name:<域名根>} → 取 id(若无域名记录,用 domain.upsertRecord 直接建)",
" 4. website.create {name, serverId, repositoryId, primaryDomain:<域名>}",
" 5. domain.upsertRecord {domainId, type:'A', name:<域名或子域>, value:<服务器公网 IP=host>, proxied:true}",
" 6. website.deploy {websiteId}(HIGH,系统会转用户审批)",
" 7. website.health 验证",
"",
"## 规则",
"- 只能使用提供的工具;ID 必须来自工具返回或快照,禁止编造。",
"- 名称解析:优先 server.find/domain.find,其次快照。",
"- HIGH/CRITICAL 工具(部署/重启容器/删除/删除服务器)也直接调用——系统会自动转为用户审批卡片,你只需说明已提交审批。",
"- 删除前必须与用户确认过上下文;不确定时先查询再行动。",
"- 回答简洁、用用户的语言(默认中文);汇报做了什么、结果如何;不要复读工具原始 JSON。",
"",
"## 当前工作区快照",
snapshot,
].join("\n"),
},
...history.slice(-8).map((h) => ({ role: h.role, content: h.content })),
{ role: "user", content: message },
];
const toolCalls: ChatToolCall[] = [];
const pendingApproval: { tool: string; args: Record<string, unknown>; risk: string }[] = [];
let iterations = 0;
let finalText = "";
while (iterations < 6) {
iterations += 1;
const res = await this.rawChatCompletion(messages, toolsParam);
const choice = res.choices?.[0];
if (!choice) throw new Error("empty completion");
const msg = choice.message as {
content?: string | null;
tool_calls?: { id: string; function: { name: string; arguments: string } }[];
};
if (!msg.tool_calls || msg.tool_calls.length === 0) {
finalText = msg.content ?? "(无回复)";
break;
}
messages.push({ role: "assistant", content: msg.content ?? null, tool_calls: msg.tool_calls });
for (const call of msg.tool_calls) {
const tool = this.tools.get(call.function.name);
if (!tool) {
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ error: "unknown tool" }) });
continue;
}
let args: Record<string, unknown> = {};
try {
args = JSON.parse(call.function.arguments || "{}") as Record<string, unknown>;
} catch {
args = {};
}
// HIGH/CRITICAL → 转审批,不直接执行
if (tool.risk === "HIGH" || tool.risk === "CRITICAL") {
pendingApproval.push({ tool: tool.name, args, risk: tool.risk });
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify({ ok: true, note: "submitted for user approval" }),
});
continue;
}
onEvent?.({ type: "tool_start", text: tool.name });
const rec: ChatToolCall = { tool: tool.name, args: maskSecrets(args) as Record<string, unknown>, ok: false, summary: "" };
try {
const result = await tool.execute(ctx, args as never);
rec.ok = true;
rec.summary = summarize(result);
messages.push({
role: "tool",
tool_call_id: call.id,
content: JSON.stringify(maskSecrets(result)).slice(0, 6000),
});
} catch (error) {
const err = error instanceof Error ? error.message : String(error);
rec.summary = err.slice(0, 200);
messages.push({ role: "tool", tool_call_id: call.id, content: JSON.stringify({ error: err.slice(0, 500) }) });
}
toolCalls.push(rec);
onEvent?.({ type: "tool", call: rec });
}
if (pendingApproval.length > 0) break; // 有审批待确认,先返回
}
if (pendingApproval.length > 0) {
const run = await this.createApprovalRun(workspaceId, ctx.actorId ?? "", message, pendingApproval);
return {
reply: `以下操作风险较高,需要你确认后执行:\n${pendingApproval
.map((s) => `• ${s.tool}(${s.risk})`)
.join("\n")}\n确认后我会立即执行。`,
engine: "llm",
toolCalls,
approval: { runId: run.id, steps: pendingApproval },
};
}
if (!finalText) finalText = summarizeDone(toolCalls);
return { reply: finalText, engine: "llm", toolCalls };
}
/** # 调用 OpenAI 兼容 chat/completions(带工具) */
private async rawChatCompletion(
messages: Record<string, unknown>[],
tools: unknown[],
): Promise<{ choices?: { message?: unknown }[] }> {
const res = await fetch(`${this.config.aiBaseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.config.aiApiKey}` },
body: JSON.stringify({
model: this.config.aiModel,
messages,
tools,
temperature: 0.2,
}),
signal: AbortSignal.timeout(90_000),
});
if (!res.ok) throw new Error(`AI provider HTTP ${res.status}`);
return (await res.json()) as { choices?: { message?: unknown }[] };
}
/** # 创建待审批运行(复用 AI Gateway 的审批执行管线) */
private async createApprovalRun(
workspaceId: string,
_userId: string,
objective: string,
steps: { tool: string; args: Record<string, unknown>; risk: string }[],
): Promise<{ id: string }> {
const plan = steps.map((s) => ({ tool: s.tool, args: s.args, reason: "chat request", risk: s.risk }));
const risk = steps.some((s) => s.risk === "CRITICAL") ? "CRITICAL" : "HIGH";
return this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: { workspaceId, type: "AI_RUN", name: objective.slice(0, 80), provider: "chat", status: "PENDING" },
});
return tx.aiRun.create({
data: {
workspaceId,
resourceId: resource.id,
objective: objective.slice(0, 2000),
status: "AWAITING_APPROVAL",
source: "CHAT",
riskLevel: risk,
plan: plan as unknown as object,
},
select: { id: true },
});
});
}
// ─────────────────────────── 规则引擎(离线兜底) ───────────────────────────
/** # 规则引擎:意图匹配核心操作 */
private async rulesChat(
workspaceId: string,
ctx: ToolContext,
message: string,
snapshot: string,
onEvent?: (event: { type: "status" | "tool_start" | "tool"; text?: string; call?: ChatToolCall }) => void,
): Promise<ChatResponse> {
const text = message.trim();
const toolCalls: ChatToolCall[] = [];
const run = async (toolName: string, args: Record<string, unknown>): Promise<unknown | null> => {
const tool = this.tools.get(toolName);
if (!tool) return null;
onEvent?.({ type: "tool_start", text: toolName });
try {
const result = await tool.execute(ctx, args as never);
const call: ChatToolCall = { tool: toolName, args, ok: true, summary: summarize(result) };
toolCalls.push(call);
onEvent?.({ type: "tool", call });
return result;
} catch (error) {
const err = error instanceof Error ? error.message : String(error);
const call: ChatToolCall = { tool: toolName, args, ok: false, summary: err.slice(0, 200) };
toolCalls.push(call);
onEvent?.({ type: "tool", call });
return { __error: err };
}
};
// 帮助
if (/帮助|help|你能做什么|能干什么/i.test(text)) {
return { reply: HELP_TEXT, engine: "rules", toolCalls: [] };
}
// URL 总结 → 存笔记(规则引擎用抽取式摘要兜底;LLM 模式下由模型深度总结)
const urlInText = text.match(/https?:\/\/[^\s,。;))]+/);
if (urlInText && /总结|摘要|summary|保存到?笔记|记到笔记/i.test(text)) {
const url = urlInText[0];
const fetchTool = this.tools.get("devos.web.fetch")!;
try {
const page = (await fetchTool.execute(ctx, { url })) as { title?: string; description?: string; text?: string };
const title = (page.title ?? url).slice(0, 120);
// 抽取式摘要:meta 描述 + 前几段
const paras = (page.text ?? "").split("\n\n").filter((p) => p && !p.startsWith("# ")).slice(0, 5);
const summaryHtml = [
`<p>📄 来源:<a href="${url}">${url}</a></p>`,
page.description ? `<p><strong>摘要:</strong>${page.description}</p>` : "",
"<ul>" + paras.map((p) => `<li>${p.slice(0, 260)}</li>`).join("") + "</ul>",
"<p><em>配置 LLM 后可获得更高质量的深度总结。</em></p>",
].filter(Boolean).join("");
const noteTool = this.tools.get("devos.note.create")!;
const note = (await noteTool.execute(ctx, {
title: `总结:${title}`,
content: summaryHtml,
tags: ["summary"],
})) as { id: string; title: string };
return {
reply: `✅ 已抓取并总结「${title}」,保存为笔记「${note.title}」。可在「笔记」页面查看与编辑(配置 LLM 后总结质量更高)。`,
engine: "rules",
toolCalls: [
{ tool: "devos.web.fetch", args: { url }, ok: true, summary: title },
{ tool: "devos.note.create", args: { title: note.title }, ok: true, summary: note.id },
],
refs: [{ type: "NOTE", id: note.id, name: note.title }],
};
} catch (error) {
return {
reply: `抓取页面失败:${error instanceof Error ? error.message : "unknown"}。请确认 URL 可公网访问。`,
engine: "rules",
toolCalls,
};
}
}
// 创建任务
const taskM = text.match(/(?:创建|新建|添加|记录)个?任务[::,,\s]*(.+)/);
if (taskM) {
const title = taskM[1].slice(0, 120).trim();
const result = await run("devos.task.create", { title });
if (result && !(result as { __error?: string }).__error) {
const t = result as { id: string; title: string };
return {
reply: `✅ 已创建任务「${t.title}」`,
engine: "rules",
toolCalls,
refs: [{ type: "TASK", id: t.id, name: t.title }],
};
}
}
// 创建笔记
const noteM = text.match(/(?:创建|新建|添加|记)个?笔记[::,,\s]*(.+)/);
if (noteM) {
const title = noteM[1].slice(0, 120).trim();
const result = await run("devos.note.create", { title, content: "" });
if (result && !(result as { __error?: string }).__error) {
const n = result as { id: string; title: string };
return {
reply: `✅ 已创建笔记「${n.title}」,可到「笔记」页面编辑内容。`,
engine: "rules",
toolCalls,
refs: [{ type: "NOTE", id: n.id, name: n.title }],
};
}
}
// 部署网站(HIGH → 审批)
const deployM = text.match(/部署\s*网站?\s*[::]?\s*(\S+)|deploy\s+(\S+)/i);
if (deployM) {
const name = (deployM[1] ?? deployM[2] ?? "").replace(/网站|到\S+$/g, "").trim();
const site = await this.findWebsite(workspaceId, name);
if (!site) {
const sites = await this.prisma.website.findMany({ where: { workspaceId }, take: 10, select: { name: true } });
return {
reply: `没有找到名称包含「${name}」的网站。${sites.length ? `当前的网站有:${sites.map((s) => s.name).join("、")}` : "当前还没有网站,可到「网站」页面创建。"}`,
engine: "rules",
toolCalls,
};
}
const run0 = await this.createApprovalRun(workspaceId, ctx.actorId ?? "", text, [
{ tool: "devos.website.deploy", args: { websiteId: site.id }, risk: "HIGH" },
]);
return {
reply: `⚠️ 部署是 HIGH 风险操作:将部署网站「${site.name}」。确认后执行(含健康验证)。`,
engine: "rules",
toolCalls,
approval: {
runId: run0.id,
steps: [{ tool: "devos.website.deploy", args: { websiteId: site.id }, risk: "HIGH" }],
},
};
}
// 重启容器(HIGH → 审批)
const restartM = text.match(/(?:重启|restart)\s*容器?\s*[::]?\s*(\S+)/i);
if (restartM) {
const name = restartM[1];
const container = await this.findContainer(workspaceId, name);
if (!container) {
const rows = await this.prisma.container.findMany({ where: { workspaceId }, take: 12, orderBy: { updatedAt: "desc" }, select: { name: true, state: true } });
return {
reply: `没有找到名称包含「${name}」的容器。${rows.length ? `运行中的容器如:${rows.map((c) => c.name).slice(0, 8).join("、")}` : "还没有发现过容器,先到服务器详情页执行「发现容器」。"}`,
engine: "rules",
toolCalls,
};
}
const run0 = await this.createApprovalRun(workspaceId, ctx.actorId ?? "", text, [
{ tool: "devos.container.restart", args: { id: container.id }, risk: "HIGH" },
]);
return {
reply: `⚠️ 重启容器是 HIGH 风险操作:将重启「${container.name}」(${container.image.slice(0, 30)})。确认后执行。`,
engine: "rules",
toolCalls,
approval: {
runId: run0.id,
steps: [{ tool: "devos.container.restart", args: { id: container.id }, risk: "HIGH" }],
},
};
}
// 列出/查看类
const listTarget = text.match(/(?:列出|查看|显示|有哪些|list)\s*(全部|所有|一下)?\s*(服务器|网站|域名|容器|任务|笔记|部署|监控|自动化|仓库|凭据)?/i);
if (listTarget) {
const target = listTarget[2] ?? inferTargetFromText(text);
if (target) {
const map: Record<string, string> = {
服务器: "devos.server.list",
网站: "devos.website.list",
域名: "devos.domain.list",
容器: "devos.server.getDockerContainers",
任务: "devos.task.list",
笔记: "devos.note.list",
部署: "devos.deployment.list",
监控: "devos.monitor.list",
自动化: "devos.automation.list",
};
const toolName = map[target];
if (toolName) {
// 容器列表需要 serverId:取第一台在线服务器
if (target === "容器") {
const firstServer = (await this.tools.get("devos.server.list")!.execute(ctx, {})) as { id: string }[] | undefined;
const sid = Array.isArray(firstServer) ? firstServer[0]?.id : undefined;
if (!sid) {
return { reply: "还没有登记服务器,无法发现容器。", engine: "rules", toolCalls };
}
const containers = await run("devos.server.getDockerContainers", { serverId: sid });
return { reply: renderList(target, containers), engine: "rules", toolCalls };
}
const result = await run(toolName, target === "任务" || target === "笔记" ? {} : {});
return { reply: renderList(target, result), engine: "rules", toolCalls };
}
}
}
// 网站健康/状态
const healthM = text.match(/(健康|health|状态|status|在线吗|正常吗)/i);
if (healthM) {
const site = await this.findAnyWebsite(workspaceId);
if (site) {
const result = await run("devos.website.health", { id: site.id });
const r = result as { ok?: boolean; status?: number; latencyMs?: number; error?: string };
return {
reply: r?.ok
? `✅ 「${site.name}」健康:HTTP ${r.status}(${r.latencyMs ?? "?"}ms)`
: `❌ 「${site.name}」异常:${r?.error ?? `HTTP ${r?.status}`}`,
engine: "rules",
toolCalls,
};
}
}
return {
reply: `我暂时没理解这句话。我可以(离线模式,配置 LLM 后理解任意表达):\n\n• 列出服务器 / 网站 / 域名 / 容器 / 任务 / 部署\n• 创建任务:<标题>\n• 创建笔记:<标题>\n• 部署网站:<名称>(需确认)\n• 重启容器:<名称>(需确认)\n• 网站健康检查\n• 帮助`,
engine: "rules",
toolCalls,
};
}
// ─────────────────────────── 上下文与解析 ───────────────────────────
/** # 构建工作区快照(名称→ID 解析用) */
private async buildSnapshot(workspaceId: string): Promise<string> {
const [servers, websites, domains, containers, tasks] = await this.prisma.$transaction([
this.prisma.server.findMany({ where: { workspaceId }, take: 15, include: { resource: { select: { status: true } } } }),
this.prisma.website.findMany({ where: { workspaceId }, take: 15, include: { resource: { select: { status: true } } } }),
this.prisma.domain.findMany({ where: { workspaceId }, take: 15 }),
this.prisma.container.findMany({ where: { workspaceId }, take: 20, orderBy: { updatedAt: "desc" } }),
this.prisma.task.findMany({ where: { workspaceId }, take: 10, orderBy: { updatedAt: "desc" } }),
]);
const lines: string[] = [];
lines.push(`服务器(${servers.length}): ${servers.map((s) => `${s.name}(id=${s.id}, ${s.resource.status})`).join(", ") || "无"}`);
lines.push(`网站(${websites.length}): ${websites.map((w) => `${w.name}(id=${w.id}, ${w.resource.status})`).join(", ") || "无"}`);
lines.push(`域名(${domains.length}): ${domains.map((d) => `${d.name}(id=${d.id})`).join(", ") || "无"}`);
lines.push(`容器(最近): ${containers.map((c) => `${c.name}(id=${c.id}, ${c.state})`).join(", ") || "无"}`);
lines.push(`任务(最近): ${tasks.map((t) => `${t.title}(status=${t.status})`).join(", ") || "无"}`);
return lines.join("\n");
}
/** # 模糊查找网站 */
private async findWebsite(workspaceId: string, name: string) {
const rows = await this.prisma.website.findMany({ where: { workspaceId } });
const lower = name.toLowerCase();
return (
rows.find((r) => r.name.toLowerCase() === lower) ??
rows.find((r) => r.name.toLowerCase().includes(lower)) ??
rows.find((r) => r.primaryDomain?.toLowerCase().includes(lower)) ??
null
);
}
/** # 任意网站(健康检查默认目标) */
private async findAnyWebsite(workspaceId: string) {
return this.prisma.website.findFirst({ where: { workspaceId }, orderBy: { updatedAt: "desc" } });
}
/** # 模糊查找容器 */
private async findContainer(workspaceId: string, name: string) {
const rows = await this.prisma.container.findMany({ where: { workspaceId } });
const lower = name.toLowerCase();
return rows.find((r) => r.name.toLowerCase() === lower) ?? rows.find((r) => r.name.toLowerCase().includes(lower)) ?? null;
}
}
/** # 从文本推断列表目标(规则引擎兜底) */
function inferTargetFromText(text: string): string | null {
const map: [RegExp, string][] = [
[/服务器|server/i, "服务器"],
[/网站|website/i, "网站"],
[/域名|domain/i, "域名"],
[/容器|container/i, "容器"],
[/任务|task/i, "任务"],
[/笔记|note/i, "笔记"],
[/部署|deployment/i, "部署"],
[/监控|monitor/i, "监控"],
[/自动化|automation/i, "自动化"],
];
for (const [re, label] of map) if (re.test(text)) return label;
return null;
}
/** # 渲染列表结果(人性化的简短文本,不吐 JSON) */
function renderList(target: string, result: unknown): string {
const rows = Array.isArray(result) ? (result as Record<string, unknown>[]) : [];
if (rows.length === 0) return `当前没有${target}。`;
const lines = rows.slice(0, 15).map((r) => {
const name = (r.name ?? r.title ?? r.fullName ?? "?") as string;
const status = (r.status ?? r.state ?? "") as string;
const extra = r.primaryDomain ?? r.host ?? "";
const image = r.image ? `(${String(r.image).slice(0, 32)})` : "";
return `- ${name}${status ? `(${status})` : ""}${extra ? ` — ${String(extra)}` : ""}${target === "容器" ? image : ""}`;
});
const more = rows.length > 15 ? `\n…及其它 ${rows.length - 15} 项` : "";
return `**${target}** 共 ${rows.length} 项:\n\n${lines.join("\n")}${more}`;
}
/** # 工具结果摘要(回显用,避免超长 JSON) */
function summarize(result: unknown): string {
if (Array.isArray(result)) {
const first = result[0] as Record<string, unknown> | undefined;
return `${result.length} 项${first ? `,如 ${String(first.name ?? first.title ?? first.id ?? "")}` : ""}`;
}
const r = result as Record<string, unknown> | null;
if (!r) return "完成";
if (r.title) return String(r.title);
if (r.name) return String(r.name);
if (r.ok !== undefined) return `ok=${r.ok}`;
return Object.keys(r).slice(0, 4).join(",");
}
/** # 无文本时的兜底总结 */
function summarizeDone(calls: ChatToolCall[]): string {
const okCount = calls.filter((c) => c.ok).length;
return `已完成 ${okCount}/${calls.length} 项操作。`;
}
/** # 能力说明(离线模式) */
const HELP_TEXT = [
"我是 DevOS 运维助手,可以直接帮我完成这些事:",
"",
"📋 任务与笔记",
" • 创建任务:修复登录页超时",
" • 创建笔记:部署排障手册",
" • 列出任务 / 列出笔记",
"",
"🖥️ 基础设施",
" • 列出服务器 / 列出容器 / 列出监控",
" • 服务器连通状态(到详情页看完整指标)",
"",
"🌐 网站与部署",
" • 列出网站 / 网站健康检查",
" • 部署网站:<名称>(需要你确认后执行)",
" • 重启容器:<名称>(需要你确认后执行)",
"",
"⚙️ 其它",
" • 列出域名 / 部署 / 自动化;运行自动化:<名称>",
"",
"配置 LLM(AI_PROVIDER_*)后,可以用任意自然语言表达,我会自动选择并组合工具完成操作。",
].join("\n");
@@ -0,0 +1,58 @@
/**
* # AI Gateway 控制器(/api/v1/ai)
*/
import { Body, Controller, Get, Param, Post } from "@nestjs/common";
import { AiGatewayService, CreateAiRunSchema } from "./ai-gateway.service";
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
import { CurrentUser, CurrentWorkspace, Roles } from "../../common/decorators";
import type { RequestUser, RequestWorkspace } from "../../common/decorators";
@Controller("ai/runs")
export class AiGatewayController {
constructor(private readonly ai: AiGatewayService) {}
/** # AI 运行列表 */
@Get()
list(@CurrentWorkspace() ws: RequestWorkspace) {
return this.ai.list(ws.id);
}
/** # 创建 AI 运行(规划→风控→审批/执行) */
@Roles("MEMBER")
@Post()
create(
@CurrentWorkspace() ws: RequestWorkspace,
@CurrentUser() user: RequestUser,
@Body(new ZodValidationPipe(CreateAiRunSchema)) dto: Parameters<AiGatewayService["create"]>[1],
) {
return this.ai.create(ws.id, dto);
}
/** # AI 运行详情 */
@Get(":id")
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.ai.get(ws.id, id);
}
/** # 审批(HIGH/CRITICAL 运行;仅 ADMIN+) */
@Roles("ADMIN")
@Post(":id/approve")
approve(
@CurrentWorkspace() ws: RequestWorkspace,
@Param("id") id: string,
@CurrentUser() user: RequestUser,
) {
return this.ai.approve(ws.id, id, user.id);
}
/** # 拒绝 */
@Roles("ADMIN")
@Post(":id/reject")
reject(
@CurrentWorkspace() ws: RequestWorkspace,
@Param("id") id: string,
@CurrentUser() user: RequestUser,
) {
return this.ai.reject(ws.id, id, user.id);
}
}
@@ -0,0 +1,395 @@
/**
* # AI Gateway 服务(Task 017)
* AI 运行抽象:objective → plan(LLM 或启发式)→ 风险评估 → 审批(HIGH/CRITICAL)→ 执行 → 验证 → 报告。
* 安全边界:AI 不绕过授权 —— 所有工具调用经由 ToolRegistry(与 UI/MCP 相同服务层 + 审计)。
*/
import { Injectable } from "@nestjs/common";
import { z } from "zod";
import type { AiRun, RiskLevel } from "@prisma/client";
import { PrismaService } from "../../prisma/prisma.service";
import { ConfigService } from "../../common/config/config.service";
import { AuditService } from "../audit/audit.service";
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
import { ToolRegistryService, maxRisk, escalateExecRisk, type ToolContext } from "./tool-registry.service";
import { maskSecrets } from "../../common/utils/mask";
import { ApiError } from "../../common/errors/api-error";
import { getRequestContext } from "../../common/request-context";
/** # 创建 AI 运行 DTO **/
export const CreateAiRunSchema = z.object({
objective: z.string().min(4).max(4000),
});
export type CreateAiRunDto = z.infer<typeof CreateAiRunSchema>;
/** # 计划步骤 **/
interface PlanStep {
tool: string;
args: Record<string, unknown>;
reason: string;
risk: RiskLevel;
}
/** # 执行历史行 **/
interface ToolCallRecord {
tool: string;
args: Record<string, unknown>;
ok: boolean;
result?: unknown;
error?: string;
at: string;
}
/** # 启发式规划规则:关键词 → 工具序列 **/
const HEURISTIC_RULES: { keywords: RegExp; steps: { tool: string; args?: Record<string, unknown>; reason: string }[] }[] = [
{
keywords: /deploy|发布|部署/i,
steps: [
{ tool: "devos.website.list", reason: "Find candidate websites" },
{ tool: "devos.website.deploy", args: { websiteId: "$(WEBSITE_ID)" }, reason: "Create deployment" },
{ tool: "devos.deployment.list", reason: "Locate latest deployment" },
{ tool: "devos.deployment.get", args: { id: "$(DEPLOYMENT_ID)" }, reason: "Check deployment status and logs" },
],
},
{
keywords: /server|服务器|ssh/i,
steps: [
{ tool: "devos.server.list", reason: "List servers" },
{ tool: "devos.server.status", args: { serverId: "$(SERVER_ID)" }, reason: "Check server status" },
],
},
{
keywords: /dns|domain|域名|record/i,
steps: [
{ tool: "devos.domain.list", reason: "List domains" },
{ tool: "devos.domain.records", args: { id: "$(DOMAIN_ID)" }, reason: "Read DNS records" },
],
},
{
keywords: /不可用|挂了|down|outage|为什么.*(不|无法)|analyze|diagnos|排查/i,
steps: [
{ tool: "devos.monitor.list", reason: "Find failing monitors" },
{ tool: "devos.website.list", reason: "List websites to locate the affected site" },
{ tool: "devos.website.health", args: { id: "$(WEBSITE_ID)" }, reason: "Run immediate health check for evidence" },
{ tool: "devos.deployment.list", reason: "Check recent deployments for regressions" },
],
},
{
keywords: /(创建|新建).*(任务|task)|record.*(issue|problem)|记录问题/i,
steps: [
{ tool: "devos.task.create", args: { title: "$(OBJECTIVE)" }, reason: "Create a task recording the issue" },
],
},
{
keywords: /重启.*(容器|container)|restart.*container|异常容器/i,
steps: [
{ tool: "devos.server.list", reason: "Locate servers" },
{ tool: "devos.server.getDockerContainers", args: { serverId: "$(SERVER_ID)" }, reason: "Discover containers to find the unhealthy one" },
{ tool: "devos.container.restart", args: { id: "$(CONTAINER_ID)" }, reason: "Restart the abnormal container (requires approval)" },
],
},
{
keywords: /health|status|监控|状态|健康/i,
steps: [
{ tool: "devos.monitor.list", reason: "Check monitors" },
{ tool: "devos.website.list", reason: "List websites" },
],
},
];
@Injectable()
export class AiGatewayService {
constructor(
private readonly prisma: PrismaService,
private readonly config: ConfigService,
private readonly audit: AuditService,
private readonly tools: ToolRegistryService,
loggers: LogService,
) {
this.logger = loggers.child("AiGateway");
}
/** # 日志器 **/
private readonly logger: StructuredLoggerInstance;
/** # 创建 AI 运行:规划 → 风险评估 →(HIGH/CRITICAL)等待审批 或 直接执行 */
async create(workspaceId: string, dto: CreateAiRunDto) {
const ctx = getRequestContext();
const run = await this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: { workspaceId, type: "AI_RUN", name: dto.objective.slice(0, 80), provider: "devos", status: "PENDING" },
});
return tx.aiRun.create({
data: {
workspaceId,
resourceId: resource.id,
objective: dto.objective,
status: "PLANNING",
},
});
});
// 规划(异步执行,创建接口立即返回)
void this.planAndMaybeExecute(run.id, workspaceId, dto.objective, ctx?.actorId).catch((error) => {
this.logger.error("ai run planning failed", error instanceof Error ? error : undefined);
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "ai.run.create",
resourceType: "AiRun",
resourceId: run.resourceId,
riskLevel: "MEDIUM",
metadata: { objective: dto.objective.slice(0, 200) },
});
return this.view(await this.prisma.aiRun.findUniqueOrThrow({ where: { id: run.id } }));
}
/** # 规划并(低风险时)自动执行 */
private async planAndMaybeExecute(runId: string, workspaceId: string, objective: string, actorId?: string): Promise<void> {
try {
const steps = await this.makePlan(objective);
const risk = maxRisk(steps.map((s) => s.risk));
await this.prisma.aiRun.update({
where: { id: runId },
data: { plan: steps as unknown as object, riskLevel: risk },
});
if (risk === "HIGH" || risk === "CRITICAL") {
await this.prisma.aiRun.update({ where: { id: runId }, data: { status: "AWAITING_APPROVAL" } });
return;
}
await this.prisma.aiRun.update({ where: { id: runId }, data: { status: "RUNNING" } });
await this.executePlan(runId, workspaceId, steps, actorId);
} catch (error) {
await this.prisma.aiRun.update({
where: { id: runId },
data: { status: "FAILED", error: error instanceof Error ? error.message.slice(0, 1000) : "planning failed" },
});
}
}
/** # 生成计划:优先 LLM(OpenAI 兼容),否则启发式规则 */
private async makePlan(objective: string): Promise<PlanStep[]> {
if (this.config.aiEnabled) {
try {
return await this.llmPlan(objective);
} catch (error) {
this.logger.warn(`LLM planning failed, falling back to heuristic: ${error instanceof Error ? error.message : ""}`);
}
}
return this.heuristicPlan(objective);
}
/** # 启发式规划 */
private heuristicPlan(objective: string): PlanStep[] {
for (const rule of HEURISTIC_RULES) {
if (rule.keywords.test(objective)) {
return rule.steps.map((s) => {
const tool = this.tools.get(s.tool);
const risk = tool?.risk ?? "MEDIUM";
return { tool: s.tool, args: s.args ?? {}, reason: s.reason, risk };
});
}
}
// 兜底:只读清单
return [
{ tool: "devos.server.list", args: {}, reason: "Default: list servers", risk: "LOW" },
{ tool: "devos.website.list", args: {}, reason: "Default: list websites", risk: "LOW" },
];
}
/** # LLM 规划(OpenAI 兼容 /chat/completions,要求 JSON 输出) */
private async llmPlan(objective: string): Promise<PlanStep[]> {
const toolList = this.tools.list();
const system = [
"You are the planner of DevOS, a developer infrastructure OS.",
"Given an objective, produce a short JSON execution plan.",
'Output format: {"steps":[{"tool":"devos.x.y","args":{},"reason":"..."}]}',
"Only use the provided tools. Prefer read-only tools. Never invent tools.",
"Unknown ids may use placeholders like $(WEBSITE_ID) which resolve at runtime.",
].join("\n");
const res = await fetch(`${this.config.aiBaseUrl.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: { "Content-Type": "application/json", Authorization: `Bearer ${this.config.aiApiKey}` },
body: JSON.stringify({
model: this.config.aiModel,
messages: [
{ role: "system", content: system },
{ role: "user", content: `Objective: ${objective}\nTools: ${JSON.stringify(toolList)}` },
],
response_format: { type: "json_object" },
}),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) throw new Error(`AI provider HTTP ${res.status}`);
const body = (await res.json()) as { choices?: { message?: { content?: string } }[] };
const content = body.choices?.[0]?.message?.content;
if (!content) throw new Error("AI provider returned no content");
const parsed = JSON.parse(content) as { steps?: { tool?: string; args?: Record<string, unknown>; reason?: string }[] };
const steps: PlanStep[] = [];
for (const s of parsed.steps ?? []) {
if (!s.tool) continue;
const tool = this.tools.get(s.tool);
if (!tool) continue; // 只允许注册表内工具
let risk: RiskLevel = tool.risk;
if (s.tool === "devos.server.exec") {
risk = escalateExecRisk(String((s.args as { command?: string })?.command ?? "")) ?? risk;
}
steps.push({ tool: s.tool, args: (s.args ?? {}) as Record<string, unknown>, reason: s.reason ?? "", risk });
}
if (steps.length === 0) throw new Error("LLM produced empty plan");
return steps;
}
/** # 审批(OWNER/ADMIN 由控制器守卫保证):批准后执行 */
async approve(workspaceId: string, id: string, approverId: string) {
const run = await this.prisma.aiRun.findFirst({ where: { id, workspaceId } });
if (!run) throw ApiError.notFound("AI run not found");
if (run.status !== "AWAITING_APPROVAL") throw ApiError.conflict(`Run is ${run.status}, not awaiting approval`);
await this.prisma.aiRun.update({
where: { id },
data: { status: "RUNNING", approvedBy: approverId, approvedAt: new Date() },
});
await this.audit.record({
workspaceId,
actorType: "USER",
actorId: approverId,
action: "ai.run.approve",
resourceType: "AiRun",
resourceId: run.resourceId,
riskLevel: run.riskLevel,
});
const steps = run.plan as unknown as PlanStep[];
void this.executePlan(id, workspaceId, steps ?? [], approverId)
.catch((e) => this.logger.error("approved execution failed", e instanceof Error ? e : undefined));
return this.view(await this.prisma.aiRun.findUniqueOrThrow({ where: { id } }));
}
/** # 拒绝 */
async reject(workspaceId: string, id: string, userId: string) {
const run = await this.prisma.aiRun.findFirst({ where: { id, workspaceId } });
if (!run) throw ApiError.notFound("AI run not found");
if (run.status !== "AWAITING_APPROVAL") throw ApiError.conflict(`Run is ${run.status}`);
await this.prisma.aiRun.update({ where: { id }, data: { status: "REJECTED", approvedBy: userId, approvedAt: new Date() } });
await this.audit.record({
workspaceId, actorType: "USER", actorId: userId,
action: "ai.run.reject", resourceType: "AiRun", resourceId: run.resourceId, riskLevel: run.riskLevel,
});
return this.view(await this.prisma.aiRun.findUniqueOrThrow({ where: { id } }));
}
/** # 执行计划:顺序调用工具(占位符解析)、写执行历史、领域验证 */
private async executePlan(runId: string, workspaceId: string, steps: PlanStep[], actorId?: string): Promise<void> {
const ctx: ToolContext = { workspaceId, actorType: "AGENT", actorId };
const calls: ToolCallRecord[] = [];
const placeholders: Record<string, string> = {};
let finalResult: unknown = null;
let verification: { ok: boolean; checks: { tool: string; ok: boolean; detail?: string }[] } | null = null;
try {
for (const step of steps) {
const tool = this.tools.get(step.tool);
if (!tool) throw new Error(`Unknown tool: ${step.tool}`);
const args = this.resolveArgs(step.args, placeholders);
try {
const result = await tool.execute(ctx, args as Record<string, unknown>);
calls.push({ tool: step.tool, args: maskSecrets(args) as Record<string, unknown>, ok: true, result: maskSecrets(result), at: new Date().toISOString() });
finalResult = result;
// 占位符回填(把列表结果的第一个 id 存起来)
this.harvestPlaceholders(step.tool, result, placeholders);
if (tool.verify) {
const v = await tool.verify(ctx, args, result);
verification = verification ?? { ok: true, checks: [] };
verification.checks.push({ tool: step.tool, ok: v.ok, detail: v.detail });
if (!v.ok) verification.ok = false;
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
calls.push({ tool: step.tool, args: maskSecrets(args) as Record<string, unknown>, ok: false, error: message.slice(0, 500), at: new Date().toISOString() });
throw new Error(`Step ${step.tool} failed: ${message}`);
}
await this.prisma.aiRun.update({ where: { id: runId }, data: { toolCalls: calls as unknown as object } });
}
await this.prisma.aiRun.update({
where: { id: runId },
data: {
status: verification && !verification.ok ? "FAILED" : "SUCCEEDED",
result: (finalResult ?? {}) as object,
verification: (verification ?? undefined) as object | undefined,
},
});
} catch (error) {
await this.prisma.aiRun.update({
where: { id: runId },
data: {
status: "FAILED",
error: error instanceof Error ? error.message.slice(0, 1000) : "execution failed",
toolCalls: calls as unknown as object,
},
});
}
}
/** # 解析 $(PLACEHOLDER) 参数 */
private resolveArgs(args: Record<string, unknown>, placeholders: Record<string, string>): Record<string, unknown> {
const resolved: Record<string, unknown> = {};
for (const [k, v] of Object.entries(args)) {
if (typeof v === "string") {
const m = v.match(/^\$\(([A-Z_]+)\)$/);
resolved[k] = m ? placeholders[m[1]] ?? v : v;
} else {
resolved[k] = v;
}
}
return resolved;
}
/** # 从工具结果提取常用 ID(占位符回填) */
private harvestPlaceholders(tool: string, result: unknown, placeholders: Record<string, string>): void {
if (!Array.isArray(result)) return;
const first = result[0] as Record<string, unknown> | undefined;
if (!first) return;
if (first.id) placeholders.WEBSITE_ID ??= String(first.id);
if (tool === "devos.domain.list" && first.id) placeholders.DOMAIN_ID = String(first.id);
if (tool === "devos.server.list" && first.id) placeholders.SERVER_ID = String(first.id);
if (tool === "devos.deployment.list" && first.id) placeholders.DEPLOYMENT_ID = String(first.id);
}
/** # AI 运行详情 */
async get(workspaceId: string, id: string) {
const run = await this.prisma.aiRun.findFirst({ where: { id, workspaceId } });
if (!run) throw ApiError.notFound("AI run not found");
return this.view(run);
}
/** # AI 运行列表 */
async list(workspaceId: string) {
const runs = await this.prisma.aiRun.findMany({
where: { workspaceId },
orderBy: { createdAt: "desc" },
take: 100,
});
return runs.map((r) => this.view(r));
}
/** # 实体 → 视图 */
private view(run: AiRun) {
return {
id: run.id,
resourceId: run.resourceId,
workspaceId: run.workspaceId,
objective: run.objective,
source: run.source,
status: run.status,
riskLevel: run.riskLevel,
plan: run.plan,
toolCalls: run.toolCalls,
verification: run.verification,
result: run.result,
error: run.error ?? undefined,
approvedBy: run.approvedBy,
createdAt: run.createdAt,
finishedAt: run.approvedAt,
};
}
}
+28
View File
@@ -0,0 +1,28 @@
/**
* # AI Gateway 模块(Task 017)
*/
import { Module } from "@nestjs/common";
import { AiGatewayController } from "./ai-gateway.controller";
import { AiChatController } from "./ai-chat.controller";
import { AiChatService } from "./ai-chat.service";
import { AiGatewayService } from "./ai-gateway.service";
import { ToolRegistryService } from "./tool-registry.service";
import { ServerModule } from "../server/server.module";
import { ContainerModule } from "../container/container.module";
import { WebsiteModule } from "../website/website.module";
import { DeploymentModule } from "../deployment/deployment.module";
import { DomainModule } from "../domain/domain.module";
import { MonitoringModule } from "../monitoring/monitoring.module";
import { AutomationModule } from "../automation/automation.module";
import { AuditModule } from "../audit/audit.module";
import { TaskModule } from "../task/task.module";
import { NoteModule } from "../note/note.module";
import { GitModule } from "../git/git.module";
@Module({
imports: [ServerModule, ContainerModule, WebsiteModule, DeploymentModule, DomainModule, MonitoringModule, AutomationModule, AuditModule, TaskModule, NoteModule, GitModule],
controllers: [AiGatewayController, AiChatController],
providers: [AiGatewayService, ToolRegistryService, AiChatService],
exports: [ToolRegistryService, AiGatewayService, AiChatService],
})
export class AiModule {}
@@ -0,0 +1,477 @@
/**
* # AI 工具注册表(Task 017)
* AI/Agent 与 MCP 共享的工具层:每个工具声明名称、描述、风险等级、参数 schema 与执行函数。
* 工具实现复用各模块 service —— 与人工操作走同一条授权/审计路径(08-AI-AGENT / 09-MCP 安全要求)。
*/
import { Injectable } from "@nestjs/common";
import type { ZodSchema } from "zod";
import type { RiskLevel } from "@devos/types";
import { ServerService } from "../server/server.service";
import { ContainerService } from "../container/container.service";
import { WebsiteService } from "../website/website.service";
import { DeploymentService } from "../deployment/deployment.service";
import { DomainService } from "../domain/domain.service";
import { MonitoringService } from "../monitoring/monitoring.service";
import { AutomationService } from "../automation/automation.service";
import { TaskService } from "../task/task.service";
import { NoteService } from "../note/note.service";
import { GitService } from "../git/git.service";
import { z as zod } from "zod";
/** # 工具执行上下文 **/
export interface ToolContext {
workspaceId: string;
actorType: "AGENT" | "MCP" | "USER" | "SYSTEM";
actorId?: string;
}
/** # 工具定义 **/
export interface ToolDefinition<Args = Record<string, unknown>> {
/** # 工具名(devos.<domain>.<action>) **/
name: string;
/** # 人类可读描述 **/
description: string;
/** # 风险等级 **/
risk: RiskLevel;
/** # 参数 schema(zod) **/
schema: ZodSchema<Args>;
/** # 执行函数(返回稳定 JSON 结果) **/
execute: (ctx: ToolContext, args: Args) => Promise<unknown>;
/** # 可选:领域验证器(执行后如何验证成功) **/
verify?: (ctx: ToolContext, args: Args, result: unknown) => Promise<{ ok: boolean; detail?: string }>;
}
/** # 常用参数 schema 片段 **/
const IdSchema = zod.object({ id: zod.string().min(1) });
const ServerIdSchema = zod.object({ serverId: zod.string().min(1) });
const ExecSchema = zod.object({
serverId: zod.string().min(1),
command: zod.string().min(1).max(4096),
});
@Injectable()
export class ToolRegistryService {
/** # 工具表 **/
private tools = new Map<string, ToolDefinition<any>>();
constructor(
private readonly servers: ServerService,
private readonly containers: ContainerService,
private readonly websites: WebsiteService,
private readonly deployments: DeploymentService,
private readonly domains: DomainService,
private readonly monitoring: MonitoringService,
private readonly automations: AutomationService,
private readonly tasks: TaskService,
private readonly notes: NoteService,
private readonly git: GitService,
) {
this.registerDefaults();
}
/** # 注册工具 */
register(tool: ToolDefinition<any>): void {
this.tools.set(tool.name, tool);
}
/** # 获取工具 */
get(name: string): ToolDefinition<any> | undefined {
return this.tools.get(name);
}
/** # 全部工具(描述视图,供规划器/MCP) */
list(): { name: string; description: string; risk: RiskLevel }[] {
return [...this.tools.values()].map((t) => ({
name: t.name,
description: t.description,
risk: t.risk,
}));
}
/** # 注册默认工具集 */
private registerDefaults(): void {
// ---- 服务器 ----
this.register({
name: "devos.server.list",
description: "List servers in the workspace",
risk: "LOW",
schema: zod.object({}),
execute: async (ctx) => this.servers.list(ctx.workspaceId),
} as ToolDefinition<any>);
this.register({
name: "devos.server.status",
description: "Get server connectivity/docker status",
risk: "LOW",
schema: ServerIdSchema,
execute: async (ctx, args) => {
const servers = await this.servers.list(ctx.workspaceId);
const server = servers.find((s) => s.id === args.serverId);
if (!server) throw new Error("Server not found");
const containers = await this.containers.listByServer(ctx.workspaceId, server.id);
return { server, containers: containers.length };
},
} as ToolDefinition<any>);
this.register({
name: "devos.server.exec",
description: "Execute a shell command on a server via SSH",
risk: "MEDIUM",
schema: ExecSchema,
execute: async (ctx, args) => this.servers.exec(ctx.workspaceId, args.serverId, { command: args.command, timeoutMs: 30_000 }),
} as ToolDefinition<any>);
// ---- 容器 ----
this.register({
name: "devos.server.getDockerContainers",
description: "Discover docker containers on a server",
risk: "LOW",
schema: ServerIdSchema,
execute: async (ctx, args) => {
const server = await this.servers.mustGet(ctx.workspaceId, args.serverId);
return this.containers.discover(ctx.workspaceId, server);
},
} as ToolDefinition<any>);
// ---- 网站 ----
this.register({
name: "devos.website.list",
description: "List websites in the workspace",
risk: "LOW",
schema: zod.object({}),
execute: async (ctx) => this.websites.list(ctx.workspaceId),
} as ToolDefinition<any>);
this.register({
name: "devos.website.deploy",
description: "Deploy a website (create deployment and queue async execution)",
risk: "HIGH",
schema: zod.object({ websiteId: zod.string().min(1) }),
execute: async (ctx, args) =>
this.websites.deploy(ctx.workspaceId, args.websiteId, { type: ctx.actorType, id: ctx.actorId, trigger: ctx.actorType }),
verify: async (ctx, args) => {
const deployments = await this.deployments.list(ctx.workspaceId, args.websiteId);
const latest = deployments[0];
return { ok: Boolean(latest), detail: latest ? `deployment ${latest.id} ${latest.status}` : "no deployment" };
},
} as ToolDefinition<any>);
this.register({
name: "devos.website.logs",
description: "Get recent deployment logs of a website",
risk: "LOW",
schema: IdSchema,
execute: async (ctx, args) => this.websites.logs(ctx.workspaceId, args.id),
} as ToolDefinition<any>);
this.register({
name: "devos.website.health",
description: "Run an immediate health check for a website",
risk: "LOW",
schema: IdSchema,
execute: async (ctx, args) => this.websites.health(ctx.workspaceId, args.id),
verify: async (_ctx, _args, result) => ({ ok: Boolean((result as { ok?: boolean }).ok) }),
} as ToolDefinition<any>);
// ---- 部署 ----
this.register({
name: "devos.deployment.get",
description: "Get a deployment by id (status, progress, logs)",
risk: "LOW",
schema: IdSchema,
execute: async (ctx, args) => this.deployments.get(ctx.workspaceId, args.id),
} as ToolDefinition<any>);
this.register({
name: "devos.deployment.list",
description: "List recent deployments (optionally filtered by websiteId)",
risk: "LOW",
schema: zod.object({ websiteId: zod.string().optional() }),
execute: async (ctx, args) => this.deployments.list(ctx.workspaceId, args.websiteId),
} as ToolDefinition<any>);
// ---- 域名 ----
this.register({
name: "devos.domain.list",
description: "List domains in the workspace",
risk: "LOW",
schema: zod.object({}),
execute: async (ctx) => this.domains.list(ctx.workspaceId),
} as ToolDefinition<any>);
this.register({
name: "devos.domain.records",
description: "List DNS records of a domain",
risk: "LOW",
schema: IdSchema,
execute: async (ctx, args) => this.domains.records(ctx.workspaceId, args.id),
} as ToolDefinition<any>);
this.register({
name: "devos.domain.upsertRecord",
description: "Create or update a DNS record",
risk: "MEDIUM",
schema: zod.object({
domainId: zod.string().min(1),
type: zod.enum(["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"]),
name: zod.string().min(1),
value: zod.string().min(1),
ttl: zod.number().int().min(60).max(86400).optional(),
proxied: zod.boolean().optional(),
}),
execute: async (ctx, args) =>
this.domains.upsertRecord(ctx.workspaceId, args.domainId, {
type: args.type,
name: args.name,
value: args.value,
ttl: args.ttl ?? 300,
proxied: args.proxied ?? false,
}),
} as ToolDefinition<any>);
// ---- 监控 ----
this.register({
name: "devos.monitor.list",
description: "List monitors and their latest status",
risk: "LOW",
schema: zod.object({}),
execute: async (ctx) => this.monitoring.list(ctx.workspaceId),
} as ToolDefinition<any>);
// ---- 容器操作(Phase G/H 新工具) ----
this.register({
name: "devos.container.restart",
description: "Restart a docker container by container row id",
risk: "HIGH",
schema: IdSchema,
execute: async (ctx, args) => this.containers.restart(ctx.workspaceId, args.id),
verify: async (_ctx, _args, result) => ({ ok: Boolean((result as { restarted?: boolean }).restarted), detail: "container restarted" }),
} as ToolDefinition<any>);
// ---- 任务/笔记(Phase G/H 新工具) ----
this.register({
name: "devos.task.create",
description: "Create a task in the workspace",
risk: "LOW",
schema: zod.object({ title: zod.string().min(1), description: zod.string().optional(), priority: zod.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).optional() }),
execute: async (ctx, args) =>
this.tasks.create(ctx.workspaceId, {
title: args.title,
description: args.description ?? "",
priority: args.priority ?? "MEDIUM",
status: "TODO",
labels: ["ai"],
}),
verify: async (_ctx, _args, result) => ({ ok: Boolean((result as { id?: string }).id), detail: "task created" }),
} as ToolDefinition<any>);
this.register({
name: "devos.note.list",
description: "List notes in the workspace",
risk: "LOW",
schema: zod.object({}),
execute: async (ctx) => this.notes.list(ctx.workspaceId, {}),
} as ToolDefinition<any>);
this.register({
name: "devos.note.create",
description: "Create a markdown note in the workspace",
risk: "LOW",
schema: zod.object({ title: zod.string().min(1), content: zod.string().optional() }),
execute: async (ctx, args) =>
this.notes.create(ctx.workspaceId, { title: args.title, content: args.content ?? "", tags: ["ai"] }),
verify: async (_ctx, _args, result) => ({ ok: Boolean((result as { id?: string }).id), detail: "note created" }),
} as ToolDefinition<any>);
this.register({
name: "devos.task.list",
description: "List tasks in the workspace",
risk: "LOW",
schema: zod.object({ status: zod.string().optional() }),
execute: async (ctx, args) => this.tasks.list(ctx.workspaceId, { status: args.status }),
} as ToolDefinition<any>);
// ---- 网页抓取(URL 总结等场景) ----
this.register({
name: "devos.web.fetch",
description: "Fetch a web page and return readable text (title + main content) for summarization",
risk: "LOW",
schema: zod.object({ url: zod.string().url(), maxChars: zod.number().int().max(30000).optional() }),
execute: async (_ctx, args) => {
const res = await fetch(args.url, {
headers: { "User-Agent": "Mozilla/5.0 (compatible; DevOS-Agent/1.0)" },
signal: AbortSignal.timeout(20_000),
redirect: "follow",
});
if (!res.ok) throw new Error(`Fetch failed HTTP ${res.status}`);
const html = await res.text();
const { extractHtmlTitle, extractMetaDescription, extractMainParagraphs } = await import("../../common/utils/html");
return {
title: extractHtmlTitle(html),
description: extractMetaDescription(html),
text: [extractHtmlTitle(html) ?? "", ...extractMainParagraphs(html, 30)].filter(Boolean).join("\n\n").slice(0, args.maxChars ?? 12000),
};
},
} as ToolDefinition<any>);
// ---- 仓库:从 URL 登记 ----
this.register({
name: "devos.repository.createFromUrl",
description: "Register a git repository from its HTTPS URL (auto-detects GitHub/GitLab provider)",
risk: "LOW",
schema: zod.object({ url: zod.string().url() }),
execute: async (ctx, args) => {
const url = args.url.replace(/\.git$/, "");
const host = new URL(url).hostname;
const provider = host.includes("github") ? "GITHUB" : host.includes("gitlab") ? "GITLAB" : "GENERIC";
const parts = new URL(url).pathname.replace(/^\//, "").split("/").filter(Boolean);
const fullName = parts.slice(0, 2).join("/");
const result = await this.git.upsertRepository(ctx.workspaceId, {
provider: provider as "GITHUB" | "GITLAB" | "GITEA" | "GENERIC",
fullName,
cloneUrl: url,
defaultBranch: "main",
visibility: "private",
});
return { ...result, hint: "website.create 可用 repositoryId 关联该仓库" };
},
} as ToolDefinition<any>);
// ---- 名称查找(LLM 参数解析辅助) ----
this.register({
name: "devos.server.find",
description: "Find a server by (partial) name, returns id/host/status",
risk: "LOW",
schema: zod.object({ name: zod.string().min(1) }),
execute: async (ctx, args) => {
const all = await this.servers.list(ctx.workspaceId);
const lower = args.name.toLowerCase();
const hits = all.filter((s) => s.name.toLowerCase().includes(lower) || s.host.toLowerCase().includes(lower));
const withStatus = await Promise.all(
hits.map(async (s) => ({
id: s.id,
name: s.name,
host: s.host,
provider: s.provider,
status: (await this.servers.mustGet(s.workspaceId, s.id)) ? undefined : undefined,
})),
);
return hits.map((s) => ({ id: s.id, name: s.name, host: s.host }));
},
} as ToolDefinition<any>);
this.register({
name: "devos.domain.find",
description: "Find a domain by (partial) name, returns id/provider",
risk: "LOW",
schema: zod.object({ name: zod.string().min(1) }),
execute: async (ctx, args) => {
const all = await this.domains.list(ctx.workspaceId);
const lower = args.name.toLowerCase();
return all.filter((d) => d.name.toLowerCase().includes(lower));
},
} as ToolDefinition<any>);
// ---- 网站创建(chat 复合流程用) ----
this.register({
name: "devos.website.create",
description: "Create a website bound to a server and (optionally) a repository; then use devos.website.deploy to deploy it",
risk: "MEDIUM",
schema: zod.object({
name: zod.string().min(1),
serverId: zod.string().optional(),
repositoryId: zod.string().optional(),
primaryDomain: zod.string().optional(),
port: zod.number().int().min(1).max(65535).optional(),
environment: zod.enum(["development", "staging", "production"]).optional(),
}),
execute: async (ctx, args) =>
this.websites.create(ctx.workspaceId, {
name: args.name,
serverId: args.serverId,
repositoryId: args.repositoryId,
primaryDomain: args.primaryDomain,
port: args.port ?? 80,
healthPath: "/",
deployType: "DOCKER",
environment: args.environment ?? "production",
}),
verify: async (ctx, args, result) => ({ ok: Boolean((result as { id?: string }).id), detail: "website created" }),
} as ToolDefinition<any>);
// ---- 编辑与删除(chat 全功能管理;删除走审批) ----
this.register({
name: "devos.task.update",
description: "Update a task (status/priority/title/description)",
risk: "LOW",
schema: zod.object({ id: zod.string().min(1), status: zod.enum(["BACKLOG", "TODO", "DOING", "REVIEW", "DONE"]).optional(), priority: zod.enum(["LOW", "MEDIUM", "HIGH", "URGENT"]).optional(), title: zod.string().optional() }),
execute: async (ctx, args) => {
const { id, ...patch } = args as { id: string } & Record<string, unknown>;
return this.tasks.update(ctx.workspaceId, id, patch as never);
},
} as ToolDefinition<any>);
this.register({
name: "devos.note.update",
description: "Update a note (title/content/tags)",
risk: "LOW",
schema: zod.object({ id: zod.string().min(1), title: zod.string().optional(), content: zod.string().optional() }),
execute: async (ctx, args) => {
const { id, ...patch } = args as { id: string } & Record<string, unknown>;
return this.notes.update(ctx.workspaceId, id, patch as never);
},
} as ToolDefinition<any>);
this.register({
name: "devos.task.delete",
description: "Delete a task",
risk: "MEDIUM",
schema: IdSchema,
execute: async (ctx, args) => this.tasks.delete(ctx.workspaceId, args.id),
} as ToolDefinition<any>);
this.register({
name: "devos.note.delete",
description: "Delete a note",
risk: "MEDIUM",
schema: IdSchema,
execute: async (ctx, args) => this.notes.delete(ctx.workspaceId, args.id),
} as ToolDefinition<any>);
this.register({
name: "devos.website.delete",
description: "Delete a website",
risk: "HIGH",
schema: IdSchema,
execute: async (ctx, args) => this.websites.delete(ctx.workspaceId, args.id),
} as ToolDefinition<any>);
this.register({
name: "devos.server.delete",
description: "Delete a server",
risk: "HIGH",
schema: IdSchema,
execute: async (ctx, args) => this.servers.delete(ctx.workspaceId, args.id),
} as ToolDefinition<any>);
this.register({
name: "devos.domain.delete",
description: "Delete a domain",
risk: "HIGH",
schema: IdSchema,
execute: async (ctx, args) => this.domains.delete(ctx.workspaceId, args.id),
} as ToolDefinition<any>);
// ---- 自动化 ----
this.register({
name: "devos.automation.list",
description: "List automations",
risk: "LOW",
schema: zod.object({}),
execute: async (ctx) => this.automations.list(ctx.workspaceId),
} as ToolDefinition<any>);
this.register({
name: "devos.automation.run",
description: "Manually trigger an automation",
risk: "MEDIUM",
schema: IdSchema,
execute: async (ctx, args) => this.automations.runManually(ctx.workspaceId, args.id, ctx.actorId),
} as ToolDefinition<any>);
}
}
/** # 风险等级比较(越大越高) **/
const RISK_RANK: Record<RiskLevel, number> = { LOW: 0, MEDIUM: 1, HIGH: 2, CRITICAL: 3 };
/** # 求多个风险的最大值 */
export function maxRisk(levels: RiskLevel[]): RiskLevel {
return levels.reduce<RiskLevel>((acc, l) => (RISK_RANK[l] > RISK_RANK[acc] ? l : acc), "LOW");
}
/** # 危险命令升级(server.exec 用):基于命令内容的 CRITICAL 判定 */
export function escalateExecRisk(command: string): RiskLevel | null {
const destructive = [/rm\s+(-[a-zA-Z]*r[a-zA-Z]*f|-[a-zA-Z]*f[a-zA-Z]*r)/, /mkfs/, /dd\s+if=/, /:\(\)\{:\|:\&\};:/, /shutdown|reboot/, /DROP\s+(TABLE|DATABASE)/i];
return destructive.some((p) => p.test(command)) ? "CRITICAL" : null;
}
@@ -0,0 +1,47 @@
/**
* # 审计查询控制器
*/
import { Controller, Get, Query } from "@nestjs/common";
import { z } from "zod";
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
import { CurrentWorkspace } from "../../common/decorators";
import { PrismaService } from "../../prisma/prisma.service";
import type { RequestWorkspace } from "../../common/decorators";
const ListQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
resourceType: z.string().optional(),
resourceId: z.string().optional(),
action: z.string().optional(),
});
@Controller("audit")
export class AuditController {
constructor(private readonly prisma: PrismaService) {}
/** # 分页查询当前工作区审计日志 */
@Get()
async list(
@CurrentWorkspace() workspace: RequestWorkspace,
@Query(new ZodValidationPipe(ListQuerySchema))
query: z.infer<typeof ListQuerySchema>,
) {
const where = {
workspaceId: workspace.id,
...(query.resourceType ? { resourceType: query.resourceType } : {}),
...(query.resourceId ? { resourceId: query.resourceId } : {}),
...(query.action ? { action: { contains: query.action } } : {}),
};
const [total, items] = await this.prisma.$transaction([
this.prisma.auditLog.count({ where }),
this.prisma.auditLog.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
]);
return { items, total, page: query.page, pageSize: query.pageSize };
}
}
@@ -0,0 +1,13 @@
/**
* # 审计模块(Task 008)
*/
import { Module } from "@nestjs/common";
import { AuditController } from "./audit.controller";
import { AuditService } from "./audit.service";
@Module({
controllers: [AuditController],
providers: [AuditService],
exports: [AuditService],
})
export class AuditModule {}
@@ -0,0 +1,60 @@
/**
* # 审计服务
* 每个变更(mutation)必须写入审计记录;敏感读取也可审计。
* 元数据自动脱敏;requestId 从请求上下文注入。
*/
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import type { ActorType, RiskLevel } from "@devos/types";
import { PrismaService } from "../../prisma/prisma.service";
import { getRequestContext } from "../../common/request-context";
import { maskSecrets } from "../../common/utils/mask";
/** # 审计记录输入 **/
export interface AuditInput {
workspaceId: string;
actorType: ActorType;
actorId?: string | null;
action: string;
resourceType?: string | null;
resourceId?: string | null;
riskLevel?: RiskLevel;
status?: "SUCCESS" | "FAILURE" | "CANCELLED";
metadata?: Record<string, unknown>;
}
@Injectable()
export class AuditService {
constructor(private readonly prisma: PrismaService) {}
/** # 写入审计记录(永不抛出异常影响主流程) */
async record(input: AuditInput): Promise<void> {
try {
const ctx = getRequestContext();
await this.prisma.auditLog.create({
data: {
workspaceId: input.workspaceId,
actorType: input.actorType,
actorId: input.actorId ?? ctx?.actorId ?? null,
action: input.action,
resourceType: input.resourceType ?? null,
resourceId: input.resourceId ?? null,
riskLevel: input.riskLevel ?? "LOW",
status: input.status ?? "SUCCESS",
requestId: ctx?.requestId ?? null,
ip: ctx?.ip ?? null,
metadata: maskSecrets(input.metadata ?? {}) as Prisma.InputJsonValue,
},
});
} catch (error) {
console.error(
JSON.stringify({
time: new Date().toISOString(),
level: "error",
ctx: "AuditService",
msg: `failed to write audit log: ${error instanceof Error ? error.message : error}`,
}),
);
}
}
}
@@ -0,0 +1,99 @@
/**
* # Automation 控制器(/api/v1/automations)
*/
import { Body, Controller, Delete, Get, Headers, Param, Patch, Post, Req } from "@nestjs/common";
import { z } from "zod";
import { AutomationService, CreateAutomationSchema, UpdateAutomationSchema } from "./automation.service";
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
import { CurrentUser, CurrentWorkspace, Roles, Public } from "../../common/decorators";
import type { RequestUser, RequestWorkspace } from "../../common/decorators";
import type { FastifyRequest } from "fastify";
const WebhookBodySchema = z.record(z.unknown()).default({});
@Controller("automations")
export class AutomationController {
constructor(private readonly automations: AutomationService) {}
/** # 自动化列表 */
@Get()
list(@CurrentWorkspace() ws: RequestWorkspace) {
return this.automations.list(ws.id);
}
/** # 创建自动化 */
@Roles("MEMBER")
@Post()
create(
@CurrentWorkspace() ws: RequestWorkspace,
@Body(new ZodValidationPipe(CreateAutomationSchema)) dto: Parameters<AutomationService["create"]>[1],
) {
return this.automations.create(ws.id, dto);
}
/** # 自动化详情(含执行历史) */
@Get(":id")
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.automations.get(ws.id, id);
}
/** # 编辑自动化 */
@Roles("MEMBER")
@Patch(":id")
update(
@CurrentWorkspace() ws: RequestWorkspace,
@Param("id") id: string,
@Body(new ZodValidationPipe(UpdateAutomationSchema)) dto: Parameters<AutomationService["update"]>[2],
) {
return this.automations.update(ws.id, id, dto);
}
/** # 启用 */
@Roles("MEMBER")
@Post(":id/enable")
enable(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.automations.setEnabled(ws.id, id, true);
}
/** # 禁用 */
@Roles("MEMBER")
@Post(":id/disable")
disable(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.automations.setEnabled(ws.id, id, false);
}
/** # 手动触发 */
@Roles("MEMBER")
@Post(":id/run")
run(
@CurrentWorkspace() ws: RequestWorkspace,
@Param("id") id: string,
@CurrentUser() user: RequestUser,
) {
return this.automations.runManually(ws.id, id, user.id);
}
/** # Webhook 触发(公开路由;X-Webhook-Secret 头校验;body 为触发载荷) */
@Public()
@Post(":id/webhook")
webhook(
@Param("id") id: string,
@Headers("x-webhook-secret") secret: string | undefined,
@Headers("x-signature") signature: string | undefined,
@Req() req: FastifyRequest,
@Body(new ZodValidationPipe(WebhookBodySchema)) body: Record<string, unknown>,
) {
return this.automations.runWebhookByToken(id, {
secret: secret ?? "",
signature,
rawBody: (req.rawBody ? req.rawBody.toString("utf8") : JSON.stringify(body ?? {})),
});
}
/** # 删除自动化 */
@Roles("ADMIN")
@Delete(":id")
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.automations.delete(ws.id, id);
}
}
@@ -0,0 +1,20 @@
/**
* # Automation 模块(Task 016)
*/
import { Module } from "@nestjs/common";
import { AutomationController } from "./automation.controller";
import { AutomationService } from "./automation.service";
import { EventModule } from "../event/event.module";
import { AuditModule } from "../audit/audit.module";
import { WebsiteModule } from "../website/website.module";
import { ResourceModule } from "../resource/resource.module";
import { ContainerModule } from "../container/container.module";
import { NotificationModule } from "../notification/notification.module";
@Module({
imports: [EventModule, AuditModule, WebsiteModule, ResourceModule, ContainerModule, NotificationModule],
controllers: [AutomationController],
providers: [AutomationService],
exports: [AutomationService],
})
export class AutomationModule {}
@@ -0,0 +1,565 @@
/**
* # Automation 服务(Task 016)
* Trigger → Condition(s) → Action(s) 工作流:
* - 触发器:MANUAL / CRON / EVENT / WEBHOOK(token 校验);
* - 条件:[{path, op, value}] 对事件/触发载荷求值;
* - 动作:LOG / WEBHOOK / DEPLOY_WEBSITE,带超时与有限重试;
* - 每次执行写 automation_runs 历史(含步骤日志与错误)。
*/
import { Injectable, OnModuleInit } from "@nestjs/common";
import { z } from "zod";
import type { Automation } from "@prisma/client";
import { PrismaService } from "../../prisma/prisma.service";
import { EventBusService, type DomainEvent } from "../event/event-bus.service";
import { AuditService } from "../audit/audit.service";
import { ResourceService } from "../resource/resource.service";
import { WebsiteService } from "../website/website.service";
import { ContainerService } from "../container/container.service";
import { NotificationService } from "../notification/notification.service";
import { cronMatches } from "./cron";
import { verifyWebhookSignature } from "../../common/security/webhook-signature";
import { ApiError } from "../../common/errors/api-error";
import { getRequestContext } from "../../common/request-context";
import { randomUUID } from "node:crypto";
/** # 动作配置 schema **/
const ActionSchema = z.object({
type: z.enum([
"LOG",
"WEBHOOK",
"DEPLOY_WEBSITE",
"CONTAINER_RESTART",
"TASK_CREATE",
"NOTE_CREATE",
"NOTIFICATION_SEND",
]),
args: z.record(z.unknown()).default({}),
timeoutMs: z.coerce.number().int().min(1000).max(600_000).default(30_000),
retries: z.coerce.number().int().min(0).max(5).default(0),
});
export type AutomationAction = z.infer<typeof ActionSchema>;
/** # 条件配置 schema **/
const ConditionSchema = z.object({
path: z.string().min(1),
op: z.enum(["eq", "neq", "contains", "exists", "gt", "lt"]),
value: z.unknown().optional(),
});
export type AutomationCondition = z.infer<typeof ConditionSchema>;
/** # 更新自动化 DTO **/
export const UpdateAutomationSchema = z.object({
name: z.string().min(1).max(120).optional(),
enabled: z.boolean().optional(),
conditions: z.array(ConditionSchema).optional(),
actions: z.array(ActionSchema).min(1).max(10).optional(),
});
export type UpdateAutomationDto = z.infer<typeof UpdateAutomationSchema>;
/** # 创建自动化 DTO **/
export const CreateAutomationSchema = z.object({
name: z.string().min(1).max(120),
triggerType: z.enum(["MANUAL", "CRON", "EVENT", "WEBHOOK"]),
triggerConfig: z
.object({
cron: z.string().optional(),
eventType: z.string().optional(),
secret: z.string().optional(),
})
.default({}),
conditions: z.array(ConditionSchema).default([]),
actions: z.array(ActionSchema).min(1).max(10),
enabled: z.boolean().default(false),
});
export type CreateAutomationDto = z.infer<typeof CreateAutomationSchema>;
/** # 动作执行日志行 **/
interface RunLogLine {
step: number;
type: string;
ok: boolean;
output?: string;
error?: string;
at: string;
}
@Injectable()
export class AutomationService implements OnModuleInit {
constructor(
private readonly prisma: PrismaService,
private readonly events: EventBusService,
private readonly audit: AuditService,
private readonly resources: ResourceService,
private readonly websites: WebsiteService,
private readonly containers: ContainerService,
private readonly notifications: NotificationService,
) {}
/** # 初始化:订阅事件总线处理 EVENT 触发器 */
onModuleInit(): void {
this.events.subscribe((event) => this.handleDomainEvent(event));
}
/** # 创建自动化 */
async create(workspaceId: string, dto: CreateAutomationDto) {
if (dto.triggerType === "CRON" && !dto.triggerConfig.cron) {
throw ApiError.validation("CRON trigger requires triggerConfig.cron");
}
if (dto.triggerType === "CRON") {
try {
cronMatches(dto.triggerConfig.cron!, new Date());
} catch {
throw ApiError.validation("Invalid cron expression");
}
}
if (dto.triggerType === "EVENT" && !dto.triggerConfig.eventType) {
throw ApiError.validation("EVENT trigger requires triggerConfig.eventType");
}
const automation = await this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: { workspaceId, type: "AUTOMATION", name: dto.name, provider: "devos", status: dto.enabled ? "ACTIVE" : "PENDING" },
});
return tx.automation.create({
data: {
workspaceId,
resourceId: resource.id,
name: dto.name,
enabled: dto.enabled,
triggerType: dto.triggerType,
triggerConfig: dto.triggerConfig as object,
conditions: dto.conditions as unknown as object,
actions: dto.actions as unknown as object,
},
});
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "automation.create",
resourceType: "Automation",
resourceId: automation.resourceId,
metadata: { triggerType: dto.triggerType },
});
return this.view(automation);
}
/** # 自动化列表 */
async list(workspaceId: string) {
const rows = await this.prisma.automation.findMany({
where: { workspaceId },
orderBy: { createdAt: "desc" },
});
return rows.map((a) => this.view(a));
}
/** # 自动化详情(含执行历史) */
async get(workspaceId: string, id: string) {
const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } });
if (!a) throw ApiError.notFound("Automation not found");
const runs = await this.prisma.automationRun.findMany({
where: { automationId: a.id },
orderBy: { startedAt: "desc" },
take: 20,
});
return { ...this.view(a), runs };
}
/** # 启用/禁用 */
async setEnabled(workspaceId: string, id: string, enabled: boolean) {
const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } });
if (!a) throw ApiError.notFound("Automation not found");
const updated = await this.prisma.$transaction(async (tx) => {
const updated = await tx.automation.update({ where: { id }, data: { enabled } });
await tx.resource.update({ where: { id: a.resourceId }, data: { status: enabled ? "ACTIVE" : "ARCHIVED" } });
return updated;
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: enabled ? "automation.enable" : "automation.disable",
resourceType: "Automation",
resourceId: a.resourceId,
});
return this.view(updated);
}
/** # 编辑自动化(Phase F:名称/条件/动作/开关) */
async update(workspaceId: string, id: string, dto: UpdateAutomationDto) {
const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } });
if (!a) throw ApiError.notFound("Automation not found");
if (dto.actions) {
// 校验动作 schema
for (const action of dto.actions) ActionSchema.parse(action);
}
const updated = await this.prisma.$transaction(async (tx) => {
const u = await tx.automation.update({
where: { id },
data: {
...(dto.name ? { name: dto.name } : {}),
...(dto.enabled !== undefined ? { enabled: dto.enabled } : {}),
...(dto.conditions ? { conditions: dto.conditions as unknown as object } : {}),
...(dto.actions ? { actions: dto.actions as unknown as object } : {}),
},
});
if (dto.enabled !== undefined) {
await tx.resource.update({
where: { id: a.resourceId },
data: { status: dto.enabled ? "ACTIVE" : "ARCHIVED" },
});
}
return u;
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "automation.update",
resourceType: "Automation",
resourceId: a.resourceId,
metadata: { fields: Object.keys(dto) },
});
return this.view(updated);
}
/** # 删除自动化 */
async delete(workspaceId: string, id: string) {
const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } });
if (!a) throw ApiError.notFound("Automation not found");
await this.prisma.automation.delete({ where: { id } });
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "automation.delete",
resourceType: "Automation",
resourceId: a.resourceId,
riskLevel: "HIGH",
});
return { deleted: true };
}
/** # 手动触发 */
async runManually(workspaceId: string, id: string, actorId?: string) {
const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } });
if (!a) throw ApiError.notFound("Automation not found");
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
actorId,
action: "automation.run",
resourceType: "Automation",
resourceId: a.resourceId,
});
return this.execute(a, { triggerType: "MANUAL", payload: {} });
}
/** # Webhook 触发(HMAC 签名优先,明文 secret 兼容回退;按 id 定位,无需工作区上下文) */
async runWebhookByToken(
id: string,
input: { secret: string; signature?: string; rawBody: string },
) {
const a = await this.prisma.automation.findUnique({ where: { id } });
if (!a) throw ApiError.notFound("Automation not found");
if (a.triggerType !== "WEBHOOK") throw ApiError.validation("Automation is not a webhook automation");
const cfg = a.triggerConfig as { secret?: string };
if (!cfg.secret) throw ApiError.forbidden("Automation has no webhook secret configured");
// 优先 HMAC(t=<ts>,v1=<hex>),防重放;legacy 明文比对保留给旧调用方
let authOk = false;
let authReason = "";
if (input.signature) {
const verdict = verifyWebhookSignature(cfg.secret, input.signature, input.rawBody);
authOk = verdict.ok;
authReason = verdict.reason ?? "";
} else if (input.secret) {
authOk = input.secret === cfg.secret;
authReason = authOk ? "legacy secret" : "legacy secret mismatch";
}
if (!authOk) {
await this.audit.record({
workspaceId: a.workspaceId, actorType: "SYSTEM", action: "automation.webhook",
resourceType: "Automation", resourceId: a.resourceId, status: "FAILURE",
riskLevel: "MEDIUM", metadata: { reason: authReason || "unauthorized" },
});
throw ApiError.forbidden(`Webhook authentication failed: ${authReason || "missing credentials"}`);
}
if (!a.enabled) throw ApiError.conflict("Automation is disabled");
let payload: Record<string, unknown> = {};
try {
payload = JSON.parse(input.rawBody || "{}") as Record<string, unknown>;
} catch {
payload = { raw: input.rawBody.slice(0, 2000) };
}
const result = await this.execute(a, { triggerType: "WEBHOOK", payload });
await this.audit.record({
workspaceId: a.workspaceId,
actorType: "SYSTEM",
action: "automation.webhook",
resourceType: "Automation",
resourceId: a.resourceId,
status: result.status === "FAILED" ? "FAILURE" : "SUCCESS",
});
return result;
}
/** # 订阅事件总线:处理 EVENT 触发器 */
handleDomainEvent(event: DomainEvent): void {
void (async () => {
const automations = await this.prisma.automation.findMany({
where: { workspaceId: event.workspaceId, triggerType: "EVENT", enabled: true },
});
for (const a of automations) {
const cfg = a.triggerConfig as { eventType?: string };
if (cfg.eventType !== event.eventType) continue;
await this.execute(a, {
triggerType: "EVENT",
payload: { eventType: event.eventType, ...event.payload },
}).catch(() => undefined);
}
})();
}
/** # cron 扫描(调度器每分钟调用) */
async runDueCron(): Promise<void> {
const automations = await this.prisma.automation.findMany({
where: { triggerType: "CRON", enabled: true },
});
const now = new Date();
for (const a of automations) {
const cfg = a.triggerConfig as { cron?: string };
if (!cfg.cron || !cronMatches(cfg.cron, now)) continue;
// 同一分钟内不重复触发
if (a.lastTriggeredAt && now.getTime() - a.lastTriggeredAt.getTime() < 60_000) continue;
await this.prisma.automation.update({ where: { id: a.id }, data: { lastTriggeredAt: now } });
await this.execute(a, { triggerType: "CRON", payload: { at: now.toISOString() } }).catch(() => undefined);
}
}
/** # 执行自动化:条件评估 → 顺序动作(重试/超时)→ 历史 */
async execute(
automation: Automation,
trigger: { triggerType: string; payload: Record<string, unknown> },
): Promise<{ runId: string; status: string; logs: RunLogLine[] }> {
const correlationId = randomUUID();
const run = await this.prisma.automationRun.create({
data: {
automationId: automation.id,
workspaceId: automation.workspaceId,
status: "RUNNING",
trigger: trigger as unknown as object,
correlationId,
},
});
const conditions = (automation.conditions as unknown as AutomationCondition[]) ?? [];
const actions = (automation.actions as unknown as AutomationAction[]) ?? [];
const logs: RunLogLine[] = [];
let status = "SUCCEEDED";
let error: string | undefined;
const allConditionsMet = conditions.every((c) => evaluateCondition(c, trigger.payload));
if (!allConditionsMet) {
logs.push({ step: 0, type: "CONDITION", ok: false, output: "conditions not met; skipped", at: new Date().toISOString() });
await this.prisma.automationRun.update({
where: { id: run.id },
data: { status: "SKIPPED", logs: logs as unknown as object[], finishedAt: new Date() },
});
return { runId: run.id, status: "SKIPPED", logs };
}
let stepIndex = 0;
for (const action of actions) {
stepIndex += 1;
const line = await this.runAction(automation.workspaceId, stepIndex, action, trigger.payload, correlationId);
logs.push(line);
if (!line.ok) {
status = "FAILED";
error = line.error;
break; // 失败即停(可按动作配置 retries 重试)
}
}
await this.prisma.automationRun.update({
where: { id: run.id },
data: { status, logs: logs as unknown as object[], stepIndex, error, finishedAt: new Date() },
});
return { runId: run.id, status, logs };
}
/** # 执行单个动作(含重试与超时) */
private async runAction(
workspaceId: string,
step: number,
action: AutomationAction,
payload: Record<string, unknown>,
correlationId: string,
): Promise<RunLogLine> {
const at = () => new Date().toISOString();
let lastError: string | undefined;
for (let attempt = 0; attempt <= action.retries; attempt++) {
try {
const output = await this.runActionOnce(workspaceId, action, payload, correlationId);
return { step, type: action.type, ok: true, output, at: at() };
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
if (attempt < action.retries) {
await new Promise((r) => setTimeout(r, Math.min(2000 * 2 ** attempt, 30_000)));
}
}
}
return { step, type: action.type, ok: false, error: lastError, at: at() };
}
/** # 动作实现 */
private async runActionOnce(
workspaceId: string,
action: AutomationAction,
payload: Record<string, unknown>,
correlationId: string,
): Promise<string> {
switch (action.type) {
case "LOG": {
const message = String(action.args.message ?? "automation log");
return `LOG: ${message}`;
}
case "WEBHOOK": {
const url = String(action.args.url ?? "");
if (!/^https?:\/\//.test(url)) throw new Error("WEBHOOK requires args.url (http/https)");
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json", "X-Correlation-Id": correlationId },
body: JSON.stringify({ payload }),
signal: AbortSignal.timeout(action.timeoutMs),
});
if (!res.ok) throw new Error(`Webhook responded ${res.status}`);
return `WEBHOOK: ${url} -> ${res.status}`;
}
case "DEPLOY_WEBSITE": {
const websiteId = String(action.args.websiteId ?? "");
const deployment = await this.websites.deploy(workspaceId, websiteId, {
type: "SYSTEM",
trigger: "AUTOMATION",
});
return `DEPLOY_WEBSITE: deployment ${deployment.id} queued`;
}
case "CONTAINER_RESTART": {
const containerId = String(action.args.containerId ?? "");
const r = await this.containers.restart(workspaceId, containerId);
return `CONTAINER_RESTART: ${r.name} restarted`;
}
case "TASK_CREATE": {
const taskId = await this.createAutomationTask(workspaceId, action.args, correlationId);
return `TASK_CREATE: task ${taskId} created`;
}
case "NOTE_CREATE": {
const noteId = await this.createAutomationNote(workspaceId, action.args, correlationId);
return `NOTE_CREATE: note ${noteId} created`;
}
case "NOTIFICATION_SEND": {
await this.notifications.push(workspaceId, {
title: String(action.args.title ?? "Automation notification"),
body: String(action.args.body ?? ""),
level: "INFO",
});
return "NOTIFICATION_SEND: notification pushed";
}
default:
throw new Error(`Unknown action type`);
}
}
/** # 自动化动作:创建任务(写 tasks 表 + TASK 资源) */
private async createAutomationTask(
workspaceId: string,
args: Record<string, unknown>,
correlationId: string,
): Promise<string> {
const title = String(args.title ?? "Automation task").slice(0, 200);
return this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: { workspaceId, type: "TASK", name: title, provider: "automation", status: "ACTIVE", metadata: { source: "automation", correlationId } },
});
const task = await tx.task.create({
data: {
workspaceId, resourceId: resource.id, title,
description: String(args.description ?? `Created by automation ${correlationId}`),
status: "TODO", priority: "MEDIUM",
},
});
return task.id;
});
}
/** # 自动化动作:创建笔记 */
private async createAutomationNote(
workspaceId: string,
args: Record<string, unknown>,
correlationId: string,
): Promise<string> {
const title = String(args.title ?? "Automation note").slice(0, 200);
return this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: { workspaceId, type: "NOTE", name: title, provider: "automation", status: "ACTIVE", metadata: { source: "automation", correlationId } },
});
const note = await tx.note.create({
data: {
workspaceId, resourceId: resource.id, title,
content: String(args.content ?? `Created by automation ${correlationId}`),
},
});
return note.id;
});
}
/** # 断言自动化属于工作区 */
async mustGet(workspaceId: string, id: string) {
const a = await this.prisma.automation.findFirst({ where: { id, workspaceId } });
if (!a) throw ApiError.notFound("Automation not found");
return a;
}
/** # 实体 → 视图 */
private view(a: Automation) {
return {
id: a.id,
resourceId: a.resourceId,
workspaceId: a.workspaceId,
name: a.name,
enabled: a.enabled,
triggerType: a.triggerType,
triggerConfig: a.triggerConfig,
conditions: a.conditions,
actions: a.actions,
lastTriggeredAt: a.lastTriggeredAt,
createdAt: a.createdAt,
};
}
}
/** # 按路径取值(支持 a.b.c / a.0.b) */
function getPath(obj: unknown, path: string): unknown {
let cur: unknown = obj;
for (const key of path.split(".")) {
if (cur === null || cur === undefined) return undefined;
cur = (cur as Record<string, unknown>)[key];
}
return cur;
}
/** # 条件评估(纯函数) */
export function evaluateCondition(condition: AutomationCondition, payload: Record<string, unknown>): boolean {
const actual = getPath(payload, condition.path);
switch (condition.op) {
case "exists":
return actual !== undefined;
case "eq":
return String(actual) === String(condition.value);
case "neq":
return String(actual) !== String(condition.value);
case "contains":
return String(actual ?? "").includes(String(condition.value));
case "gt":
return Number(actual) > Number(condition.value);
case "lt":
return Number(actual) < Number(condition.value);
default:
return false;
}
}
+78
View File
@@ -0,0 +1,78 @@
/**
* # Cron 解析器
* 标准 5 字段 cron(分 时 日 月 周),支持星号、数字、步进(星号加斜杠n)、区间(a-b)、列表(a,b,c)混合。
* 纯函数实现,便于测试。
*/
/** # cron 字段取值范围 **/
const FIELD_RANGES: [number, number][] = [
[0, 59], // minute
[0, 23], // hour
[1, 31], // day of month
[1, 12], // month
[0, 6], // day of week (0=Sunday)
];
/** # 解析单个字段为允许值集合
* @param field 字段表达式
* @param range 取值范围
* @returns 允许值集合
*/
export function parseCronField(field: string, range: [number, number]): Set<number> {
const [min, max] = range;
const values = new Set<number>();
for (const part of field.split(",")) {
const [base, stepStr] = part.split("/");
const step = stepStr ? Number(stepStr) : 1;
if (!Number.isInteger(step) || step < 1) throw new Error(`Invalid cron step: ${part}`);
let lo = min;
let hi = max;
if (base !== "*" && base !== "") {
if (base.includes("-")) {
const [a, b] = base.split("-").map(Number);
if (!Number.isInteger(a) || !Number.isInteger(b)) throw new Error(`Invalid cron range: ${part}`);
lo = a;
hi = b;
} else {
const v = Number(base);
if (!Number.isInteger(v)) throw new Error(`Invalid cron value: ${part}`);
lo = v;
hi = stepStr ? max : v;
}
}
if (lo < min || hi > max || lo > hi) throw new Error(`Cron value out of range: ${part}`);
for (let v = lo; v <= hi; v += step) values.add(v);
}
return values;
}
/** # 解析完整 cron 表达式
* @param expression 5 字段表达式
* @returns 各字段允许值
*/
export function parseCron(expression: string): Set<number>[] {
const fields = expression.trim().split(/\s+/);
if (fields.length !== 5) throw new Error(`Cron expression must have 5 fields: ${expression}`);
return fields.map((f, i) => parseCronField(f, FIELD_RANGES[i]));
}
/** # 判断给定时间是否匹配 cron 表达式
* @param expression 5 字段表达式
* @param date 待判定时间(本地时区)
*/
export function cronMatches(expression: string, date: Date): boolean {
let sets: Set<number>[];
try {
sets = parseCron(expression);
} catch {
return false;
}
const values = [
date.getMinutes(),
date.getHours(),
date.getDate(),
date.getMonth() + 1,
date.getDay(),
];
return values.every((v, i) => sets[i].has(v));
}
@@ -0,0 +1,49 @@
/**
* # Certificate 控制器(/api/v1/certificates)
*/
import { Body, Controller, Delete, Get, Param, Post } from "@nestjs/common";
import { CertificateService, CreateCertificateSchema } from "./certificate.service";
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
import { CurrentWorkspace, Roles } from "../../common/decorators";
import type { RequestWorkspace } from "../../common/decorators";
@Controller("certificates")
export class CertificateController {
constructor(private readonly certificates: CertificateService) {}
/** # 证书列表 */
@Get()
list(@CurrentWorkspace() ws: RequestWorkspace) {
return this.certificates.list(ws.id);
}
/** # 添加证书(可选 TLS 探测) */
@Roles("MEMBER")
@Post()
create(
@CurrentWorkspace() ws: RequestWorkspace,
@Body(new ZodValidationPipe(CreateCertificateSchema)) dto: Parameters<CertificateService["create"]>[1],
) {
return this.certificates.create(ws.id, dto);
}
/** # 证书详情 */
@Get(":id")
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.certificates.get(ws.id, id);
}
/** # 重新探测 */
@Roles("MEMBER")
@Post(":id/recheck")
recheck(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.certificates.recheck(ws.id, id);
}
/** # 删除证书 */
@Roles("ADMIN")
@Delete(":id")
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.certificates.delete(ws.id, id);
}
}
@@ -0,0 +1,15 @@
/**
* # Certificate 模块
*/
import { Module } from "@nestjs/common";
import { CertificateController } from "./certificate.controller";
import { CertificateService } from "./certificate.service";
import { AuditModule } from "../audit/audit.module";
@Module({
imports: [AuditModule],
controllers: [CertificateController],
providers: [CertificateService],
exports: [CertificateService],
})
export class CertificateModule {}
@@ -0,0 +1,243 @@
/**
* # Certificate 服务(Phase E/05 SSL 基础)
* 统一证书模型 + TLS 探针(node:tls 直连取对端证书):
* 状态 pending/active/expired/error;到期时间与签发者入库;可关联 Domain 资源。
*/
import { Injectable, OnModuleInit } from "@nestjs/common";
import { z } from "zod";
import { connect as tlsConnect, type PeerCertificate } from "node:tls";
import { PrismaService } from "../../prisma/prisma.service";
import { AuditService } from "../../modules/audit/audit.service";
import { ApiError } from "../../common/errors/api-error";
import { getRequestContext } from "../../common/request-context";
/** # 创建证书 DTO **/
export const CreateCertificateSchema = z.object({
domain: z
.string()
.min(3)
.max(253)
.regex(/^(?!-)[a-zA-Z0-9-*]{1,63}(\.[a-zA-Z0-9-*]{1,63})+$/, "invalid domain"),
domainId: z.string().optional(),
/** # 立即执行 TLS 探测(默认 true) **/
probe: z.boolean().default(true),
});
export type CreateCertificateDto = z.infer<typeof CreateCertificateSchema>;
/** # TLS 探测结果 **/
export interface TlsProbeResult {
ok: boolean;
issuer?: string;
notAfter?: Date;
daysRemaining?: number;
error?: string;
}
@Injectable()
export class CertificateService implements OnModuleInit {
/** # 到期阈值(天):低于则标记并提示 */
private static readonly EXPIRY_WARN_DAYS = 30;
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
) {}
/** # worker 调度预留:每小时刷新(V1 手动触发为主) */
onModuleInit(): void {
/* 预留 */
}
/** # 创建证书记录并可选探测 */
async create(workspaceId: string, dto: CreateCertificateDto) {
if (dto.domainId) {
const domain = await this.prisma.domain.findFirst({ where: { id: dto.domainId, workspaceId } });
if (!domain) throw ApiError.notFound("Domain not found");
}
const probe = dto.probe ? await this.probeTls(dto.domain) : null;
const cert = await this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: {
workspaceId,
type: "CERTIFICATE",
name: dto.domain,
provider: "tls-probe",
status: this.resourceStatusFromProbe(probe),
},
});
return tx.certificate.create({
data: {
workspaceId,
resourceId: resource.id,
domain: dto.domain,
domainId: dto.domainId ?? null,
status: this.statusFromProbe(probe),
issuer: probe?.issuer ?? null,
notAfter: probe?.notAfter ?? null,
lastCheckedAt: probe ? new Date() : null,
},
});
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "certificate.create",
resourceType: "Certificate",
resourceId: cert.resourceId,
metadata: { domain: dto.domain, probe: probe ? { ok: probe.ok, days: probe.daysRemaining } : null },
});
return this.get(workspaceId, cert.id);
}
/** # 证书列表 */
async list(workspaceId: string) {
const rows = await this.prisma.certificate.findMany({
where: { workspaceId },
orderBy: { createdAt: "desc" },
});
return rows.map((c) => this.view(c));
}
/** # 证书详情 */
async get(workspaceId: string, id: string) {
const c = await this.prisma.certificate.findFirst({ where: { id, workspaceId } });
if (!c) throw ApiError.notFound("Certificate not found");
return this.view(c);
}
/** # 重新探测(刷新状态/到期时间/签发者) */
async recheck(workspaceId: string, id: string) {
const cert = await this.prisma.certificate.findFirst({ where: { id, workspaceId } });
if (!cert) throw ApiError.notFound("Certificate not found");
const probe = await this.probeTls(cert.domain);
const status = this.statusFromProbe(probe);
const updated = await this.prisma.certificate.update({
where: { id: cert.id },
data: {
status,
issuer: probe.issuer ?? cert.issuer,
notAfter: probe.notAfter ?? cert.notAfter,
lastCheckedAt: new Date(),
},
});
await this.prisma.resource.update({
where: { id: cert.resourceId },
data: { status: status === "ACTIVE" ? "ACTIVE" : status === "EXPIRED" ? "ERROR" : "PENDING" },
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "certificate.recheck",
resourceType: "Certificate",
resourceId: cert.resourceId,
status: probe.ok ? "SUCCESS" : "FAILURE",
metadata: { daysRemaining: probe.daysRemaining },
});
return { ...this.view(updated), probe };
}
/** # 删除证书记录 */
async delete(workspaceId: string, id: string) {
const cert = await this.prisma.certificate.findFirst({ where: { id, workspaceId } });
if (!cert) throw ApiError.notFound("Certificate not found");
await this.prisma.certificate.delete({ where: { id: cert.id } });
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "certificate.delete",
resourceType: "Certificate",
resourceId: cert.resourceId,
riskLevel: "MEDIUM",
});
return { deleted: true };
}
/** # TLS 探针:443 直连取对端证书(不做链校验,仅读取信息) */
async probeTls(domain: string, port = 443, timeoutMs = 8000): Promise<TlsProbeResult> {
return new Promise<TlsProbeResult>((resolve) => {
let settled = false;
const done = (r: TlsProbeResult) => {
if (!settled) {
settled = true;
try {
socket.destroy();
} catch {
/* ignore */
}
resolve(r);
}
};
const socket = tlsConnect(
{ host: domain, port, servername: domain, rejectUnauthorized: false, timeout: timeoutMs },
() => {
const cert = socket.getPeerCertificate() as PeerCertificate;
if (!cert || !cert.valid_to) {
done({ ok: false, error: "no peer certificate" });
return;
}
const notAfter = new Date(cert.valid_to);
const daysRemaining = Math.floor((notAfter.getTime() - Date.now()) / 86_400_000);
const issuerObj = cert.issuer as unknown as Record<string, Record<string, string>> | undefined;
done({
ok: true,
issuer: issuerObj?.O
? (issuerObj.O[Object.keys(issuerObj.O)[0]] as string)
: typeof issuerObj?.CN === "string"
? issuerObj.CN
: "unknown",
notAfter,
daysRemaining,
});
},
);
socket.on("error", (err: Error) => done({ ok: false, error: err.message.slice(0, 200) }));
socket.on("timeout", () => done({ ok: false, error: "timeout" }));
});
}
/** # 探测结果 → 资源状态(resources.status 为 Prisma 枚举) */
private resourceStatusFromProbe(probe: TlsProbeResult | null): "PENDING" | "ACTIVE" | "ERROR" {
const s = this.statusFromProbe(probe);
return s === "ACTIVE" ? "ACTIVE" : s === "ERROR" ? "ERROR" : "PENDING";
}
/** # 由探测结果推导状态 */
private statusFromProbe(probe: TlsProbeResult | null): "PENDING" | "ACTIVE" | "EXPIRED" | "ERROR" {
if (!probe) return "PENDING";
if (!probe.ok) return "ERROR";
if ((probe.daysRemaining ?? 0) <= 0) return "EXPIRED";
return "ACTIVE";
}
/** # 实体 → 视图 */
private view(c: {
id: string;
resourceId: string;
workspaceId: string;
domain: string;
domainId: string | null;
status: string;
issuer: string | null;
notAfter: Date | null;
lastCheckedAt: Date | null;
createdAt: Date;
}) {
const daysRemaining = c.notAfter
? Math.floor((c.notAfter.getTime() - Date.now()) / 86_400_000)
: null;
return {
id: c.id,
resourceId: c.resourceId,
workspaceId: c.workspaceId,
domain: c.domain,
domainId: c.domainId,
status: c.status,
issuer: c.issuer,
notAfter: c.notAfter,
daysRemaining,
expiringSoon: daysRemaining !== null && daysRemaining >= 0 && daysRemaining <= CertificateService.EXPIRY_WARN_DAYS,
lastCheckedAt: c.lastCheckedAt,
createdAt: c.createdAt,
};
}
}
@@ -0,0 +1,95 @@
/**
* # Container 控制器(/api/v1/containers、/api/v1/servers/:id/containers 的容器部分)
*/
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { ContainerService, RestartContainerSchema } from "./container.service";
import { ServerService } from "../server/server.service";
import { z } from "zod";
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
const LogsQuerySchema = z.object({ tail: z.coerce.number().int().min(10).max(1000).default(200) });
import { CurrentWorkspace, Roles } from "../../common/decorators";
import type { RequestWorkspace } from "../../common/decorators";
@Controller("containers")
export class ContainerController {
constructor(
private readonly containers: ContainerService,
private readonly servers: ServerService,
) {}
/** # 全工作区容器列表 */
@Get()
list(@CurrentWorkspace() ws: RequestWorkspace) {
return this.containers.list(ws.id);
}
/** # 容器详情(实时 inspect,敏感 env 已脱敏) */
@Get(":id/inspect")
inspect(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.containers.inspect(ws.id, id);
}
/** # 启动容器 */
@Roles("MEMBER")
@Post(":id/start")
start(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.containers.start(ws.id, id);
}
/** # 停止容器 */
@Roles("MEMBER")
@Post(":id/stop")
stop(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.containers.stop(ws.id, id);
}
/** # 容器日志(tail) */
@Get(":id/logs")
logs(
@CurrentWorkspace() ws: RequestWorkspace,
@Param("id") id: string,
@Query(new ZodValidationPipe(LogsQuerySchema)) query: { tail: number },
) {
return this.containers.logs(ws.id, id, query.tail);
}
/** # 容器资源用量 */
@Get(":id/stats")
stats(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.containers.stats(ws.id, id);
}
/** # 重启容器(需显式确认) */
@Roles("MEMBER")
@Post(":id/restart")
restart(
@CurrentWorkspace() ws: RequestWorkspace,
@Param("id") id: string,
@Body(new ZodValidationPipe(RestartContainerSchema)) _dto: { confirm: true },
) {
return this.containers.restart(ws.id, id);
}
}
/** # 服务器维度容器端点(挂到 /servers/:id/containers) **/
@Controller("servers")
export class ServerContainersController {
constructor(
private readonly containers: ContainerService,
private readonly servers: ServerService,
) {}
/** # 发现服务器容器(docker ps → 入库 → 返回) */
@Roles("MEMBER")
@Post(":id/containers")
discover(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.servers.mustGet(ws.id, id).then((server) => this.containers.discover(ws.id, server));
}
/** # 服务器容器列表(读库) */
@Get(":id/containers")
listByServer(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.containers.listByServer(ws.id, id);
}
}
@@ -0,0 +1,19 @@
/**
* # Container 模块(Task 010)
*/
import { forwardRef } from "@nestjs/common";
import { Module } from "@nestjs/common";
import { ContainerController, ServerContainersController } from "./container.controller";
import { ContainerService } from "./container.service";
import { ServerModule } from "../server/server.module";
import { AuditModule } from "../audit/audit.module";
import { SshModule } from "../../integrations/ssh/ssh.module";
import { DockerModule } from "../../integrations/docker/docker.module";
@Module({
imports: [forwardRef(() => ServerModule), AuditModule, SshModule, DockerModule],
controllers: [ContainerController, ServerContainersController],
providers: [ContainerService],
exports: [ContainerService],
})
export class ContainerModule {}
@@ -0,0 +1,271 @@
/**
* # Container 服务(Task 010)
* Docker 发现:列出/检查/重启容器,容器以 CONTAINER 资源入图谱并与 SERVER 建立关系。
*/
import { Injectable } from "@nestjs/common";
import { z } from "zod";
import type { Server } from "@prisma/client";
import { PrismaService } from "../../prisma/prisma.service";
import { DockerService } from "../../integrations/docker/docker.service";
import { AuditService } from "../audit/audit.service";
import { ServerService } from "../server/server.service";
import { ApiError } from "../../common/errors/api-error";
import { getRequestContext } from "../../common/request-context";
/** # 重启容器 DTO **/
export const RestartContainerSchema = z.object({
confirm: z.literal(true),
});
@Injectable()
export class ContainerService {
constructor(
private readonly prisma: PrismaService,
private readonly docker: DockerService,
private readonly audit: AuditService,
private readonly serverService: ServerService,
) {}
/** # Docker 可用性 */
async dockerStatus(workspaceId: string, server: Server) {
const target = this.docker.isLocal(server)
? { host: "", port: 0, username: "" }
: await this.serverService.sshTarget(workspaceId, server);
return this.docker.checkDocker(target, this.docker.isLocal(server));
}
/** # 发现服务器上的容器并同步入库(CONTAINER 资源 + deployed_on 关系) */
async discover(workspaceId: string, server: Server) {
const isLocal = this.docker.isLocal(server);
const target = isLocal ? { host: "", port: 0, username: "" } : await this.serverService.sshTarget(workspaceId, server);
const containers = await this.docker.listContainers(target, isLocal);
for (const c of containers) {
const existing = await this.prisma.container.findFirst({
where: { workspaceId, serverId: server.id, containerId: c.id },
});
if (existing) {
await this.prisma.container.update({
where: { id: existing.id },
data: {
name: c.name,
image: c.image,
state: c.state,
ports: c.ports as unknown as object,
labels: c.labels as unknown as object,
},
});
await this.prisma.resource.update({
where: { id: existing.resourceId },
data: { status: c.state === "running" ? "ACTIVE" : "OFFLINE", name: c.name },
});
} else {
await this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: {
workspaceId,
type: "CONTAINER",
name: c.name || c.id,
provider: "docker",
status: c.state === "running" ? "ACTIVE" : "OFFLINE",
metadata: { image: c.image, serverId: server.id },
},
});
await tx.container.create({
data: {
workspaceId,
resourceId: resource.id,
serverId: server.id,
containerId: c.id,
name: c.name,
image: c.image,
state: c.state,
ports: c.ports as unknown as object,
labels: c.labels as unknown as object,
},
});
await tx.resourceRelation.upsert({
where: {
sourceId_targetId_relationType: {
sourceId: server.resourceId,
targetId: resource.id,
relationType: "runs_container",
},
},
create: {
workspaceId,
sourceId: server.resourceId,
targetId: resource.id,
relationType: "runs_container",
},
update: {},
});
});
}
}
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "container.discover",
resourceType: "Server",
resourceId: server.resourceId,
metadata: { count: containers.length },
});
return this.listByServer(workspaceId, server.id);
}
/** # 某服务器的容器列表(读库;如需刷新调用 discover) */
async listByServer(workspaceId: string, serverId: string) {
await this.serverService.mustGet(workspaceId, serverId);
const rows = await this.prisma.container.findMany({
where: { workspaceId, serverId },
orderBy: { name: "asc" },
});
return rows.map((c) => this.toView(c));
}
/** # 全工作区容器列表 */
async list(workspaceId: string) {
const rows = await this.prisma.container.findMany({
where: { workspaceId },
orderBy: { updatedAt: "desc" },
});
return rows.map((c) => this.toView(c));
}
/** # 容器详情(实时 inspect) */
async inspect(workspaceId: string, containerRowId: string) {
const row = await this.mustGet(workspaceId, containerRowId);
const server = await this.serverService.mustGet(workspaceId, row.serverId);
const isLocal = this.docker.isLocal(server);
const target = isLocal ? { host: "", port: 0, username: "" } : await this.serverService.sshTarget(workspaceId, server);
const info = await this.docker.inspectContainer(target, row.containerId, isLocal);
// 剥离可能的敏感 env
const config = info.Config as Record<string, unknown> | undefined;
if (config && Array.isArray(config.Env)) {
config.Env = (config.Env as string[]).map((e) => e.split("=")[0] + "=***");
}
return info;
}
/** # 启动容器 */
async start(workspaceId: string, containerRowId: string) {
return this.lifecycle(workspaceId, containerRowId, "start");
}
/** # 停止容器 */
async stop(workspaceId: string, containerRowId: string) {
return this.lifecycle(workspaceId, containerRowId, "stop");
}
/** # 生命周期通用实现(审计) */
private async lifecycle(workspaceId: string, containerRowId: string, action: "start" | "stop") {
const row = await this.mustGet(workspaceId, containerRowId);
const server = await this.serverService.mustGet(workspaceId, row.serverId);
const isLocal = this.docker.isLocal(server);
const target = isLocal ? { host: "localhost", port: 0, username: "local" } : await this.serverService.sshTarget(workspaceId, server);
if (action === "start") {
await this.docker.startContainer(target, row.containerId, isLocal);
} else {
await this.docker.stopContainer(target, row.containerId, isLocal);
}
await this.prisma.container.update({
where: { id: row.id },
data: { state: action === "start" ? "running" : "exited" },
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: `container.${action}`,
resourceType: "Container",
resourceId: row.resourceId,
riskLevel: "HIGH",
metadata: { name: row.name },
});
return { [action]: true, name: row.name };
}
/** # 容器日志(tail) */
async logs(workspaceId: string, containerRowId: string, tail = 200) {
const row = await this.mustGet(workspaceId, containerRowId);
const server = await this.serverService.mustGet(workspaceId, row.serverId);
const isLocal = this.docker.isLocal(server);
const target = isLocal ? { host: "localhost", port: 0, username: "local" } : await this.serverService.sshTarget(workspaceId, server);
const lines = await this.docker.containerLogs(target, row.containerId, Math.min(tail, 1000), isLocal);
return { name: row.name, lines };
}
/** # 容器实时资源用量 */
async stats(workspaceId: string, containerRowId: string) {
const row = await this.mustGet(workspaceId, containerRowId);
const server = await this.serverService.mustGet(workspaceId, row.serverId);
const isLocal = this.docker.isLocal(server);
const target = isLocal ? { host: "localhost", port: 0, username: "local" } : await this.serverService.sshTarget(workspaceId, server);
return { name: row.name, ...(await this.docker.containerStats(target, row.containerId, isLocal)) };
}
/** # 镜像列表 */
async listImages(workspaceId: string, serverId: string) {
const server = await this.serverService.mustGet(workspaceId, serverId);
const isLocal = this.docker.isLocal(server);
const target = isLocal ? { host: "localhost", port: 0, username: "local" } : await this.serverService.sshTarget(workspaceId, server);
return this.docker.listImages(target, isLocal);
}
/** # 重启容器(需 confirm=true;审计 + container.restarted 事件) */
async restart(workspaceId: string, containerRowId: string) {
const row = await this.mustGet(workspaceId, containerRowId);
const server = await this.serverService.mustGet(workspaceId, row.serverId);
const isLocal = this.docker.isLocal(server);
const target = isLocal ? { host: "", port: 0, username: "" } : await this.serverService.sshTarget(workspaceId, server);
await this.docker.restartContainer(target, row.containerId, isLocal);
await this.prisma.container.update({ where: { id: row.id }, data: { state: "running" } });
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "container.restart",
resourceType: "Container",
resourceId: row.resourceId,
riskLevel: "HIGH",
metadata: { name: row.name, serverId: server.id },
});
return { restarted: true, name: row.name };
}
/** # 断言容器属于工作区 */
private async mustGet(workspaceId: string, id: string) {
const row = await this.prisma.container.findFirst({ where: { id, workspaceId } });
if (!row) throw ApiError.notFound("Container not found");
return row;
}
/** # 实体 → 视图 */
private toView(c: {
id: string;
resourceId: string;
containerId: string;
name: string;
image: string;
state: string;
ports: unknown;
labels: unknown;
serverId: string;
workspaceId: string;
updatedAt: Date;
}) {
return {
id: c.id,
resourceId: c.resourceId,
containerId: c.containerId,
name: c.name,
image: c.image,
state: c.state,
ports: c.ports,
labels: c.labels,
serverId: c.serverId,
workspaceId: c.workspaceId,
updatedAt: c.updatedAt,
};
}
}
@@ -0,0 +1,76 @@
/**
* # 凭据保险库
* AES-256-GCM 信封式加密:
* Application Master Key (ENCRYPTION_KEY, 32B) → 加密凭据载荷。
* - AAD 绑定 workspaceId,防止跨租户密文移植;
* - 明文只在内存中短暂存在,绝不入库、绝不入日志(审计层另有脱敏);
* - API 层不提供任何“读取明文”端点。
*/
import { Injectable } from "@nestjs/common";
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
import { ConfigService } from "../../common/config/config.service";
import { ApiError } from "../../common/errors/api-error";
/** # 密文载荷结构(JSON 序列化后存储) **/
interface EncryptedPayload {
v: 1;
iv: string;
tag: string;
data: string;
}
@Injectable()
export class CredentialVaultService {
/** # 主密钥 **/
private readonly key: Buffer;
constructor(config: ConfigService) {
this.key = Buffer.from(config.encryptionKey, "hex");
}
/** # 加密
* @param workspaceId 租户 ID(作为 AAD 绑定)
* @param plaintext 明文(如私钥/密码/API Token)
*/
encrypt(workspaceId: string, plaintext: string): string {
const iv = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", this.key, iv, { authTagLength: 16 });
cipher.setAAD(Buffer.from(workspaceId, "utf8"));
const data = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const payload: EncryptedPayload = {
v: 1,
iv: iv.toString("base64"),
tag: cipher.getAuthTag().toString("base64"),
data: data.toString("base64"),
};
return JSON.stringify(payload);
}
/** # 解密(仅限服务端内部使用)
* @throws 完整性校验失败(密文被篡改或跨租户使用)时抛出
*/
decrypt(workspaceId: string, encrypted: string): string {
let payload: EncryptedPayload;
try {
payload = JSON.parse(encrypted) as EncryptedPayload;
} catch {
throw ApiError.forbidden("Credential payload corrupted");
}
try {
const decipher = createDecipheriv(
"aes-256-gcm",
this.key,
Buffer.from(payload.iv, "base64"),
{ authTagLength: 16 },
);
decipher.setAAD(Buffer.from(workspaceId, "utf8"));
decipher.setAuthTag(Buffer.from(payload.tag, "base64"));
return Buffer.concat([
decipher.update(Buffer.from(payload.data, "base64")),
decipher.final(),
]).toString("utf8");
} catch {
throw ApiError.forbidden("Credential integrity check failed");
}
}
}
@@ -0,0 +1,54 @@
/**
* # Credential 控制器(/api/v1/credentials)
* 安全约束:任何端点都不返回明文密钥。
*/
import { Body, Controller, Delete, Get, Param, Patch, Post } from "@nestjs/common";
import { CredentialService, CreateCredentialSchema, UpdateCredentialSchema } from "./credential.service";
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
import { CurrentWorkspace, Roles } from "../../common/decorators";
import type { RequestWorkspace } from "../../common/decorators";
@Controller("credentials")
export class CredentialController {
constructor(private readonly credentials: CredentialService) {}
/** # 凭据列表(仅元数据) */
@Get()
list(@CurrentWorkspace() ws: RequestWorkspace) {
return this.credentials.list(ws.id);
}
/** # 创建凭据(明文立即加密入库) */
@Roles("ADMIN")
@Post()
create(
@CurrentWorkspace() ws: RequestWorkspace,
@Body(new ZodValidationPipe(CreateCredentialSchema)) dto: Parameters<CredentialService["create"]>[1],
) {
return this.credentials.create(ws.id, dto);
}
/** # 凭据详情(仅元数据) */
@Get(":id")
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.credentials.get(ws.id, id);
}
/** # 更新/轮换凭据 */
@Roles("ADMIN")
@Patch(":id")
update(
@CurrentWorkspace() ws: RequestWorkspace,
@Param("id") id: string,
@Body(new ZodValidationPipe(UpdateCredentialSchema)) dto: Parameters<CredentialService["update"]>[2],
) {
return this.credentials.update(ws.id, id, dto);
}
/** # 删除凭据 */
@Roles("ADMIN")
@Delete(":id")
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.credentials.delete(ws.id, id);
}
}
@@ -0,0 +1,16 @@
/**
* # 凭据保险库模块
*/
import { Module } from "@nestjs/common";
import { CredentialController } from "./credential.controller";
import { CredentialService } from "./credential.service";
import { CredentialVaultService } from "./credential-vault.service";
import { AuditModule } from "../audit/audit.module";
@Module({
imports: [AuditModule],
controllers: [CredentialController],
providers: [CredentialService, CredentialVaultService],
exports: [CredentialService, CredentialVaultService],
})
export class CredentialModule {}
@@ -0,0 +1,203 @@
/**
* # 凭据服务
* 凭据的 CRUD 与“按需解密”。凭据同时是一条 CREDENTIAL 资源(进入统一资源图谱)。
*/
import { Injectable } from "@nestjs/common";
import { z } from "zod";
import type { CredentialType } from "@prisma/client";
import { PrismaService } from "../../prisma/prisma.service";
import { CredentialVaultService } from "./credential-vault.service";
import { AuditService } from "../audit/audit.service";
import { ApiError } from "../../common/errors/api-error";
import { getRequestContext } from "../../common/request-context";
/** # 创建凭据 DTO **/
export const CreateCredentialSchema = z.object({
name: z.string().min(1).max(120),
type: z.enum(["SSH_PASSWORD", "SSH_KEY", "API_TOKEN", "DNS_PROVIDER", "GIT_TOKEN"]),
provider: z.string().max(64).optional(),
/** # 明文机密(仅出现在请求中,服务端立即加密) **/
secret: z.string().min(1).max(65536),
metadata: z.record(z.unknown()).optional(),
});
export type CreateCredentialDto = z.infer<typeof CreateCredentialSchema>;
/** # 更新凭据 DTO **/
export const UpdateCredentialSchema = z.object({
name: z.string().min(1).max(120).optional(),
/** # 轮换:提供新的明文机密 **/
secret: z.string().min(1).max(65536).optional(),
metadata: z.record(z.unknown()).optional(),
});
export type UpdateCredentialDto = z.infer<typeof UpdateCredentialSchema>;
@Injectable()
export class CredentialService {
constructor(
private readonly prisma: PrismaService,
private readonly vault: CredentialVaultService,
private readonly audit: AuditService,
) {}
/** # 创建凭据(资源 + 密文,一个事务) */
async create(workspaceId: string, dto: CreateCredentialDto) {
const credential = await this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: {
workspaceId,
type: "CREDENTIAL",
name: dto.name,
provider: dto.provider ?? null,
status: "ACTIVE",
metadata: { credentialType: dto.type, ...(dto.metadata ?? {}) },
},
});
return tx.credential.create({
data: {
workspaceId,
resourceId: resource.id,
name: dto.name,
type: dto.type as CredentialType,
provider: dto.provider ?? null,
encryptedData: this.vault.encrypt(workspaceId, dto.secret),
metadata: (dto.metadata ?? {}) as object,
},
});
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "credential.create",
resourceType: "Credential",
resourceId: credential.id,
riskLevel: "MEDIUM",
});
return this.view(credential.id, workspaceId);
}
/** # 凭据列表(不含密文) */
async list(workspaceId: string) {
const rows = await this.prisma.credential.findMany({
where: { workspaceId },
orderBy: { createdAt: "desc" },
});
return rows.map((r) => this.toView(r));
}
/** # 凭据详情(不含密文) */
async get(workspaceId: string, id: string) {
const row = await this.prisma.credential.findFirst({ where: { id, workspaceId } });
if (!row) throw ApiError.notFound("Credential not found");
return this.toView(row);
}
/** # 更新(含轮换密文) */
async update(workspaceId: string, id: string, dto: UpdateCredentialDto) {
const row = await this.prisma.credential.findFirst({ where: { id, workspaceId } });
if (!row) throw ApiError.notFound("Credential not found");
const updated = await this.prisma.$transaction(async (tx) => {
const cred = await tx.credential.update({
where: { id },
data: {
...(dto.name ? { name: dto.name } : {}),
...(dto.metadata ? { metadata: dto.metadata as object } : {}),
...(dto.secret
? { encryptedData: this.vault.encrypt(workspaceId, dto.secret) }
: {}),
},
});
await tx.resource.update({
where: { id: cred.resourceId ?? "" },
data: { ...(dto.name ? { name: dto.name } : {}) },
});
return cred;
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "credential.update",
resourceType: "Credential",
resourceId: id,
riskLevel: "MEDIUM",
});
return this.toView(updated);
}
/** # 删除凭据 */
async delete(workspaceId: string, id: string) {
const row = await this.prisma.credential.findFirst({ where: { id, workspaceId } });
if (!row) throw ApiError.notFound("Credential not found");
await this.prisma.$transaction(async (tx) => {
await tx.credential.delete({ where: { id } });
if (row.resourceId) await tx.resource.delete({ where: { id: row.resourceId } });
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "credential.delete",
resourceType: "Credential",
resourceId: id,
riskLevel: "HIGH",
});
return { deleted: true };
}
/** # 解密取回明文(内部使用;绝不返回给 API 调用方)
* 每次使用写入审计(credential.use)。
*/
async getSecret(workspaceId: string, credentialId: string | null): Promise<string | null> {
if (!credentialId) return null;
const row = await this.prisma.credential.findFirst({
where: { id: credentialId, workspaceId },
});
if (!row) throw ApiError.notFound("Credential not found");
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "SYSTEM",
action: "credential.use",
resourceType: "Credential",
resourceId: row.id,
riskLevel: "MEDIUM",
metadata: { purpose: "internal-execution" },
});
await this.prisma.credential.update({
where: { id: row.id },
data: { lastUsedAt: new Date() },
});
return this.vault.decrypt(workspaceId, row.encryptedData);
}
/** # 外部视图(不含任何密文) */
private toView(row: {
id: string;
workspaceId: string;
resourceId: string | null;
name: string;
type: CredentialType;
provider: string | null;
metadata: unknown;
lastUsedAt: Date | null;
createdAt: Date;
updatedAt: Date;
}) {
return {
id: row.id,
resourceId: row.resourceId,
workspaceId: row.workspaceId,
name: row.name,
type: row.type,
provider: row.provider,
metadata: row.metadata,
lastUsedAt: row.lastUsedAt,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/** # 兼容入口:按 id 返回视图 */
private async view(id: string, workspaceId: string) {
const row = await this.prisma.credential.findFirst({ where: { id, workspaceId } });
if (!row) throw ApiError.notFound("Credential not found");
return this.toView(row);
}
}
@@ -0,0 +1,51 @@
/**
* # Deployment 控制器(/api/v1/deployments)
*/
import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common";
import { z } from "zod";
import { DeploymentService, CreateDeploymentSchema, type CreateDeploymentDto } from "./deployment.service";
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
import { CurrentWorkspace, Roles } from "../../common/decorators";
import type { RequestWorkspace } from "../../common/decorators";
const ListQuerySchema = z.object({ websiteId: z.string().optional() });
@Controller("deployments")
export class DeploymentController {
constructor(private readonly deployments: DeploymentService) {}
/** # 部署列表(可按网站过滤) */
@Get()
list(
@CurrentWorkspace() ws: RequestWorkspace,
@Query(new ZodValidationPipe(ListQuerySchema)) query: { websiteId?: string },
) {
return this.deployments.list(ws.id, query.websiteId);
}
/** # 创建部署 */
@Roles("MEMBER")
@Post()
create(
@CurrentWorkspace() ws: RequestWorkspace,
@Body(new ZodValidationPipe(CreateDeploymentSchema)) dto: CreateDeploymentDto,
) {
return this.deployments.createDeployment(ws.id, dto.websiteId, {
trigger: dto.trigger,
branch: dto.branch,
});
}
/** # 部署详情(含日志) */
@Get(":id")
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.deployments.get(ws.id, id);
}
/** # 取消部署 */
@Roles("MEMBER")
@Post(":id/cancel")
cancel(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.deployments.cancel(ws.id, id);
}
}
@@ -0,0 +1,20 @@
/**
* # Deployment 模块(Task 012)
*/
import { Module } from "@nestjs/common";
import { DeploymentController } from "./deployment.controller";
import { DeploymentService } from "./deployment.service";
import { ServerModule } from "../server/server.module";
import { EventModule } from "../event/event.module";
import { AuditModule } from "../audit/audit.module";
import { SshModule } from "../../integrations/ssh/ssh.module";
import { DockerModule } from "../../integrations/docker/docker.module";
import { GitModule } from "../git/git.module";
@Module({
imports: [ServerModule, EventModule, AuditModule, SshModule, DockerModule, GitModule],
controllers: [DeploymentController],
providers: [DeploymentService],
exports: [DeploymentService],
})
export class DeploymentModule {}
@@ -0,0 +1,429 @@
/**
* # Deployment 服务(Task 012)
* 部署任务模型:QUEUED→RUNNING→SUCCEEDED/FAILED/CANCELLED,异步 Job 执行。
* 执行步骤(DOCKER 类型):检查 docker → 拉取仓库 → 构建镜像 → 替换容器 → 健康验证。
* 每一步写 DeploymentLog;进度写入 progress;结束发 deployment.success/failed 事件。
*/
import { Injectable, OnModuleInit } from "@nestjs/common";
import { z } from "zod";
import type { Website, Deployment } from "@prisma/client";
import { PrismaService } from "../../prisma/prisma.service";
import { JobQueueService } from "../job/job-queue.service";
import { EventBusService } from "../event/event-bus.service";
import { AuditService } from "../audit/audit.service";
import { ServerService } from "../server/server.service";
import { SshClientService, type SshTarget } from "../../integrations/ssh/ssh-client.service";
import { DockerService } from "../../integrations/docker/docker.service";
import { GitService } from "../git/git.service";
import { ApiError } from "../../common/errors/api-error";
import { getRequestContext, runWithContext } from "../../common/request-context";
/** # 创建部署 DTO **/
export const CreateDeploymentSchema = z.object({
websiteId: z.string().min(1),
branch: z.string().max(200).optional(),
trigger: z.string().max(32).default("MANUAL"),
});
export type CreateDeploymentDto = z.infer<typeof CreateDeploymentSchema>;
/** # 部署目标目录根(目标服务器上) **/
const REMOTE_ROOT = "/opt/devos/websites";
@Injectable()
export class DeploymentService implements OnModuleInit {
constructor(
private readonly prisma: PrismaService,
private readonly jobs: JobQueueService,
private readonly events: EventBusService,
private readonly audit: AuditService,
private readonly servers: ServerService,
private readonly ssh: SshClientService,
private readonly docker: DockerService,
private readonly git: GitService,
) {}
/** # 模块初始化:注册部署任务处理器 */
onModuleInit(): void {
this.registerJobHandler();
}
/** # 创建部署并入队 */
async createDeployment(
workspaceId: string,
websiteId: string,
opts: { trigger: string; branch?: string },
) {
const website = await this.prisma.website.findFirst({ where: { id: websiteId, workspaceId } });
if (!website) throw ApiError.notFound("Website not found");
// 部署来源断言:仓库登记或旧版 URL 至少其一
if (!website.repositoryId && !website.repositoryUrl) {
throw ApiError.validation("Website has no repository configured");
}
const deployment = await this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: {
workspaceId,
type: "DEPLOYMENT",
name: `${website.name} #${new Date().toISOString()}`,
provider: "devos",
status: "PENDING",
},
});
const d = await tx.deployment.create({
data: {
workspaceId,
resourceId: resource.id,
websiteId: website.id,
status: "QUEUED",
trigger: opts.trigger,
config: { branch: opts.branch ?? "main" } as object,
},
});
await tx.resourceRelation.upsert({
where: {
sourceId_targetId_relationType: {
sourceId: website.resourceId,
targetId: resource.id,
relationType: "has_deployment",
},
},
create: { workspaceId, sourceId: website.resourceId, targetId: resource.id, relationType: "has_deployment" },
update: {},
});
return d;
});
await this.jobs.enqueue("deployment", workspaceId, { deploymentId: deployment.id });
await this.events.publish("deployment.created", {
workspaceId,
resourceId: deployment.resourceId,
payload: { deploymentId: deployment.id, websiteId: website.id, websiteName: website.name, trigger: opts.trigger },
});
return this.view(deployment, website);
}
/** # 部署列表 */
async list(workspaceId: string, websiteId?: string) {
const where = { workspaceId, ...(websiteId ? { websiteId } : {}) };
const rows = await this.prisma.deployment.findMany({
where,
orderBy: { createdAt: "desc" },
take: 200,
include: { website: { select: { id: true, name: true } } },
});
return rows.map((d) => ({ ...this.view(d), website: d.website }));
}
/** # 部署详情(含日志) */
async get(workspaceId: string, id: string) {
const d = await this.prisma.deployment.findFirst({
where: { id, workspaceId },
include: {
website: { select: { id: true, name: true, resourceId: true } },
logs: { orderBy: { seq: "asc" }, take: 2000 },
},
});
if (!d) throw ApiError.notFound("Deployment not found");
return {
...this.view(d),
website: d.website,
logs: d.logs.map((l) => ({ seq: l.seq, stream: l.stream, line: l.line, time: l.createdAt })),
};
}
/** # 取消部署:QUEUED 立即取消;RUNNING 打上取消标记(步骤间检查) */
async cancel(workspaceId: string, id: string) {
const d = await this.prisma.deployment.findFirst({ where: { id, workspaceId } });
if (!d) throw ApiError.notFound("Deployment not found");
if (d.status === "QUEUED") {
await this.prisma.deployment.update({ where: { id }, data: { status: "CANCELLED", finishedAt: new Date() } });
await this.prisma.resource.update({ where: { id: d.resourceId }, data: { status: "ARCHIVED" } });
await this.audit.record({
workspaceId, actorType: getRequestContext()?.actorType ?? "USER",
action: "deployment.cancel", resourceType: "Deployment", resourceId: d.resourceId,
});
return { cancelled: true };
}
if (d.status === "RUNNING") {
await this.prisma.deployment.update({
where: { id },
data: { config: { ...(d.config as object), cancelRequested: true } as object },
});
return { cancelling: true, message: "Cancellation requested; will stop between steps" };
}
throw ApiError.conflict(`Deployment is ${d.status}; cannot cancel`);
}
/** # 注册 job handler(DeploymentModule 初始化时调用) */
registerJobHandler(): void {
this.jobs.register("deployment", async (job) => {
await this.execute(job.workspaceId, String(job.payload.deploymentId ?? ""));
return {};
});
}
/** # 执行部署(worker 上下文调用) */
private async execute(workspaceId: string, deploymentId: string): Promise<void> {
const d = await this.prisma.deployment.findFirst({
where: { id: deploymentId, workspaceId },
include: { website: true },
});
if (!d || d.status !== "QUEUED") return;
const website = d.website;
await this.prisma.deployment.update({ where: { id: d.id }, data: { status: "RUNNING", progress: 2 } });
await this.prisma.resource.update({ where: { id: d.resourceId }, data: { status: "ACTIVE" } });
await this.events.publish("deployment.started", {
workspaceId, resourceId: d.resourceId, payload: { deploymentId: d.id, websiteId: website.id },
});
try {
await runWithContext(
{ requestId: `deploy-${d.id.slice(0, 8)}`, correlationId: d.id, workspaceId, actorType: "SYSTEM" },
() => this.runSteps(workspaceId, d, website),
);
await this.prisma.deployment.update({
where: { id: d.id },
data: { status: "SUCCEEDED", progress: 100, finishedAt: new Date() },
});
await this.prisma.resource.update({ where: { id: d.resourceId }, data: { status: "ARCHIVED" } });
await this.prisma.resource.update({ where: { id: website.resourceId }, data: { status: "ACTIVE" } });
await this.events.publish("deployment.success", {
workspaceId, resourceId: d.resourceId, correlationId: d.id,
payload: { deploymentId: d.id, websiteId: website.id, websiteName: website.name },
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const cancelled = message.includes("cancelled by user");
await this.prisma.deployment.update({
where: { id: d.id },
data: {
status: cancelled ? "CANCELLED" : "FAILED",
finishedAt: new Date(),
error: message.slice(0, 2000),
},
});
await this.prisma.resource.update({ where: { id: d.resourceId }, data: { status: cancelled ? "ARCHIVED" : "ERROR" } });
await this.events.publish("deployment.failed", {
workspaceId, resourceId: d.resourceId, correlationId: d.id,
payload: { deploymentId: d.id, websiteId: website.id, websiteName: website.name, cancelled, error: message.slice(0, 500) },
});
await this.audit.record({
workspaceId, actorType: "SYSTEM", action: cancelled ? "deployment.cancelled" : "deployment.failed",
resourceType: "Deployment", resourceId: d.resourceId, status: cancelled ? "CANCELLED" : "FAILURE",
riskLevel: "HIGH", metadata: { deploymentId: d.id, error: message.slice(0, 300) },
});
}
}
/** # 分步执行(每步前检查取消标记) */
private async runSteps(workspaceId: string, d: Deployment, website: Website): Promise<void> {
if (!website.serverId) throw new Error("Website target server missing");
const server = await this.servers.mustGet(workspaceId, website.serverId);
const isLocal = this.docker.isLocal(server);
const target: SshTarget = isLocal
? { host: "localhost", port: 0, username: "local" }
: await this.servers.sshTarget(workspaceId, server);
/** # 远程/本地命令执行(统一入口) **/
const exec = async (command: string, timeoutMs = 300_000): Promise<string> => {
if (isLocal) {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const { stdout } = await promisify(execFile)("sh", ["-c", command], { timeout: timeoutMs });
return stdout;
}
const res = await this.ssh.exec(target, command, timeoutMs);
if (res.code !== 0) {
throw new Error(`Command failed (exit ${res.code}): ${(res.stderr || res.stdout).slice(0, 500)}`);
}
return res.stdout;
};
const log = async (line: string, stream = "stdout") => {
const count = await this.prisma.deploymentLog.count({ where: { deploymentId: d.id } });
await this.prisma.deploymentLog.create({
data: { deploymentId: d.id, seq: count, stream, line: line.slice(0, 4000) },
});
};
const setProgress = async (p: number) => {
await this.prisma.deployment.update({ where: { id: d.id }, data: { progress: p } });
};
const checkCancel = async () => {
const row = await this.prisma.deployment.findUnique({ where: { id: d.id }, select: { config: true } });
if ((row?.config as Record<string, unknown> | undefined)?.cancelRequested === true) {
throw new Error("Deployment cancelled by user");
}
};
await checkCancel();
await log(`[1/6] Checking docker on ${server.name} (${server.host})`);
const dockerCheck = await this.docker.checkDocker(target, isLocal);
if (!dockerCheck.available) throw new Error(`Docker not available: ${dockerCheck.version}`);
await log(`Docker ${dockerCheck.version} available`);
await setProgress(15);
const slug = website.name.toLowerCase().replace(/[^a-z0-9-]+/g, "-").replace(/^-+|-+$/g, "") || "site";
const dir = `${REMOTE_ROOT}/${slug}`;
const branch = String((d.config as Record<string, unknown>).branch ?? "main");
// 解析仓库来源:优先 Git Provider 凭据克隆(Phase D),旧版 URL 回退
let cloneSource = website.repositoryUrl;
if (website.repositoryId) {
const resolved = await this.git.buildCloneCommand(workspaceId, website.repositoryId);
cloneSource = resolved.cloneUrl;
await log(`[2/6] Fetching repository ${resolved.provider} via stored credential (branch ${branch})`);
}
if (cloneSource && !isLocal) {
await checkCancel();
await log(`[2/6] Fetching repository (branch ${branch})`);
await exec(`mkdir -p ${REMOTE_ROOT}`);
const exists = await exec(`test -d ${dir}/.git && echo yes || echo no`);
if (exists.trim() === "yes") {
await exec(
`cd ${dir} && git remote set-url origin ${cloneSource} && git fetch --all && git reset --hard origin/${branch}`,
120_000,
);
} else {
await exec(`git clone --branch ${branch} ${cloneSource} ${dir}`, 300_000);
}
const commit = await exec(`cd ${dir} && git rev-parse --short HEAD`);
await this.prisma.deployment.update({ where: { id: d.id }, data: { commit: commit.trim() || null } });
await log(`Repository ready at commit ${commit.trim() || "unknown"}`);
await setProgress(35);
} else if (!website.repositoryUrl) {
await log("[2/6] No repository configured; using existing build context on server");
} else {
await log("[2/6] Local provider: using local directory build context (git clone skipped)");
}
// 凭据不得残留在 git remote 配置中(克隆后重置 origin 为干净 URL)
if (website.repositoryId && !isLocal && cloneSource && cloneSource.includes("@")) {
const cleanUrl = `https://${cloneSource.split("@").pop()!}`;
await exec(`cd ${dir} && git remote set-url origin ${cleanUrl}`, 30_000).catch(() => undefined);
}
await checkCancel();
const imageName = `devos-${slug}:${d.id.slice(0, 12)}`;
await log(`[3/6] Building image ${imageName}`);
if (!isLocal) {
await exec(`cd ${dir} && docker build -t ${imageName} .`, 600_000);
await log("Image built");
}
await setProgress(60);
await checkCancel();
await log(`[4/6] (Re)starting container devos-${slug} on port ${website.port}`);
if (!isLocal) {
const envArgs = Object.entries((website.env ?? {}) as Record<string, unknown>)
.map(([k, v]) => `-e ${k}='${String(v).replace(/'/g, `'\\''`)}'`)
.join(" ");
await exec(`docker rm -f devos-${slug} 2>/dev/null || true`);
await exec(
`docker run -d --name devos-${slug} --restart unless-stopped -p ${website.port}:${website.port} ${envArgs} ${imageName}`,
120_000,
);
await log("Container started");
}
await setProgress(80);
await checkCancel();
await log(`[5/6] Verifying health at :${website.port}${website.healthPath}`);
const health = await this.verifyOnServer(exec, website.port, website.healthPath);
const verification = { ...health, checkedAt: new Date().toISOString(), strategy: "http-curl-local" };
await this.prisma.deployment.update({
where: { id: d.id },
data: { verification: verification as object },
});
if (!health.ok) {
throw new Error(`Health check failed: ${health.error ?? `status ${health.status}`}`);
}
await log(`Health check passed (${health.latencyMs ?? 0}ms)`);
await setProgress(95);
await log("[6/6] Deployment complete");
}
/** # 在服务器上 curl 本机端口做健康验证(重试 5 次 × 2s) */
private async verifyOnServer(
exec: (cmd: string, timeoutMs?: number) => Promise<string>,
port: number,
healthPath: string,
): Promise<{ ok: boolean; status?: number; latencyMs?: number; error?: string }> {
const cmd = `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${port}${healthPath}`;
for (let attempt = 1; attempt <= 5; attempt++) {
try {
const start = Date.now();
const out = await exec(cmd, 15_000);
const status = Number(out.trim());
if (status >= 200 && status < 500) {
return { ok: true, status, latencyMs: Date.now() - start };
}
return { ok: false, status, error: `HTTP ${status}` };
} catch (error) {
if (attempt === 5) {
return { ok: false, error: error instanceof Error ? error.message : "health check failed" };
}
await new Promise((r) => setTimeout(r, 2000));
}
}
return { ok: false, error: "unreachable" };
}
/** # 网站即时健康检查(供 WebsiteService.health 使用) */
async runHealthCheck(workspaceId: string, website: Website) {
if (!website.serverId) {
if (!website.primaryDomain) return { ok: false, error: "No server or domain configured" };
const url = `http://${website.primaryDomain}${website.healthPath}`;
const start = Date.now();
try {
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
return { ok: res.status < 500, status: res.status, latencyMs: Date.now() - start, url };
} catch (error) {
return { ok: false, error: error instanceof Error ? error.message : "failed", url };
}
}
const server = await this.servers.mustGet(workspaceId, website.serverId);
const isLocal = this.docker.isLocal(server);
const target: SshTarget = isLocal
? { host: "localhost", port: 0, username: "local" }
: await this.servers.sshTarget(workspaceId, server);
const cmd = `curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${website.port}${website.healthPath}`;
const out = await this.execDirect(target, isLocal, cmd, 15_000);
const status = Number(out.trim());
return { ok: status >= 200 && status < 500, status };
}
/** # 直接执行(local 走本机 sh,ssh 走 SSH;不校验退出码) */
private async execDirect(
target: SshTarget,
isLocal: boolean,
command: string,
timeoutMs: number,
): Promise<string> {
if (isLocal) {
const { execFile } = await import("node:child_process");
const { promisify } = await import("node:util");
const { stdout } = await promisify(execFile)("sh", ["-c", command], { timeout: timeoutMs });
return stdout;
}
const res = await this.ssh.exec(target, command, timeoutMs);
return res.stdout;
}
/** # 实体 → 视图 */
private view(d: Deployment, website?: Website) {
return {
id: d.id,
resourceId: d.resourceId,
workspaceId: d.workspaceId,
websiteId: d.websiteId,
websiteName: website?.name,
status: d.status,
trigger: d.trigger,
progress: d.progress,
commit: d.commit,
error: d.error ?? undefined,
createdAt: d.createdAt,
finishedAt: d.finishedAt,
};
}
}
@@ -0,0 +1,70 @@
/**
* # Domain 控制器(/api/v1/domains)
*/
import { Body, Controller, Delete, Get, Param, Post } from "@nestjs/common";
import { DomainService, CreateDomainSchema, UpsertRecordSchema } from "./domain.service";
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
import { CurrentWorkspace, Roles } from "../../common/decorators";
import type { RequestWorkspace } from "../../common/decorators";
@Controller("domains")
export class DomainController {
constructor(private readonly domains: DomainService) {}
/** # 域名列表 */
@Get()
list(@CurrentWorkspace() ws: RequestWorkspace) {
return this.domains.list(ws.id);
}
/** # 创建域名 */
@Roles("MEMBER")
@Post()
create(
@CurrentWorkspace() ws: RequestWorkspace,
@Body(new ZodValidationPipe(CreateDomainSchema)) dto: Parameters<DomainService["create"]>[1],
) {
return this.domains.create(ws.id, dto);
}
/** # 域名详情 */
@Get(":id")
get(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.domains.get(ws.id, id);
}
/** # 删除域名 */
@Roles("ADMIN")
@Delete(":id")
delete(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.domains.delete(ws.id, id);
}
/** # 记录列表(CLOUDFLARE 实时拉取) */
@Get(":id/records")
records(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.domains.records(ws.id, id);
}
/** # 写入记录 */
@Roles("MEMBER")
@Post(":id/records")
upsertRecord(
@CurrentWorkspace() ws: RequestWorkspace,
@Param("id") id: string,
@Body(new ZodValidationPipe(UpsertRecordSchema)) dto: Parameters<DomainService["upsertRecord"]>[2],
) {
return this.domains.upsertRecord(ws.id, id, dto);
}
/** # 删除记录 */
@Roles("MEMBER")
@Post(":id/records/:localId/delete")
deleteRecord(
@CurrentWorkspace() ws: RequestWorkspace,
@Param("id") id: string,
@Param("localId") localId: string,
) {
return this.domains.deleteRecord(ws.id, id, localId);
}
}
@@ -0,0 +1,17 @@
/**
* # Domain 模块(Task 013)
*/
import { Module } from "@nestjs/common";
import { DomainController } from "./domain.controller";
import { DomainService } from "./domain.service";
import { CredentialModule } from "../credential/credential.module";
import { AuditModule } from "../audit/audit.module";
import { DnsModule } from "../../integrations/dns/dns.module";
@Module({
imports: [CredentialModule, AuditModule, DnsModule],
controllers: [DomainController],
providers: [DomainService],
exports: [DomainService],
})
export class DomainModule {}
@@ -0,0 +1,271 @@
/**
* # Domain 服务(Task 013)
* 域名资源 + DNS 记录读写。CLOUDFLARE 提供商通过 token 凭据调用其 API;
* MANUAL 提供商以本地数据库为事实源。
*/
import { Injectable } from "@nestjs/common";
import { z } from "zod";
import type { Domain } from "@prisma/client";
import { PrismaService } from "../../prisma/prisma.service";
import { CredentialService } from "../credential/credential.service";
import { AuditService } from "../audit/audit.service";
import { CloudflareDnsProvider, ManualDnsProvider, type DnsProvider } from "../../integrations/dns/dns.providers";
import { ApiError } from "../../common/errors/api-error";
import { getRequestContext } from "../../common/request-context";
/** # 创建域名 DTO **/
export const CreateDomainSchema = z.object({
name: z
.string()
.min(3)
.max(253)
.regex(/^(?!-)[a-zA-Z0-9-]{1,63}(\.[a-zA-Z0-9-]{1,63})+$/, "invalid domain name"),
provider: z.enum(["CLOUDFLARE", "MANUAL"]).default("MANUAL"),
credentialId: z.string().optional(),
});
export type CreateDomainDto = z.infer<typeof CreateDomainSchema>;
/** # 域名记录写入 DTO **/
export const UpsertRecordSchema = z.object({
type: z.enum(["A", "AAAA", "CNAME", "TXT", "MX", "NS", "SRV", "CAA"]),
name: z.string().min(1).max(253),
value: z.string().min(1).max(4096),
ttl: z.coerce.number().int().min(60).max(86400).default(300),
proxied: z.boolean().default(false),
});
export type UpsertRecordDto = z.infer<typeof UpsertRecordSchema>;
@Injectable()
export class DomainService {
constructor(
private readonly prisma: PrismaService,
private readonly credentials: CredentialService,
private readonly audit: AuditService,
private readonly cloudflare: CloudflareDnsProvider,
private readonly manual: ManualDnsProvider,
) {}
/** # 选择 DNS 提供商适配器 */
private provider(providerName: string): DnsProvider {
return providerName === "CLOUDFLARE" ? this.cloudflare : this.manual;
}
/** # 创建域名资源 */
async create(workspaceId: string, dto: CreateDomainDto) {
if (dto.provider === "CLOUDFLARE" && !dto.credentialId) {
throw ApiError.validation("CLOUDFLARE provider requires credentialId (API token)");
}
if (dto.credentialId) await this.credentials.get(workspaceId, dto.credentialId);
const existing = await this.prisma.domain.findFirst({ where: { workspaceId, name: dto.name } });
if (existing) throw ApiError.conflict("Domain already exists in this workspace");
const domain = await this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: { workspaceId, type: "DOMAIN", name: dto.name, provider: dto.provider, status: "ACTIVE" },
});
return tx.domain.create({
data: {
workspaceId,
resourceId: resource.id,
name: dto.name,
provider: dto.provider,
credentialId: dto.credentialId ?? null,
},
});
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "domain.create",
resourceType: "Domain",
resourceId: domain.resourceId,
metadata: { provider: dto.provider },
});
return this.view(domain);
}
/** # 域名列表 */
async list(workspaceId: string) {
const rows = await this.prisma.domain.findMany({ where: { workspaceId }, orderBy: { createdAt: "desc" } });
return rows.map((d) => this.view(d));
}
/** # 域名详情 */
async get(workspaceId: string, id: string) {
return this.view(await this.mustGet(workspaceId, id));
}
/** # 删除域名 */
async delete(workspaceId: string, id: string) {
const domain = await this.mustGet(workspaceId, id);
await this.prisma.domain.delete({ where: { id: domain.id } });
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "domain.delete",
resourceType: "Domain",
resourceId: domain.resourceId,
riskLevel: "HIGH",
});
return { deleted: true };
}
/** # 记录列表:CLOUDFLARE → 实时拉取并与本地同步;MANUAL → 本地记录 */
async records(workspaceId: string, id: string) {
const domain = await this.mustGet(workspaceId, id);
if (domain.provider === "MANUAL") {
const rows = await this.prisma.domainRecord.findMany({
where: { domainId: domain.id },
orderBy: [{ type: "asc" }, { name: "asc" }],
});
return rows.map((r) => ({
id: r.providerRecordId ?? r.id,
localId: r.id,
type: r.type,
name: r.name,
value: r.value,
ttl: r.ttl,
proxied: r.proxied,
}));
}
const token = await this.providerToken(workspaceId, domain);
const records = await this.cloudflare.listRecords({ token, zoneName: domain.name });
// 与本地库同步(以 providerRecordId 为键)
for (const r of records) {
await this.prisma.domainRecord.upsert({
where: {
domainId_type_name_value: {
domainId: domain.id,
type: r.type,
name: r.name,
value: r.value,
},
},
create: {
workspaceId,
domainId: domain.id,
type: r.type,
name: r.name,
value: r.value,
ttl: r.ttl,
proxied: r.proxied,
providerRecordId: r.id,
},
update: { ttl: r.ttl, proxied: r.proxied },
});
}
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "domain.records.read",
resourceType: "Domain",
resourceId: domain.resourceId,
metadata: { count: records.length },
});
return records.map((r) => ({ ...r, localId: undefined }));
}
/** # 写入记录(upsert 到 provider,MANUAL 落库) */
async upsertRecord(workspaceId: string, id: string, dto: UpsertRecordDto) {
const domain = await this.mustGet(workspaceId, id);
let providerRecordId: string | null = null;
if (domain.provider === "CLOUDFLARE") {
const token = await this.providerToken(workspaceId, domain);
const record = await this.cloudflare.upsertRecord({
token,
zoneName: domain.name,
record: { type: dto.type, name: dto.name, value: dto.value, ttl: dto.ttl, proxied: dto.proxied },
});
providerRecordId = record.id;
}
const saved = await this.prisma.domainRecord.upsert({
where: {
domainId_type_name_value: {
domainId: domain.id,
type: dto.type,
name: dto.name,
value: dto.value,
},
},
create: {
workspaceId,
domainId: domain.id,
type: dto.type,
name: dto.name,
value: dto.value,
ttl: dto.ttl,
proxied: dto.proxied,
providerRecordId,
},
update: { ttl: dto.ttl, proxied: dto.proxied, providerRecordId },
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "domain.updateRecords",
resourceType: "Domain",
resourceId: domain.resourceId,
riskLevel: "MEDIUM",
metadata: { type: dto.type, name: dto.name },
});
return {
id: providerRecordId ?? saved.id,
localId: saved.id,
type: saved.type,
name: saved.name,
value: saved.value,
ttl: saved.ttl,
proxied: saved.proxied,
};
}
/** # 删除记录 */
async deleteRecord(workspaceId: string, id: string, localId: string) {
const domain = await this.mustGet(workspaceId, id);
const record = await this.prisma.domainRecord.findFirst({
where: { id: localId, domainId: domain.id, workspaceId },
});
if (!record) throw ApiError.notFound("Record not found");
if (domain.provider === "CLOUDFLARE" && record.providerRecordId) {
const token = await this.providerToken(workspaceId, domain);
await this.cloudflare.deleteRecord({ token, zoneName: domain.name, recordId: record.providerRecordId });
}
await this.prisma.domainRecord.delete({ where: { id: record.id } });
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "domain.updateRecords",
resourceType: "Domain",
resourceId: domain.resourceId,
riskLevel: "MEDIUM",
metadata: { deleted: `${record.type} ${record.name}` },
});
return { deleted: true };
}
/** # 取得提供商 token(解密凭据) */
private async providerToken(workspaceId: string, domain: Domain): Promise<string> {
const token = await this.credentials.getSecret(workspaceId, domain.credentialId);
if (!token) throw ApiError.validation("Domain provider credential missing");
return token;
}
/** # 断言域名属于工作区 */
private async mustGet(workspaceId: string, id: string) {
const domain = await this.prisma.domain.findFirst({ where: { id, workspaceId } });
if (!domain) throw ApiError.notFound("Domain not found");
return domain;
}
/** # 实体 → 视图 */
private view(d: Domain) {
return {
id: d.id,
resourceId: d.resourceId,
workspaceId: d.workspaceId,
name: d.name,
provider: d.provider,
createdAt: d.createdAt,
};
}
}
@@ -0,0 +1,121 @@
/**
* # 事件总线
* 领域事件的持久化 + 进程内分发(Task 015)。
* - 事件命名遵循 `<resource>.<verb>` 规范(见 @devos/types EVENTS);
* - 每个事件持久化到 events 表(可追溯/可重放);
* - 进程内订阅者同步分发(错误隔离,不影响发布方);
* - 若 Redis 可用则额外 PUBLISH(跨进程可观测,为 V3 预留)。
*/
import { Injectable, OnModuleDestroy } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
import { randomUUID } from "node:crypto";
import { PrismaService } from "../../prisma/prisma.service";
import { LogService, StructuredLoggerInstance } from "../../common/logger/logger.module";
/** # 领域事件结构 **/
export interface DomainEvent {
id: string;
eventType: string;
workspaceId: string;
resourceId: string | null;
payload: Record<string, unknown>;
correlationId: string | null;
createdAt: Date;
}
/** # 事件处理器 **/
export type EventHandler = (event: DomainEvent) => void | Promise<void>;
@Injectable()
export class EventBusService implements OnModuleDestroy {
/** # 进程内订阅者 **/
private handlers = new Set<EventHandler>();
/** # 日志器 **/
private readonly logger: StructuredLoggerInstance;
constructor(
private readonly prisma: PrismaService,
loggers: LogService,
) {
this.logger = loggers.child("EventBus");
}
/** # 注册订阅者
* @returns 取消订阅函数
*/
subscribe(handler: EventHandler): () => void {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
/** # 发布领域事件:持久化 + 分发
* @param eventType 规范事件名
* @param data 事件数据
*/
async publish(
eventType: string,
data: {
workspaceId: string;
resourceId?: string | null;
payload?: Record<string, unknown>;
correlationId?: string | null;
},
): Promise<DomainEvent | null> {
const record: DomainEvent = {
id: randomUUID(),
eventType,
workspaceId: data.workspaceId,
resourceId: data.resourceId ?? null,
payload: data.payload ?? {},
correlationId: data.correlationId ?? null,
createdAt: new Date(),
};
try {
await this.prisma.eventRecord.create({
data: {
id: record.id,
workspaceId: record.workspaceId,
eventType: record.eventType,
resourceId: record.resourceId,
payload: record.payload as Prisma.InputJsonValue,
correlationId: record.correlationId,
},
});
} catch (error) {
this.logger.error("failed to persist event", error instanceof Error ? error : undefined);
return null;
}
// 进程内分发:错误隔离
for (const handler of this.handlers) {
Promise.resolve()
.then(() => handler(record))
.catch((error) =>
this.logger.error(
`event handler failed for ${eventType}`,
error instanceof Error ? error : undefined,
),
);
}
// 跨进程发布(尽力而为)
void this.publishToRedis(record);
return record;
}
/** # Redis 发布(Bun 原生 redis 客户端,失败静默) */
private async publishToRedis(record: DomainEvent): Promise<void> {
try {
const bun = (globalThis as Record<string, unknown>).Bun as typeof Bun | undefined;
if (bun?.redis) {
await bun.redis.publish("devos:events", JSON.stringify(record));
}
} catch {
/* Redis 不可用时忽略 */
}
}
/** # 停止时清空订阅者 */
onModuleDestroy(): void {
this.handlers.clear();
}
}
@@ -0,0 +1,42 @@
/**
* # 事件查询控制器
*/
import { Controller, Get, Query } from "@nestjs/common";
import { z } from "zod";
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
import { CurrentWorkspace } from "../../common/decorators";
import { PrismaService } from "../../prisma/prisma.service";
import type { RequestWorkspace } from "../../common/decorators";
const ListQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(20),
eventType: z.string().optional(),
});
@Controller("events")
export class EventController {
constructor(private readonly prisma: PrismaService) {}
/** # 分页查询当前工作区领域事件 */
@Get()
async list(
@CurrentWorkspace() workspace: RequestWorkspace,
@Query(new ZodValidationPipe(ListQuerySchema)) query: z.infer<typeof ListQuerySchema>,
) {
const where = {
workspaceId: workspace.id,
...(query.eventType ? { eventType: query.eventType } : {}),
};
const [total, items] = await this.prisma.$transaction([
this.prisma.eventRecord.count({ where }),
this.prisma.eventRecord.findMany({
where,
orderBy: { createdAt: "desc" },
skip: (query.page - 1) * query.pageSize,
take: query.pageSize,
}),
]);
return { items, total, page: query.page, pageSize: query.pageSize };
}
}
@@ -0,0 +1,13 @@
/**
* # 事件总线模块(Task 015)
*/
import { Module } from "@nestjs/common";
import { EventController } from "./event.controller";
import { EventBusService } from "./event-bus.service";
@Module({
controllers: [EventController],
providers: [EventBusService],
exports: [EventBusService],
})
export class EventModule {}
@@ -0,0 +1,90 @@
/**
* # Git 控制器(/api/v1/git)
*/
import { Body, Controller, Delete, Get, Param, Post } from "@nestjs/common";
import { z } from "zod";
import { GitService, CreateGitConnectionSchema, CreateRepositorySchema } from "./git.service";
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
import { CurrentWorkspace, Roles } from "../../common/decorators";
import type { RequestWorkspace } from "../../common/decorators";
const FullNameSchema = z.object({ fullName: z.string().min(3).max(300) });
@Controller("git")
export class GitController {
constructor(private readonly git: GitService) {}
/** # Git 连接列表 */
@Get("connections")
listConnections(@CurrentWorkspace() ws: RequestWorkspace) {
return this.git.listConnections(ws.id);
}
/** # 创建 Git 连接 */
@Roles("ADMIN")
@Post("connections")
createConnection(
@CurrentWorkspace() ws: RequestWorkspace,
@Body(new ZodValidationPipe(CreateGitConnectionSchema)) dto: Parameters<GitService["createConnection"]>[1],
) {
return this.git.createConnection(ws.id, dto);
}
/** # 删除连接 */
@Roles("ADMIN")
@Delete("connections/:id")
deleteConnection(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.git.deleteConnection(ws.id, id);
}
/** # 拉取 provider 远端仓库(实时) */
@Roles("MEMBER")
@Post("connections/:id/repos")
listRemoteRepos(
@CurrentWorkspace() ws: RequestWorkspace,
@Param("id") id: string,
@Body(new ZodValidationPipe(FullNameSchema)) _body: { fullName: string },
) {
return this.git.listRemoteRepos(ws.id, id);
}
/** # 导入远端仓库 */
@Roles("MEMBER")
@Post("connections/:id/repos/import")
importRepo(
@CurrentWorkspace() ws: RequestWorkspace,
@Param("id") id: string,
@Body(new ZodValidationPipe(FullNameSchema)) body: { fullName: string },
) {
return this.git.importRepo(ws.id, id, body.fullName);
}
/** # 仓库列表 */
@Get("repositories")
listRepositories(@CurrentWorkspace() ws: RequestWorkspace) {
return this.git.listRepositories(ws.id);
}
/** # 手工登记仓库 */
@Roles("MEMBER")
@Post("repositories")
createRepository(
@CurrentWorkspace() ws: RequestWorkspace,
@Body(new ZodValidationPipe(CreateRepositorySchema)) dto: Parameters<GitService["upsertRepository"]>[1],
) {
return this.git.upsertRepository(ws.id, dto);
}
/** # 分支列表 */
@Get("repositories/:id/branches")
listBranches(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.git.listBranches(ws.id, id);
}
/** # 删除仓库登记 */
@Roles("MEMBER")
@Delete("repositories/:id")
deleteRepository(@CurrentWorkspace() ws: RequestWorkspace, @Param("id") id: string) {
return this.git.deleteRepository(ws.id, id);
}
}
+17
View File
@@ -0,0 +1,17 @@
/**
* # Git 模块
*/
import { Module } from "@nestjs/common";
import { GitController } from "./git.controller";
import { GitService } from "./git.service";
import { GithubAdapter, GitlabAdapter, GiteaAdapter } from "./git.providers";
import { CredentialModule } from "../credential/credential.module";
import { AuditModule } from "../audit/audit.module";
@Module({
imports: [CredentialModule, AuditModule],
controllers: [GitController],
providers: [GitService, GithubAdapter, GitlabAdapter, GiteaAdapter],
exports: [GitService],
})
export class GitModule {}
+187
View File
@@ -0,0 +1,187 @@
/**
* # Git Provider 适配器(Phase D/05)
* GitHub / GitLab / Gitea 统一 REST 聚合:账号验证、仓库列表、分支列表。
* Token 从凭据保险库解密后注入请求头,绝不写入日志或返回体。
*/
import { Injectable } from "@nestjs/common";
/** # 仓库摘要 **/
export interface RemoteRepo {
/** # 全名 owner/repo **/
fullName: string;
cloneUrl: string;
defaultBranch: string;
private: boolean;
description?: string;
}
/** # 分支摘要 **/
export interface RemoteBranch {
name: string;
commit: string;
}
/** # Git Provider 统一接口 **/
export interface GitProviderAdapter {
readonly provider: string;
/** # 验证 token 并返回账号登录名 */
verifyAccount(params: { token: string; apiBase?: string }): Promise<{ login: string }>;
/** # 列出账号可见仓库 */
listRepos(params: { token: string; apiBase?: string }): Promise<RemoteRepo[]>;
/** # 列出仓库分支 */
listBranches(params: { token: string; apiBase?: string; fullName: string }): Promise<RemoteBranch[]>;
}
/** # 默认 API 基址 **/
const DEFAULTS: Record<string, string> = {
GITHUB: "https://api.github.com",
GITLAB: "https://gitlab.com/api/v4",
GITEA: "", // 必填 apiBase
};
/** # 规范化 apiBase(去尾斜杠) */
function base(apiBase: string | undefined, provider: string): string {
const url = (apiBase && apiBase.replace(/\/$/, "")) || DEFAULTS[provider];
if (!url) throw new Error(`${provider} requires apiBase (self-hosted instance)`);
return url;
}
@Injectable()
export class GithubAdapter implements GitProviderAdapter {
readonly provider = "GITHUB";
/** # GitHub REST 请求 */
private async call<T>(token: string, url: string): Promise<T> {
const res = await fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
Accept: "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${(await res.text()).slice(0, 200)}`);
return (await res.json()) as T;
}
async verifyAccount(params: { token: string; apiBase?: string }) {
const user = await this.call<{ login: string }>(params.token, `${base(params.apiBase, this.provider)}/user`);
return { login: user.login };
}
async listRepos(params: { token: string; apiBase?: string }) {
const repos = await this.call<
{ full_name: string; clone_url: string; default_branch: string; private: boolean; description: string | null }[]
>(`${base(params.apiBase, this.provider)}/user/repos?per_page=100&sort=updated`, params.token);
return repos.map((r) => ({
fullName: r.full_name,
cloneUrl: r.clone_url,
defaultBranch: r.default_branch || "main",
private: r.private,
description: r.description ?? undefined,
}));
}
async listBranches(params: { token: string; apiBase?: string; fullName: string }) {
const branches = await this.call<{ name: string; commit: { sha: string } }[]>(
`${base(params.apiBase, this.provider)}/repos/${params.fullName}/branches?per_page=100`,
params.token,
);
return branches.map((b) => ({ name: b.name, commit: b.commit.sha }));
}
}
@Injectable()
export class GitlabAdapter implements GitProviderAdapter {
readonly provider = "GITLAB";
/** # GitLab REST 请求 */
private async call<T>(token: string, url: string): Promise<T> {
const res = await fetch(url, {
headers: { "PRIVATE-TOKEN": token },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`GitLab API ${res.status}: ${(await res.text()).slice(0, 200)}`);
return (await res.json()) as T;
}
async verifyAccount(params: { token: string; apiBase?: string }) {
const user = await this.call<{ username: string }>(params.token, `${base(params.apiBase, this.provider)}/user`);
return { login: user.username };
}
async listRepos(params: { token: string; apiBase?: string }) {
const projects = await this.call<
{ path_with_namespace: string; http_url_to_repo: string; default_branch: string; visibility: string; description: string | null }[]
>(`${base(params.apiBase, this.provider)}/projects?membership=true&per_page=100&order_by=last_activity_at`, params.token);
return projects.map((p) => ({
fullName: p.path_with_namespace,
cloneUrl: p.http_url_to_repo,
defaultBranch: p.default_branch || "main",
private: p.visibility !== "public",
description: p.description ?? undefined,
}));
}
async listBranches(params: { token: string; apiBase?: string; fullName: string }) {
const encoded = encodeURIComponent(params.fullName);
const branches = await this.call<{ name: string; commit: { id: string } }[]>(
`${base(params.apiBase, this.provider)}/projects/${encoded}/repository/branches?per_page=100`,
params.token,
);
return branches.map((b) => ({ name: b.name, commit: b.commit.id }));
}
}
@Injectable()
export class GiteaAdapter implements GitProviderAdapter {
readonly provider = "GITEA";
/** # Gitea REST 请求 */
private async call<T>(token: string, url: string): Promise<T> {
const res = await fetch(url, {
headers: { Authorization: `token ${token}` },
signal: AbortSignal.timeout(15_000),
});
if (!res.ok) throw new Error(`Gitea API ${res.status}: ${(await res.text()).slice(0, 200)}`);
return (await res.json()) as T;
}
async verifyAccount(params: { token: string; apiBase?: string }) {
const user = await this.call<{ login: string }>(params.token, `${base(params.apiBase, this.provider)}/user`);
return { login: user.login };
}
async listRepos(params: { token: string; apiBase?: string }) {
const repos = await this.call<
{ full_name: string; clone_url: string; default_branch: string; private: boolean; description: string }[]
>(`${base(params.apiBase, this.provider)}/user/repos?limit=100`, params.token);
return repos.map((r) => ({
fullName: r.full_name,
cloneUrl: r.clone_url,
defaultBranch: r.default_branch || "main",
private: r.private,
description: r.description || undefined,
}));
}
async listBranches(params: { token: string; apiBase?: string; fullName: string }) {
const branches = await this.call<{ name: string; commit: { id: string } }[]>(
`${base(params.apiBase, this.provider)}/repos/${params.fullName}/branches?limit=100`,
params.token,
);
return branches.map((b) => ({ name: b.name, commit: b.commit.id }));
}
}
/** # 构造带凭据的 HTTPS clone URL(服务端内部使用,绝不返回给客户端)
* GitHub: x-access-token:<token>@;GitLab: oauth2:<token>@;Gitea/其它: <token>@
*/
export function buildAuthenticatedCloneUrl(provider: string, cloneUrl: string, token: string): string {
if (!cloneUrl.startsWith("https://")) {
throw new Error("Only HTTPS clone URLs are supported for credential injection");
}
const rest = cloneUrl.slice("https://".length);
const user = provider === "GITHUB" ? "x-access-token" : provider === "GITLAB" ? "oauth2" : "";
return user ? `https://${user}:${token}@${rest}` : `https://${token}@${rest}`;
}
+309
View File
@@ -0,0 +1,309 @@
/**
* # Git 服务(Phase D)
* GitProviderConnection + Repository 聚合模型:
* - 连接:provider + credentialId(token 存凭据保险库)+ apiBase(自建实例);
* - 仓库:从 provider 拉取或手工登记;clone 凭据仅由部署执行器在服务端注入。
*/
import { Injectable } from "@nestjs/common";
import { z } from "zod";
import { PrismaService } from "../../prisma/prisma.service";
import { CredentialService } from "../credential/credential.service";
import { AuditService } from "../audit/audit.service";
import {
GithubAdapter,
GitlabAdapter,
GiteaAdapter,
buildAuthenticatedCloneUrl,
type GitProviderAdapter,
} from "./git.providers";
import { ApiError } from "../../common/errors/api-error";
import { getRequestContext } from "../../common/request-context";
/** # 创建连接 DTO **/
export const CreateGitConnectionSchema = z.object({
name: z.string().min(1).max(120),
provider: z.enum(["GITHUB", "GITLAB", "GITEA"]),
apiBase: z.string().url().max(500).optional(),
credentialId: z.string().min(1),
});
export type CreateGitConnectionDto = z.infer<typeof CreateGitConnectionSchema>;
/** # 手工登记仓库 DTO **/
export const CreateRepositorySchema = z.object({
connectionId: z.string().optional(),
provider: z.enum(["GITHUB", "GITLAB", "GITEA", "GENERIC"]).default("GENERIC"),
fullName: z.string().min(3).max(300),
cloneUrl: z.string().url().max(1000),
defaultBranch: z.string().max(200).default("main"),
visibility: z.enum(["public", "private"]).default("private"),
});
export type CreateRepositoryDto = z.infer<typeof CreateRepositorySchema>;
@Injectable()
export class GitService {
constructor(
private readonly prisma: PrismaService,
private readonly credentials: CredentialService,
private readonly audit: AuditService,
private readonly github: GithubAdapter,
private readonly gitlab: GitlabAdapter,
private readonly gitea: GiteaAdapter,
) {}
/** # 选择 provider 适配器 */
private adapter(provider: string): GitProviderAdapter {
switch (provider) {
case "GITHUB":
return this.github;
case "GITLAB":
return this.gitlab;
case "GITEA":
return this.gitea;
default:
throw ApiError.validation(`Unsupported git provider: ${provider}`);
}
}
/** # 创建 Git 连接(校验 token 有效后入账) */
async createConnection(workspaceId: string, dto: CreateGitConnectionDto) {
await this.credentials.get(workspaceId, dto.credentialId);
const token = await this.credentials.getSecret(workspaceId, dto.credentialId);
if (!token) throw ApiError.validation("Credential secret missing");
const account = await this.adapter(dto.provider).verifyAccount({ token, apiBase: dto.apiBase });
const connection = await this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: {
workspaceId,
type: "CREDENTIAL",
name: `git:${dto.name}`,
provider: dto.provider,
status: "ACTIVE",
metadata: { kind: "git-connection", account: account.login },
},
});
return tx.gitConnection.create({
data: {
workspaceId,
resourceId: resource.id,
name: dto.name,
provider: dto.provider,
apiBase: dto.apiBase ?? null,
credentialId: dto.credentialId,
},
});
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "git.connection.create",
resourceType: "GitConnection",
resourceId: connection.resourceId,
riskLevel: "MEDIUM",
metadata: { provider: dto.provider, account: account.login },
});
return { ...this.connectionView(connection), account: account.login };
}
/** # 连接列表 */
async listConnections(workspaceId: string) {
const rows = await this.prisma.gitConnection.findMany({
where: { workspaceId },
orderBy: { createdAt: "desc" },
});
return rows.map((c) => this.connectionView(c));
}
/** # 删除连接 */
async deleteConnection(workspaceId: string, id: string) {
const c = await this.prisma.gitConnection.findFirst({ where: { id, workspaceId } });
if (!c) throw ApiError.notFound("Git connection not found");
await this.prisma.gitConnection.delete({ where: { id } });
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "git.connection.delete",
resourceType: "GitConnection",
resourceId: c.resourceId,
riskLevel: "HIGH",
});
return { deleted: true };
}
/** # 从 provider 拉取仓库列表(实时;不落库) */
async listRemoteRepos(workspaceId: string, connectionId: string) {
const conn = await this.mustGetConnection(workspaceId, connectionId);
const token = await this.credentials.getSecret(workspaceId, conn.credentialId);
if (!token) throw ApiError.validation("Connection credential missing");
const adapter = this.adapter(conn.provider);
return adapter.listRepos({ token, apiBase: conn.apiBase ?? undefined });
}
/** # 导入 provider 仓库到本地仓库表 */
async importRepo(workspaceId: string, connectionId: string, fullName: string) {
const conn = await this.mustGetConnection(workspaceId, connectionId);
const remote = (await this.listRemoteRepos(workspaceId, connectionId)).find((r) => r.fullName === fullName);
if (!remote) throw ApiError.notFound(`Repository ${fullName} not found on ${conn.provider}`);
return this.upsertRepository(workspaceId, {
connectionId,
provider: conn.provider as "GITHUB" | "GITLAB" | "GITEA" | "GENERIC",
fullName: remote.fullName,
cloneUrl: remote.cloneUrl,
defaultBranch: remote.defaultBranch,
visibility: remote.private ? "private" : "public",
});
}
/** # 分支列表(实时) */
async listBranches(workspaceId: string, repositoryId: string) {
const repo = await this.mustGetRepository(workspaceId, repositoryId);
if (!repo.connectionId) throw ApiError.validation("Repository has no connection; branches unavailable");
const conn = await this.mustGetConnection(workspaceId, repo.connectionId);
const token = await this.credentials.getSecret(workspaceId, conn.credentialId);
if (!token) throw ApiError.validation("Connection credential missing");
return this.adapter(conn.provider).listBranches({ token, apiBase: conn.apiBase ?? undefined, fullName: repo.fullName });
}
/** # 手工登记/导入仓库(REPOSITORY 资源入图谱) */
async upsertRepository(workspaceId: string, dto: CreateRepositoryDto) {
if (dto.connectionId) {
const conn = await this.prisma.gitConnection.findFirst({ where: { id: dto.connectionId, workspaceId } });
if (!conn) throw ApiError.notFound("Git connection not found");
}
const existing = await this.prisma.repository.findFirst({
where: { workspaceId, cloneUrl: dto.cloneUrl },
});
if (existing) return this.repoView(existing);
const repo = await this.prisma.$transaction(async (tx) => {
const resource = await tx.resource.create({
data: {
workspaceId,
type: "REPOSITORY",
name: dto.fullName,
provider: dto.provider,
status: "ACTIVE",
metadata: { cloneUrl: dto.cloneUrl, visibility: dto.visibility },
},
});
return tx.repository.create({
data: {
workspaceId,
resourceId: resource.id,
connectionId: dto.connectionId ?? null,
provider: dto.provider,
fullName: dto.fullName,
cloneUrl: dto.cloneUrl,
defaultBranch: dto.defaultBranch,
visibility: dto.visibility,
},
});
});
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "git.repository.import",
resourceType: "Repository",
resourceId: repo.resourceId,
metadata: { fullName: dto.fullName, provider: dto.provider },
});
return this.repoView(repo);
}
/** # 仓库列表 */
async listRepositories(workspaceId: string) {
const rows = await this.prisma.repository.findMany({
where: { workspaceId },
orderBy: { updatedAt: "desc" },
});
return rows.map((r) => this.repoView(r));
}
/** # 删除仓库登记 */
async deleteRepository(workspaceId: string, id: string) {
const repo = await this.mustGetRepository(workspaceId, id);
await this.prisma.repository.delete({ where: { id: repo.id } });
await this.audit.record({
workspaceId,
actorType: getRequestContext()?.actorType ?? "USER",
action: "git.repository.delete",
resourceType: "Repository",
resourceId: repo.resourceId,
riskLevel: "MEDIUM",
});
return { deleted: true };
}
/** # 构造带凭据的 clone URL(部署执行器内部使用;审计记 use,不落日志) */
async buildCloneCommand(workspaceId: string, repositoryId: string): Promise<{ cloneUrl: string; defaultBranch: string; provider: string }> {
const repo = await this.mustGetRepository(workspaceId, repositoryId);
let cloneUrl = repo.cloneUrl;
if (repo.connectionId) {
const conn = await this.mustGetConnection(workspaceId, repo.connectionId);
const token = await this.credentials.getSecret(workspaceId, conn.credentialId);
if (token) {
cloneUrl = buildAuthenticatedCloneUrl(conn.provider, repo.cloneUrl, token);
}
}
return { cloneUrl, defaultBranch: repo.defaultBranch, provider: repo.provider };
}
/** # 断言连接属于工作区 */
private async mustGetConnection(workspaceId: string, id: string) {
const c = await this.prisma.gitConnection.findFirst({ where: { id, workspaceId } });
if (!c) throw ApiError.notFound("Git connection not found");
return c;
}
/** # 断言仓库属于工作区 */
private async mustGetRepository(workspaceId: string, id: string) {
const r = await this.prisma.repository.findFirst({ where: { id, workspaceId } });
if (!r) throw ApiError.notFound("Repository not found");
return r;
}
/** # 连接视图(不含凭据) */
private connectionView(c: {
id: string;
resourceId: string;
name: string;
provider: string;
apiBase: string | null;
credentialId: string;
createdAt: Date;
}) {
return {
id: c.id,
resourceId: c.resourceId,
name: c.name,
provider: c.provider,
apiBase: c.apiBase,
credentialId: c.credentialId,
createdAt: c.createdAt,
};
}
/** # 仓库视图(不含凭据) */
private repoView(r: {
id: string;
resourceId: string;
connectionId: string | null;
provider: string;
fullName: string;
cloneUrl: string;
defaultBranch: string;
visibility: string;
updatedAt: Date;
}) {
return {
id: r.id,
resourceId: r.resourceId,
connectionId: r.connectionId,
provider: r.provider,
fullName: r.fullName,
cloneUrl: r.cloneUrl,
defaultBranch: r.defaultBranch,
visibility: r.visibility,
updatedAt: r.updatedAt,
};
}
}
@@ -0,0 +1,47 @@
/**
* # 认证控制器(/api/v1/auth)
*/
import { Body, Controller, Get, Post } from "@nestjs/common";
import {
IdentityService,
LoginSchema,
RegisterSchema,
type RegisterDto,
type LoginDto,
} from "./identity.service";
import { ZodValidationPipe } from "../../common/pipes/zod-validation.pipe";
import { Public, CurrentUser, NoWorkspace } from "../../common/decorators";
import type { RequestUser } from "../../common/decorators";
@Controller("auth")
export class IdentityController {
constructor(private readonly identity: IdentityService) {}
/** # 注册 */
@Public()
@Post("register")
register(@Body(new ZodValidationPipe(RegisterSchema)) dto: RegisterDto) {
return this.identity.register(dto);
}
/** # 登录 */
@Public()
@Post("login")
login(@Body(new ZodValidationPipe(LoginSchema)) dto: LoginDto) {
return this.identity.login(dto);
}
/** # 当前用户信息 */
@Get("me")
@NoWorkspace()
me(@CurrentUser() user: RequestUser) {
return this.identity.me(user.id);
}
/** # 登出(无状态令牌由客户端丢弃) */
@Post("logout")
@NoWorkspace()
logout(@CurrentUser() user: RequestUser) {
return this.identity.logout(user.id, null);
}
}

Some files were not shown because too many files have changed in this diff Show More