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 }