Add allowlisted markdown indexing, read-only git plumbing via injected runners, and docker/readiness collection with path and loopback guards. Co-authored-by: Cursor <cursoragent@cursor.com>
352 lines
9.2 KiB
Go
352 lines
9.2 KiB
Go
package engineeringdashboard
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// ReadyEndpoint maps an environment name to a loopback readiness URL.
|
|
type ReadyEndpoint struct {
|
|
Env string
|
|
URL string
|
|
}
|
|
|
|
var defaultReadyEndpoints = []ReadyEndpoint{
|
|
{Env: "dev", URL: "http://127.0.0.1:8080/ready"},
|
|
{Env: "test", URL: "http://127.0.0.1:8081/ready"},
|
|
{Env: "main", URL: "http://127.0.0.1:8000/ready"},
|
|
}
|
|
|
|
var allowedComposeProjects = map[string]string{
|
|
"agent-dev": "dev",
|
|
"agent-test": "test",
|
|
"agent-main": "main",
|
|
"agent-infra": "shared",
|
|
}
|
|
|
|
// RuntimeCollectResult is the runtime collector output.
|
|
type RuntimeCollectResult struct {
|
|
Source SourceState
|
|
Environments []EnvironmentState
|
|
Facts []Fact
|
|
Warnings []string
|
|
}
|
|
|
|
// CollectRuntime gathers docker + readiness + receipt observations.
|
|
func CollectRuntime(ctx context.Context, repoRoot string, runner CommandRunner, now time.Time) RuntimeCollectResult {
|
|
start := time.Now()
|
|
res := RuntimeCollectResult{}
|
|
if runner == nil {
|
|
runner = ExecRunner{}
|
|
}
|
|
|
|
envs := map[string]*EnvironmentState{
|
|
"shared": {Name: "shared"},
|
|
"dev": {Name: "dev"},
|
|
"test": {Name: "test"},
|
|
"main": {Name: "main"},
|
|
}
|
|
|
|
psOut, stderr, err := runner.Run(ctx, repoRoot, "docker", "ps", "-a", "--format", "{{json .}}")
|
|
if err != nil {
|
|
res.Warnings = append(res.Warnings, "docker unavailable: "+strings.TrimSpace(stderr+" "+err.Error()))
|
|
for _, e := range envs {
|
|
e.Facets = DeploymentFacets{
|
|
ContainerStatus: "unknown", AppReadiness: "unknown", ServicesOK: "unknown",
|
|
IdentityNote: "docker unavailable",
|
|
}
|
|
}
|
|
} else {
|
|
for _, line := range strings.Split(psOut, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
cv, envName, ok := parseDockerLine(line)
|
|
if !ok {
|
|
continue
|
|
}
|
|
e := envs[envName]
|
|
if e == nil {
|
|
continue
|
|
}
|
|
e.Containers = append(e.Containers, cv)
|
|
}
|
|
}
|
|
|
|
client := &http.Client{
|
|
Timeout: 2 * time.Second,
|
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
|
if err := assertLoopbackReadyURL(req.URL.String()); err != nil {
|
|
return err
|
|
}
|
|
if len(via) >= 3 {
|
|
return fmt.Errorf("too many redirects")
|
|
}
|
|
return nil
|
|
},
|
|
}
|
|
for _, ep := range defaultReadyEndpoints {
|
|
if err := assertLoopbackReadyURL(ep.URL); err != nil {
|
|
res.Warnings = append(res.Warnings, err.Error())
|
|
continue
|
|
}
|
|
status, body, err := getReady(ctx, client, ep.URL)
|
|
e := envs[ep.Env]
|
|
if e == nil {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
e.Readiness = "error"
|
|
e.Facets.AppReadiness = "error"
|
|
res.Warnings = append(res.Warnings, ep.Env+" ready: "+err.Error())
|
|
continue
|
|
}
|
|
if status >= 200 && status < 300 {
|
|
e.Readiness = "ok"
|
|
e.Facets.AppReadiness = "ok"
|
|
} else {
|
|
e.Readiness = fmt.Sprintf("http_%d", status)
|
|
e.Facets.AppReadiness = e.Readiness
|
|
}
|
|
if strings.Contains(body, "schema_version") {
|
|
e.Schema = extractJSONField(body, "schema_version")
|
|
}
|
|
res.Facts = append(res.Facts, Fact{
|
|
ID: FactID("env:"+ep.Env, "readiness", "ready"), Subject: "env:" + ep.Env, Field: "readiness",
|
|
Value: e.Readiness, Provenance: ProvenanceObserved, SourceRef: ep.URL, ObservedAt: now,
|
|
})
|
|
}
|
|
|
|
for name, e := range envs {
|
|
e.Facets.ContainerStatus = summarizeContainers(e.Containers)
|
|
e.Facets.ServicesOK = summarizeServices(e.Containers, e.Facets.AppReadiness)
|
|
if e.Facets.AppReadiness == "" {
|
|
e.Facets.AppReadiness = "unknown"
|
|
}
|
|
receipt := findLatestReceipt(repoRoot)
|
|
if receipt != "" {
|
|
e.Receipt = receipt
|
|
e.Facets.ArtifactID = filepath.Base(receipt)
|
|
}
|
|
if e.Facets.ContainerStatus == "partial" && e.Facets.AppReadiness == "ok" {
|
|
e.DriftNotes = append(e.DriftNotes, "app readiness ok coexists with partial container health — no aggregate green")
|
|
e.Facets.IdentityNote = "running visible; service completeness partial"
|
|
}
|
|
if e.Facets.ContainerStatus != "unknown" && e.Facets.ArtifactID == "" {
|
|
e.Facets.IdentityNote = "running visible, version unverified"
|
|
}
|
|
res.Facts = append(res.Facts, Fact{
|
|
ID: FactID("env:"+name, "container_status", "docker"), Subject: "env:" + name, Field: "container_status",
|
|
Value: e.Facets.ContainerStatus, Provenance: ProvenanceObserved, SourceRef: "docker", ObservedAt: now,
|
|
})
|
|
}
|
|
|
|
for _, name := range []string{"shared", "dev", "test", "main"} {
|
|
res.Environments = append(res.Environments, *envs[name])
|
|
}
|
|
|
|
status := SourceOK
|
|
fresh := FreshnessFresh
|
|
if len(res.Warnings) > 0 {
|
|
status = SourceDegraded
|
|
}
|
|
res.Source = SourceState{
|
|
Name: "runtime", Status: status, Freshness: fresh,
|
|
ObservedAt: now, LastSuccessAt: &now, DurationMS: time.Since(start).Milliseconds(),
|
|
}
|
|
return res
|
|
}
|
|
|
|
func parseDockerLine(line string) (ContainerView, string, bool) {
|
|
var raw map[string]any
|
|
if err := json.Unmarshal([]byte(line), &raw); err != nil {
|
|
return ContainerView{}, "", false
|
|
}
|
|
// docker --format {{json .}} fields vary; support both Names and names
|
|
name := strField(raw, "Names", "names", "Name")
|
|
image := strField(raw, "Image", "image")
|
|
status := strField(raw, "Status", "status", "State")
|
|
labels := strField(raw, "Labels", "labels")
|
|
project, service := parseComposeLabels(labels)
|
|
envName := allowedComposeProjects[project]
|
|
if envName == "" {
|
|
// try infer from name
|
|
lname := strings.ToLower(name)
|
|
switch {
|
|
case strings.Contains(lname, "-dev"), strings.Contains(lname, "_dev"):
|
|
envName = "dev"
|
|
case strings.Contains(lname, "-test"), strings.Contains(lname, "_test"):
|
|
envName = "test"
|
|
case strings.Contains(lname, "-main"), strings.Contains(lname, "_main"):
|
|
envName = "main"
|
|
default:
|
|
return ContainerView{}, "", false
|
|
}
|
|
}
|
|
health := ""
|
|
lowerStatus := strings.ToLower(status)
|
|
switch {
|
|
case strings.Contains(lowerStatus, "unhealthy"):
|
|
health = "unhealthy"
|
|
case strings.Contains(lowerStatus, "healthy"):
|
|
health = "healthy"
|
|
case strings.Contains(lowerStatus, "restarting"):
|
|
health = "unhealthy"
|
|
}
|
|
restarts := 0
|
|
if strings.Contains(lowerStatus, "restarting") {
|
|
restarts = 1
|
|
}
|
|
cv := ContainerView{
|
|
Name: name, Image: image, Status: status, Health: health,
|
|
RestartCount: restarts, Project: project, Service: service,
|
|
}
|
|
return cv, envName, true
|
|
}
|
|
|
|
func strField(m map[string]any, keys ...string) string {
|
|
for _, k := range keys {
|
|
if v, ok := m[k]; ok {
|
|
switch t := v.(type) {
|
|
case string:
|
|
return t
|
|
default:
|
|
return fmt.Sprint(t)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func parseComposeLabels(labels string) (project, service string) {
|
|
for _, part := range strings.Split(labels, ",") {
|
|
kv := strings.SplitN(part, "=", 2)
|
|
if len(kv) != 2 {
|
|
continue
|
|
}
|
|
switch kv[0] {
|
|
case "com.docker.compose.project":
|
|
project = kv[1]
|
|
case "com.docker.compose.service":
|
|
service = kv[1]
|
|
}
|
|
}
|
|
return project, service
|
|
}
|
|
|
|
func summarizeContainers(cs []ContainerView) string {
|
|
if len(cs) == 0 {
|
|
return "unknown"
|
|
}
|
|
bad := 0
|
|
for _, c := range cs {
|
|
ls := strings.ToLower(c.Status + " " + c.Health)
|
|
if strings.Contains(ls, "restart") || strings.Contains(ls, "unhealthy") || strings.Contains(ls, "exited") {
|
|
bad++
|
|
}
|
|
}
|
|
if bad == 0 {
|
|
return "ok"
|
|
}
|
|
if bad < len(cs) {
|
|
return "partial"
|
|
}
|
|
return "unhealthy"
|
|
}
|
|
|
|
func summarizeServices(cs []ContainerView, readiness string) string {
|
|
cstat := summarizeContainers(cs)
|
|
if cstat == "ok" && readiness == "ok" {
|
|
return "complete"
|
|
}
|
|
if cstat == "unknown" && (readiness == "" || readiness == "unknown") {
|
|
return "unknown"
|
|
}
|
|
if cstat == "partial" || readiness == "ok" {
|
|
return "partial"
|
|
}
|
|
return "partial"
|
|
}
|
|
|
|
func assertLoopbackReadyURL(raw string) error {
|
|
u, err := url.Parse(raw)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if u.Scheme != "http" && u.Scheme != "https" {
|
|
return fmt.Errorf("ready scheme denied: %s", u.Scheme)
|
|
}
|
|
host := u.Hostname()
|
|
ip := net.ParseIP(host)
|
|
if ip == nil || !ip.IsLoopback() {
|
|
return fmt.Errorf("ready host must be loopback: %s", host)
|
|
}
|
|
port := u.Port()
|
|
switch port {
|
|
case "8080", "8081", "8000", "":
|
|
// ok (empty only if default — still require explicit in our list)
|
|
default:
|
|
return fmt.Errorf("ready port not allowlisted: %s", port)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func getReady(ctx context.Context, client *http.Client, rawURL string) (int, string, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return 0, "", err
|
|
}
|
|
defer resp.Body.Close()
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
|
return resp.StatusCode, string(body), nil
|
|
}
|
|
|
|
func extractJSONField(body, key string) string {
|
|
var m map[string]any
|
|
if err := json.Unmarshal([]byte(body), &m); err != nil {
|
|
return ""
|
|
}
|
|
if v, ok := m[key]; ok {
|
|
return fmt.Sprint(v)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func findLatestReceipt(repoRoot string) string {
|
|
dir := filepath.Join(repoRoot, ".doc", "test-reports", "assets", "promotion-receipts")
|
|
entries, err := os.ReadDir(dir)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
var latest string
|
|
var latestMod time.Time
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
continue
|
|
}
|
|
info, err := e.Info()
|
|
if err != nil {
|
|
continue
|
|
}
|
|
if info.ModTime().After(latestMod) {
|
|
latestMod = info.ModTime()
|
|
latest = filepath.ToSlash(filepath.Join(".doc/test-reports/assets/promotion-receipts", e.Name()))
|
|
}
|
|
}
|
|
return latest
|
|
}
|