diff --git a/.gitea/workflows/template-delivery-e2e.yml b/.gitea/workflows/template-delivery-e2e.yml index 4c7b27426..73be5cc03 100644 --- a/.gitea/workflows/template-delivery-e2e.yml +++ b/.gitea/workflows/template-delivery-e2e.yml @@ -53,6 +53,7 @@ jobs: name: Template-asset delivery (fresh seo-agent boots WITH skills) runs-on: ubuntu-latest # Phase 1: advisory. Remove this line in Phase 2 to make it merge-blocking. + # mc#2981: pre-existing continue-on-error mask for the advisory template-delivery E2E; root-fix and remove once CP consumes TemplateAssets, do not renew silently. continue-on-error: true timeout-minutes: 30 env: diff --git a/workspace-server/internal/provisioner/platform_agent_image_drift_test.go b/workspace-server/internal/provisioner/platform_agent_image_drift_test.go index 4031f2b0d..b3774be4d 100644 --- a/workspace-server/internal/provisioner/platform_agent_image_drift_test.go +++ b/workspace-server/internal/provisioner/platform_agent_image_drift_test.go @@ -119,6 +119,93 @@ func isConciergeIdentityPath(rel string) bool { strings.HasPrefix(rel, "prompts/") } +// hasDockerfileCopyForRel reports whether Dockerfile.platform-agent contains +// a COPY instruction for the expected IMAGE-BAKED file `rel` (relative to the +// platform-agent template SSOT root). The Dockerfile uses two patterns: +// +// - COPY ${PLATFORM_AGENT_TEMPLATE_DIR}/ ... for top-level files +// (config.yaml, mcp_servers.yaml, identity-fallback.sh). +// - COPY ${PLATFORM_AGENT_TEMPLATE_DIR}// ... for directory-baked +// content (prompts/concierge.md is shipped via the prompts/ dir copy). +// +// COPY instructions may also carry Dockerfile flags such as +// `--chmod=0755` before the source path, so the matcher permits an +// optional flag segment between `COPY` and the source path. +// +// This helper centralises the pattern matching so the test body stays readable +// and the two valid COPY shapes are documented in one place. +func hasDockerfileCopyForRel(dockerfileStr, rel string) bool { + rel = filepath.ToSlash(filepath.Clean(rel)) + relRe := regexp.QuoteMeta(rel) + dirRe := regexp.QuoteMeta(filepath.Dir(rel) + "/") + + // Match: COPY [flags] ${PLATFORM_AGENT_TEMPLATE_DIR}/ ... + // or: COPY [flags] ${PLATFORM_AGENT_TEMPLATE_DIR}// ... + pattern := `(?m)^COPY(?:\s+--[A-Za-z0-9=]+)?\s+\$\{PLATFORM_AGENT_TEMPLATE_DIR\}/(?:` + relRe + `|` + dirRe + `)\s` + matched, err := regexp.MatchString(pattern, dockerfileStr) + if err != nil { + // regexp.QuoteMeta only produces safe patterns; a compile error + // here is a test-authoring bug, not a product failure. + panic("invalid hasDockerfileCopyForRel pattern: " + err.Error()) + } + return matched +} + +func TestHasDockerfileCopyForRel(t *testing.T) { + tests := []struct { + name string + dockerfile string + rel string + wantMatched bool + }{ + { + name: "top-level file COPY", + dockerfile: "COPY ${PLATFORM_AGENT_TEMPLATE_DIR}/config.yaml /opt/molecule-platform-agent-template/config.yaml\n", + rel: "config.yaml", + wantMatched: true, + }, + { + name: "top-level file COPY with --chmod", + dockerfile: "COPY --chmod=0755 ${PLATFORM_AGENT_TEMPLATE_DIR}/identity-fallback.sh /opt/molecule-platform-agent-template/identity-fallback.sh\n", + rel: "identity-fallback.sh", + wantMatched: true, + }, + { + name: "directory COPY for nested file", + dockerfile: "COPY ${PLATFORM_AGENT_TEMPLATE_DIR}/prompts/ /opt/molecule-platform-agent-template/prompts/\n", + rel: "prompts/concierge.md", + wantMatched: true, + }, + { + name: "missing COPY", + dockerfile: "RUN echo no-copy\n", + rel: "config.yaml", + wantMatched: false, + }, + { + name: "wrong source variable", + dockerfile: "COPY ${OTHER_DIR}/config.yaml /opt/molecule-platform-agent-template/config.yaml\n", + rel: "config.yaml", + wantMatched: false, + }, + { + name: "nested file missing directory COPY", + dockerfile: "COPY ${PLATFORM_AGENT_TEMPLATE_DIR}/prompts/concierge.md /opt/molecule-platform-agent-template/prompts/concierge.md\n", + rel: "prompts/concierge.md", + wantMatched: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := hasDockerfileCopyForRel(tt.dockerfile, tt.rel) + if got != tt.wantMatched { + t.Errorf("hasDockerfileCopyForRel(%q, %q) = %v, want %v", tt.dockerfile, tt.rel, got, tt.wantMatched) + } + }) + } +} + // canonicalPlatformAgentSSOTRelPath is the default SSOT path the // drift-gate reads from when PLATFORM_AGENT_TEMPLATE_REPO_PATH is // unset, RELATIVE TO THE REPO ROOT. It mirrors Dockerfile.platform- @@ -234,13 +321,7 @@ func TestPlatformAgentImageDriftGate(t *testing.T) { dockerfileStr := string(dockerfile) for _, rel := range expectedImageBakedFiles { - // The Dockerfile uses two patterns: COPY /opt/... - // for the top-level files (config.yaml, mcp_servers.yaml) - // and COPY / /opt/.../ for the prompts/ directory. - // We check that EITHER pattern appears for the expected file. - topLevel := `COPY ${PLATFORM_AGENT_TEMPLATE_DIR}/` + rel - dirPattern := `COPY ${PLATFORM_AGENT_TEMPLATE_DIR}/` + filepath.Dir(rel) + `/` - if !strings.Contains(dockerfileStr, topLevel) && !strings.Contains(dockerfileStr, dirPattern) { + if !hasDockerfileCopyForRel(dockerfileStr, rel) { t.Errorf("Dockerfile COPY missing: %s — the IMAGE-BAKED impl must COPY %s from the platform-agent template SSOT; if a new identity file is added, update Dockerfile.platform-agent AND expectedImageBakedFiles", rel, rel) } }