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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-13 19:21:56 +00:00
co-authored by Cursor
parent 7be683d9c7
commit 35b2e922bf
4 changed files with 240 additions and 3 deletions
+2
View File
@@ -10,6 +10,8 @@ web/dist
web/.vite
bin
*.md
!templates/**/*.md
!configs/**/*.md
.env
.env.*
!.env.example
+2
View File
@@ -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
+221
View File
@@ -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
}
+15 -3
View File
@@ -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
}