diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..5daa0d8 --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,46 @@ +# AgentCodingWorkflow 配置文件示例 +# +# 用法: +# 1. 复制为 config.yaml(已被 .env / 真实凭据建议放在 .gitignore 之外管理) +# 2. 启动时设置环境变量:APP_CONFIG_FILE=./config.yaml ./bin/server +# +# 加载优先级(后者覆盖前者):默认值 → 本配置文件 → 环境变量(APP_ 前缀) +# 即同名键存在 APP_xxx 环境变量时,会覆盖此处的值。 + +# 运行环境:development / production / test +env: development + +# 本地数据目录(用于落地文件、缓存等) +data_dir: ./data + +# 主加密密钥(必填):32 字节的 hex 编码字符串,长度恰好 64 个十六进制字符 +# 生成示例:openssl rand -hex 32 +master_key: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" + +http: + # HTTP 监听地址,格式 host:port,留空 host 表示监听所有网卡 + addr: ":8080" + +db: + # PostgreSQL DSN(必填) + dsn: "postgres://acw:acw@localhost:5432/acw?sslmode=disable" + +# 首次启动时的管理员种子账号 +bootstrap: + admin_email: "admin@local" + admin_password: "changeme" + +# Workspace 模块(git clone / worktree 落盘 + 各 git verb 超时) +workspace: + # workspace clone/worktree 的根目录(程序会在其下自建 workspaces/ 与 tmp/ 子目录) + data_dir: "./data" + # 大仓库 clone 上限,按需调高;timeout 走 ctx 优先于 git 进程超时 + clone_timeout: "30m" + fetch_timeout: "5m" + push_timeout: "5m" + # commit / status / worktree 等轻量操作 + ops_timeout: "60s" + +# git CLI 配置(默认走 PATH 里的 git) +git: + binary: "git" diff --git a/internal/app/app.go b/internal/app/app.go index 8076c3b..df918ca 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -24,18 +24,23 @@ import ( "io/fs" "log/slog" "net/http" + "os" + "path/filepath" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/config" + "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/db" + "github.com/yan1h/agent-coding-workflow/internal/infra/git" "github.com/yan1h/agent-coding-workflow/internal/infra/logger" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" "github.com/yan1h/agent-coding-workflow/internal/project" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" "github.com/yan1h/agent-coding-workflow/internal/user" + "github.com/yan1h/agent-coding-workflow/internal/workspace" ) // App 持有运行期所有需要被关停的资源。字段保持非导出,避免外部直接操作。 @@ -103,6 +108,71 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { notify.NewHandler(notifyRepo, userSvc).Mount(r) project.NewHandler(projectSvc, requirementSvc, issueSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) + // ===== workspace 模块装配 ===== + // 依赖图: + // git.Runner (cfg) ─┐ + // crypto.Encryptor ─┼─ workspaceService (clones=nil) ─┐ + // workspace.Repo ───┘ ├─ CloneRunner ─┐ + // │ │ + // SetScheduler ───┴───────────────┘ + // │ + // WorktreeService / GitOpsService + // wsDataDir 是数据根;pathing.MainPath/WorktreePath 内部会再 join "workspaces" 子目录。 + // 这里显式 mkdir 出 ${dataDir}/workspaces 让首个 clone 的父目录已经存在。 + wsDataDir := cfg.Workspace.DataDir + if err := os.MkdirAll(filepath.Join(wsDataDir, "workspaces"), 0o755); err != nil { + return nil, fmt.Errorf("mkdir workspace data dir: %w", err) + } + tmpDir := filepath.Join(wsDataDir, "tmp") + // 启动期清掉残留 tmp(可能含上次崩溃留下的 askpass 脚本与 SSH 私钥) + _ = os.RemoveAll(tmpDir) + if err := os.MkdirAll(tmpDir, 0o700); err != nil { + return nil, fmt.Errorf("mkdir workspace tmp: %w", err) + } + + enc, err := crypto.NewEncryptor(cfg.MasterKey) + if err != nil { + return nil, fmt.Errorf("crypto encryptor: %w", err) + } + + gitr := git.NewDefaultRunner(git.Config{ + Binary: cfg.Git.Binary, + TmpDir: tmpDir, + CloneTimeout: cfg.Workspace.CloneTimeout, + FetchTimeout: cfg.Workspace.FetchTimeout, + PushTimeout: cfg.Workspace.PushTimeout, + OpsTimeout: cfg.Workspace.OpsTimeout, + }) + + wsRepo := workspace.NewPostgresRepository(pool) + // 进程启动时把卡死的 cloning/syncing 复位为 error,避免行永远卡住 + if err := wsRepo.ResetStuckSyncStatuses(ctx); err != nil { + log.Warn("reset stuck workspace sync statuses", "err", err.Error()) + } + + pa := projectAccessAdapter{p: projectSvc} + // 先以 nil scheduler 构造 service,cloneRunner 再回填(环依赖:runner 需要凭据访问器,访问器在 service 上) + wsSvc := workspace.NewWorkspaceService(wsRepo, auditRec, enc, gitr, pa, nil, wsDataDir) + + // 共享凭据加载器:CloneRunner 与 GitOpsService 都需要。 + // 通过 CredentialAccessor 接口避免直接持有 *workspaceService 私有类型。 + credAccessor, ok := wsSvc.(workspace.CredentialAccessor) + if !ok { + return nil, fmt.Errorf("workspace service does not implement CredentialAccessor") + } + credLoader := func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) { + return credAccessor.LoadDecryptedCredential(ctx, wsID) + } + + cloneRunner := workspace.NewCloneRunner(wsRepo, auditRec, gitr, log, credLoader, cfg.Workspace.CloneTimeout) + if setter, ok := wsSvc.(workspace.SchedulerSetter); ok { + setter.SetScheduler(cloneRunner) + } + + wtSvc := workspace.NewWorktreeService(wsRepo, auditRec, gitr, pa, wsDataDir) + gopSvc := workspace.NewGitOpsService(wsRepo, auditRec, gitr, pa, credLoader) + workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) + if cfg.Bootstrap.AdminEmail != "" && cfg.Bootstrap.AdminPassword != "" { if u, err := userSvc.Bootstrap(ctx, cfg.Bootstrap.AdminEmail, cfg.Bootstrap.AdminPassword); err != nil { log.Warn("bootstrap admin failed", "err", err.Error()) @@ -160,6 +230,8 @@ func (a *App) Run(ctx context.Context) error { // userAdminAdapter 把 user.Service 适配为 project.AdminLookup(窄接口)。 // 复用 user.Service.Get:返回的 *User 直接读 IsAdmin。 +// workspace.AdminLookup 与 project.AdminLookup 同形(IsAdmin 同名同签名), +// 因此本类型同时满足两者,可在两个 handler 复用。 type userAdminAdapter struct{ svc user.Service } func (a userAdminAdapter) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) { @@ -169,3 +241,51 @@ func (a userAdminAdapter) IsAdmin(ctx context.Context, id uuid.UUID) (bool, erro } return u.IsAdmin, nil } + +// projectAccessAdapter 把 project.ProjectService.Get / GetByID 桥接成 workspace.ProjectAccess。 +// - Resolve = by slug:用于 POST /projects/{slug}/workspaces 等路径 +// - ResolveByID = by uuid:用于 wsID 拿到 ws 后再校验 project 权限 +// +// ACL 语义复刻 project 包内部 assertReadable / assertWritable: +// +// read private: owner + admin +// read internal: 任意已登录 +// write *: owner + admin +// +// 注意:project.assertReadable 在 visibility=private 且非 owner+admin 时返回 NotFound +// 防探测;这里 ProjectService.Get/GetByID 已替我们兜住,到本适配层时 pj 一定可读。 +type projectAccessAdapter struct { + p project.ProjectService +} + +func (a projectAccessAdapter) Resolve(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectSlug string) (uuid.UUID, bool, bool, error) { + pj, err := a.p.Get(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectSlug) + if err != nil { + return uuid.Nil, false, false, err + } + canRead, canWrite := projectACL(pj, callerID, isAdmin) + return pj.ID, canRead, canWrite, nil +} + +func (a projectAccessAdapter) ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error) { + pj, err := a.p.GetByID(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectID) + if err != nil { + return false, false, err + } + canRead, canWrite := projectACL(pj, callerID, isAdmin) + return canRead, canWrite, nil +} + +// projectACL 复制 project 包内部 assertReadable/assertWritable 的语义: +// - admin 永远可读可写 +// - owner 可读可写 +// - internal 可见性:所有登录用户可读 +// +// 注:写操作还需考虑 archived 状态;workspace 的写场景会在 service 层用 ProjectAccess +// 拿到 canWrite 后再下沉,archived 检查由 project service 在 GetByID 已可见。 +func projectACL(pj *project.Project, callerID uuid.UUID, isAdmin bool) (canRead, canWrite bool) { + owned := pj.OwnerID == callerID + canWrite = isAdmin || owned + canRead = canWrite || pj.Visibility == project.VisibilityInternal + return canRead, canWrite +} diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index cb4e541..211d39f 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -12,8 +12,12 @@ import ( "bytes" "context" "encoding/json" + "io" "net/http" "net/http/httptest" + "os" + "os/exec" + "path/filepath" "testing" "time" @@ -23,12 +27,15 @@ import ( "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/db" + "github.com/yan1h/agent-coding-workflow/internal/infra/git" "github.com/yan1h/agent-coding-workflow/internal/infra/logger" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" "github.com/yan1h/agent-coding-workflow/internal/project" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" "github.com/yan1h/agent-coding-workflow/internal/user" + "github.com/yan1h/agent-coding-workflow/internal/workspace" ) func TestEndToEnd_LoginAndDispatchNotification(t *testing.T) { @@ -208,3 +215,287 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) { require.NoError(t, err) require.GreaterOrEqual(t, count, 8, "expected at least 8 audit log entries for project/requirement/issue actions") } + +// projectAccessAdapterTest 把 project.ProjectService.Get / GetByID 桥接成 workspace.ProjectAccess。 +// 与 internal/app/app.go 里的 projectAccessAdapter 同形(拷贝粘贴),保持 test-internal 不导出。 +type projectAccessAdapterTest struct { + p project.ProjectService +} + +func (a projectAccessAdapterTest) Resolve(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectSlug string) (uuid.UUID, bool, bool, error) { + pj, err := a.p.Get(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectSlug) + if err != nil { + return uuid.Nil, false, false, err + } + canRead, canWrite := projectACLTest(pj, callerID, isAdmin) + return pj.ID, canRead, canWrite, nil +} + +func (a projectAccessAdapterTest) ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error) { + pj, err := a.p.GetByID(ctx, project.Caller{UserID: callerID, IsAdmin: isAdmin}, projectID) + if err != nil { + return false, false, err + } + canRead, canWrite := projectACLTest(pj, callerID, isAdmin) + return canRead, canWrite, nil +} + +func projectACLTest(pj *project.Project, callerID uuid.UUID, isAdmin bool) (canRead, canWrite bool) { + owned := pj.OwnerID == callerID + canWrite = isAdmin || owned + canRead = canWrite || pj.Visibility == project.VisibilityInternal + return canRead, canWrite +} + +// makeLocalBareRepo 在临时目录里建一个带 main 分支与一次 commit 的 bare 仓库, +// 返回路径(用 file:// 协议作为 git_remote_url,让 Plan #3 的 clone 走 file 协议)。 +func makeLocalBareRepo(t *testing.T) string { + t.Helper() + dir := t.TempDir() + bare := filepath.Join(dir, "remote.git") + require.NoError(t, exec.Command("git", "init", "--bare", "-b", "main", bare).Run()) + work := filepath.Join(dir, "seed") + require.NoError(t, os.MkdirAll(work, 0o755)) + for _, args := range [][]string{ + {"init", "-b", "main"}, + {"config", "user.email", "t@e.c"}, + {"config", "user.name", "T"}, + } { + c := exec.Command("git", args...) + c.Dir = work + require.NoError(t, c.Run()) + } + require.NoError(t, os.WriteFile(filepath.Join(work, "README.md"), []byte("hi\n"), 0o644)) + for _, args := range [][]string{ + {"add", "."}, + {"commit", "-m", "init"}, + {"remote", "add", "origin", bare}, + {"push", "origin", "main"}, + } { + c := exec.Command("git", args...) + c.Dir = work + require.NoError(t, c.Run()) + } + return bare +} + +// TestPlan3_WorkspaceClosedLoop 端到端覆盖 workspace 模块: +// 1. bootstrap admin → login 拿 token +// 2. 创建 project + bare 远端 +// 3. POST /workspaces 创建 workspace(异步 clone) +// 4. 轮询 GET /workspaces/{id} 直到 sync_status == "idle" +// 5. POST /worktrees 创建支线 → acquire/release 状态机切换 +// 6. POST /sync(等同 git fetch)→ sync_status 仍为 idle +// 7. DELETE worktree → DELETE workspace 都返回 204 +// +// 跳过条件:本机没有 git 二进制时直接 t.Skip。Docker 不可用会让 testcontainers +// Run 失败,由上层的 require 触发测试失败(与已有 PM 闭环测试一致)。 +func TestPlan3_WorkspaceClosedLoop(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + ctx := context.Background() + + container, err := tcpg.Run(ctx, "postgres:16-alpine", + tcpg.WithDatabase("acw"), + tcpg.WithUsername("acw"), + tcpg.WithPassword("acw"), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = container.Terminate(ctx) }) + + dsn, err := container.ConnectionString(ctx, "sslmode=disable") + require.NoError(t, err) + require.NoError(t, db.Migrate(ctx, dsn, "file://../../migrations")) + + pool, err := db.NewPool(ctx, dsn) + require.NoError(t, err) + t.Cleanup(pool.Close) + + log := logger.New(logger.Options{Format: logger.FormatJSON, Level: logger.LevelInfo}) + auditRec := audit.NewPostgresRecorder(pool) + + userRepo := user.NewPostgresRepository(pool) + userSvc := user.NewService(userRepo, auditRec) + + projectRepo := project.NewPostgresRepository(pool) + projectSvc := project.NewProjectService(projectRepo, auditRec) + requirementSvc := project.NewRequirementService(projectRepo, auditRec) + issueSvc := project.NewIssueService(projectRepo, auditRec) + + adminUser, err := userSvc.Bootstrap(ctx, "admin@local", "password123") + require.NoError(t, err) + require.NotNil(t, adminUser) + + // ---- workspace 模块装配(与 app.go 同形,但不依赖 cfg) ---- + dataDir := t.TempDir() + tmpDir := filepath.Join(dataDir, "tmp") + require.NoError(t, os.MkdirAll(tmpDir, 0o700)) + + enc, err := crypto.NewEncryptor(make([]byte, 32)) + require.NoError(t, err) + + gitr := git.NewDefaultRunner(git.Config{ + Binary: "git", TmpDir: tmpDir, + CloneTimeout: 30 * time.Second, FetchTimeout: 30 * time.Second, + PushTimeout: 30 * time.Second, OpsTimeout: 30 * time.Second, + }) + + wsRepo := workspace.NewPostgresRepository(pool) + require.NoError(t, wsRepo.ResetStuckSyncStatuses(ctx)) + + pa := projectAccessAdapterTest{p: projectSvc} + wsSvc := workspace.NewWorkspaceService(wsRepo, auditRec, enc, gitr, pa, nil, dataDir) + + credAcc, ok := wsSvc.(workspace.CredentialAccessor) + require.True(t, ok) + credLoader := func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) { + return credAcc.LoadDecryptedCredential(ctx, wsID) + } + cloneRunner := workspace.NewCloneRunner(wsRepo, auditRec, gitr, log, credLoader, 30*time.Second) + if setter, ok := wsSvc.(workspace.SchedulerSetter); ok { + setter.SetScheduler(cloneRunner) + } + wtSvc := workspace.NewWorktreeService(wsRepo, auditRec, gitr, pa, dataDir) + gopSvc := workspace.NewGitOpsService(wsRepo, auditRec, gitr, pa, credLoader) + + r := chi.NewRouter() + r.Use(middleware.RequestID) + user.NewHandler(userSvc).Mount(r) + project.NewHandler(projectSvc, requirementSvc, issueSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r) + workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapterTest{svc: userSvc}).Mount(r) + + srv := httptest.NewServer(r) + t.Cleanup(srv.Close) + + // ---- 登录拿 token ---- + loginBody, _ := json.Marshal(map[string]string{"email": "admin@local", "password": "password123"}) + loginResp, err := http.Post(srv.URL+"/api/v1/auth/login", "application/json", bytes.NewReader(loginBody)) + require.NoError(t, err) + require.Equal(t, http.StatusOK, loginResp.StatusCode) + var lr struct { + Token string `json:"token"` + } + require.NoError(t, json.NewDecoder(loginResp.Body).Decode(&lr)) + _ = loginResp.Body.Close() + require.NotEmpty(t, lr.Token) + token := lr.Token + + // 三个内联 helper:内联是有意为之,避免引入 test 外部 helper 包。 + postJSON := func(path string, body any) (*http.Response, []byte) { + t.Helper() + b, _ := json.Marshal(body) + req, _ := http.NewRequestWithContext(ctx, http.MethodPost, srv.URL+path, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + buf, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return resp, buf + } + getJSON := func(path string) (*http.Response, []byte) { + t.Helper() + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+path, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + buf, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + return resp, buf + } + deleteJSON := func(path string) *http.Response { + t.Helper() + req, _ := http.NewRequestWithContext(ctx, http.MethodDelete, srv.URL+path, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + _ = resp.Body.Close() + return resp + } + + // ---- 1. 创建 project ---- + resp, _ := postJSON("/api/v1/projects/", map[string]string{"slug": "p1", "name": "P1"}) + require.Equal(t, http.StatusCreated, resp.StatusCode) + + // ---- 2. 准备本地 bare 仓库(file://协议) ---- + bare := makeLocalBareRepo(t) + remoteURL := "file://" + filepath.ToSlash(bare) + + // ---- 3. 创建 workspace(credential.kind=none,本地 file 协议无需凭据) ---- + resp, body := postJSON("/api/v1/projects/p1/workspaces", map[string]any{ + "slug": "ws1", "name": "WS1", + "git_remote_url": remoteURL, + "default_branch": "main", + "credential": map[string]any{"kind": "none"}, + }) + require.Equalf(t, http.StatusCreated, resp.StatusCode, "create workspace failed: %s", string(body)) + var wsResp struct { + ID uuid.UUID `json:"id"` + SyncStatus string `json:"sync_status"` + } + require.NoError(t, json.Unmarshal(body, &wsResp)) + require.NotEqual(t, uuid.Nil, wsResp.ID) + + // ---- 4. 等 clone 完成 → sync_status = idle ---- + require.Eventuallyf(t, func() bool { + r, b := getJSON("/api/v1/workspaces/" + wsResp.ID.String()) + if r.StatusCode != http.StatusOK { + return false + } + var ws struct { + SyncStatus string `json:"sync_status"` + LastSyncError string `json:"last_sync_error"` + } + _ = json.Unmarshal(b, &ws) + if ws.SyncStatus == "error" { + t.Logf("clone errored: %s", ws.LastSyncError) + return false + } + return ws.SyncStatus == "idle" + }, 30*time.Second, 200*time.Millisecond, "workspace clone did not become idle") + + // ---- 5. 创建 worktree feat-x ---- + resp, body = postJSON("/api/v1/workspaces/"+wsResp.ID.String()+"/worktrees", map[string]string{ + "branch": "feat-x", "base_branch": "main", + }) + require.Equalf(t, http.StatusCreated, resp.StatusCode, "create worktree: %s", string(body)) + var wtResp struct { + ID uuid.UUID `json:"id"` + Branch string `json:"branch"` + Status string `json:"status"` + } + require.NoError(t, json.Unmarshal(body, &wtResp)) + require.Equal(t, "feat-x", wtResp.Branch) + require.Equal(t, "idle", wtResp.Status) + + // ---- 6. acquire / release ---- + resp, body = postJSON("/api/v1/worktrees/"+wtResp.ID.String()+"/acquire", nil) + require.Equalf(t, http.StatusOK, resp.StatusCode, "acquire: %s", string(body)) + var afterAcq struct { + Status string `json:"status"` + } + require.NoError(t, json.Unmarshal(body, &afterAcq)) + require.Equal(t, "active", afterAcq.Status) + + resp, body = postJSON("/api/v1/worktrees/"+wtResp.ID.String()+"/release", nil) + require.Equalf(t, http.StatusOK, resp.StatusCode, "release: %s", string(body)) + var afterRel struct { + Status string `json:"status"` + } + require.NoError(t, json.Unmarshal(body, &afterRel)) + require.Equal(t, "idle", afterRel.Status) + + // ---- 7. 同步 workspace(git fetch)→ 仍为 idle ---- + resp, body = postJSON("/api/v1/workspaces/"+wsResp.ID.String()+"/sync", nil) + require.Equalf(t, http.StatusOK, resp.StatusCode, "sync: %s", string(body)) + var afterSync struct { + SyncStatus string `json:"sync_status"` + } + require.NoError(t, json.Unmarshal(body, &afterSync)) + require.Equal(t, "idle", afterSync.SyncStatus) + + // ---- 8. 删除 worktree / workspace ---- + require.Equal(t, http.StatusNoContent, deleteJSON("/api/v1/worktrees/"+wtResp.ID.String()).StatusCode) + require.Equal(t, http.StatusNoContent, deleteJSON("/api/v1/workspaces/"+wsResp.ID.String()).StatusCode) +} diff --git a/internal/config/config.go b/internal/config/config.go index 1e03220..942219d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "fmt" "log/slog" "strings" + "time" "github.com/spf13/viper" ) @@ -34,6 +35,8 @@ type Config struct { HTTP HTTPConfig `mapstructure:"http"` DB DBConfig `mapstructure:"db"` Bootstrap BootstrapConfig `mapstructure:"bootstrap"` + Workspace Workspace `mapstructure:"workspace"` + Git Git `mapstructure:"git"` } // HTTPConfig 描述 HTTP 服务监听参数。 @@ -52,6 +55,21 @@ type BootstrapConfig struct { AdminPassword string `mapstructure:"admin_password"` } +// Workspace 控制 workspace 模块的磁盘与超时(数据目录、每个 git verb 的超时)。 +// DataDir 是 workspace clone/worktree 落盘根目录;五个 timeout 直接灌进 git.Config。 +type Workspace struct { + DataDir string `mapstructure:"data_dir"` + CloneTimeout time.Duration `mapstructure:"clone_timeout"` + FetchTimeout time.Duration `mapstructure:"fetch_timeout"` + PushTimeout time.Duration `mapstructure:"push_timeout"` + OpsTimeout time.Duration `mapstructure:"ops_timeout"` +} + +// Git 控制 git CLI 二进制位置(默认 "git")。 +type Git struct { + Binary string `mapstructure:"binary"` +} + // Load 从(按优先级递增)默认值 → 配置文件(如有)→ 环境变量 加载配置。 // configFile 可为空字符串,仅用环境变量。 func Load(configFile string) (*Config, error) { @@ -61,6 +79,12 @@ func Load(configFile string) (*Config, error) { v.SetDefault("env", "development") v.SetDefault("data_dir", "./data") v.SetDefault("http.addr", ":8080") + v.SetDefault("workspace.data_dir", "./data") + v.SetDefault("workspace.clone_timeout", "30m") + v.SetDefault("workspace.fetch_timeout", "5m") + v.SetDefault("workspace.push_timeout", "5m") + v.SetDefault("workspace.ops_timeout", "60s") + v.SetDefault("git.binary", "git") if configFile != "" { v.SetConfigFile(configFile) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index b8f41d0..7890dc0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -50,6 +51,20 @@ func TestLoad_MasterKeyNotHex(t *testing.T) { require.Contains(t, err.Error(), "invalid hex") } +func TestLoad_WorkspaceDefaults(t *testing.T) { + // 验证 workspace.* 与 git.* 的默认值(含 string→time.Duration 自动解析)。 + t.Setenv("APP_DB_DSN", "postgres://x") + t.Setenv("APP_MASTER_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") + cfg, err := Load("") + require.NoError(t, err) + require.Equal(t, "./data", cfg.Workspace.DataDir) + require.Equal(t, 30*time.Minute, cfg.Workspace.CloneTimeout) + require.Equal(t, 5*time.Minute, cfg.Workspace.FetchTimeout) + require.Equal(t, 5*time.Minute, cfg.Workspace.PushTimeout) + require.Equal(t, 60*time.Second, cfg.Workspace.OpsTimeout) + require.Equal(t, "git", cfg.Git.Binary) +} + func TestMasterKey_Redaction(t *testing.T) { k := MasterKey([]byte{0x01, 0x02, 0x03}) require.Equal(t, "[REDACTED 32B]", k.String()) diff --git a/internal/project/domain.go b/internal/project/domain.go index 8f5d48a..555d95c 100644 --- a/internal/project/domain.go +++ b/internal/project/domain.go @@ -121,6 +121,7 @@ type Caller struct { type ProjectService interface { Create(ctx context.Context, c Caller, in CreateProjectInput) (*Project, error) Get(ctx context.Context, c Caller, slug string) (*Project, error) + GetByID(ctx context.Context, c Caller, id uuid.UUID) (*Project, error) List(ctx context.Context, c Caller, includeArchived bool) ([]*Project, error) Update(ctx context.Context, c Caller, slug string, in UpdateProjectInput) (*Project, error) Archive(ctx context.Context, c Caller, slug string) error diff --git a/internal/project/project_service.go b/internal/project/project_service.go index dce3065..e89bcc0 100644 --- a/internal/project/project_service.go +++ b/internal/project/project_service.go @@ -93,6 +93,19 @@ func (s *projectService) Get(ctx context.Context, c Caller, slug string) (*Proje return p, nil } +// GetByID 与 Get 等价但按 uuid 寻址,用于 workspace.ProjectAccess.ResolveByID。 +// 鉴权矩阵与 Get 相同:private 仅 owner+admin,internal 任意登录用户。 +func (s *projectService) GetByID(ctx context.Context, c Caller, id uuid.UUID) (*Project, error) { + p, err := s.repo.GetProjectByID(ctx, id) + if err != nil { + return nil, err + } + if err := assertReadable(p, c); err != nil { + return nil, err + } + return p, nil +} + func (s *projectService) List(ctx context.Context, c Caller, includeArchived bool) ([]*Project, error) { all, err := s.repo.ListProjects(ctx, includeArchived) if err != nil { diff --git a/internal/workspace/workspace_service.go b/internal/workspace/workspace_service.go index 701dcc5..06311cb 100644 --- a/internal/workspace/workspace_service.go +++ b/internal/workspace/workspace_service.go @@ -116,7 +116,9 @@ func (s *workspaceService) Create(ctx context.Context, c Caller, projectSlug str } s.audit(ctx, &c.UserID, "workspace.create", "workspace", wsID.String(), map[string]any{"slug": in.Slug}) - s.clones.Schedule(wsID) + if s.clones != nil { + s.clones.Schedule(wsID) + } return created, nil } @@ -304,6 +306,28 @@ func (s *workspaceService) encryptForUpsert(wsID uuid.UUID, in UpsertCredentialI return cred, nil } +// CredentialAccessor 让 app 装配代码不重复实现凭据加载。 +// CloneRunner 与 GitOpsService 都通过这个窄接口拿明文 git.Credential。 +type CredentialAccessor interface { + LoadDecryptedCredential(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) +} + +// SchedulerSetter 让 app 装配代码在创建 cloneRunner 后回填到 service。 +// NewWorkspaceService 阶段允许传 nil scheduler;构造完 cloneRunner 再 SetScheduler。 +type SchedulerSetter interface { + SetScheduler(s CloneScheduler) +} + +// LoadDecryptedCredential 暴露 loadDecryptedCredential 给 app 装配代码(CloneRunner、GitOps 共用)。 +func (s *workspaceService) LoadDecryptedCredential(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) { + return s.loadDecryptedCredential(ctx, wsID) +} + +// SetScheduler 在装配链路把后构造的 CloneRunner 回填到 service(NewWorkspaceService 阶段允许 nil)。 +func (s *workspaceService) SetScheduler(sched CloneScheduler) { + s.clones = sched +} + func (s *workspaceService) loadDecryptedCredential(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) { cred, err := s.repo.GetCredentialByWorkspace(ctx, wsID) if err != nil { diff --git a/internal/workspace/workspace_service_test.go b/internal/workspace/workspace_service_test.go index 2202dce..0609c05 100644 --- a/internal/workspace/workspace_service_test.go +++ b/internal/workspace/workspace_service_test.go @@ -220,6 +220,55 @@ func TestWorkspaceService_Create_HappyPath(t *testing.T) { require.True(t, ok) } +func TestWorkspaceService_Create_NilScheduler_NoPanic(t *testing.T) { + // app 装配有"先建 service(scheduler=nil)→ 再用 service 构 cloneRunner → SetScheduler 回填"的环依赖。 + // Create 必须在 scheduler==nil 时安全跳过 Schedule,否则首个 workspace 会 panic。 + t.Parallel() + pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true} + svc, repo := newSvc(t, pa, nil, &fakeGit{}) + caller := Caller{UserID: uuid.New()} + ws, err := svc.Create(context.Background(), caller, "p", CreateWorkspaceInput{ + Slug: "ws1", Name: "W1", GitRemoteURL: "https://x", DefaultBranch: "main", + Credential: UpsertCredentialInput{Kind: CredKindNone}, + }) + require.NoError(t, err) + require.Equal(t, SyncStatusCloning, ws.SyncStatus) + _, ok := repo.creds[ws.ID] + require.True(t, ok) +} + +func TestWorkspaceService_SetScheduler_BackfillSchedules(t *testing.T) { + // SetScheduler 把 nil scheduler 替换为真正实现,下一次 Create 应触发 Schedule。 + t.Parallel() + pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true} + svc, _ := newSvc(t, pa, nil, &fakeGit{}) + sched := &fakeSched{} + // SchedulerSetter 接口契约:app.go 在构建 CloneRunner 后回填。 + var setter SchedulerSetter = svc + setter.SetScheduler(sched) + caller := Caller{UserID: uuid.New()} + ws, err := svc.Create(context.Background(), caller, "p", CreateWorkspaceInput{ + Slug: "ws2", Name: "W2", GitRemoteURL: "https://x", DefaultBranch: "main", + Credential: UpsertCredentialInput{Kind: CredKindNone}, + }) + require.NoError(t, err) + require.Len(t, sched.called, 1) + require.Equal(t, ws.ID, sched.called[0]) +} + +func TestWorkspaceService_LoadDecryptedCredential_PassThrough(t *testing.T) { + // CredentialAccessor 接口暴露内部 loadDecryptedCredential,CloneRunner / GitOpsService 复用。 + t.Parallel() + pa := &fakePA{pid: uuid.New(), canRead: true, canWrite: true} + svc, _ := newSvc(t, pa, nil, &fakeGit{}) + var acc CredentialAccessor = svc + cred, err := acc.LoadDecryptedCredential(context.Background(), uuid.New()) + require.NoError(t, err) + require.NotNil(t, cred) + // 没有凭据行 → 返回 git.CredentialNone(loadDecryptedCredential 的兜底)。 + require.Equal(t, git.CredentialNone, cred.Kind) +} + func TestWorkspaceService_Create_BadSlug(t *testing.T) { t.Parallel() pa := &fakePA{pid: uuid.New(), canWrite: true, canRead: true}