From 35b2e922bfa183eb188a81604c4fdc18cebd9ca4 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 13 Sep 2026 19:21:56 +0000 Subject: [PATCH] fix(deploy): keep official templates in the image build context *.md was excluding templates/docs from Docker context, so COPY and go:embed could not see prelim_design.md at /app. Un-ignore those trees and assert the smoke image can cat the official file. Co-authored-by: Cursor --- .dockerignore | 2 + deploy/app/Dockerfile | 2 + internal/template/docker_context_test.go | 221 +++++++++++++++++++++++ internal/template/loader.go | 18 +- 4 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 internal/template/docker_context_test.go diff --git a/.dockerignore b/.dockerignore index 37225ead..cc9436c9 100644 --- a/.dockerignore +++ b/.dockerignore @@ -10,6 +10,8 @@ web/dist web/.vite bin *.md +!templates/**/*.md +!configs/**/*.md .env .env.* !.env.example diff --git a/deploy/app/Dockerfile b/deploy/app/Dockerfile index b582061d..3fa1d126 100644 --- a/deploy/app/Dockerfile +++ b/deploy/app/Dockerfile @@ -30,6 +30,8 @@ WORKDIR /app COPY --from=go-builder /server /app/server COPY --from=web-builder /web/dist /app/web/dist COPY deploy/postgres/goose /app/deploy/postgres/goose +# Official markdown must be on disk at /app/templates/docs and /app/configs. +# .dockerignore keeps *.md out of context except these two trees. COPY templates /app/templates COPY configs /app/configs diff --git a/internal/template/docker_context_test.go b/internal/template/docker_context_test.go new file mode 100644 index 00000000..5cdc0476 --- /dev/null +++ b/internal/template/docker_context_test.go @@ -0,0 +1,221 @@ +package template_test + +import ( + "bytes" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "chat-agent-system/internal/template" +) + +func repoRoot(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller") + } + return filepath.Clean(filepath.Join(filepath.Dir(file), "..", "..")) +} + +func TestDockerfileCopiesTemplatesAndConfigsToApp(t *testing.T) { + raw, err := os.ReadFile(filepath.Join(repoRoot(t), "deploy", "app", "Dockerfile")) + if err != nil { + t.Fatal(err) + } + text := string(raw) + for _, line := range []string{ + "COPY templates /app/templates", + "COPY configs /app/configs", + "WORKDIR /app", + } { + if !strings.Contains(text, line) { + t.Fatalf("deploy/app/Dockerfile must contain %q so the image runtime can read official docs", line) + } + } +} + +func TestDockerignoreKeepsOfficialTemplateDocs(t *testing.T) { + root := repoRoot(t) + patterns := readDockerignore(t, filepath.Join(root, ".dockerignore")) + docs, err := filepath.Glob(filepath.Join(root, "templates", "docs", "*.md")) + if err != nil || len(docs) == 0 { + t.Fatalf("templates/docs/*.md missing on disk: %v", err) + } + for _, abs := range docs { + rel, err := filepath.Rel(root, abs) + if err != nil { + t.Fatal(err) + } + rel = filepath.ToSlash(rel) + if dockerIgnored(patterns, rel) { + t.Fatalf(".dockerignore excludes %s; COPY templates and go:embed docs/*.md cannot see it in the image", rel) + } + } + if dockerIgnored(patterns, "templates/embed.go") { + t.Fatal("templates/embed.go must stay in the go-builder context") + } + if dockerIgnored(patterns, "configs/agents/writer.md") { + t.Fatal("configs/agents/*.md must stay in the image for GetAgentInstruction") + } +} + +func TestGetDocTemplate_AppRootLayout(t *testing.T) { + root := repoRoot(t) + app := filepath.Join(t.TempDir(), "app") + dest := filepath.Join(app, "templates", "docs") + if err := os.MkdirAll(dest, 0o755); err != nil { + t.Fatal(err) + } + src := filepath.Join(root, "templates", "docs", "prelim_design.md") + body, err := os.ReadFile(src) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dest, "prelim_design.md"), body, 0o644); err != nil { + t.Fatal(err) + } + agentDir := filepath.Join(app, "configs", "agents") + if err := os.MkdirAll(agentDir, 0o755); err != nil { + t.Fatal(err) + } + writer, err := os.ReadFile(filepath.Join(root, "configs", "agents", "writer.md")) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(agentDir, "writer.md"), writer, 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("APP_ROOT", app) + t.Chdir(app) + got, err := template.GetDocTemplate("prelim_design") + if err != nil { + t.Fatal(err) + } + if !strings.Contains(got, "系统初步设计方案") || !strings.Contains(got, "子系统划分") { + t.Fatalf(" /app layout must yield the official template, got %d bytes", len(got)) + } + if strings.Contains(got, "## 1. 结论") && !strings.Contains(got, "子系统划分") { + t.Fatal("must not use the two-section stub") + } + inst, err := template.GetAgentInstruction("writer") + if err != nil { + t.Fatalf("image /app/configs/agents must be readable: %v", err) + } + if strings.TrimSpace(inst) == "" { + t.Fatal("writer instruction empty") + } +} + +func TestDockerBuildContext_TemplatesDocsReadable(t *testing.T) { + if _, err := exec.LookPath("docker"); err != nil { + t.Skip("docker CLI not available") + } + root := repoRoot(t) + df := filepath.Join(t.TempDir(), "Dockerfile") + // Same context and COPY destinations as deploy/app/Dockerfile final stage. + if err := os.WriteFile(df, []byte("FROM debian:bookworm-slim\nWORKDIR /app\nCOPY templates /app/templates\nCOPY configs /app/configs\n"), 0o644); err != nil { + t.Fatal(err) + } + tag := "agent-system-templates-smoke:local" + build := exec.Command("docker", "build", "-f", df, "-t", tag, ".") + build.Dir = root + out, err := runTimed(build, 3*time.Minute) + if err != nil { + t.Fatalf("docker build context failed: %v\n%s", err, out) + } + t.Cleanup(func() { + _ = exec.Command("docker", "rmi", "-f", tag).Run() + }) + + cat := exec.Command("docker", "run", "--rm", "--entrypoint", "cat", tag, "/app/templates/docs/prelim_design.md") + body, err := runTimed(cat, 30*time.Second) + if err != nil { + t.Fatalf("image missing /app/templates/docs/prelim_design.md: %v\n%s", err, body) + } + if !bytes.Contains(body, []byte("系统初步设计方案")) || !bytes.Contains(body, []byte("子系统划分")) { + t.Fatalf("image template is not the official prelim_design (%d bytes)", len(body)) + } + ls := exec.Command("docker", "run", "--rm", "--entrypoint", "ls", tag, "/app/configs/agents") + agents, err := runTimed(ls, 30*time.Second) + if err != nil { + t.Fatalf("image missing /app/configs/agents: %v\n%s", err, agents) + } + if !bytes.Contains(agents, []byte("writer.md")) { + t.Fatalf("image configs/agents incomplete: %s", agents) + } +} + +func runTimed(cmd *exec.Cmd, d time.Duration) ([]byte, error) { + var buf bytes.Buffer + cmd.Stdout = &buf + cmd.Stderr = &buf + if err := cmd.Start(); err != nil { + return buf.Bytes(), err + } + done := make(chan error, 1) + go func() { done <- cmd.Wait() }() + select { + case err := <-done: + return buf.Bytes(), err + case <-time.After(d): + _ = cmd.Process.Kill() + return buf.Bytes(), os.ErrDeadlineExceeded + } +} + +func readDockerignore(t *testing.T, path string) []string { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return strings.Split(string(raw), "\n") +} + +func dockerIgnored(patterns []string, rel string) bool { + rel = filepath.ToSlash(rel) + ignored := false + for _, raw := range patterns { + p := strings.TrimSpace(raw) + if p == "" || strings.HasPrefix(p, "#") { + continue + } + negate := false + if strings.HasPrefix(p, "!") { + negate = true + p = strings.TrimPrefix(p, "!") + } + if matchDockerPattern(p, rel) { + ignored = !negate + } + } + return ignored +} + +func matchDockerPattern(pattern, rel string) bool { + pattern = filepath.ToSlash(pattern) + if pattern == rel { + return true + } + if strings.HasSuffix(pattern, "/**/*.md") { + prefix := strings.TrimSuffix(pattern, "/**/*.md") + return strings.HasPrefix(rel, prefix+"/") && strings.HasSuffix(rel, ".md") + } + if pattern == "*.md" { + return strings.HasSuffix(rel, ".md") + } + if !strings.Contains(pattern, "/") && !strings.Contains(pattern, "*") { + return rel == pattern || strings.HasPrefix(rel, pattern+"/") + } + ok, _ := filepath.Match(pattern, rel) + if ok { + return true + } + ok, _ = filepath.Match(pattern, filepath.Base(rel)) + return ok +} diff --git a/internal/template/loader.go b/internal/template/loader.go index 2d825343..1b93e5d6 100644 --- a/internal/template/loader.go +++ b/internal/template/loader.go @@ -11,17 +11,29 @@ import ( func findProjectRoot() string { wd, _ := os.Getwd() + candidates := []string{wd} dir := wd for i := 0; i < 10; i++ { - if _, err := os.Stat(filepath.Join(dir, "templates", "docs")); err == nil { - return dir - } + candidates = append(candidates, dir) parent := filepath.Dir(dir) if parent == dir { break } dir = parent } + if env := strings.TrimSpace(os.Getenv("APP_ROOT")); env != "" { + candidates = append(candidates, env) + } + // Image WORKDIR is /app; Dockerfile copies templates/configs here. + candidates = append(candidates, "/app") + for _, root := range candidates { + if root == "" { + continue + } + if _, err := os.Stat(filepath.Join(root, "templates", "docs")); err == nil { + return root + } + } return wd }