diff --git a/packages/orchestrator/cmd/inspect-build/inspect.go b/packages/orchestrator/cmd/inspect-build/inspect.go new file mode 100644 index 0000000000..10f3608cee --- /dev/null +++ b/packages/orchestrator/cmd/inspect-build/inspect.go @@ -0,0 +1,172 @@ +package main + +import ( + "context" + "flag" + "log" + "os" + "slices" + "strconv" + "strings" + + "golang.org/x/term" + + "github.com/e2b-dev/infra/packages/orchestrator/cmd/internal/cmdutil" + "github.com/e2b-dev/infra/packages/shared/pkg/storage" +) + +var expandSections = []string{sectionMappings, sectionFrames, sectionMetadata, sectionAll} + +// runInspect is the entry point for the redesigned dashboard CLI. main() in +// main.go dispatches here unless --old selects the legacy header-dump tool. +func runInspect() { + build := flag.String("build", "", "build ID") + template := flag.String("template", "", "template ID or alias (requires E2B_API_KEY)") + storagePath := flag.String("storage", ".local-build", "storage: local path or gs://bucket") + memfile := flag.Bool("memfile", false, "inspect memfile artifact (default)") + rootfs := flag.Bool("rootfs", false, "inspect rootfs artifact") + human := flag.Bool("human", false, "force the human dashboard") + jsonOut := flag.Bool("json", false, "force JSON output") + decimal := flag.Bool("decimal", false, "human mode: exact offsets/sizes in decimal, not hex") + expand := flag.String("expand", "", "sections to show in full: "+strings.Join(expandSections, ",")) + rangeArg := flag.String("range", "", "limit expanded mapping/frame lists to offset:size") + validate := flag.Bool("validate", false, "fetch+decompress every frame via the production read path and verify the checksum") + recursive := flag.Bool("recursive", false, "also inspect the full ancestor chain") + flag.Parse() + + // Keep the standard log enabled — the tool reports fatal errors via + // log.Fatal; only the zap/OTEL/LaunchDarkly noise is suppressed. + cmdutil.SuppressNoisyLogsKeepStdLog() + + buildID, artifact := resolveTarget(*build, *template, *memfile, *rootfs) + ctx := context.Background() + + if *validate { + if err := runValidate(ctx, *storagePath, buildID, artifact, *recursive); err != nil { + log.Fatalf("validate: %s", err) + } + + return + } + + vw := view{ + decimal: *decimal, + expand: parseExpand(*expand), + rng: parseRange(*rangeArg), + width: detectTermWidth(os.Stdout), + } + + chain, err := gatherChain(ctx, *storagePath, buildID, artifact, *recursive) + if err != nil { + log.Fatalf("%s", err) + } + + if *human || (!*jsonOut && isTTY(os.Stdout)) { + renderHuman(os.Stdout, chain, vw) + + return + } + if err := renderJSON(os.Stdout, jsonValue(chain, vw, *recursive)); err != nil { + log.Fatal(err) + } +} + +// jsonValue builds the JSON payload: a dependency-ordered array of reports for +// --recursive, or the single report otherwise. +func jsonValue(chain []*report, vw view, recursive bool) any { + if !recursive { + return filterReport(chain[0], vw) + } + + out := make([]*report, len(chain)) + for i, r := range chain { + out[i] = filterReport(r, vw) + } + + return out +} + +// resolveTarget validates the target flags and returns the build ID to inspect +// and the artifact name (memfile or rootfs). +func resolveTarget(build, template string, memfile, rootfs bool) (buildID, artifact string) { + switch { + case build == "" && template == "": + log.Fatal("specify -build or -template") + case build != "" && template != "": + log.Fatal("specify either -build or -template, not both") + } + + buildID = build + if template != "" { + resolved, err := cmdutil.ResolveTemplateID(template) + if err != nil { + log.Fatalf("resolve template: %s", err) + } + buildID = resolved + } + + if memfile && rootfs { + log.Fatal("specify either -memfile or -rootfs, not both") + } + artifact = storage.MemfileName + if rootfs { + artifact = storage.RootfsName + } + + return buildID, artifact +} + +// parseExpand parses the comma-separated --expand list into a section set. +func parseExpand(s string) map[string]bool { + expand := map[string]bool{} + if s == "" { + return expand + } + for name := range strings.SplitSeq(s, ",") { + name = strings.TrimSpace(name) + if !slices.Contains(expandSections, name) { + log.Fatalf("--expand: unknown section %q (valid: %s)", name, strings.Join(expandSections, ", ")) + } + expand[name] = true + } + + return expand +} + +// parseRange parses the --range offset:size value; both numbers accept 0x hex. +func parseRange(s string) span { + if s == "" { + return span{} + } + + offStr, sizeStr, ok := strings.Cut(s, ":") + if !ok { + log.Fatalf("--range: expected offset:size, got %q", s) + } + offset, err := strconv.ParseUint(strings.TrimSpace(offStr), 0, 64) + if err != nil { + log.Fatalf("--range: %s", err) + } + size, err := strconv.ParseUint(strings.TrimSpace(sizeStr), 0, 64) + if err != nil { + log.Fatalf("--range: %s", err) + } + + return span{set: true, start: offset, end: offset + size} +} + +func isTTY(f *os.File) bool { + fi, err := f.Stat() + + return err == nil && fi.Mode()&os.ModeCharDevice != 0 +} + +// detectTermWidth queries the terminal for its column count; falls back to a +// sensible default when stdout is piped or the syscall fails. +func detectTermWidth(f *os.File) int { + if w, _, err := term.GetSize(int(f.Fd())); err == nil && w > 20 { + return w + } + + return 100 +} diff --git a/packages/orchestrator/cmd/inspect-build/main.go b/packages/orchestrator/cmd/inspect-build/main.go index 5c5ef0ff45..7231a4d9e6 100644 --- a/packages/orchestrator/cmd/inspect-build/main.go +++ b/packages/orchestrator/cmd/inspect-build/main.go @@ -3,16 +3,11 @@ package main import ( "bytes" "context" - "encoding/json" - "errors" "flag" "fmt" - "io" "log" - "net/http" "os" "slices" - "strings" "unsafe" "github.com/e2b-dev/infra/packages/orchestrator/cmd/internal/cmdutil" @@ -22,6 +17,21 @@ import ( const nilUUID = "00000000-0000-0000-0000-000000000000" func main() { + // Default to the redesigned dashboard CLI (runInspect, see inspect.go); + // --old keeps the original header-dump tool implemented below. + runLegacy := false + for _, name := range []string{"--old", "-old"} { + if i := slices.Index(os.Args, name); i != -1 { + os.Args = slices.Delete(os.Args, i, i+1) + runLegacy = true + } + } + if !runLegacy { + runInspect() + + return + } + build := flag.String("build", "", "build ID") template := flag.String("template", "", "template ID or alias (requires E2B_API_KEY)") storagePath := flag.String("storage", ".local-build", "storage: local path or gs://bucket") @@ -38,7 +48,7 @@ func main() { log.Fatal("specify either -build or -template, not both") } if *template != "" { - resolvedBuild, err := resolveTemplateID(*template) + resolvedBuild, err := cmdutil.ResolveTemplateID(*template) if err != nil { log.Fatalf("failed to resolve template: %s", err) } @@ -224,94 +234,3 @@ func inspectData(ctx context.Context, storagePath, buildID, dataFile string, h * reader.Close() } - -// templateInfo represents a template from the E2B API. -type templateInfo struct { - TemplateID string `json:"templateID"` - BuildID string `json:"buildID"` - Aliases []string `json:"aliases"` - Names []string `json:"names"` -} - -// resolveTemplateID fetches the build ID for a template from the E2B API. -// Input can be a template ID, alias, or full name (e.g., "e2b/base"). -func resolveTemplateID(input string) (string, error) { - apiKey := os.Getenv("E2B_API_KEY") - if apiKey == "" { - return "", errors.New("E2B_API_KEY environment variable required for -template flag") - } - - // Determine API URL - apiURL := "https://api.e2b.dev/templates" - if domain := os.Getenv("E2B_DOMAIN"); domain != "" { - apiURL = fmt.Sprintf("https://api.%s/templates", domain) - } - - // Make HTTP request - ctx := context.Background() - req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) - if err != nil { - return "", fmt.Errorf("failed to create request: %w", err) - } - req.Header.Set("X-API-Key", apiKey) - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return "", fmt.Errorf("failed to fetch templates: %w", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - body, _ := io.ReadAll(resp.Body) - - return "", fmt.Errorf("API returned %d: %s", resp.StatusCode, string(body)) - } - - // Parse response - var templates []templateInfo - if err := json.NewDecoder(resp.Body).Decode(&templates); err != nil { - return "", fmt.Errorf("failed to parse API response: %w", err) - } - - // Find matching template - var match *templateInfo - var availableAliases []string - - for i := range templates { - t := &templates[i] - - // Collect aliases for error message - availableAliases = append(availableAliases, t.Aliases...) - - // Match by template ID - if t.TemplateID == input { - match = t - - break - } - - // Match by alias - if slices.Contains(t.Aliases, input) { - match = t - - break - } - - // Match by full name (e.g., "e2b/base") - if slices.Contains(t.Names, input) { - match = t - - break - } - } - - if match == nil { - return "", fmt.Errorf("template %q not found. Available aliases: %s", input, strings.Join(availableAliases, ", ")) - } - - if match.BuildID == "" || match.BuildID == nilUUID { - return "", fmt.Errorf("template %q has no successful build", input) - } - - return match.BuildID, nil -} diff --git a/packages/orchestrator/cmd/inspect-build/render_human.go b/packages/orchestrator/cmd/inspect-build/render_human.go new file mode 100644 index 0000000000..2c29d9d5ff --- /dev/null +++ b/packages/orchestrator/cmd/inspect-build/render_human.go @@ -0,0 +1,925 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "maps" + "slices" + "sort" + "strings" + + "github.com/google/uuid" + + "github.com/e2b-dev/infra/packages/shared/pkg/storage" + "github.com/e2b-dev/infra/packages/shared/pkg/storage/header" +) + +const ( + heatmapWidth = 100 // cells per wrapped row + heatmapCells = 1000 // max cells across a whole heatmap +) + +// view controls hex/decimal formatting, which sections expand to full detail, +// the offset:size range applied to expanded lists, and the terminal width +// available for full-width heatmap rows. +type view struct { + decimal bool + expand map[string]bool // section name → expanded; "all" expands every section + rng span // --range: limits expanded mapping/frame lists + width int // terminal columns; falls back to a default when piped +} + +// --expand section identifiers. +const ( + sectionMappings = "mappings" + sectionFrames = "frames" + sectionMetadata = "metadata" + sectionAll = "all" +) + +func (vw view) expanded(section string) bool { + return vw.expand[sectionAll] || vw.expand[section] +} + +// span is an inclusive-start, exclusive-end filter range. +type span struct { + set bool + start, end uint64 +} + +func (s span) overlaps(start, end uint64) bool { + return !s.set || (start < s.end && end > s.start) +} + +const ( + ansiReset = "\x1b[0m" + ansiBold = "\x1b[1m" + ansiDim = "\x1b[2m" +) + +// rgb is a true-color value that can paint a foreground or a background. +type rgb struct{ r, g, b uint8 } + +func (c rgb) fg() string { return fmt.Sprintf("\x1b[38;2;%d;%d;%dm", c.r, c.g, c.b) } + +// num renders an exact offset/size — hex by default, decimal with --decimal. +func (vw view) num(v uint64) string { + if vw.decimal { + return fmt.Sprintf("%d", v) + } + + return fmt.Sprintf("0x%X", v) +} + +// size pairs an exact value with its human-readable magnitude, or "unknown" +// for a sizeUnknown sentinel. +func (vw view) size(v int64) string { + if v < 0 { + return "unknown" + } + + return fmt.Sprintf("%s (%s)", vw.num(uint64(v)), humanSize(v)) +} + +func humanSize(b int64) string { + const u = 1024 + switch { + case b < 0: + return "unknown" + case b >= u*u*u: + return fmt.Sprintf("%.1f GiB", float64(b)/(u*u*u)) + case b >= u*u: + return fmt.Sprintf("%.1f MiB", float64(b)/(u*u)) + case b >= u: + return fmt.Sprintf("%.1f KiB", float64(b)/u) + default: + return fmt.Sprintf("%d B", b) + } +} + +// zeroColor is the dim swatch for nil (zero-block) mappings. +func zeroColor() rgb { return rgb{70, 70, 70} } + +// buildPalette assigns each build ID a stable color so the mappings heatmap, +// the mappings summary, and the builds list all cross-reference by swatch. +// The current build is always green; zero blocks are dim. +func buildPalette(byBuild []buildExtent) map[uuid.UUID]rgb { + currentColor := rgb{120, 220, 130} // green — the current build + swatches := []rgb{ + {80, 200, 220}, // cyan + {100, 150, 235}, // blue + {200, 130, 225}, // magenta + {230, 165, 70}, // amber + {225, 110, 110}, // red + } + colors := map[uuid.UUID]rgb{uuid.Nil: zeroColor()} + next := 0 + for _, b := range byBuild { + if _, seen := colors[b.BuildID]; seen { + continue + } + if b.Role == roleCurrent { + colors[b.BuildID] = currentColor + + continue + } + colors[b.BuildID] = swatches[next%len(swatches)] + next++ + } + + return colors +} + +func colorOf(colors map[uuid.UUID]rgb, id uuid.UUID) rgb { + if c, ok := colors[id]; ok { + return c + } + + return zeroColor() +} + +func swatch(c rgb) string { return c.fg() + "█" + ansiReset } + +// heatColor maps t in [0,1] to a red→yellow→green gradient. +func heatColor(t float64) rgb { + t = max(0, min(1, t)) + if t < 0.5 { + return rgb{220, 60 + uint8(t*2*140), 60} + } + + return rgb{220 - uint8((t-0.5)*2*160), 200, 60 + uint8((t-0.5)*2*30)} +} + +// blue paints the compression type. +func blue(s string) string { return rgb{90, 160, 245}.fg() + s + ansiReset } + +// ratioColored paints a compression ratio on the same red→green heat gradient +// used by the frame heatmap. +func ratioColored(r float64) string { + return heatColor(ratioNorm(r)).fg() + fmt.Sprintf("%.2fx", r) + ansiReset +} + +func renderHuman(w io.Writer, chain []*report, vw view) { + head := chain[0] + colors := buildPalette(head.Mappings.ByBuild) + renderHeader(w, head, vw) + renderImage(w, head, vw) + renderMappings(w, head, vw, colors) + renderBuilds(w, chain, vw, colors) + renderData(w, chain, vw) + if len(chain) == 1 { + renderFrameMap(w, head, vw, colors) + } + renderMetadata(w, head, vw) +} + +func section(w io.Writer, title string) { + fmt.Fprintf(w, "\n%s%s%s%s\n", ansiBold, rgb{120, 220, 180}.fg(), title, ansiReset) +} + +func field(w io.Writer, label, value string) { + fmt.Fprintf(w, " %s%-16s%s %s\n", ansiDim, label, ansiReset, value) +} + +// fieldIf prints a field only when the value is non-empty. +func fieldIf(w io.Writer, label, value string) { + if value != "" { + field(w, label, value) + } +} + +// listHeader labels an expanded list, noting the active --range. +func listHeader(w io.Writer, vw view, kind string) { + label := kind + if vw.rng.set { + label += fmt.Sprintf(" · range %s + %s", vw.num(vw.rng.start), vw.num(vw.rng.end-vw.rng.start)) + } + fmt.Fprintf(w, "\n %s── %s ──%s\n", ansiDim, label, ansiReset) +} + +func renderHeader(w io.Writer, r *report, vw view) { + section(w, "HEADER") + + field(w, "Artifact", fmt.Sprintf("%s%s%s", ansiBold, r.Artifact, ansiReset)) + field(w, "Source", r.Source) + + verColor := rgb{230, 150, 60}.fg() // orange — V3 / legacy + if r.Header.Version >= 4 { + verColor = rgb{120, 220, 180}.fg() // green — V4+ + } + field(w, "Version", fmt.Sprintf("%sV%d%s", verColor, r.Header.Version, ansiReset)) + + if r.Header.HeaderSize > 0 { + hdr := vw.size(r.Header.HeaderSize) + if r.Header.HeaderRatio > 0 { + hdr += " " + ratioColored(r.Header.HeaderRatio) + } + field(w, "Header size", hdr) + } + + storage := vw.size(r.Header.StorageSize) + if r.Header.Ratio > 0 { + storage += " " + ratioColored(r.Header.Ratio) + } + field(w, "Storage size", storage) +} + +func renderImage(w io.Writer, r *report, vw view) { + section(w, "IMAGE") + field(w, "Virtual size", vw.size(int64(r.Image.VirtualSize))) + field(w, "Diff size", vw.size(r.Image.DiffSize)) + field(w, "Build ID", r.Image.BuildID.String()) + if r.Image.BaseBuildID != r.Image.BuildID { + field(w, "Base build ID", r.Image.BaseBuildID.String()) + } + field(w, "Ancestors", fmt.Sprintf("%d", r.Image.Ancestors)) + fieldIf(w, "From image", r.Image.FromImage) + fieldIf(w, "Kernel", r.Image.Kernel) + fieldIf(w, "Firecracker", r.Image.Firecracker) + fieldIf(w, "User", r.Image.User) +} + +// renderMetadata prints metadata.json as a structured key/value tree, kept out +// of IMAGE so its bulk doesn't disrupt the dashboard. +func renderMetadata(w io.Writer, r *report, vw view) { + if !vw.expanded(sectionMetadata) || len(r.Image.Metadata) == 0 { + return + } + + dec := json.NewDecoder(bytes.NewReader(r.Image.Metadata)) + dec.UseNumber() // keep integers out of float scientific notation + var v any + if dec.Decode(&v) != nil { + return + } + m, ok := v.(map[string]any) + if !ok { + return + } + + section(w, "METADATA") + renderMetaMap(w, m, 1) +} + +// renderMetaMap prints a parsed JSON object as an indented key/value tree; +// arrays are summarized by length so bulky prefetch lists don't flood the view. +func renderMetaMap(w io.Writer, m map[string]any, depth int) { + indent := strings.Repeat(" ", depth) + // Top-level keys and object keys are bold; nested scalars are dim. + keyStyle := ansiDim + if depth == 1 { + keyStyle = ansiBold + } + for _, k := range slices.Sorted(maps.Keys(m)) { + switch val := m[k].(type) { + case map[string]any: + fmt.Fprintf(w, "%s%s%s%s\n", indent, ansiBold, k, ansiReset) + renderMetaMap(w, val, depth+1) + case []any: + fmt.Fprintf(w, "%s%s%-22s%s %d items\n", indent, keyStyle, k, ansiReset, len(val)) + default: + fmt.Fprintf(w, "%s%s%-22s%s %v\n", indent, keyStyle, k, ansiReset, val) + } + } +} + +func renderMappings(w io.Writer, r *report, vw view, colors map[uuid.UUID]rgb) { + section(w, fmt.Sprintf("MAPPINGS (%d)", r.Mappings.Count)) + + for _, e := range r.Mappings.ByBuild { + fmt.Fprintf(w, " %s %s %-8s %s %d mappings\n", + swatch(colorOf(colors, e.BuildID)), e.BuildID, e.Role, vw.size(int64(e.Bytes)), e.Mappings) + } + + if !vw.expanded(sectionMappings) { + return + } + listHeader(w, vw, "mappings") + for _, m := range filteredMappings(r.Mappings.List, vw.rng) { + fmt.Fprintf(w, " %s %-12s + %-9s %s\n", + swatch(colorOf(colors, m.BuildID)), vw.num(m.Offset), vw.num(m.Length), m.BuildID) + } +} + +// renderBuilds lists the build layers. With --recursive each build is a card +// enriched by gathering it; otherwise it is the compact one-per-line list. +func renderBuilds(w io.Writer, chain []*report, vw view, colors map[uuid.UUID]rgb) { + head := chain[0] + + if len(chain) == 1 { + section(w, fmt.Sprintf("BUILDS (%d)", len(head.Builds))) + for _, b := range head.Builds { + renderBuildLine(w, b, vw, colors) + } + + return + } + + section(w, fmt.Sprintf("BUILDS (%d, dependency order)", len(chain))) + for _, c := range chain { + renderBuildCard(w, head, c, colors) + } +} + +// renderBuildLine prints one build's short card: identity, role, compression, +// ratio, sizes, frame count, checksum. +func renderBuildLine(w io.Writer, b buildInfo, vw view, colors map[uuid.UUID]rgb) { + line := fmt.Sprintf("%s %s %-8s ", swatch(colorOf(colors, b.BuildID)), b.BuildID, b.Role) + if b.Ratio > 0 { + line += blue(b.Compression) + " " + ratioColored(b.Ratio) + } else { + line += b.Compression + } + line += " " + humanSize(b.UncompressedSize) + if b.FrameCount > 0 { + line += fmt.Sprintf(" %d frames", b.FrameCount) + } + fmt.Fprintf(w, " %s\n", line) + + fmt.Fprintf(w, " uncompressed %s\n", vw.size(b.UncompressedSize)) + if b.CompressedSize > 0 { + fmt.Fprintf(w, " compressed %s in %d frames\n", vw.size(b.CompressedSize), b.FrameCount) + } + if b.Checksum != "" { + fmt.Fprintf(w, " %s\n", b.Checksum) + } +} + +// renderBuildCard prints one build in --recursive mode: identity and +// compression, what the target header records about it ("header"), what +// gathering it revealed ("build"), and how much of it the target uses ("usage"). +func renderBuildCard(w io.Writer, head, c *report, colors map[uuid.UUID]rgb) { + id := c.Image.BuildID + + verColor := rgb{230, 150, 60} // orange — V3 + if c.Header.Version >= 4 { + verColor = rgb{120, 220, 180} // green — V4+ + } + line := fmt.Sprintf("%s %s %-8s %sV%d%s", swatch(colorOf(colors, id)), + id, roleOf(id, head.h.Metadata), verColor.fg(), c.Header.Version, ansiReset) + if c.Data.Compressed { + line += " " + blue(c.Data.CompressionType) + " " + ratioColored(c.Data.Ratio) + } else { + line += " uncompressed" + } + fmt.Fprintf(w, "\n %s\n", line) + + if b, ok := head.buildInfoFor(id); ok { + // The header's contribution: the compressed bytes the read path + // downloads for this build, plus its integrity checksum. + rec := humanSize(b.UncompressedSize) + " uncompressed" + if b.CompressedSize > 0 { + rec = humanSize(b.CompressedSize) + " compressed" + } + if b.Checksum != "" { + rec += " · " + b.Checksum + } + cardField(w, "header", rec) + } + + origin := fmt.Sprintf("%d ancestors", c.Image.Ancestors) + if c.Image.Ancestors == 1 { + origin = "1 ancestor" + } + if c.Image.FromImage != "" { + origin = "from " + c.Image.FromImage + " · " + origin + } + build := fmt.Sprintf("virtual %s · diff %s", humanSize(int64(c.Image.VirtualSize)), humanSize(c.Image.DiffSize)) + if c.Data.Compressed { + build += fmt.Sprintf(" · %d frames", c.Data.FrameCount) + } + cardField(w, "build", build+" · "+origin) + + if u := c.Usage; u != nil { + cardField(w, "usage", usageSummary(u)) + } +} + +// usageSummary describes how much of an ancestor the target build draws on. +func usageSummary(u *ancestorUsage) string { + if u.UsedBytes == 0 { + return "unused" + } + s := humanSize(u.UsedBytes) + if u.DiffBytes > 0 { + s += fmt.Sprintf(" · %.1f%% of diff", u.UsedFraction*100) + } + if u.TotalFrames > 0 { + s += fmt.Sprintf(" · %d/%d frames touched", u.FramesTouched, u.TotalFrames) + } + + return s +} + +// cardField prints a dim, labeled, indented build-card sub-line. +func cardField(w io.Writer, label, value string) { + fmt.Fprintf(w, " %s%-8s%s %s\n", ansiDim, label, ansiReset, value) +} + +func renderData(w io.Writer, chain []*report, vw view) { + head := chain[0] + section(w, "DATA (current build)") + d := head.Data + if d.Compressed { + field(w, "Compression", blue(d.CompressionType)) + field(w, "Ratio", ratioColored(d.Ratio)) + field(w, "Uncompressed", vw.size(d.UncompressedSize)) + field(w, "Compressed", vw.size(d.CompressedSize)) + field(w, "Frames", fmt.Sprintf("%d", d.FrameCount)) + } else { + field(w, "Compression", "none") + field(w, "Size", vw.size(d.UncompressedSize)) + } + + // With --recursive, the per-build chain heatmap shows every frame across + // the chain; the single-build view gets the unified FRAMEMAP further down. + if len(chain) > 1 { + renderChainFrames(w, chain) + } + + if !d.Compressed || !vw.expanded(sectionFrames) { + return + } + listHeader(w, vw, "frames") + for _, f := range framesInRange(d.Frames, head.h, head.h.Metadata.BuildId, vw.rng) { + fr := frameRatio(f) + fmt.Fprintf(w, " %s U %s + %s C %s + %s %.2fx\n", + swatch(heatColor(ratioNorm(fr))), + vw.num(uint64(f.StartU)), vw.num(uint64(f.EndU-f.StartU)), + vw.num(uint64(f.StartC)), vw.num(uint64(f.EndC-f.StartC)), + fr) + } +} + +// renderChainFrames draws one heatmap of every build's data across the chain: +// compression-colored per frame for compressed builds, blue per chunk for +// uncompressed ones (raw data has no ratio, so it sits off the heat gradient). +func renderChainFrames(w io.Writer, chain []*report) { + var cells []string + builds, anyUncompressed := 0, false + for _, c := range chain { + bc := buildHeatCells(c) + if len(bc) == 0 { + continue + } + cells = append(cells, bc...) + builds++ + anyUncompressed = anyUncompressed || !c.Data.Compressed + } + if len(cells) == 0 { + return + } + + label := fmt.Sprintf("%d frames across %d builds", len(cells), builds) + if anyUncompressed { + label += " · blue = uncompressed" + } + fmt.Fprintf(w, "\n %s── %s ──%s\n", ansiDim, label, ansiReset) + heatmap(w, cells) + ratioLegend(w) +} + +// buildHeatCells renders one build's data as heatmap cells: compression-colored +// per frame when compressed, or blue per MemoryChunkSize chunk when not. +func buildHeatCells(c *report) []string { + if c.Data.Compressed { + return frameCells(c.Data.Frames) + } + if c.Data.UncompressedSize <= 0 { + return nil + } + + chunk := int64(storage.MemoryChunkSize) + n := min(int((c.Data.UncompressedSize+chunk-1)/chunk), heatmapCells) + cell := blue("█") + cells := make([]string, n) + for i := range cells { + cells[i] = cell + } + + return cells +} + +// ratioLegend prints the red→green scale used for ratios and the frame heatmap. +func ratioLegend(w io.Writer) { + var b strings.Builder + b.WriteString(" " + ansiDim + "ratio 1x " + ansiReset) + for i := range 21 { + b.WriteString(heatColor(float64(i) / 20).fg()) + b.WriteString("█") + b.WriteString(ansiReset) + } + b.WriteString(ansiDim + " 10x+" + ansiReset + "\n") + fmt.Fprintln(w, b.String()) +} + +// renderFrameMap draws four stacked, column-aligned rows over the virtual +// address space: one cell per hugepage-sized chunk. +// +// C: compression ratio of the SELF frame(s) covering each chunk +// (ancestor-only chunks show as sparse — we don't have ancestor frame +// tables in scope) +// F: cold-restore fetch fanout: distinct backing fetches across all builds +// B: build provenance — the build that serves the most bytes in each +// chunk, colored to match the BUILDS palette above +// M: mapping density: number of distinct mappings touching each chunk +// (high count = fragmented; tends to correlate with F) +// +// C/F/M use the same red→green heatColor gradient so doubly-bad zones jump +// out as vertical hot stripes; B uses the buildPalette swatches. Legends +// for C/F/M ride the section header so the data rows can spread the full +// terminal width. +func renderFrameMap(w io.Writer, r *report, vw view, colors map[uuid.UUID]rgb) { + if r.Fetchmap == nil || r.Fetchmap.ChunkCount == 0 { + return + } + fm := r.Fetchmap + bs := fm.ChunkSize + + cmp := compressionPerChunk(r.h, r.Data.Frames, fm.ChunkCount, bs) + dens := mappingDensityPerChunk(r.h, fm.ChunkCount, bs) + builds := buildPerChunk(r.h, fm.ChunkCount, bs) + width := min(fm.ChunkCount, heatmapRowWidth(vw)) + cmpRow := framemapRow(cmp, fm.ChunkCount, width, compressionCell, worstCompression) + fetchRow := framemapRow(intsToFloat(fm.Cells), fm.ChunkCount, width, densityCell, worstFetch) + buildRow := buildmapRow(builds, fm.ChunkCount, width, colors) + densRow := framemapRow(dens, fm.ChunkCount, width, densityCell, worstFetch) + + avgDensity := averageNonZero(dens) + title := fmt.Sprintf("HEATMAP %s · %d × %s (%s)", + r.Artifact, fm.ChunkCount, vw.num(uint64(bs)), humanSize(int64(fm.ChunkCount)*bs)) + cmpAvg := "" + if r.Data.Ratio > 0 { + cmpAvg = " avg " + ratioColored(r.Data.Ratio) + } + cmpLegend := "C: " + gradientLegend("10x+", "1x", 1.0, 0.0) + cmpAvg + fetchLegend := "F: " + gradientLegend("2", "8+", 1.0, 0.0) + + fmt.Sprintf(" avg %.2f", fm.AvgSegments) + densLegend := "M: " + gradientLegend("2", "8+", 1.0, 0.0) + + fmt.Sprintf(" avg %.2f", avgDensity) + + // Section header: title + C/F/M legends on one line; data rows below + // get the C: / F: / B: / M: prefixes. B has no scalar legend — its + // colors match the BUILDS palette above. + fmt.Fprintf(w, "\n%s%s%s%s %s %s %s\n", + ansiBold, rgb{120, 220, 180}.fg(), title, ansiReset, cmpLegend, fetchLegend, densLegend) + fmt.Fprintln(w) + fmt.Fprintf(w, " B: %s\n", buildRow) + fmt.Fprintf(w, " C: %s\n", cmpRow) + fmt.Fprintf(w, " F: %s\n", fetchRow) + fmt.Fprintf(w, " M: %s\n", densRow) +} + +// buildPerChunk maps each virtual chunk to the build ID whose mappings +// cover the most bytes inside that chunk. Nil mappings are excluded — they +// carry no fetch cost so they shouldn't outvote a real backing build. A +// chunk with no non-Nil mappings (fully sparse) stays at uuid.Nil. +func buildPerChunk(h *header.Header, chunkCount int, chunkSize int64) []uuid.UUID { + winners := make([]uuid.UUID, chunkCount) + bytesPer := make([]map[uuid.UUID]uint64, chunkCount) + for _, m := range h.Mapping.All() { + if m.Length == 0 || m.BuildId == uuid.Nil { + continue + } + vStart := int64(m.Offset) + vEnd := vStart + int64(m.Length) + first := vStart / chunkSize + last := (vEnd - 1) / chunkSize + if last >= int64(chunkCount) { + last = int64(chunkCount - 1) + } + for c := first; c <= last; c++ { + cStart := c * chunkSize + cEnd := cStart + chunkSize + overlap := uint64(min(vEnd, cEnd) - max(vStart, cStart)) + if bytesPer[c] == nil { + bytesPer[c] = map[uuid.UUID]uint64{} + } + bytesPer[c][m.BuildId] += overlap + } + } + for c := range winners { + var winner uuid.UUID + var top uint64 + for id, n := range bytesPer[c] { + if n > top { + top, winner = n, id + } + } + winners[c] = winner + } + + return winners +} + +// mappingDensityPerChunk counts how many distinct backing mappings overlap +// each chunk — sparse (Nil) mappings are excluded since they carry no fetch +// cost. With Nil out, M matches F's "fetchable layers" semantics, and the +// invariant M ≤ (number of distinct builds touching the chunk) holds. +func mappingDensityPerChunk(h *header.Header, chunkCount int, chunkSize int64) []float64 { + out := make([]float64, chunkCount) + for _, m := range h.Mapping.All() { + if m.Length == 0 || m.BuildId == uuid.Nil { + continue + } + first := int64(m.Offset) / chunkSize + last := (int64(m.Offset) + int64(m.Length) - 1) / chunkSize + if last >= int64(chunkCount) { + last = int64(chunkCount - 1) + } + for c := first; c <= last; c++ { + out[c]++ + } + } + + return out +} + +// buildmapRow downsamples a per-chunk winner-build series to width cells. +// Each output cell picks the most common non-Nil winner across its bucket +// (ties broken by first occurrence). A cell is only rendered as Nil when +// every input chunk in its bucket is Nil — otherwise we'd hide real +// backing builds whenever a bucket is mostly-but-not-all sparse. +func buildmapRow(winners []uuid.UUID, n, width int, colors map[uuid.UUID]rgb) string { + if n == 0 || width == 0 { + return "" + } + perCell := (n + width - 1) / width + var b strings.Builder + counts := map[uuid.UUID]int{} + for i := 0; i < n; i += perCell { + hi := min(i+perCell, n) + clear(counts) + var top uuid.UUID + var topN int + for _, id := range winners[i:hi] { + if id == uuid.Nil { + continue + } + counts[id]++ + if counts[id] > topN { + topN, top = counts[id], id + } + } + if top == uuid.Nil { + b.WriteString(ansiDim) + b.WriteString("·") + b.WriteString(ansiReset) + + continue + } + b.WriteString(colorOf(colors, top).fg()) + b.WriteString("█") + b.WriteString(ansiReset) + } + + return b.String() +} + +// averageNonZero returns the mean of the non-zero entries in v, or 0 if +// there are none. Used for the M legend's "avg N" stat. +func averageNonZero(v []float64) float64 { + var sum float64 + var n int + for _, x := range v { + if x > 0 { + sum += x + n++ + } + } + if n == 0 { + return 0 + } + + return sum / float64(n) +} + +// heatmapRowWidth is the cell budget for a single data row: terminal width +// minus the " C: " / " F: " prefix (5 chars). Falls back to heatmapWidth +// when the terminal is too narrow or unset (piped output). +func heatmapRowWidth(vw view) int { + const prefix = 5 + avail := vw.width - prefix + if avail < 20 { + return heatmapWidth + } + + return avail +} + +// gradientLegend renders "leftLabel rightLabel" where the +// strip walks heatColor from leftHeat to rightHeat across a fixed number of +// cells, and each label is tinted by its endpoint heat. Use leftHeat=0 (red) +// + rightHeat=1 (green) for "low = bad, high = good" semantics; flip them +// when low values are the desirable end. +func gradientLegend(leftLabel, rightLabel string, leftHeat, rightHeat float64) string { + const cells = 16 + var b strings.Builder + b.WriteString(heatColor(leftHeat).fg()) + b.WriteString(leftLabel) + b.WriteString(ansiReset) + b.WriteByte(' ') + for i := range cells { + t := leftHeat + (rightHeat-leftHeat)*float64(i)/float64(cells-1) + b.WriteString(heatColor(t).fg()) + b.WriteString("█") + b.WriteString(ansiReset) + } + b.WriteByte(' ') + b.WriteString(heatColor(rightHeat).fg()) + b.WriteString(rightLabel) + b.WriteString(ansiReset) + + return b.String() +} + +// framemapRow downsamples a per-block series to width cells by picking the +// worst value in each bucket, then renders each cell via cellFn. +func framemapRow(src []float64, n, width int, cellFn func(float64) string, worst func(a, b float64) float64) string { + if n == 0 || width == 0 { + return "" + } + perCell := (n + width - 1) / width + var b strings.Builder + for i := 0; i < n; i += perCell { + hi := min(i+perCell, n) + w := src[i] + for _, v := range src[i+1 : hi] { + w = worst(w, v) + } + b.WriteString(cellFn(w)) + } + + return b.String() +} + +// compressionPerChunk maps each virtual block to the compression ratio of +// the SELF frame(s) covering it. A virtual block not backed by any SELF +// mapping (zero-fill or ancestor-only) gets -1 (sparse). When multiple +// self frames overlap a single block, the worst (lowest) ratio wins. +// +// Frames are indexed in SELF's U-space (storage offsets in the current +// build's data file); we project U→V through every SELF mapping. For any +// mapping m with BuildId == self, the U-range [m.BuildStorageOffset, +// m.BuildStorageOffset+m.Length) corresponds to V-range [m.Offset, +// m.Offset+m.Length). +func compressionPerChunk(h *header.Header, frames []frameInfo, chunkCount int, chunkSize int64) []float64 { + out := make([]float64, chunkCount) + for i := range out { + out[i] = -1 + } + if h == nil || len(frames) == 0 { + return out + } + selfID := h.Metadata.BuildId + for _, m := range h.Mapping.All() { + if m.BuildId != selfID || m.Length == 0 { + continue + } + uStart := int64(m.BuildStorageOffset) + uEnd := uStart + int64(m.Length) + + // Frames are sorted by StartU; skip to the first one that ends + // past this mapping's U start. + fi := sort.Search(len(frames), func(i int) bool { + return frames[i].EndU > uStart + }) + for ; fi < len(frames) && frames[fi].StartU < uEnd; fi++ { + f := frames[fi] + if f.EndU <= f.StartU || f.EndC <= f.StartC { + continue + } + r := ratio(f.EndU-f.StartU, f.EndC-f.StartC) + + // U-overlap between this mapping and this frame, projected + // to V-space via the mapping. + overlapU0 := max(uStart, f.StartU) + overlapU1 := min(uEnd, f.EndU) + vStart := int64(m.Offset) + (overlapU0 - uStart) + vEnd := int64(m.Offset) + (overlapU1 - uStart) + + first := vStart / chunkSize + last := (vEnd - 1) / chunkSize + if last >= int64(chunkCount) { + last = int64(chunkCount - 1) + } + for b := first; b <= last; b++ { + if out[b] < 0 || r < out[b] { + out[b] = r + } + } + } + } + + return out +} + +// compressionCell picks the char + color for a single block's compression +// ratio. -1 means no frame covers this block (sparse → dimmed dot). +func compressionCell(r float64) string { + if r < 0 { + return ansiDim + "·" + ansiReset + } + + return heatColor(ratioNorm(r)).fg() + "█" + ansiReset +} + +// densityCell picks the char + color for a single chunk's count-of-thing +// metric — used by both F (fetches/hugepage) and M (mappings/hugepage). +// 0 (untouched) and 1 (single fetch/mapping → unavoidable, ideal) render as +// a dimmed dot; 2..8+ are colored cool→hot. +func densityCell(n float64) string { + v := int(n) + if v <= 1 { + return ansiDim + "·" + ansiReset + } + // 2 → t=0 (green), 8+ → t=1 (red); invert for heatColor. + t := float64(min(v, 8)-2) / 6 + + return heatColor(1-t).fg() + "█" + ansiReset +} + +// worstCompression returns the lower of two ratios, treating -1 (no frame) +// as "no worse than" any real ratio — so a covered block dominates a sparse +// block in the downsample bucket. +func worstCompression(a, b float64) float64 { + switch { + case a < 0: + return b + case b < 0: + return a + case a < b: + return a + default: + return b + } +} + +// worstFetch returns the higher segment count (more fetches = worse). +func worstFetch(a, b float64) float64 { + if a > b { + return a + } + + return b +} + +// intsToFloat converts a []int to []float64 so framemapRow can take both +// dimensions through one signature. +func intsToFloat(in []int) []float64 { + out := make([]float64, len(in)) + for i, v := range in { + out[i] = float64(v) + } + + return out +} + +// heatmap prints pre-rendered cells as a wrapped block. +func heatmap(w io.Writer, cells []string) { + if len(cells) == 0 { + return + } + var b strings.Builder + b.WriteString("\n ") + for i, c := range cells { + if i > 0 && i%heatmapWidth == 0 { + b.WriteString("\n ") + } + b.WriteString(c) + } + b.WriteString("\n") + fmt.Fprintln(w, b.String()) +} + +// frameCells colors each frame (or group of frames, when over the cell cap) +// by how well it compressed. +func frameCells(frames []frameInfo) []string { + if len(frames) == 0 { + return nil + } + n := min(len(frames), heatmapCells) + perCell := (len(frames) + n - 1) / n + + cells := make([]string, 0, n) + for i := 0; i < len(frames); i += perCell { + var u, c int64 + for _, f := range frames[i:min(i+perCell, len(frames))] { + u += f.EndU - f.StartU + c += f.EndC - f.StartC + } + cells = append(cells, heatColor(ratioNorm(ratio(u, c))).fg()+"█"+ansiReset) + } + + return cells +} + +func frameRatio(f frameInfo) float64 { return ratio(f.EndU-f.StartU, f.EndC-f.StartC) } + +// ratioNorm maps a compression ratio onto [0,1] for the heat gradient: +// 1x→0 (red, barely compressed), 10x→1 (green, well compressed). +func ratioNorm(r float64) float64 { + return (r - 1) / 9 +} diff --git a/packages/orchestrator/cmd/inspect-build/render_json.go b/packages/orchestrator/cmd/inspect-build/render_json.go new file mode 100644 index 0000000000..b696228b26 --- /dev/null +++ b/packages/orchestrator/cmd/inspect-build/render_json.go @@ -0,0 +1,19 @@ +package main + +import ( + "encoding/json" + "fmt" + "io" +) + +// renderJSON writes the report — or, for --recursive, the dependency-ordered +// chain — as indented JSON: the machine/AI mode, with no summarization. +func renderJSON(w io.Writer, v any) error { + enc := json.NewEncoder(w) + enc.SetIndent("", " ") + if err := enc.Encode(v); err != nil { + return fmt.Errorf("encode json: %w", err) + } + + return nil +} diff --git a/packages/orchestrator/cmd/inspect-build/report.go b/packages/orchestrator/cmd/inspect-build/report.go new file mode 100644 index 0000000000..4b8fd34307 --- /dev/null +++ b/packages/orchestrator/cmd/inspect-build/report.go @@ -0,0 +1,806 @@ +package main + +import ( + "context" + "encoding/binary" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "slices" + "sort" + "strings" + + "github.com/google/uuid" + + "github.com/e2b-dev/infra/packages/orchestrator/cmd/internal/cmdutil" + "github.com/e2b-dev/infra/packages/shared/pkg/storage" + "github.com/e2b-dev/infra/packages/shared/pkg/storage/header" +) + +// report is the inspected build, gathered once and rendered per mode. Detail +// payloads (Mappings.List, Data.Frames, Image.Metadata) are populated by gather +// and dropped by filterReport unless their section is expanded. +type report struct { + Source string `json:"source"` + Artifact string `json:"artifact"` + Header headerInfo `json:"header"` + Image imageInfo `json:"image"` + Mappings mappingsSection `json:"mappings"` + Builds []buildInfo `json:"builds"` + // Data is an internal self-summary used by the human renderer; everything + // it carries is already in builds[self], so it is not serialized. + Data dataSection `json:"-"` + // Usage measures how much of this build a --recursive head draws on; set + // only on ancestor reports. + Usage *ancestorUsage `json:"usage,omitempty"` + // Fetchmap is the per-chunk fetch-fanout map. Rendered as the bottom row + // of the FRAMEMAP visualization. Not serialized — it's a derivable index + // (mappings + each build's frame table give the same info, and builds[] + // now carries those frame tables). + Fetchmap *fetchmap `json:"-"` + + h *header.Header // not serialized +} + +// fetchmap is the per-chunk read-segment density map. The virtual address +// space is divided into fixed-size chunks (typically 2 MiB, the frame and +// hugepage size). For each chunk, counts the distinct backing fetches a cold +// restore would issue to fill it: a maximal run of contiguous same-BuildId +// mappings whose BuildStorageOffsets continue the previous mapping's stream +// collapses into one segment; anything that breaks that run (a different +// BuildId, or a non-adjacent storage offset) starts a new one. Excludes +// uuid.Nil (sparse zero-fill, served as zeros without a fetch). Chunks are +// the visualization unit, distinct from device-blocks (Metadata.BlockSize) +// and from stored compression frames (Data.Frames). This is the +// fragmentation metric memfile dedup density work cares about — see #2862. +type fetchmap struct { + ChunkSize int64 `json:"chunk_size"` // bytes per chunk + ChunkCount int `json:"chunk_count"` // ceil(image_size / chunk_size) + TouchedChunks int `json:"touched_chunks"` // chunks with at least one non-Nil mapping + MaxSegments int `json:"max_segments"` // worst chunk's distinct fetch-segment count + AvgSegments float64 `json:"avg_segments"` // mean fetch segments over touched chunks + MaxLayers int `json:"max_layers"` // worst chunk's distinct ancestor build count + AvgLayers float64 `json:"avg_layers"` // mean ancestor build count over touched chunks + MaxChunkOff int64 `json:"max_chunk_off"` // virtual offset of the first MaxSegments chunk + Cells []int `json:"cells,omitempty"` // populated only when expanded; len == ChunkCount; cell = # fetch segments +} + +type headerInfo struct { + Version uint64 `json:"version"` + HeaderSize int64 `json:"header_size"` // on-disk header bytes + HeaderUncompressed int64 `json:"header_uncompressed,omitempty"` // V4+: header size if its LZ4 payload were inflated + HeaderRatio float64 `json:"header_ratio,omitempty"` // HeaderUncompressed / HeaderSize, when LZ4-compressed + StorageSize int64 `json:"storage_size"` // stored (possibly compressed) data-file bytes + Ratio float64 `json:"ratio,omitempty"` // data-file uncompressed / storage size, when compressed +} + +type imageInfo struct { + VirtualSize uint64 `json:"virtual_size"` + DiffSize int64 `json:"diff_size"` // this layer's own uncompressed bytes + BuildID uuid.UUID `json:"build_id"` + BaseBuildID uuid.UUID `json:"base_build_id"` + Ancestors int `json:"ancestors"` + FromImage string `json:"from_image,omitempty"` + Kernel string `json:"kernel,omitempty"` + Firecracker string `json:"firecracker,omitempty"` + User string `json:"user,omitempty"` + // Metadata is the full metadata.json — populated only when expanded. + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +type mappingsSection struct { + Count int `json:"count"` + ByBuild []buildExtent `json:"by_build"` + List []mapping `json:"list,omitempty"` // populated only when expanded +} + +type buildExtent struct { + BuildID uuid.UUID `json:"build_id"` + Role string `json:"role"` + Bytes uint64 `json:"bytes"` + Mappings int `json:"mappings"` +} + +type mapping struct { + Offset uint64 `json:"offset"` + Length uint64 `json:"length"` + BuildID uuid.UUID `json:"build_id"` + Role string `json:"role"` + StorageOffset uint64 `json:"storage_offset"` +} + +type buildInfo struct { + BuildID uuid.UUID `json:"build_id"` + Role string `json:"role"` + Compression string `json:"compression"` + UncompressedSize int64 `json:"uncompressed_size"` + CompressedSize int64 `json:"compressed_size,omitempty"` + Ratio float64 `json:"ratio,omitempty"` + FrameCount int `json:"frame_count,omitempty"` + // Frames is the build's frame table when known. Populated for V4+ + // headers with this build present in Builds. Only the self build's + // frames carry the Fetches field; ancestor entries are bare tables. + Frames []frameInfo `json:"frames,omitempty"` + Checksum string `json:"checksum"` +} + +type dataSection struct { + Compressed bool `json:"compressed"` + CompressionType string `json:"compression_type"` + UncompressedSize int64 `json:"uncompressed_size"` + CompressedSize int64 `json:"compressed_size,omitempty"` + Ratio float64 `json:"ratio,omitempty"` + FrameCount int `json:"frame_count"` + Frames []frameInfo `json:"frames,omitempty"` // populated only when expanded +} + +type frameInfo struct { + StartU int64 `json:"start_u"` + EndU int64 `json:"end_u"` + StartC int64 `json:"start_c"` + EndC int64 `json:"end_c"` + // Fetches is the cold-restore fetch count for the V-chunk aligned with + // this frame's U position (assumes identity-ish mapping at the chunk + // granularity — exact for non-deduped builds). One fetch is one frame + // from one build's storage; 1 here means "just this frame". Set only + // for self's frames; ancestor frames carry just the bare frame table. + Fetches *int `json:"fetches,omitempty"` +} + +// metadataSummary is the subset of metadata.json surfaced as scalar fields. +type metadataSummary struct { + FromImage string `json:"from_image"` + Template struct { + KernelVersion string `json:"kernel_version"` + FirecrackerVersion string `json:"firecracker_version"` + } `json:"template"` + Context struct { + User string `json:"user"` + } `json:"context"` +} + +// ancestorUsage measures how much of an ancestor build the recursive target +// draws on: the uncompressed bytes and frames its mappings actually reach. +type ancestorUsage struct { + UsedBytes int64 `json:"used_bytes"` // union of ancestor offsets the target maps + DiffBytes int64 `json:"diff_bytes"` // ancestor's full uncompressed data + UsedFraction float64 `json:"used_fraction"` // UsedBytes / DiffBytes + Mappings int `json:"mappings"` // target-build mappings into this ancestor + FramesTouched int `json:"frames_touched"` // ancestor frames the target must fetch + TotalFrames int `json:"total_frames"` // ancestor's full frame count +} + +const ( + roleCurrent = "current" + roleParent = "parent" + roleAncestor = "ancestor" + roleZero = "zero" +) + +// fetchmapChunkSize is the FRAMEMAP cell size: 2 MiB. It matches the default +// compressed frame size, the x86_64 huge-page size, and the production +// chunker's per-page-fault fetch unit for compressed reads. Distinct from +// device blocks (Metadata.BlockSize) and from stored compression frames +// (Data.Frames); a chunk is a virtual address-space region. +const fetchmapChunkSize = 2 * 1024 * 1024 + +// roleOf classifies a build ID relative to the inspected header. +func roleOf(id uuid.UUID, meta *header.Metadata) string { + switch id { + case uuid.Nil: + return roleZero + case meta.BuildId: + return roleCurrent + case meta.BaseBuildId: + return roleParent + default: + return roleAncestor + } +} + +// gather loads a build's header, metadata, and stored size into a full report. +func gather(ctx context.Context, storagePath, buildID, artifact string) (*report, error) { + headerData, source, err := cmdutil.ReadFile(ctx, storagePath, buildID, artifact+storage.HeaderSuffix) + if err != nil { + return nil, fmt.Errorf("read header: %w", err) + } + + h, err := header.DeserializeBytes(headerData) + if err != nil { + return nil, fmt.Errorf("deserialize header: %w", err) + } + + hdr := headerInfo{Version: h.Metadata.Version, HeaderSize: int64(len(headerData))} + if u, err := headerUncompressedSize(headerData, h.Metadata.Version); err == nil { + hdr.HeaderUncompressed = u + if u > hdr.HeaderSize { + hdr.HeaderRatio = ratio(u, hdr.HeaderSize) + } + } + + r := &report{ + Source: source, + Artifact: artifact, + h: h, + Header: hdr, + Image: imageInfo{ + VirtualSize: h.Metadata.Size, + BuildID: h.Metadata.BuildId, + BaseBuildID: h.Metadata.BaseBuildId, + }, + } + + currentFrames := h.GetBuildFrameData(h.Metadata.BuildId) + r.Header.StorageSize, err = storedSize(ctx, storagePath, buildID, artifact, currentFrames) + if err != nil { + // The header inspects fine without the data file — degrade rather + // than abort, so a build whose data was GC'd is still inspectable. + fmt.Fprintf(os.Stderr, "warning: %s\n", err) + r.Header.StorageSize = sizeUnknown + } + r.Data = gatherData(h, currentFrames, r.Header.StorageSize) + r.Image.DiffSize = r.Data.UncompressedSize + if r.Data.Compressed { + r.Header.Ratio = ratio(r.Data.UncompressedSize, r.Header.StorageSize) + } + r.Mappings, r.Image.Ancestors = gatherMappings(h) + r.Builds = gatherBuilds(h, r.Header.StorageSize) + r.Fetchmap = gatherFetchmap(h, fetchmapChunkSize) + // Self's frames live in two places — r.Data.Frames (the rendering path) + // and r.Builds[self].Frames (the JSON contract). Annotate both. + annotateFrameFetches(r.Data.Frames, r.Fetchmap, fetchmapChunkSize) + for i := range r.Builds { + if r.Builds[i].BuildID == h.Metadata.BuildId { + annotateFrameFetches(r.Builds[i].Frames, r.Fetchmap, fetchmapChunkSize) + } + } + + if meta, _, err := cmdutil.ReadFile(ctx, storagePath, buildID, storage.MetadataName); err == nil { + r.Image.Metadata = json.RawMessage(meta) + var m metadataSummary + if uErr := json.Unmarshal(meta, &m); uErr != nil { + fmt.Fprintf(os.Stderr, "warning: metadata.json is not valid JSON: %s\n", uErr) + } else { + r.Image.FromImage = m.FromImage + r.Image.Kernel = m.Template.KernelVersion + r.Image.Firecracker = m.Template.FirecrackerVersion + r.Image.User = m.Context.User + } + } + + return r, nil +} + +// gatherChain gathers the target build and, when recursive is set, every +// transitive ancestor. The result is dependency-ordered: the target build +// first, then its parent and ancestors, nearer ones before farther ones. +func gatherChain(ctx context.Context, storagePath, buildID, artifact string, recursive bool) ([]*report, error) { + head, err := gather(ctx, storagePath, buildID, artifact) + if err != nil { + return nil, err + } + + chain := []*report{head} + if !recursive { + return chain, nil + } + + // Breadth-first over the ancestor graph; the chain itself is the queue, so + // nearer ancestors are gathered (and listed) before farther ones. + seen := map[uuid.UUID]bool{head.Image.BuildID: true} + for i := 0; i < len(chain); i++ { + for _, id := range ancestorIDs(chain[i]) { + if seen[id] { + continue + } + seen[id] = true + + anc, err := gather(ctx, storagePath, id.String(), artifact) + if err != nil { + // A missing ancestor is skipped rather than failing the whole + // inspection — the rest of the chain still inspects fine. + fmt.Fprintf(os.Stderr, "warning: skipping ancestor %s: %s\n", id, err) + + continue + } + anc.Usage = ancestorUsageOf(head, anc) + chain = append(chain, anc) + } + } + + return chain, nil +} + +// ancestorIDs returns the distinct builds r descends from: every build its +// mappings reference, plus its BaseBuildId — excluding r itself and the zero +// build. +func ancestorIDs(r *report) []uuid.UUID { + var ids []uuid.UUID + seen := map[uuid.UUID]bool{r.Image.BuildID: true, uuid.Nil: true} + add := func(id uuid.UUID) { + if seen[id] { + return + } + seen[id] = true + ids = append(ids, id) + } + + for _, e := range r.Mappings.ByBuild { + add(e.BuildID) + } + add(r.Image.BaseBuildID) + + return ids +} + +// ancestorUsageOf measures how much of ancestor anc the head build draws on, +// from head's mappings into anc and anc's own frame table. +func ancestorUsageOf(head, anc *report) *ancestorUsage { + u := &ancestorUsage{ + DiffBytes: anc.Data.UncompressedSize, + TotalFrames: anc.Data.FrameCount, + } + + var refs []byteRange + for _, m := range head.Mappings.List { + if m.BuildID != anc.Image.BuildID { + continue + } + u.Mappings++ + refs = append(refs, byteRange{int64(m.StorageOffset), int64(m.StorageOffset + m.Length)}) + } + + used := mergeRanges(refs) + for _, r := range used { + u.UsedBytes += r.hi - r.lo + } + if u.DiffBytes > 0 { + u.UsedFraction = float64(u.UsedBytes) / float64(u.DiffBytes) + } + for _, f := range anc.Data.Frames { + if intersectsAny(used, byteRange{f.StartU, f.EndU}) { + u.FramesTouched++ + } + } + + return u +} + +// annotateFrameFetches sets each self frame's Fetches field to the fetchmap +// cell at the V-chunk aligned with the frame's StartU. This is exact in the +// common (non-deduped, identity-mapped) case where a frame's U-position equals +// its backed V-position. For heavily-deduped builds the alignment is best- +// effort, since self frames pack pages from various V offsets. +func annotateFrameFetches(frames []frameInfo, fm *fetchmap, chunkSize uint64) { + if fm == nil || len(fm.Cells) == 0 || chunkSize == 0 { + return + } + for i := range frames { + ci := int(uint64(frames[i].StartU) / chunkSize) + if ci < 0 || ci >= len(fm.Cells) { + continue + } + n := fm.Cells[ci] + frames[i].Fetches = &n + } +} + +// framesInRange returns the subset of frames whose U-range is touched by any +// of buildID's mappings whose V-range overlaps rng. Used to filter the per- +// frame view when --range is set, per build. O(mappings log frames + frames). +func framesInRange(frames []frameInfo, h *header.Header, buildID uuid.UUID, rng span) []frameInfo { + if !rng.set || len(frames) == 0 { + return frames + } + hit := make([]bool, len(frames)) + for _, m := range h.Mapping.All() { + if m.BuildId != buildID || m.Length == 0 { + continue + } + vLo := max(m.Offset, rng.start) + vHi := min(m.Offset+m.Length, rng.end) + if vLo >= vHi { + continue + } + uLo := int64(m.BuildStorageOffset + (vLo - m.Offset)) + uHi := int64(m.BuildStorageOffset + (vHi - m.Offset)) + fi := sort.Search(len(frames), func(i int) bool { + return frames[i].EndU > uLo + }) + for ; fi < len(frames) && frames[fi].StartU < uHi; fi++ { + hit[fi] = true + } + } + out := make([]frameInfo, 0, len(frames)) + for i, f := range frames { + if hit[i] { + out = append(out, f) + } + } + + return out +} + +func gatherData(h *header.Header, ft *storage.FrameTable, storedSize int64) dataSection { + d := dataSection{ + Compressed: ft.IsCompressed(), + CompressionType: ft.CompressionType().String(), + } + + if !ft.IsCompressed() { + // V4+ stores the uncompressed size per build; V3 has no Builds map, so + // the data file itself is the uncompressed data. + if bd, ok := h.Builds[h.Metadata.BuildId]; ok { + d.UncompressedSize = bd.Size + } else { + d.UncompressedSize = storedSize + } + + return d + } + + // Canonical sizes: Builds[self].Size is the recorded uncompressed image + // size, the on-disk storedSize is the compressed file's bytes. ft.*Size() + // would be smaller when the self frame table is sparse-trimmed (V4+ can + // drop frames while preserving original U offsets). + if bd, ok := h.Builds[h.Metadata.BuildId]; ok && bd.Size > 0 { + d.UncompressedSize = bd.Size + } else { + d.UncompressedSize = ft.UncompressedSize() + } + d.CompressedSize = storedSize + d.Ratio = ratio(d.UncompressedSize, d.CompressedSize) + d.FrameCount = ft.NumFrames() + for i := range d.FrameCount { + startU, endU, startC, endC := ft.FrameAt(i) + d.Frames = append(d.Frames, frameInfo{StartU: startU, EndU: endU, StartC: startC, EndC: endC}) + } + + return d +} + +func gatherMappings(h *header.Header) (mappingsSection, int) { + sec := mappingsSection{Count: h.Mapping.Len()} + + totals := map[uuid.UUID]uint64{} + counts := map[uuid.UUID]int{} + var order []uuid.UUID + ancestors := map[uuid.UUID]struct{}{} + for _, m := range h.Mapping.All() { + if _, seen := totals[m.BuildId]; !seen { + order = append(order, m.BuildId) + } + totals[m.BuildId] += m.Length + counts[m.BuildId]++ + sec.List = append(sec.List, mapping{ + Offset: m.Offset, + Length: m.Length, + BuildID: m.BuildId, + Role: roleOf(m.BuildId, h.Metadata), + StorageOffset: m.BuildStorageOffset, + }) + if m.BuildId != uuid.Nil && m.BuildId != h.Metadata.BuildId { + ancestors[m.BuildId] = struct{}{} + } + } + for _, id := range order { + sec.ByBuild = append(sec.ByBuild, buildExtent{ + BuildID: id, Role: roleOf(id, h.Metadata), Bytes: totals[id], Mappings: counts[id], + }) + } + rolePriority := map[string]int{roleCurrent: 0, roleParent: 1, roleAncestor: 2, roleZero: 3} + slices.SortStableFunc(sec.ByBuild, func(a, b buildExtent) int { + return rolePriority[a.Role] - rolePriority[b.Role] + }) + + return sec, len(ancestors) +} + +// gatherFetchmap computes the per-chunk cold-restore fetch count by exactly +// mirroring block.Chunker.locateChunk: for each mapping covering a chunk, +// the fetch unit is the *frame containing the mapping's storage offset* +// (compressed) or the MemoryChunkSize-aligned chunk (uncompressed). Each +// chunk's cell value is the count of distinct (build, frame_index) tuples +// needed; mappings into the same frame share a fetch. +// +// chunkSize is the unit of one cell — a 2 MiB hugepage by default. +func gatherFetchmap(h *header.Header, chunkSize uint64) *fetchmap { + if chunkSize == 0 || h.Metadata.Size == 0 { + return nil + } + chunkCount := int((h.Metadata.Size + chunkSize - 1) / chunkSize) + + // fetchKey identifies a unique cold-restore fetch. + // Compressed builds: build + frame index in that build's frame table. + // Uncompressed builds: build + MemoryChunkSize-aligned chunk index, + // encoded as a negative value to avoid collision with frame indices. + type fetchKey struct { + build uuid.UUID + idx int64 + } + fetches := make([]map[fetchKey]struct{}, chunkCount) + layers := make([]map[uuid.UUID]struct{}, chunkCount) + + addFetch := func(c int, k fetchKey) { + if fetches[c] == nil { + fetches[c] = map[fetchKey]struct{}{} + } + fetches[c][k] = struct{}{} + if layers[c] == nil { + layers[c] = map[uuid.UUID]struct{}{} + } + layers[c][k.build] = struct{}{} + } + + for _, m := range h.Mapping.All() { + if m.BuildId == uuid.Nil || m.Length == 0 { + continue // sparse zero-fill: no fetch + } + first := int(m.Offset / chunkSize) + last := int((m.Offset + m.Length - 1) / chunkSize) + if last >= chunkCount { + last = chunkCount - 1 + } + + ft := h.GetBuildFrameData(m.BuildId) + compressed := ft.IsCompressed() + + for c := first; c <= last; c++ { + cLo := uint64(c) * chunkSize + cHi := cLo + chunkSize + vLo := max(m.Offset, cLo) + vHi := min(m.Offset+m.Length, cHi) + uLo := m.BuildStorageOffset + (vLo - m.Offset) + uHi := m.BuildStorageOffset + (vHi - m.Offset) + + if compressed { + // Binary search for first frame whose EndU > uLo, then walk + // forward while StartU < uHi. + lo, hi := 0, ft.NumFrames() + for lo < hi { + mid := (lo + hi) / 2 + _, endU, _, _ := ft.FrameAt(mid) + if endU > int64(uLo) { + hi = mid + } else { + lo = mid + 1 + } + } + for fi := lo; fi < ft.NumFrames(); fi++ { + startU, _, _, _ := ft.FrameAt(fi) + if startU >= int64(uHi) { + break + } + addFetch(c, fetchKey{build: m.BuildId, idx: int64(fi)}) + } + } else { + // Uncompressed: production fetches MemoryChunkSize-aligned chunks. + const mc = uint64(storage.MemoryChunkSize) + chFirst := uLo / mc + chLast := (uHi - 1) / mc + for ch := chFirst; ch <= chLast; ch++ { + // Negate so uncompressed chunk indices don't collide with frame indices. + addFetch(c, fetchKey{build: m.BuildId, idx: -int64(ch) - 1}) + } + } + } + } + + cells := make([]int, chunkCount) + var ( + segSum, layerSum int + touched int + maxSegments int + maxLayers int + maxOff int64 + ) + for c := range chunkCount { + n := 0 + if fetches[c] != nil { + n = len(fetches[c]) + } + cells[c] = n + if n == 0 { + continue + } + touched++ + segSum += n + ls := len(layers[c]) + layerSum += ls + if n > maxSegments { + maxSegments = n + maxOff = int64(c) * int64(chunkSize) + } + if ls > maxLayers { + maxLayers = ls + } + } + fm := &fetchmap{ + ChunkSize: int64(chunkSize), + ChunkCount: chunkCount, + TouchedChunks: touched, + MaxSegments: maxSegments, + MaxLayers: maxLayers, + MaxChunkOff: maxOff, + Cells: cells, + } + if touched > 0 { + fm.AvgSegments = float64(segSum) / float64(touched) + fm.AvgLayers = float64(layerSum) / float64(touched) + } + + return fm +} + +func gatherBuilds(h *header.Header, selfStoredSize int64) []buildInfo { + builds := make([]buildInfo, 0, len(h.Builds)) + for id, bd := range h.Builds { + b := buildInfo{ + BuildID: id, + Role: roleOf(id, h.Metadata), + Compression: bd.FrameData.CompressionType().String(), + UncompressedSize: bd.Size, + Checksum: checksumString(bd.Checksum), + } + if bd.FrameData.IsCompressed() { + // Canonical uncompressed size is bd.Size (set above). The header + // has no recorded compressed size, so only self's CompressedSize + // is known here (from the on-disk storedSize); ancestor entries + // stay zero and the renderer omits the line. + if id == h.Metadata.BuildId && selfStoredSize > 0 { + b.CompressedSize = selfStoredSize + b.Ratio = ratio(b.UncompressedSize, b.CompressedSize) + } + b.FrameCount = bd.FrameData.NumFrames() + b.Frames = make([]frameInfo, b.FrameCount) + for i := range b.Frames { + startU, endU, startC, endC := bd.FrameData.FrameAt(i) + b.Frames[i] = frameInfo{StartU: startU, EndU: endU, StartC: startC, EndC: endC} + } + } + builds = append(builds, b) + } + + slices.SortFunc(builds, func(a, b buildInfo) int { + return strings.Compare(a.BuildID.String(), b.BuildID.String()) + }) + + return builds +} + +// filterReport returns a copy with detail payloads dropped for sections that +// are not expanded, and the offset:size filters applied to those that are — +// the output phase (between gather and render) for the JSON renderer. +func filterReport(r *report, vw view) *report { + out := *r + + if vw.expanded(sectionMappings) { + out.Mappings.List = filteredMappings(r.Mappings.List, vw.rng) + } else { + out.Mappings.List = nil + } + + // Each build's frame table is gated on -expand=frames; with -range, each + // build's frames are filtered by V→U projection through THAT build's + // mappings (so an ancestor's frames are filtered by ancestor mappings). + if len(r.Builds) > 0 { + out.Builds = make([]buildInfo, len(r.Builds)) + copy(out.Builds, r.Builds) + for i := range out.Builds { + if !vw.expanded(sectionFrames) { + out.Builds[i].Frames = nil + } else { + out.Builds[i].Frames = framesInRange(out.Builds[i].Frames, r.h, out.Builds[i].BuildID, vw.rng) + } + } + } + + if !vw.expanded(sectionMetadata) { + out.Image.Metadata = nil + } + + return &out +} + +func filteredMappings(list []mapping, s span) []mapping { + if !s.set { + return list + } + var out []mapping + for _, m := range list { + if s.overlaps(m.Offset, m.Offset+m.Length) { + out = append(out, m) + } + } + + return out +} + +func filteredFrames(list []frameInfo, s span) []frameInfo { + if !s.set { + return list + } + var out []frameInfo + for _, f := range list { + if s.overlaps(uint64(f.StartU), uint64(f.EndU)) { + out = append(out, f) + } + } + + return out +} + +func storedSize(ctx context.Context, storagePath, buildID, artifact string, currentFrames *storage.FrameTable) (int64, error) { + dataFile := artifact + if currentFrames.IsCompressed() { + dataFile += currentFrames.CompressionType().Suffix() + } + + reader, size, _, err := cmdutil.OpenDataFile(ctx, storagePath, buildID, dataFile) + if err != nil { + return 0, fmt.Errorf("open data file %s: %w", dataFile, err) + } + reader.Close() + + return size, nil +} + +// sizeUnknown marks a size that couldn't be determined (e.g. the data file is +// missing); renderers show it as "unknown". +const sizeUnknown = -1 + +func ratio(uncompressed, compressed int64) float64 { + if compressed <= 0 { + return 0 + } + + return float64(uncompressed) / float64(compressed) +} + +// headerUncompressedSize returns the size a V4+ header would have if its LZ4 +// payload were expanded. Returns len(data) for V3. Reads the on-disk size +// prefix without running a full deserialize. +// +// V4+ layout (see header/serialization_v4.go serializeV4 doc): +// +// [Metadata] [uint8 flags] [uint32 uncompressedPayloadSize] [LZ4(payload)] +func headerUncompressedSize(data []byte, version uint64) (int64, error) { + if version < header.MetadataVersionV4 { + return int64(len(data)), nil + } + const ( + flagsLen = 1 + sizeLen = 4 + sizeStart = flagsLen // size prefix starts immediately after the flags byte + ) + metaSize := binary.Size(header.Metadata{}) + if len(data) < metaSize+flagsLen+sizeLen { + return 0, errors.New("v4+ header truncated before size prefix") + } + payload := int64(binary.LittleEndian.Uint32(data[metaSize+sizeStart:])) + + return int64(metaSize) + flagsLen + sizeLen + payload, nil +} + +// checksumString renders a SHA-256 digest, or "" when unknown (zero value). +func checksumString(cs [32]byte) string { + if cs == ([32]byte{}) { + return "" + } + + return "sha256:" + hex.EncodeToString(cs[:]) +} + +// buildInfoFor returns the header's own record for build id, when it references +// it — the trimmed view of how much of that build the header draws on. +func (r *report) buildInfoFor(id uuid.UUID) (buildInfo, bool) { + for _, b := range r.Builds { + if b.BuildID == id { + return b, true + } + } + + return buildInfo{}, false +} diff --git a/packages/orchestrator/cmd/inspect-build/report_test.go b/packages/orchestrator/cmd/inspect-build/report_test.go new file mode 100644 index 0000000000..40ddb56be7 --- /dev/null +++ b/packages/orchestrator/cmd/inspect-build/report_test.go @@ -0,0 +1,559 @@ +package main + +import ( + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/shared/pkg/storage" + "github.com/e2b-dev/infra/packages/shared/pkg/storage/header" +) + +const miB = 1 << 20 + +// testMapping builds a compact header.Mapping for tests. chunkSize must divide +// every Offset/Length/BuildStorageOffset in maps. +func testMapping(t *testing.T, chunkSize uint64, maps []header.BuildMap) header.Mapping { + t.Helper() + m, err := header.NewMapping(chunkSize, maps) + require.NoError(t, err) + + return m +} + +func TestRoleOf(t *testing.T) { + t.Parallel() + + cur, par, anc := uuid.New(), uuid.New(), uuid.New() + meta := &header.Metadata{BuildId: cur, BaseBuildId: par} + + require.Equal(t, roleCurrent, roleOf(cur, meta)) + require.Equal(t, roleParent, roleOf(par, meta)) + require.Equal(t, roleAncestor, roleOf(anc, meta)) + require.Equal(t, roleZero, roleOf(uuid.Nil, meta)) +} + +func TestRatio(t *testing.T) { + t.Parallel() + + require.Zero(t, ratio(100, 0)) // divide-by-zero guard + require.InDelta(t, 4.0, ratio(400, 100), 1e-9) +} + +func TestChecksumString(t *testing.T) { + t.Parallel() + + require.Empty(t, checksumString([32]byte{})) + + var cs [32]byte + cs[0] = 0xAB + require.True(t, strings.HasPrefix(checksumString(cs), "sha256:ab")) +} + +func TestFilteredMappings(t *testing.T) { + t.Parallel() + + list := []mapping{ + {Offset: 0, Length: 0x100}, + {Offset: 0x100, Length: 0x100}, + {Offset: 0x200, Length: 0x100}, + } + + require.Len(t, filteredMappings(list, span{}), 3) // no filter → all + + got := filteredMappings(list, span{set: true, start: 0x80, end: 0x180}) + require.Len(t, got, 2) + require.Equal(t, uint64(0), got[0].Offset) + require.Equal(t, uint64(0x100), got[1].Offset) +} + +func TestFilteredFrames(t *testing.T) { + t.Parallel() + + list := []frameInfo{ + {StartU: 0, EndU: 0x100}, + {StartU: 0x100, EndU: 0x200}, + } + + require.Len(t, filteredFrames(list, span{}), 2) + + got := filteredFrames(list, span{set: true, start: 0x150, end: 0x250}) + require.Len(t, got, 1) + require.Equal(t, int64(0x100), got[0].StartU) +} + +func TestGatherMappings(t *testing.T) { + t.Parallel() + + cur, anc := uuid.New(), uuid.New() + h := &header.Header{ + Metadata: &header.Metadata{BuildId: cur, BaseBuildId: cur}, + Mapping: testMapping(t, 50, []header.BuildMap{ + {Offset: 0, Length: 100, BuildId: anc}, + {Offset: 100, Length: 100, BuildId: cur}, + {Offset: 200, Length: 50, BuildId: anc}, + {Offset: 250, Length: 50, BuildId: uuid.Nil}, + }), + } + + sec, ancestors := gatherMappings(h) + + require.Equal(t, 4, sec.Count) + require.Equal(t, 1, ancestors) // distinct non-current non-zero builds + require.Len(t, sec.List, 4) + + // ByBuild is one entry per build, sorted current → ancestor → zero. + require.Len(t, sec.ByBuild, 3) + require.Equal(t, roleCurrent, sec.ByBuild[0].Role) + require.Equal(t, cur, sec.ByBuild[0].BuildID) + require.Equal(t, uint64(100), sec.ByBuild[0].Bytes) + require.Equal(t, 1, sec.ByBuild[0].Mappings) + + require.Equal(t, roleAncestor, sec.ByBuild[1].Role) + require.Equal(t, uint64(150), sec.ByBuild[1].Bytes) // 100 + 50 + require.Equal(t, 2, sec.ByBuild[1].Mappings) + + require.Equal(t, roleZero, sec.ByBuild[2].Role) +} + +func TestAncestorIDs(t *testing.T) { + t.Parallel() + + self, par, anc := uuid.New(), uuid.New(), uuid.New() + r := &report{ + Image: imageInfo{BuildID: self, BaseBuildID: par}, + Mappings: mappingsSection{ByBuild: []buildExtent{ + {BuildID: self, Role: roleCurrent}, + {BuildID: par, Role: roleParent}, + {BuildID: anc, Role: roleAncestor}, + {BuildID: uuid.Nil, Role: roleZero}, + }}, + } + require.Equal(t, []uuid.UUID{par, anc}, ancestorIDs(r)) // self and zero excluded + + // A BaseBuildID that no mapping references is still included. + r2 := &report{ + Image: imageInfo{BuildID: self, BaseBuildID: par}, + Mappings: mappingsSection{ByBuild: []buildExtent{{BuildID: self, Role: roleCurrent}}}, + } + require.Equal(t, []uuid.UUID{par}, ancestorIDs(r2)) +} + +func TestBuildInfoFor(t *testing.T) { + t.Parallel() + + a, b := uuid.New(), uuid.New() + r := &report{Builds: []buildInfo{{BuildID: a, Role: roleCurrent}}} + + got, ok := r.buildInfoFor(a) + require.True(t, ok) + require.Equal(t, roleCurrent, got.Role) + + _, ok = r.buildInfoFor(b) + require.False(t, ok) +} + +func TestJSONValue(t *testing.T) { + t.Parallel() + + chain := []*report{{Artifact: "rootfs"}, {Artifact: "memfile"}} + + single, ok := jsonValue(chain, view{}, false).(*report) + require.True(t, ok) + require.Equal(t, "rootfs", single.Artifact) + + all, ok := jsonValue(chain, view{}, true).([]*report) + require.True(t, ok) + require.Len(t, all, 2) +} + +func TestAncestorUsageOf(t *testing.T) { + t.Parallel() + + anc := uuid.New() + ancestor := &report{ + Image: imageInfo{BuildID: anc}, + Data: dataSection{ + UncompressedSize: 4 * miB, + FrameCount: 2, + Frames: []frameInfo{ + {StartU: 0, EndU: 2 * miB}, + {StartU: 2 * miB, EndU: 4 * miB}, + }, + }, + } + head := &report{Mappings: mappingsSection{List: []mapping{ + {Offset: 0, Length: 1 * miB, BuildID: anc, StorageOffset: 0}, + {Offset: 1 * miB, Length: 1 * miB, BuildID: uuid.New()}, // a different build + {Offset: 2 * miB, Length: 0x1000, BuildID: anc, StorageOffset: 3 * miB}, + }}} + + u := ancestorUsageOf(head, ancestor) + require.Equal(t, 2, u.Mappings) + require.Equal(t, int64(1*miB+0x1000), u.UsedBytes) // union of the two refs into anc + require.Equal(t, int64(4*miB), u.DiffBytes) + require.InDelta(t, float64(1*miB+0x1000)/float64(4*miB), u.UsedFraction, 1e-9) + require.Equal(t, 2, u.TotalFrames) + require.Equal(t, 2, u.FramesTouched) // ref [0,1MiB) hits frame 0; [3MiB,..) hits frame 1 +} + +// TestGatherFetchmap covers the per-chunk cold-restore fetch counter. The +// counter must mirror block.Chunker.locateChunk: a fetch is one frame for +// compressed builds, one MemoryChunkSize-aligned chunk for uncompressed ones, +// and distinct mappings landing in the SAME (build, frame) share a fetch. +// +// Chunk size is 2 MiB throughout (the hugepage / frame size). Mappings inside +// a chunk are at 4 KiB granularity to mirror page-dedup density. +func TestGatherFetchmap(t *testing.T) { + t.Parallel() + + const ( + bs = 2 * miB + page = 4096 + ) + buildA, buildB := uuid.New(), uuid.New() + + // frames(perFrameU): builds a FrameTable with consecutive frames of the + // given U-sizes, dummy 1 KiB compressed size each. + frames := func(perFrameU ...int32) *storage.FrameTable { + sizes := make([]storage.FrameSize, len(perFrameU)) + for i, u := range perFrameU { + sizes[i] = storage.FrameSize{U: u, C: 1024} + } + + return storage.NewFullFrameTable(storage.CompressionZstd, sizes).Table() + } + + // fixture: header with the given mappings, and a Builds map that backs + // each referenced build with the frame table from builds[id]. + fixture := func(t *testing.T, size uint64, maps []header.BuildMap, builds map[uuid.UUID]*storage.FrameTable) *header.Header { + t.Helper() + bm := make(map[uuid.UUID]header.BuildData, len(builds)) + for id, ft := range builds { + bm[id] = header.BuildData{FrameData: ft} + } + + return &header.Header{ + Metadata: &header.Metadata{Version: header.MetadataVersionV4, BlockSize: bs, Size: size, BuildId: buildA}, + Mapping: testMapping(t, page, maps), + Builds: bm, + } + } + + t.Run("nil for empty header", func(t *testing.T) { + t.Parallel() + require.Nil(t, gatherFetchmap(&header.Header{Metadata: &header.Metadata{}}, 0)) + require.Nil(t, gatherFetchmap(&header.Header{Metadata: &header.Metadata{Size: 0}}, bs)) + }) + + t.Run("single mapping into one frame: 1 fetch", func(t *testing.T) { + t.Parallel() + // Self has one 2 MiB frame at U[0,2MiB); mapping V[0,2MiB) → U[0,2MiB). + h := fixture(t, bs, []header.BuildMap{ + {Offset: 0, Length: bs, BuildId: buildA, BuildStorageOffset: 0}, + }, map[uuid.UUID]*storage.FrameTable{buildA: frames(bs)}) + fm := gatherFetchmap(h, bs) + require.Equal(t, 1, fm.ChunkCount) + require.Equal(t, []int{1}, fm.Cells) + require.Equal(t, 1, fm.MaxLayers) + }) + + t.Run("many small mappings, all in same frame: still 1 fetch", func(t *testing.T) { + t.Parallel() + // 4 disjoint 4 KiB pages whose U-offsets are jumpy but all in frame 0. + // In the OLD storage-run algorithm this was 4 segments — the bug. + // Production fetches frame 0 once and serves all 4 pages from it. + h := fixture(t, bs, []header.BuildMap{ + {Offset: 0 * page, Length: page, BuildId: buildA, BuildStorageOffset: 0 * page}, + {Offset: 1 * page, Length: page, BuildId: buildA, BuildStorageOffset: 100 * page}, + {Offset: 2 * page, Length: page, BuildId: buildA, BuildStorageOffset: 200 * page}, + {Offset: 3 * page, Length: page, BuildId: buildA, BuildStorageOffset: 300 * page}, + {Offset: 4 * page, Length: bs - 4*page, BuildId: uuid.Nil}, + }, map[uuid.UUID]*storage.FrameTable{buildA: frames(bs)}) + fm := gatherFetchmap(h, bs) + require.Equal(t, []int{1}, fm.Cells, "all four mappings land in frame 0 → 1 fetch") + }) + + t.Run("mappings into distinct frames of same build: one fetch per frame", func(t *testing.T) { + t.Parallel() + // Self has 4 × 0.5 MiB frames covering U[0, 2 MiB); two mappings hit + // frames 0 and 2 — two distinct fetches. + h := fixture(t, bs, []header.BuildMap{ + {Offset: 0, Length: page, BuildId: buildA, BuildStorageOffset: 0}, + {Offset: page, Length: page, BuildId: buildA, BuildStorageOffset: bs / 2}, + {Offset: 2 * page, Length: bs - 2*page, BuildId: uuid.Nil}, + }, map[uuid.UUID]*storage.FrameTable{buildA: frames(bs/4, bs/4, bs/4, bs/4)}) + fm := gatherFetchmap(h, bs) + require.Equal(t, []int{2}, fm.Cells) + }) + + t.Run("mappings to distinct builds, same frame index: still distinct fetches", func(t *testing.T) { + t.Parallel() + // Each build has its own frame 0 covering [0, 2 MiB). Both fetches + // are distinct since they target different storage objects. + h := fixture(t, bs, []header.BuildMap{ + {Offset: 0, Length: page, BuildId: buildA, BuildStorageOffset: 0}, + {Offset: page, Length: page, BuildId: buildB, BuildStorageOffset: 0}, + {Offset: 2 * page, Length: bs - 2*page, BuildId: uuid.Nil}, + }, map[uuid.UUID]*storage.FrameTable{ + buildA: frames(bs), + buildB: frames(bs), + }) + fm := gatherFetchmap(h, bs) + require.Equal(t, []int{2}, fm.Cells) + require.Equal(t, 2, fm.MaxLayers) + }) + + t.Run("alternating A,B,A,B sharing one frame each: 2 fetches", func(t *testing.T) { + t.Parallel() + // A's frame 0 covers all of A's data; B's frame 0 covers all of B's + // data. The orchestrator fetches each frame once for the chunk + // regardless of how interleaved the mappings are. + // In the OLD algorithm this was 4 segments — the bug. + h := fixture(t, bs, []header.BuildMap{ + {Offset: 0 * page, Length: page, BuildId: buildA, BuildStorageOffset: 0 * page}, + {Offset: 1 * page, Length: page, BuildId: buildB, BuildStorageOffset: 0 * page}, + {Offset: 2 * page, Length: page, BuildId: buildA, BuildStorageOffset: 1 * page}, + {Offset: 3 * page, Length: page, BuildId: buildB, BuildStorageOffset: 1 * page}, + {Offset: 4 * page, Length: bs - 4*page, BuildId: uuid.Nil}, + }, map[uuid.UUID]*storage.FrameTable{ + buildA: frames(bs), + buildB: frames(bs), + }) + fm := gatherFetchmap(h, bs) + require.Equal(t, []int{2}, fm.Cells, "A's frame 0 + B's frame 0 = 2 fetches") + require.Equal(t, 2, fm.MaxLayers) + }) + + t.Run("Nil mappings never contribute fetches", func(t *testing.T) { + t.Parallel() + h := fixture(t, bs, []header.BuildMap{ + {Offset: 0, Length: page, BuildId: buildA, BuildStorageOffset: 0}, + {Offset: page, Length: page, BuildId: uuid.Nil}, + {Offset: 2 * page, Length: page, BuildId: buildA, BuildStorageOffset: 100 * page}, + {Offset: 3 * page, Length: bs - 3*page, BuildId: uuid.Nil}, + }, map[uuid.UUID]*storage.FrameTable{buildA: frames(bs)}) + fm := gatherFetchmap(h, bs) + require.Equal(t, []int{1}, fm.Cells, "both A mappings hit frame 0 → 1 fetch") + }) + + t.Run("mapping spanning two chunks AND two frames: 1 fetch per chunk", func(t *testing.T) { + t.Parallel() + // Two frames of 2 MiB each; a single 4 MiB mapping covers both. Chunk + // 0 needs frame 0, chunk 1 needs frame 1 — independent fetches. + h := fixture(t, 2*bs, []header.BuildMap{ + {Offset: 0, Length: 2 * bs, BuildId: buildA, BuildStorageOffset: 0}, + }, map[uuid.UUID]*storage.FrameTable{buildA: frames(bs, bs)}) + fm := gatherFetchmap(h, bs) + require.Equal(t, []int{1, 1}, fm.Cells) + }) + + t.Run("untouched chunks excluded from averages", func(t *testing.T) { + t.Parallel() + h := fixture(t, 4*bs, []header.BuildMap{ + {Offset: 0, Length: page, BuildId: buildA, BuildStorageOffset: 0}, + {Offset: page, Length: 3*bs - page, BuildId: uuid.Nil}, + {Offset: 3 * bs, Length: page, BuildId: buildA, BuildStorageOffset: page}, + {Offset: 3*bs + page, Length: bs - page, BuildId: uuid.Nil}, + }, map[uuid.UUID]*storage.FrameTable{buildA: frames(bs, bs, bs, bs)}) + fm := gatherFetchmap(h, bs) + require.Equal(t, 4, fm.ChunkCount) + require.Equal(t, 2, fm.TouchedChunks) + require.Equal(t, []int{1, 0, 0, 1}, fm.Cells) + require.InDelta(t, 1.0, fm.AvgSegments, 1e-9, "averaged over touched chunks only") + }) + + t.Run("MaxChunkOff reports the virtual offset of the worst chunk", func(t *testing.T) { + t.Parallel() + // Chunk 0: 1 fetch (one mapping into frame 0). + // Chunk 1: 3 fetches (mappings spanning frames 2, 4, 6 of self). + // Chunk 2: 2 fetches (mappings spanning frames 8 and 10). + // frames(bs/4 × 12) gives 12 frames of 0.5 MiB each across U[0, 6 MiB). + fr := make([]int32, 12) + for i := range fr { + fr[i] = bs / 4 + } + // Each frame is bs/4 wide; frame i starts at U = i*(bs/4). + const fbs = bs / 4 + h := fixture(t, 3*bs, []header.BuildMap{ + {Offset: 0, Length: page, BuildId: buildA, BuildStorageOffset: 0}, + {Offset: page, Length: bs - page, BuildId: uuid.Nil}, + {Offset: bs + 0*page, Length: page, BuildId: buildA, BuildStorageOffset: 2 * fbs}, // frame 2 + {Offset: bs + 1*page, Length: page, BuildId: buildA, BuildStorageOffset: 4 * fbs}, // frame 4 + {Offset: bs + 2*page, Length: page, BuildId: buildA, BuildStorageOffset: 6 * fbs}, // frame 6 + {Offset: bs + 3*page, Length: bs - 3*page, BuildId: uuid.Nil}, + {Offset: 2*bs + 0*page, Length: page, BuildId: buildA, BuildStorageOffset: 8 * fbs}, // frame 8 + {Offset: 2*bs + 1*page, Length: page, BuildId: buildA, BuildStorageOffset: 10 * fbs}, // frame 10 + {Offset: 2*bs + 2*page, Length: bs - 2*page, BuildId: uuid.Nil}, + }, map[uuid.UUID]*storage.FrameTable{buildA: frames(fr...)}) + fm := gatherFetchmap(h, bs) + require.Equal(t, []int{1, 3, 2}, fm.Cells) + require.Equal(t, 3, fm.MaxSegments) + require.Equal(t, int64(bs), fm.MaxChunkOff) + }) + + t.Run("uncompressed build uses MemoryChunkSize-aligned chunks", func(t *testing.T) { + t.Parallel() + // Two mappings, both into an uncompressed build, separated by less + // than MemoryChunkSize (4 MiB) in U-space → same prod-fetch chunk. + h := fixture(t, bs, []header.BuildMap{ + {Offset: 0, Length: page, BuildId: buildA, BuildStorageOffset: 0}, + {Offset: page, Length: page, BuildId: buildA, BuildStorageOffset: storage.MemoryChunkSize / 2}, + {Offset: 2 * page, Length: bs - 2*page, BuildId: uuid.Nil}, + }, nil) // no Builds → no frame table → uncompressed path + fm := gatherFetchmap(h, bs) + require.Equal(t, []int{1}, fm.Cells, "both pages in same 4 MiB MemoryChunkSize chunk") + }) + + t.Run("uncompressed build spanning two MemoryChunkSize chunks: 2 fetches", func(t *testing.T) { + t.Parallel() + h := fixture(t, bs, []header.BuildMap{ + {Offset: 0, Length: page, BuildId: buildA, BuildStorageOffset: 0}, + {Offset: page, Length: page, BuildId: buildA, BuildStorageOffset: storage.MemoryChunkSize + page}, + {Offset: 2 * page, Length: bs - 2*page, BuildId: uuid.Nil}, + }, nil) + fm := gatherFetchmap(h, bs) + require.Equal(t, []int{2}, fm.Cells) + }) +} + +// TestCompressionPerChunk covers projection of self-frame compression ratios +// from U-space (storage offsets in self's data file) to V-space (virtual +// blocks) through SELF mappings. The U/V split — and the rule that only self +// frames count — was the bug that motivated this function. +func TestCompressionPerChunk(t *testing.T) { + t.Parallel() + + const ( + bs = 2 * miB + page = 4096 + ) + selfID, ancestorID := uuid.New(), uuid.New() + + hdr := func(t *testing.T, size uint64, maps []header.BuildMap) *header.Header { + t.Helper() + + return &header.Header{ + Metadata: &header.Metadata{Version: header.MetadataVersionV4, BlockSize: bs, Size: size, BuildId: selfID}, + Mapping: testMapping(t, page, maps), + } + } + // One 2 MiB frame at U=[0, 2MiB), ratio 4x; one at U=[2MiB, 4MiB), ratio 2x. + frames := []frameInfo{ + {StartU: 0, EndU: bs, StartC: 0, EndC: bs / 4}, // 4.0x + {StartU: bs, EndU: 2 * bs, StartC: bs / 4, EndC: bs - 1}, // ~2.66x (compressed denser at end) + } + + t.Run("nil header or no frames returns all -1", func(t *testing.T) { + t.Parallel() + require.Equal(t, []float64{-1, -1}, compressionPerChunk(nil, frames, 2, bs)) + h := hdr(t, 2*bs, []header.BuildMap{{Offset: 0, Length: 2 * bs, BuildId: selfID, BuildStorageOffset: 0}}) + require.Equal(t, []float64{-1, -1}, compressionPerChunk(h, nil, 2, bs)) + }) + + t.Run("identity mapping projects frame to its same V-block", func(t *testing.T) { + t.Parallel() + // One full-size mapping: V[0,4MiB) == U[0,4MiB). Each block gets its frame's ratio. + h := hdr(t, 2*bs, []header.BuildMap{ + {Offset: 0, Length: 2 * bs, BuildId: selfID, BuildStorageOffset: 0}, + }) + got := compressionPerChunk(h, frames, 2, bs) + require.InDelta(t, 4.0, got[0], 1e-9, "block 0 = frame 0 ratio") + require.InDelta(t, ratio(frames[1].EndU-frames[1].StartU, frames[1].EndC-frames[1].StartC), got[1], 1e-9) + }) + + t.Run("non-identity mapping projects U→V correctly", func(t *testing.T) { + t.Parallel() + // V-block 0 has nothing self. V-block 1 has self pages whose storage + // offsets sit in frame 0 (U=[0, 2MiB)). So block 1 should get frame 0's + // ratio (4.0x), not frame 1's. + h := hdr(t, 2*bs, []header.BuildMap{ + {Offset: 0, Length: bs, BuildId: uuid.Nil}, + {Offset: bs, Length: bs, BuildId: selfID, BuildStorageOffset: 0}, + }) + got := compressionPerChunk(h, frames, 2, bs) + require.Less(t, got[0], 0.0, "V-block 0 is sparse") + require.InDelta(t, 4.0, got[1], 1e-9, "V-block 1 was mapped from U=[0,2MiB) → frame 0") + }) + + t.Run("ancestor-only mappings yield no compression cell", func(t *testing.T) { + t.Parallel() + // V-block 0 covered by ancestor mapping; block 1 sparse. Compression row + // should be all -1 — we only show SELF frames. + h := hdr(t, 2*bs, []header.BuildMap{ + {Offset: 0, Length: bs, BuildId: ancestorID, BuildStorageOffset: 0}, + {Offset: bs, Length: bs, BuildId: uuid.Nil}, + }) + got := compressionPerChunk(h, frames, 2, bs) + require.Less(t, got[0], 0.0) + require.Less(t, got[1], 0.0) + }) + + t.Run("multiple self frames in one V-block: worst (lowest) ratio wins", func(t *testing.T) { + t.Parallel() + // One 2 MiB V-block backed by two SELF half-mappings, each landing in a + // different frame (one 4.0x, one ~2.66x). Worst wins. + h := hdr(t, bs, []header.BuildMap{ + {Offset: 0, Length: bs / 2, BuildId: selfID, BuildStorageOffset: 0}, + {Offset: bs / 2, Length: bs / 2, BuildId: selfID, BuildStorageOffset: bs}, + }) + got := compressionPerChunk(h, frames, 1, bs) + want := ratio(frames[1].EndU-frames[1].StartU, frames[1].EndC-frames[1].StartC) + require.InDelta(t, want, got[0], 1e-9, "the worse of the two frames") + }) + + t.Run("self mapping whose U-range falls in a gap yields no cell", func(t *testing.T) { + t.Parallel() + // Frame table covers only U=[0,2MiB); a self mapping with storage at + // U=8MiB has no covering frame — block stays sparse. + h := hdr(t, bs, []header.BuildMap{ + {Offset: 0, Length: bs, BuildId: selfID, BuildStorageOffset: 8 * miB}, + }) + got := compressionPerChunk(h, frames[:1], 1, bs) + require.Less(t, got[0], 0.0) + }) +} + +// TestFramesInRange covers the --range filter for the per-frame view: a frame +// is included iff some self mapping with a V-overlap with the range also +// overlaps the frame's U-range. +func TestFramesInRange(t *testing.T) { + t.Parallel() + + const bs = 2 * miB + selfID := uuid.New() + + frames := []frameInfo{ + {StartU: 0, EndU: bs}, + {StartU: bs, EndU: 2 * bs}, + {StartU: 2 * bs, EndU: 3 * bs}, + } + h := &header.Header{ + Metadata: &header.Metadata{Version: header.MetadataVersionV4, BlockSize: bs, Size: 3 * bs, BuildId: selfID}, + // Identity mapping covers V[0,6 MiB) → U[0, 6 MiB). + Mapping: testMapping(t, 4096, []header.BuildMap{ + {Offset: 0, Length: 3 * bs, BuildId: selfID, BuildStorageOffset: 0}, + }), + } + + t.Run("range unset returns all frames", func(t *testing.T) { + t.Parallel() + require.Equal(t, frames, framesInRange(frames, h, selfID, span{})) + }) + + t.Run("range covers middle frame only", func(t *testing.T) { + t.Parallel() + got := framesInRange(frames, h, selfID, span{set: true, start: bs + 0x100, end: bs + 0x200}) + require.Len(t, got, 1) + require.Equal(t, int64(bs), got[0].StartU) + }) + + t.Run("range straddles two frames", func(t *testing.T) { + t.Parallel() + got := framesInRange(frames, h, selfID, span{set: true, start: bs - 0x1000, end: bs + 0x1000}) + require.Len(t, got, 2) + require.Equal(t, []int64{0, bs}, []int64{got[0].StartU, got[1].StartU}) + }) +} diff --git a/packages/orchestrator/cmd/inspect-build/validate.go b/packages/orchestrator/cmd/inspect-build/validate.go new file mode 100644 index 0000000000..76b6f73c02 --- /dev/null +++ b/packages/orchestrator/cmd/inspect-build/validate.go @@ -0,0 +1,375 @@ +package main + +import ( + "cmp" + "context" + "crypto/sha256" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sync/atomic" + + "github.com/google/uuid" + "go.opentelemetry.io/otel/metric/noop" + "golang.org/x/sync/errgroup" + + "github.com/e2b-dev/infra/packages/orchestrator/cmd/internal/cmdutil" + "github.com/e2b-dev/infra/packages/orchestrator/pkg/sandbox/block" + "github.com/e2b-dev/infra/packages/orchestrator/pkg/sandbox/block/metrics" + "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" + "github.com/e2b-dev/infra/packages/shared/pkg/storage" + "github.com/e2b-dev/infra/packages/shared/pkg/storage/header" +) + +// validateConcurrency is the worker count for the parallel chunk fetch in +// validate. Hardcoded — matches the prod orchestrator's typical fetch fanout. +const validateConcurrency = 10 + +// runValidate validates the target build through the production read path +// (block.Chunker) and, with recursive, every ancestor its mappings draw on. +// In recursive mode the target's Builds map is treated as authoritative: each +// ancestor's own recorded Size is cross-checked against the target's record. +func runValidate(ctx context.Context, storagePath, buildID, artifact string, recursive bool) error { + if !recursive { + return validateBuild(ctx, storagePath, buildID, artifact, 0) + } + + chain, err := gatherChain(ctx, storagePath, buildID, artifact, true) + if err != nil { + return err + } + + expected := map[uuid.UUID]int64{} + for id, bd := range chain[0].h.Builds { + if bd.Size > 0 { + expected[id] = bd.Size + } + } + + var validated, failed int + for _, c := range chain { + if c.Usage != nil && c.Usage.UsedBytes == 0 { + continue // an ancestor the target build doesn't draw on + } + + id := c.Image.BuildID + fmt.Printf("\n──── %s (%s) ────\n", id, roleOf(id, chain[0].h.Metadata)) + + validated++ + if err := validateBuild(ctx, storagePath, id.String(), artifact, expected[id]); err != nil { + failed++ + fmt.Fprintf(os.Stderr, " %s\n", err) + } + } + + fmt.Printf("\n%d build(s) validated, %d failed\n", validated, failed) + if failed > 0 { + return fmt.Errorf("%d build(s) failed validation", failed) + } + + return nil +} + +// validateBuild reads a build's entire image through the production block +// Chunker — the same fetch+decompress path the orchestrator uses — and verifies +// the SHA-256 checksum and the frame table. expectedSize is the size the +// caller expects (e.g., a descendant's record of this build); 0 = no +// expectation. Mismatches are reported by reportValidation. +func validateBuild(ctx context.Context, storagePath, buildID, artifact string, expectedSize int64) error { + headerData, _, err := cmdutil.ReadFile(ctx, storagePath, buildID, artifact+storage.HeaderSuffix) + if err != nil { + return fmt.Errorf("read header: %w", err) + } + h, err := header.DeserializeBytes(headerData) + if err != nil { + return fmt.Errorf("deserialize header: %w", err) + } + + ft := h.GetBuildFrameData(h.Metadata.BuildId) + // expected is the zero value when the build records no checksum (V3); the + // fetch still runs and reportValidation shows the checksum as n/a. + expected := h.Builds[h.Metadata.BuildId].Checksum + + chunker, size, cleanup, err := openChunker(ctx, storagePath, buildID, artifact, h, ft) + if err != nil { + return err + } + defer cleanup() + if size <= 0 { + return errors.New("build has no data to validate") + } + + chunks := validationUnits(ft, size) + blockSize := int64(h.Metadata.BlockSize) + + // Fetch with the production access pattern: one blockSize Slice at a time. + // Chunker.Slice is block-granular — it blocks only until the requested block + // lands — so a worker takes a whole chunk and slices it block by block, and + // validateConcurrency workers keep that many chunks in flight. + var nextChunk atomic.Int64 + eg, egCtx := errgroup.WithContext(ctx) + for range validateConcurrency { + eg.Go(func() error { + for { + ci := nextChunk.Add(1) - 1 + if ci >= int64(len(chunks)) { + return nil + } + c := chunks[ci] + for off := c.lo; off < c.hi; off += blockSize { + if _, err := chunker.Slice(egCtx, off, min(blockSize, c.hi-off), ft); err != nil { + return fmt.Errorf("fetch block at %d: %w", off, err) + } + } + } + }) + } + if err := eg.Wait(); err != nil { + return err + } + + // The cache is warm — sweep it one block at a time to hash the image. + hasher := sha256.New() + for off := int64(0); off < size; off += blockSize { + b, err := chunker.Slice(ctx, off, min(blockSize, size-off), ft) + if err != nil { + return fmt.Errorf("read block at %d: %w", off, err) + } + hasher.Write(b) + } + var got [32]byte + copy(got[:], hasher.Sum(nil)) + + return reportValidation(h, ft, got, expected, size, expectedSize) +} + +// openChunker wires a production block.Chunker over the build's data file, +// returning the chunker, the image's uncompressed size, and a cleanup function. +func openChunker(ctx context.Context, storagePath, buildID, artifact string, h *header.Header, ft *storage.FrameTable) (*block.Chunker, int64, func(), error) { + if err := cmdutil.SetupStorage(storagePath); err != nil { + return nil, 0, nil, err + } + provider, err := storage.GetStorageProvider(ctx, storage.TemplateStorageConfig) + if err != nil { + return nil, 0, nil, fmt.Errorf("storage provider: %w", err) + } + + dataPath := buildID + "/" + artifact + if ft.IsCompressed() { + dataPath += ft.CompressionType().Suffix() + } + obj, err := provider.OpenSeekable(ctx, dataPath, seekableType(artifact)) + if err != nil { + return nil, 0, nil, fmt.Errorf("open data: %w", err) + } + + // Mirror production (build/storage_diff.go:Init): the canonical uncompressed + // size is h.Builds[self].Size — correct even when the serialized self frame + // table is sparse-trimmed (V4+ can drop frames while preserving original U + // offsets, so ft.UncompressedSize() would be smaller). Fall back to the + // storage object's own Size() for V3 headers, which have no Builds map. + var size int64 + if bd, ok := h.Builds[h.Metadata.BuildId]; ok && bd.Size > 0 { + size = bd.Size + } else if size, err = obj.Size(ctx); err != nil { + return nil, 0, nil, fmt.Errorf("get data size: %w", err) + } + + flags, err := featureflags.NewClient() + if err != nil { + return nil, 0, nil, fmt.Errorf("feature flags: %w", err) + } + m, err := metrics.NewMetrics(noop.NewMeterProvider()) + if err != nil { + return nil, 0, nil, fmt.Errorf("metrics: %w", err) + } + cacheDir, err := os.MkdirTemp("", "inspect-build-validate-") + if err != nil { + return nil, 0, nil, err + } + + chunker, err := block.NewChunker(flags, size, int64(h.Metadata.BlockSize), obj, filepath.Join(cacheDir, "cache"), m) + if err != nil { + os.RemoveAll(cacheDir) + + return nil, 0, nil, fmt.Errorf("chunker: %w", err) + } + + cleanup := func() { + chunker.Close() + os.RemoveAll(cacheDir) + } + + return chunker, size, cleanup, nil +} + +// reportValidation prints the VALIDATION block: size, checksum, frame-table +// verdicts. size is the canonical uncompressed image size; expectedSize is +// what a descendant's Builds map recorded for this build (0 = no expectation). +func reportValidation(h *header.Header, ft *storage.FrameTable, got, expected [32]byte, size, expectedSize int64) error { + var ftProblems []string + if ft.IsCompressed() { + ftProblems = validateFrameTable(h, ft) + } + sizeMismatch := expectedSize > 0 && expectedSize != size + + fmt.Printf("\nVALIDATION\n") + if ft.IsCompressed() { + fmt.Printf(" Frames %d\n", ft.NumFrames()) + } + switch { + case expectedSize == 0: + fmt.Printf(" Size %d (%s)\n", size, humanSize(size)) + case sizeMismatch: + fmt.Printf(" Size MISMATCH\n expected %d (%s)\n actual %d (%s)\n", + expectedSize, humanSize(expectedSize), size, humanSize(size)) + default: + fmt.Printf(" Size OK %d (%s) (matches descendant's record)\n", size, humanSize(size)) + } + noChecksum := expected == ([32]byte{}) + switch { + case noChecksum: + fmt.Printf(" Checksum n/a %s (no recorded checksum to verify)\n", checksumString(got)) + case got == expected: + fmt.Printf(" Checksum OK %s\n", checksumString(got)) + default: + fmt.Printf(" Checksum MISMATCH\n expected %s\n actual %s\n", checksumString(expected), checksumString(got)) + } + if ft.IsCompressed() { + if len(ftProblems) == 0 { + fmt.Printf(" Frame table OK (covers all mappings, no extra frames)\n") + } else { + fmt.Printf(" Frame table FAILED\n") + for _, p := range ftProblems { + fmt.Printf(" %s\n", p) + } + } + } + + if sizeMismatch || (!noChecksum && got != expected) || len(ftProblems) > 0 { + return errors.New("validation failed") + } + + return nil +} + +type byteRange struct{ lo, hi int64 } + +// validationUnits returns a build's Chunker fetch units: one per frame for +// compressed builds, one per MemoryChunkSize chunk for uncompressed. +func validationUnits(ft *storage.FrameTable, size int64) []byteRange { + if ft.IsCompressed() { + units := make([]byteRange, ft.NumFrames()) + for i := range units { + startU, endU, _, _ := ft.FrameAt(i) + units[i] = byteRange{startU, endU} + } + + return units + } + + var units []byteRange + chunk := int64(storage.MemoryChunkSize) + for off := int64(0); off < size; off += chunk { + units = append(units, byteRange{off, min(off+chunk, size)}) + } + + return units +} + +// validateFrameTable verifies the current build's V4+ metadata: its Builds entry +// exists, and its stored frame table matches the ranges its mappings reference +// — every mapped byte is covered by a frame, and every frame is referenced +// (the TrimToRanges invariant, both ways). +func validateFrameTable(h *header.Header, ft *storage.FrameTable) []string { + currentID := h.Metadata.BuildId + + var problems []string + if h.Metadata.Version >= header.MetadataVersionV4 { + if _, ok := h.Builds[currentID]; !ok { + problems = append(problems, fmt.Sprintf("V4+ header is missing its current build entry %s in Builds", currentID)) + } + } + + var refs []byteRange + for _, m := range h.Mapping.All() { + if m.BuildId == currentID { + refs = append(refs, byteRange{int64(m.BuildStorageOffset), int64(m.BuildStorageOffset + m.Length)}) + } + } + refs = mergeRanges(refs) + + frames := make([]byteRange, ft.NumFrames()) + for i := range frames { + startU, endU, _, _ := ft.FrameAt(i) + frames[i] = byteRange{startU, endU} + } + + for _, r := range refs { + if !coveredBy(frames, r) { + problems = append(problems, fmt.Sprintf("mapped range [0x%X,0x%X) not fully covered by frames", r.lo, r.hi)) + } + } + for _, f := range frames { + if !intersectsAny(refs, f) { + problems = append(problems, fmt.Sprintf("frame [0x%X,0x%X) is not referenced by any mapping", f.lo, f.hi)) + } + } + + return problems +} + +// mergeRanges sorts and coalesces overlapping/adjacent ranges. +func mergeRanges(ranges []byteRange) []byteRange { + if len(ranges) == 0 { + return nil + } + slices.SortFunc(ranges, func(a, b byteRange) int { return cmp.Compare(a.lo, b.lo) }) + + merged := ranges[:1] + for _, r := range ranges[1:] { + last := &merged[len(merged)-1] + if r.lo <= last.hi { + last.hi = max(last.hi, r.hi) + } else { + merged = append(merged, r) + } + } + + return merged +} + +// coveredBy reports whether sorted frames collectively cover all of r. +func coveredBy(frames []byteRange, r byteRange) bool { + cur := r.lo + for _, f := range frames { + if f.lo > cur { + break + } + if f.hi > cur { + cur = f.hi + } + } + + return cur >= r.hi +} + +func intersectsAny(ranges []byteRange, r byteRange) bool { + for _, x := range ranges { + if r.lo < x.hi && r.hi > x.lo { + return true + } + } + + return false +} + +func seekableType(artifact string) storage.SeekableObjectType { + if artifact == storage.RootfsName { + return storage.RootFSObjectType + } + + return storage.MemfileObjectType +} diff --git a/packages/orchestrator/cmd/inspect-build/validate_test.go b/packages/orchestrator/cmd/inspect-build/validate_test.go new file mode 100644 index 0000000000..23f2fd2cb7 --- /dev/null +++ b/packages/orchestrator/cmd/inspect-build/validate_test.go @@ -0,0 +1,79 @@ +package main + +import ( + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/shared/pkg/storage" + "github.com/e2b-dev/infra/packages/shared/pkg/storage/header" +) + +func TestMergeRanges(t *testing.T) { + t.Parallel() + + require.Nil(t, mergeRanges(nil)) + + // Unsorted, overlapping and adjacent ranges coalesce. + got := mergeRanges([]byteRange{{10, 20}, {0, 5}, {18, 30}, {5, 8}}) + require.Equal(t, []byteRange{{0, 8}, {10, 30}}, got) +} + +func TestCoveredBy(t *testing.T) { + t.Parallel() + + frames := []byteRange{{0, 10}, {10, 20}, {30, 40}} + + require.True(t, coveredBy(frames, byteRange{5, 15})) + require.True(t, coveredBy(frames, byteRange{0, 20})) + require.False(t, coveredBy(frames, byteRange{15, 35})) // gap [20, 30) + require.False(t, coveredBy(frames, byteRange{0, 50})) +} + +func TestIntersectsAny(t *testing.T) { + t.Parallel() + + ranges := []byteRange{{0, 10}, {20, 30}} + + require.True(t, intersectsAny(ranges, byteRange{5, 25})) + require.True(t, intersectsAny(ranges, byteRange{8, 9})) + require.False(t, intersectsAny(ranges, byteRange{10, 20})) // exactly the gap + require.False(t, intersectsAny(ranges, byteRange{30, 40})) +} + +func TestValidateFrameTable(t *testing.T) { + t.Parallel() + + cur := uuid.New() + ft := storage.NewFullFrameTable(storage.CompressionZstd, []storage.FrameSize{ + {U: 2 * miB, C: 1}, + {U: 2 * miB, C: 1}, + }).Table() + h := &header.Header{ + Metadata: &header.Metadata{Version: header.MetadataVersionV4, BuildId: cur}, + Mapping: testMapping(t, miB, []header.BuildMap{ + {Offset: 0, Length: 4 * miB, BuildId: cur, BuildStorageOffset: 0}, + }), + Builds: map[uuid.UUID]header.BuildData{cur: {FrameData: ft}}, + } + + // Frames exactly cover the current build's mappings — no problems. + require.Empty(t, validateFrameTable(h, ft)) + + // Missing self entry in the Builds map. + noSelf := *h + noSelf.Builds = map[uuid.UUID]header.BuildData{} + probs := validateFrameTable(&noSelf, ft) + require.Len(t, probs, 1) + require.Contains(t, probs[0], "missing its current build entry") + + // A frame the mappings don't reference (mapping covers only the first). + shortMap := *h + shortMap.Mapping = testMapping(t, miB, []header.BuildMap{ + {Offset: 0, Length: 2 * miB, BuildId: cur, BuildStorageOffset: 0}, + }) + probs = validateFrameTable(&shortMap, ft) + require.NotEmpty(t, probs) + require.Contains(t, probs[0], "not referenced") +} diff --git a/packages/orchestrator/cmd/internal/cmdutil/template.go b/packages/orchestrator/cmd/internal/cmdutil/template.go new file mode 100644 index 0000000000..21c8958dd7 --- /dev/null +++ b/packages/orchestrator/cmd/internal/cmdutil/template.go @@ -0,0 +1,83 @@ +package cmdutil + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "slices" + "strings" +) + +const nilUUID = "00000000-0000-0000-0000-000000000000" + +// templateInfo represents a template from the E2B API. +type templateInfo struct { + TemplateID string `json:"templateID"` + BuildID string `json:"buildID"` + Aliases []string `json:"aliases"` + Names []string `json:"names"` +} + +// ResolveTemplateID fetches the build ID for a template from the E2B API. +// Input can be a template ID, alias, or full name (e.g. "e2b/base"). +func ResolveTemplateID(input string) (string, error) { + apiKey := os.Getenv("E2B_API_KEY") + if apiKey == "" { + return "", errors.New("E2B_API_KEY environment variable required for -template flag") + } + + apiURL := "https://api.e2b.dev/templates" + if domain := os.Getenv("E2B_DOMAIN"); domain != "" { + apiURL = fmt.Sprintf("https://api.%s/templates", domain) + } + + ctx := context.Background() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) + if err != nil { + return "", fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("X-API-Key", apiKey) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("failed to fetch templates: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + + return "", fmt.Errorf("API returned %d: %s", resp.StatusCode, string(body)) + } + + var templates []templateInfo + if err := json.NewDecoder(resp.Body).Decode(&templates); err != nil { + return "", fmt.Errorf("failed to parse API response: %w", err) + } + + var match *templateInfo + var availableAliases []string + for i := range templates { + t := &templates[i] + availableAliases = append(availableAliases, t.Aliases...) + + if t.TemplateID == input || slices.Contains(t.Aliases, input) || slices.Contains(t.Names, input) { + match = t + + break + } + } + + if match == nil { + return "", fmt.Errorf("template %q not found. Available aliases: %s", input, strings.Join(availableAliases, ", ")) + } + if match.BuildID == "" || match.BuildID == nilUUID { + return "", fmt.Errorf("template %q has no successful build", input) + } + + return match.BuildID, nil +}