diff --git a/.gitea/workflows/cf-tunnel-platform-paths-drift.yml b/.gitea/workflows/cf-tunnel-platform-paths-drift.yml new file mode 100644 index 000000000..c94f2c942 --- /dev/null +++ b/.gitea/workflows/cf-tunnel-platform-paths-drift.yml @@ -0,0 +1,46 @@ +# cf-tunnel-platform-paths-drift +# +# Prod-safety gate (molecule-controlplane#820): every top-level route in +# molecule-core/workspace-server/internal/router/router.go must be covered by a +# regex in molecule-controlplane/internal/cloudflareapi/tunnel.go's +# platformPaths list. If a core PR adds a tenant route without a matching +# platformPaths regex, production tenants will misroute it to canvas:3000. +# +# This workflow checks out molecule-controlplane (public repo) and runs the +# core-side drift test, catching the drift at the source rather than relying on +# the reactive controlplane gate. + +name: cf-tunnel-platform-paths-drift + +on: + push: + branches: [main, staging] + pull_request: + branches: [main, staging] + +env: + GITHUB_SERVER_URL: https://git.moleculesai.app + +jobs: + core-platform-paths-drift: + name: Core routes covered by platformPaths + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: checkout molecule-controlplane + # Manual git clone because actions/checkout@v6 fails on cross-repo + # public clones when the auto-injected GITEA_TOKEN is scoped to this + # repo only. molecule-controlplane is public — no token needed. + run: | + git clone --depth 1 https://git.moleculesai.app/molecule-ai/molecule-controlplane.git _molecule-controlplane + + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + with: + go-version: '1.25' + cache: false + + - name: drift gate + env: + MOLECULE_CP_DIR: ${{ github.workspace }}/_molecule-controlplane + run: go test -count=1 -v -run TestCoreRoutesCoveredByPlatformPaths ./workspace-server/internal/router/ diff --git a/workspace-server/internal/router/router_cf_drift_test.go b/workspace-server/internal/router/router_cf_drift_test.go new file mode 100644 index 000000000..4f43ae9fb --- /dev/null +++ b/workspace-server/internal/router/router_cf_drift_test.go @@ -0,0 +1,195 @@ +package router + +import ( + "os" + "path/filepath" + "regexp" + "sort" + "strings" + "testing" +) + +// TestCoreRoutesCoveredByPlatformPaths is the molecule-core side of the +// cross-repo drift gate for cf-tunnel platformPaths (molecule-controlplane#820). +// It ensures that every top-level route registered in this router is matched by +// at least one regex in the controlplane's platformPaths list. Drift is caught +// in the dedicated workflow (which clones molecule-controlplane); in the +// standard CI Go job the sibling checkout is absent, so the test skips. +// +// The hard-fail only fires when the operator explicitly configured +// MOLECULE_CP_DIR but the file is unreadable — that signals a broken checkout +// and must not pass silently. +func TestCoreRoutesCoveredByPlatformPaths(t *testing.T) { + cpTunnelPath := findControlPlaneTunnel(t) + if cpTunnelPath == "" { + if os.Getenv("MOLECULE_CP_DIR") != "" { + t.Fatal("MOLECULE_CP_DIR is set but molecule-controlplane tunnel.go is not readable; " + + "the drift gate checkout is broken") + } + t.Skip("molecule-controlplane tunnel.go not found; " + + "set MOLECULE_CP_DIR to enable cross-repo drift gate") + } + t.Logf("loading controlplane platformPaths from %s", cpTunnelPath) + + cpSrc, err := os.ReadFile(cpTunnelPath) + if err != nil { + t.Fatalf("read %s: %v", cpTunnelPath, err) + } + + platformPaths := extractPlatformPaths(string(cpSrc)) + if len(platformPaths) == 0 { + t.Fatalf("no platformPaths extracted from %s — extractor is broken", cpTunnelPath) + } + t.Logf("extracted %d platformPaths regex(es)", len(platformPaths)) + + platformRegexes := make([]*regexp.Regexp, 0, len(platformPaths)) + for _, p := range platformPaths { + re, err := regexp.Compile(p) + if err != nil { + t.Fatalf("platformPaths regex %q failed to compile: %v", p, err) + } + platformRegexes = append(platformRegexes, re) + } + + routerPath := "router.go" + if _, err := os.Stat(routerPath); err != nil { + t.Fatalf("cannot read own router.go at %s: %v", routerPath, err) + } + routerSrc, err := os.ReadFile(routerPath) + if err != nil { + t.Fatalf("read %s: %v", routerPath, err) + } + + routes := extractGinRoutes(string(routerSrc)) + if len(routes) == 0 { + t.Fatalf("no routes extracted from %s — extractor is broken", routerPath) + } + t.Logf("extracted %d route(s) from molecule-core router", len(routes)) + + var unmatched []string + for _, route := range routes { + if !routeMatchesAny(route, platformRegexes) { + unmatched = append(unmatched, route) + } + } + + if len(unmatched) > 0 { + sort.Strings(unmatched) + t.Errorf("\n=== platformPaths drift detected ===\n"+ + "%d molecule-core route(s) are NOT matched by any controlplane platformPaths regex.\n"+ + "They will silently misroute to canvas:3000 in production tenants.\n\n"+ + "Unmatched routes:\n - %s\n\n"+ + "Fix: add a matching regex to platformPaths in molecule-controlplane/internal/cloudflareapi/tunnel.go,\n"+ + "then add the same regex to the wantPlatformPaths lockstep test in tunnel_test.go.\n", + len(unmatched), + strings.Join(unmatched, "\n - ")) + } +} + +// findControlPlaneTunnel resolves the path to molecule-controlplane's +// internal/cloudflareapi/tunnel.go using the search order documented on the +// test. Returns "" if no candidate exists. +func findControlPlaneTunnel(t *testing.T) string { + t.Helper() + rel := filepath.Join("internal", "cloudflareapi", "tunnel.go") + candidates := []string{} + if env := os.Getenv("MOLECULE_CP_DIR"); env != "" { + candidates = append(candidates, filepath.Join(env, rel)) + } + if home, err := os.UserHomeDir(); err == nil { + candidates = append(candidates, filepath.Join(home, "molecule-controlplane", rel)) + } + candidates = append(candidates, filepath.Join("..", "..", "molecule-controlplane", rel)) + candidates = append(candidates, filepath.Join("..", "molecule-controlplane", rel)) + for _, c := range candidates { + if _, err := os.Stat(c); err == nil { + return c + } + } + return "" +} + +// extractPlatformPaths parses tunnel.go source and returns the platformPaths +// regex slice as it would be pushed to cloudflared. +func extractPlatformPaths(src string) []string { + startMarker := "platformPaths := []string{" + start := strings.Index(src, startMarker) + if start < 0 { + return nil + } + start += len(startMarker) + end := strings.Index(src[start:], "}") + if end < 0 { + return nil + } + block := src[start : start+end] + + // Each entry is a backtick-quoted Go raw string. + re := regexp.MustCompile("`([^`]*)`") + var out []string + for _, m := range re.FindAllStringSubmatch(block, -1) { + out = append(out, m[1]) + } + return out +} + +// extractGinRoutes parses router.go source and returns every concrete +// top-level path registered on the gin router, with group prefixes resolved. +// +// Adapted from molecule-controlplane/internal/cloudflareapi/cross_repo_drift_test.go +// which performs the inverse check. The extractor is regex-based and sufficient +// for molecule-core's structure where Groups don't nest and prefixes are static +// literals. +func extractGinRoutes(src string) []string { + groupRe := regexp.MustCompile(`(\w+)\s*:=\s*r\.Group\(\s*"([^"]*)"`) + groupMap := make(map[string]string) // varname → prefix + for _, m := range groupRe.FindAllStringSubmatch(src, -1) { + groupMap[m[1]] = m[2] + } + + methodRe := regexp.MustCompile( + `(\w+)\.(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS|Handle)\(\s*` + + `(?:"[^"]*"\s*,\s*)?` + // Handle("METHOD", "/path") form + `"([^"]*)"`) + + seen := make(map[string]struct{}) + for _, m := range methodRe.FindAllStringSubmatch(src, -1) { + recv := m[1] + path := m[3] + + full := "" + switch { + case recv == "r": + full = path + default: + if prefix, ok := groupMap[recv]; ok { + full = prefix + path + } else { + continue + } + } + if full == "" { + continue + } + if i := strings.IndexAny(full, "?#"); i >= 0 { + full = full[:i] + } + seen[full] = struct{}{} + } + + out := make([]string, 0, len(seen)) + for p := range seen { + out = append(out, p) + } + sort.Strings(out) + return out +} + +func routeMatchesAny(route string, patterns []*regexp.Regexp) bool { + for _, re := range patterns { + if re.MatchString(route) { + return true + } + } + return false +}