blob: 4a57e49066b433fed7bbd8b0446521fc87e6f1a8 [file] [log] [blame]
// Copyright 2019 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
package main
import (
"bufio"
"encoding/json"
"flag"
"fmt"
"io"
"io/ioutil"
"log"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
)
type FileSystemSizes []FileSystemSize
type FileSystemSize struct {
Name string `json:"name"`
Value json.Number `json:"value"`
Limit json.Number `json:"limit"`
}
type SizeLimits struct {
// Specifies a size limit in bytes for ICU data files.
ICUDataLimit json.Number `json:"icu_data_limit"`
// Specifies a size limit in bytes for uncategorized packages.
CoreLimit json.Number `json:"core_limit"`
// Specifies the files that contribute to the ICU data limit.
ICUData []string `json:"icu_data"`
// Specifies the files that contributed to the distributed shared library
// size limits.
DistributedShlibs []string `json:"distributed_shlibs"`
// Specifies the distributed shared library size limit in bytes.
DistributedShlibsLimit json.Number `json:"distributed_shlibs_limit"`
// Specifies a series of size components/categories with a struct describing each.
Components []Component `json:"components"`
}
// Display prints the contents of node and its children into a string. All
// strings are printed at the supplied indentation level.
type DisplayFn func(node *Node, level int) string
type Node struct {
fullPath string
size int64
copies int64
parent *Node
children map[string]*Node
// display is a function used to print the contents of Node in a human-friendly way.
// If unset, a default display function is used.
display DisplayFn
}
type Component struct {
Component string `json:"component"`
Limit json.Number `json:"limit"`
Src []string `json:"src"`
}
type ComponentSize struct {
Size int64 `json:"size"`
Budget int64 `json:"budget"`
nodes []*Node
}
type Blob struct {
dep []string
size int64
name string
hash string
}
type BlobFromSizes struct {
SourcePath string `json:"source_path"`
Merkle string `json:"merkle"`
Bytes int `json:"bytes"`
Size int `json:"size"`
}
type BlobFromJSON struct {
Merkle string `json:"merkle"`
Path string `json:"path"`
SourcePath string `json:"source_path"`
}
const (
MetaFar = "meta.far"
PackageList = "gen/build/images/blob.manifest.list"
BlobsJSON = "blobs.json"
ConfigData = "config-data"
DataPrefix = "data/"
SizeCheckerJSON = "size_checker.json"
FileSystemSizesJSON = "filesystem_sizes.json"
)
func newDummyNode() *Node {
return newNode("")
}
func newNode(p string) *Node {
return &Node{
fullPath: p,
children: make(map[string]*Node),
}
}
// newNodeWithDisplay creates a new Node, and supplies a custom function to be
// used to print its contents.
func newNodeWithDisplay(p string, display DisplayFn) *Node {
n := newNode(p)
n.display = display
return n
}
// Breaks down the given path divided by "/" and updates the size of each node on the path with the
// size of the given blob.
func (root *Node) add(p string, blob *Blob) {
fullPath := strings.Split(p, "/")
curr := root
var nodePath string
// A blob may be used by many packages, so the size of each blob is the total space allocated to
// that blob in blobfs.
// We divide the size by the total number of packages that depend on it to get a rough estimate of
// the size of the individual blob.
size := blob.size / int64(len(blob.dep))
curr.size += size
for _, name := range fullPath {
name = strings.TrimSuffix(name, ".meta")
nodePath = filepath.Join(nodePath, name)
if _, ok := curr.children[name]; !ok {
target := newNode(nodePath)
target.parent = curr
curr.children[name] = target
}
curr = curr.children[name]
curr.size += size
}
}
// Finds the node whose fullPath matches the given path. The path is guaranteed to be unique as it
// corresponds to the filesystem of the build directory. Detaches the node from the tree.
func (root *Node) detachByPath(p string) *Node {
fullPath := strings.Split(p, "/")
curr := root
for _, name := range fullPath {
next := curr.children[name]
if next == nil {
return nil
}
curr = next
}
curr.detach()
return curr
}
// Detach this subtree from its parent, removing the size of this subtree from
// the aggregate size in the parent.
func (root *Node) detach() {
size := root.size
curr := root
for curr.parent != nil {
curr.parent.size -= size
curr = curr.parent
}
if root.parent != nil {
delete(root.parent.children, filepath.Base(root.fullPath))
}
root.parent = nil
}
// Returns the only child of a node. Useful for finding the root node.
func (node *Node) getOnlyChild() (*Node, error) {
if len(node.children) == 1 {
for _, child := range node.children {
return child, nil
}
}
return nil, fmt.Errorf("this node does not contain a single child.")
}
// displayAsDefault returns a human-readable representation of the supplied
// node and its children, using level as the indentation level for display
// pretty-printing.
func displayAsDefault(node *Node, level int) string {
var copies string
if node.copies > 1 {
copies = fmt.Sprintf("| %3d %-6s", node.copies, "copies")
} else if node.copies == 1 {
copies = fmt.Sprintf("| %3d %-6s", node.copies, "copy")
}
var path = strings.TrimPrefix(node.fullPath, "obj/")
path = strings.TrimPrefix(path, "lib/")
if level > 1 {
path = filepath.Base(path)
}
var maxLen = 80 - 2*level
if maxLen < 0 {
maxLen = 0
}
var pathLength = len(path)
if pathLength > maxLen {
var startPos = pathLength - maxLen + 3
if startPos > pathLength {
startPos = pathLength
}
path = "..." + path[startPos:]
}
path = fmt.Sprintf("%s%s", strings.Repeat(" ", level), path)
ret := fmt.Sprintf("%-80s | %10s %10s\n", path, formatSize(node.size), copies)
for _, n := range node.children {
ret += n.storageBreakdown(level + 1)
}
return ret
}
// displayAsBlob returns a human-readable representation of the supplied Node and
// its children, formatted suitably for a blob ID, at the given indentation
// level.
func displayAsBlob(node *Node, level int) string {
nc := len(node.children)
ret := fmt.Sprintf("%vBlob ID %v (%v reuses):\n", strings.Repeat(" ", level), node.fullPath, nc)
for _, n := range node.children {
ret += n.storageBreakdown(level + 1)
}
return ret
}
// MetaRegex matches strings like: "/some/path/foo.meta/something_else" and
// grabs "foo" from them.
var metaRegex = regexp.MustCompile(`/([^/]+)\.meta/`)
// displayAsMeta returns a human-readable representation of the supplied Node and
// its children, formatted suitably as a metadata item, at the given indentation
// level.
func displayAsMeta(node *Node, level int) string {
m := metaRegex.FindStringSubmatch(node.fullPath)
var n string
if len(m) == 0 || m[1] == "" {
n = node.fullPath
} else {
n = m[1]
}
ret := fmt.Sprintf("%s%s\n", strings.Repeat(" ", level), n)
// No children to iterate over.
return ret
}
// storageBreakdown constructs a string including detailed storage
// consumption for the subtree of this node.
//
// `level` controls the indentation level to preserve hierarchy in the
// output.
func (node *Node) storageBreakdown(level int) string {
if node.display == nil {
// If the node has no custom display function.
return displayAsDefault(node, level)
}
return node.display(node, level)
}
// Formats a given number into human friendly string representation of bytes, rounded to 2 decimal places.
func formatSize(sizeInBytes int64) string {
sizeInMiB := float64(sizeInBytes) / (1024 * 1024)
return fmt.Sprintf("%.2f MiB", sizeInMiB)
}
// Extract all the packages from a given blob.manifest.list and blobs.json.
// It also returns a map containing all blobs, with the merkle root as the key.
func extractPackages(buildDir, packageListFileName, blobsJSON string) (blobMap map[string]*Blob, packages []string, err error) {
blobMap = make(map[string]*Blob)
var merkleRootToSizeMap map[string]int64
if merkleRootToSizeMap, err = openAndProcessBlobsJSON(filepath.Join(buildDir, blobsJSON)); err != nil {
return
}
packageList, err := os.Open(filepath.Join(buildDir, packageListFileName))
if err != nil {
return
}
defer packageList.Close()
packageListScanner := bufio.NewScanner(packageList)
for packageListScanner.Scan() {
pkg, err := openAndParseBlobsManifest(blobMap, merkleRootToSizeMap, buildDir, packageListScanner.Text())
if err != nil {
return blobMap, packages, err
}
packages = append(packages, pkg...)
}
return
}
// Opens a blobs.manifest file to populate the blob map and extract all meta.far blobs.
// We expect each entry of blobs.manifest to have the following format:
// `$MERKLE_ROOT=$PATH_TO_BLOB`
func openAndParseBlobsManifest(
blobMap map[string]*Blob,
merkleRootToSizeMap map[string]int64,
buildDir, blobsManifestFileName string) ([]string, error) {
blobsManifestFile, err := os.Open(filepath.Join(buildDir, blobsManifestFileName))
if err != nil {
return nil, err
}
defer blobsManifestFile.Close()
return parseBlobsManifest(blobMap, merkleRootToSizeMap, blobsManifestFileName, blobsManifestFile), nil
}
// Similar to openAndParseBlobsManifest, except it doesn't throw an I/O error.
func parseBlobsManifest(
merkleRootToBlobMap map[string]*Blob,
merkleRootToSizeMap map[string]int64,
blobsManifestFileName string, blobsManifestFile io.Reader) []string {
packages := []string{}
blobsManifestScanner := bufio.NewScanner(blobsManifestFile)
for blobsManifestScanner.Scan() {
temp := strings.Split(blobsManifestScanner.Text(), "=")
merkleRoot := temp[0]
fileName := temp[1]
if blob, ok := merkleRootToBlobMap[merkleRoot]; !ok {
blob = &Blob{
dep: []string{blobsManifestFileName},
name: fileName,
size: merkleRootToSizeMap[merkleRoot],
hash: merkleRoot,
}
merkleRootToBlobMap[merkleRoot] = blob
// This blob is a Fuchsia package.
if strings.HasSuffix(fileName, MetaFar) {
packages = append(packages, fileName)
}
} else {
blob.dep = append(blob.dep, blobsManifestFileName)
}
}
return packages
}
// Translates blobs.json into a map, with the key as the merkle root and the value as the size of
// that blob.
func openAndProcessBlobsJSON(blobsJSONFilePath string) (map[string]int64, error) {
blobsJSONFile, err := os.Open(blobsJSONFilePath)
if err != nil {
return nil, err
}
defer blobsJSONFile.Close()
return processBlobsJSON(blobsJSONFile)
}
func processBlobsJSON(blobsJSONFile io.Reader) (map[string]int64, error) {
blobs := []BlobFromSizes{}
if err := json.NewDecoder(blobsJSONFile).Decode(&blobs); err != nil {
return nil, err
}
m := map[string]int64{}
for _, b := range blobs {
m[b.Merkle] = int64(b.Size)
}
return m, nil
}
type processingState struct {
blobMap map[string]*Blob
icuDataMap map[string]*Node
distributedShlibs map[string]*Node
root *Node
}
// Process the packages extracted from blob.manifest.list and process the blobs.json file to build a
// tree of packages.
func openAndParseBlobsJSON(
buildDir string,
packages []string,
state *processingState) error {
absBuildDir, err := filepath.Abs(buildDir)
if err != nil {
return fmt.Errorf("could not find abs path of directory: %v: %w", buildDir, err)
}
for _, metaFar := range packages {
// From the meta.far file, we can get the path to the blobs.json for that package.
dir := filepath.Dir(metaFar)
blobsJSON := filepath.Join(buildDir, dir, BlobsJSON)
// We then parse the blobs.json
blobs := []BlobFromJSON{}
data, err := ioutil.ReadFile(blobsJSON)
if err != nil {
return fmt.Errorf(readError(blobsJSON, err))
}
if err := json.Unmarshal(data, &blobs); err != nil {
return fmt.Errorf(unmarshalError(blobsJSON, err))
}
// Finally, we add the blob and the package to the tree.
parseBlobsJSON(state, blobs, dir, absBuildDir)
}
return nil
}
// Similar to openAndParseBlobsJSON except it doesn't throw an I/O error.
func parseBlobsJSON(
state *processingState,
blobs []BlobFromJSON,
pkgPath string,
absBuildDir string) {
for _, blob := range blobs {
// If the blob is an ICU data file, we don't add it to the tree.
// We check the path instead of the source path because prebuilt packages have hashes as the
// source path for their blobs
baseBlobFilepath := filepath.Base(blob.Path)
var (
// Node must always be a pointer stored in state.icuDataMap, or nil.
node *Node
ok bool
)
if node, ok = state.icuDataMap[baseBlobFilepath]; ok {
// The size of each blob is the total space occupied by the blob in blobfs (each blob may be
// referenced several times by different packages). Therefore, once we have already add the
// size, we should remove it from the map
if state.blobMap[blob.Merkle] != nil {
if node == nil {
state.icuDataMap[baseBlobFilepath] = newNode(baseBlobFilepath)
// Ensure that node remains a pointer into the map.
node = state.icuDataMap[baseBlobFilepath]
}
node.size += state.blobMap[blob.Merkle].size
node.copies += 1
delete(state.blobMap, blob.Merkle)
}
// Save the full path of the ICU data file, so ICU data file
// proliferation can be debugged.
var blobNode *Node
blobNode = node.children[blob.Merkle]
if blobNode == nil {
blobNode = newNodeWithDisplay(blob.Merkle, displayAsBlob)
node.children[blob.Merkle] = blobNode
}
icuCopyNode := newNodeWithDisplay(blob.SourcePath, displayAsMeta)
blobNode.children[blob.SourcePath] = icuCopyNode
} else if node, ok = state.distributedShlibs[blob.Path]; ok {
if state.blobMap[blob.Merkle] != nil {
if node == nil {
state.distributedShlibs[blob.Path] = newNode(blob.Path)
node = state.distributedShlibs[blob.Path]
}
node.size += state.blobMap[blob.Merkle].size
node.copies += 1
delete(state.blobMap, blob.Merkle)
}
} else {
var configPkgPath string
if filepath.Base(pkgPath) == ConfigData && strings.HasPrefix(blob.Path, DataPrefix) {
// If the package is config-data, we want to group the blobs by the name of the package
// the config data is for.
// By contract defined in config.gni, the path has the format 'data/$for_pkg/$outputs'
configPkgPath = strings.TrimPrefix(blob.Path, DataPrefix)
}
state.root.add(filepath.Join(pkgPath, configPkgPath), state.blobMap[blob.Merkle])
}
}
}
func parseBlobfsBudget(buildDir, fileSystemSizesJSONFilename string) int64 {
fileSystemSizesJSON := filepath.Join(buildDir, fileSystemSizesJSONFilename)
fileSystemSizesJSONData, err := ioutil.ReadFile(fileSystemSizesJSON)
if err != nil {
log.Fatal(readError(fileSystemSizesJSON, err))
}
var fileSystemSizes = new(FileSystemSizes)
if err := json.Unmarshal(fileSystemSizesJSONData, &fileSystemSizes); err != nil {
log.Fatal(unmarshalError(fileSystemSizesJSON, err))
}
for _, fileSystemSize := range *fileSystemSizes {
if fileSystemSize.Name == "blob/contents_size" {
budget, err := fileSystemSize.Limit.Int64()
if err != nil {
log.Fatalf("Failed to parse %s as an int64: %s\n", fileSystemSize.Limit, err)
}
return budget
}
}
return 0
}
// Processes the given sizeLimits and throws an error if any component in the sizeLimits is above its
// allocated space limit.
func parseSizeLimits(sizeLimits *SizeLimits, buildDir, packageList, blobsJSON string) map[string]*ComponentSize {
outputSizes := map[string]*ComponentSize{}
blobMap, packages, err := extractPackages(buildDir, packageList, blobsJSON)
if err != nil {
return outputSizes
}
// We create a set of ICU data filenames.
icuDataMap := make(map[string]*Node)
for _, icu_data := range sizeLimits.ICUData {
icuDataMap[icu_data] = newNode(icu_data)
}
// We also create a map of paths that should be considered distributed shlibs.
distributedShlibs := make(map[string]*Node)
for _, path := range sizeLimits.DistributedShlibs {
distributedShlibs[path] = newNode(path)
}
st := processingState{
blobMap,
icuDataMap,
distributedShlibs,
// The dummy node will have the root node as its only child.
newDummyNode(),
}
// We process the meta.far files that were found in the blobs.manifest here.
if err := openAndParseBlobsJSON(buildDir, packages, &st); err != nil {
return outputSizes
}
var distributedShlibsNodes []*Node
var totalDistributedShlibsSize int64
for _, node := range st.distributedShlibs {
totalDistributedShlibsSize += node.size
distributedShlibsNodes = append(distributedShlibsNodes, node)
}
var icuDataNodes []*Node
var totalIcuDataSize int64
for _, node := range st.icuDataMap {
totalIcuDataSize += node.size
icuDataNodes = append(icuDataNodes, node)
}
var total int64
root, err := st.root.getOnlyChild()
if err != nil {
return outputSizes
}
for _, component := range sizeLimits.Components {
var size int64
var nodes []*Node
for _, src := range component.Src {
if node := root.detachByPath(src); node != nil {
nodes = append(nodes, node)
size += node.size
}
}
total += size
budget, err := component.Limit.Int64()
if err != nil {
log.Fatalf("Failed to parse %s as an int64: %s\n", component.Limit, err)
}
// There is only ever one copy of Update ZBIs.
if component.Component == "Update ZBIs" {
budget /= 2
size /= 2
}
outputSizes[component.Component] = &ComponentSize{
Size: size,
Budget: budget,
nodes: nodes,
}
}
ICUDataLimit, err := sizeLimits.ICUDataLimit.Int64()
if err != nil {
log.Fatalf("Failed to parse %s as an int64: %s\n", sizeLimits.ICUDataLimit, err)
}
const icuDataName = "ICU Data"
outputSizes[icuDataName] = &ComponentSize{
Size: totalIcuDataSize,
Budget: ICUDataLimit,
nodes: icuDataNodes,
}
CoreSizeLimit, err := sizeLimits.CoreLimit.Int64()
if err != nil {
log.Fatalf("Failed to parse %s as an int64: %s\n", sizeLimits.CoreLimit, err)
}
const coreName = "Core system+services"
coreNodes := make([]*Node, 0)
// `root` contains the leftover nodes that have not been detached by the path
// filters.
coreNodes = append(coreNodes, root)
outputSizes[coreName] = &ComponentSize{
Size: root.size,
Budget: CoreSizeLimit,
nodes: coreNodes,
}
if sizeLimits.DistributedShlibsLimit.String() != "" {
DistributedShlibsSizeLimit, err := sizeLimits.DistributedShlibsLimit.Int64()
if err != nil {
log.Fatalf("Failed to parse %s as an int64: %s\n", sizeLimits.DistributedShlibsLimit, err)
}
const distributedShlibsName = "Distributed shared libraries"
outputSizes[distributedShlibsName] = &ComponentSize{
Size: totalDistributedShlibsSize,
Budget: DistributedShlibsSizeLimit,
nodes: distributedShlibsNodes,
}
}
return outputSizes
}
func readError(file string, err error) string {
return verbError("read", file, err)
}
func unmarshalError(file string, err error) string {
return verbError("unmarshal", file, err)
}
func verbError(verb, file string, err error) string {
return fmt.Sprintf("Failed to %s %s: %s", verb, file, err)
}
func writeOutputSizes(sizes map[string]*ComponentSize, outPath string) error {
f, err := os.Create(outPath)
if err != nil {
return err
}
defer f.Close()
encoder := json.NewEncoder(f)
encoder.SetIndent("", " ")
simpleSizes := make(map[string]interface{})
budgetSuffix := ".budget"
// Use the size_limits gni file as the source of truth/reference for budget
// info until we're able to provide better contextual links.
ownerSuffix := ".owner"
for name, cs := range sizes {
simpleSizes[name] = cs.Size
simpleSizes[name+budgetSuffix] = cs.Budget
simpleSizes[name+ownerSuffix] = "http://goto.google.com/fuchsia-binary-size-limits"
}
if err := encoder.Encode(&simpleSizes); err != nil {
log.Fatal("failed to encode simpleSizes: ", err)
}
return nil
}
func generateReport(outputSizes map[string]*ComponentSize, showBudgetOnly bool, ignorePerComponentBudget bool, blobFsBudget int64) (bool, string) {
var overBudget = false
var totalSize int64 = 0
var totalBudget int64 = 0
var totalRemaining int64 = 0
var report strings.Builder
componentNames := make([]string, 0, len(outputSizes))
for componentName := range outputSizes {
componentNames = append(componentNames, componentName)
}
sort.Strings(componentNames)
report.WriteString("\n")
report.WriteString(fmt.Sprintf("%-80s | %-10s | %-10s | %-10s\n", "Component", "Size", "Budget", "Remaining"))
report.WriteString(strings.Repeat("-", 119) + "\n")
for _, componentName := range componentNames {
var componentSize = outputSizes[componentName]
var remainingBudget = componentSize.Budget - componentSize.Size
var startColorCharacter string
var endColorCharacter string
// If any component is overbudget, then size_checker will fail.
if componentSize.Size > componentSize.Budget && !ignorePerComponentBudget {
overBudget = true
}
if showBudgetOnly {
if componentSize.Size > componentSize.Budget {
// Red
startColorCharacter = "\033[31m"
} else {
// Green
startColorCharacter = "\033[32m"
}
endColorCharacter = "\033[0m"
}
totalSize += componentSize.Size
totalBudget += componentSize.Budget
totalRemaining += remainingBudget
report.WriteString(
fmt.Sprintf("%-80s | %10s | %10s | %s%10s%s\n", componentName, formatSize(componentSize.Size), formatSize(componentSize.Budget), startColorCharacter, formatSize(remainingBudget), endColorCharacter))
if !showBudgetOnly {
for _, n := range componentSize.nodes {
report.WriteString(n.storageBreakdown(1))
}
report.WriteString("\n")
}
}
report.WriteString(strings.Repeat("-", 119) + "\n")
report.WriteString(fmt.Sprintf("%-80s | %10s | %10s | %10s\n", "Total", formatSize(totalSize), formatSize(totalBudget), formatSize(totalRemaining)))
report.WriteString(fmt.Sprintf("%-80s | %10s | %10s | %10s\n", "Allocated System Data Budget", formatSize(totalBudget), formatSize(blobFsBudget), formatSize(blobFsBudget-totalBudget)))
if totalSize > blobFsBudget {
report.WriteString(
fmt.Sprintf("ERROR: Total data size [%s] exceeds total system data budget [%s]\n", formatSize(totalSize), formatSize(blobFsBudget)))
overBudget = true
}
if totalBudget > blobFsBudget && !ignorePerComponentBudget {
report.WriteString(
fmt.Sprintf("WARNING: Total per-component data budget [%s] exceeds total system data budget [%s]\n", formatSize(totalBudget), formatSize(blobFsBudget)))
overBudget = true
}
return overBudget, report.String()
}
func main() {
flag.Usage = func() {
fmt.Fprintln(os.Stderr, `Usage: size_checker [--budget-only] [--ignore-per-component-budget] --build-dir BUILD_DIR [--sizes-json-out SIZES_JSON]
A executable that checks if any component from a build has exceeded its allocated space limit.
See //tools/size_checker for more details.`)
flag.PrintDefaults()
}
var buildDir string
flag.StringVar(&buildDir, "build-dir", "", `(required) the output directory of a Fuchsia build.`)
var fileSizeOutPath string
flag.StringVar(&fileSizeOutPath, "sizes-json-out", "", "If set, will write a json object to this path with schema { <name (str)>: <file size (int)> }.")
var showBudgetOnly bool
flag.BoolVar(&showBudgetOnly, "budget-only", false, "If set, only budgets and total sizes of components will be shown.")
var ignorePerComponentBudget bool
flag.BoolVar(&ignorePerComponentBudget, "ignore-per-component-budget", false,
"If set, output will go to stderr only if the total size of components exceeds the total blobFs budget.")
flag.Parse()
if buildDir == "" {
flag.Usage()
os.Exit(2)
}
sizeCheckerJSON := filepath.Join(buildDir, SizeCheckerJSON)
sizeCheckerJSONData, err := ioutil.ReadFile(sizeCheckerJSON)
if err != nil {
log.Fatal(readError(sizeCheckerJSON, err))
}
var sizeLimits SizeLimits
if err := json.Unmarshal(sizeCheckerJSONData, &sizeLimits); err != nil {
log.Fatal(unmarshalError(sizeCheckerJSON, err))
}
// If there are no components, then there are no work to do. We are done.
if len(sizeLimits.Components) == 0 {
os.Exit(0)
}
outputSizes := parseSizeLimits(&sizeLimits, buildDir, PackageList, BlobsJSON)
if len(fileSizeOutPath) > 0 {
if err := writeOutputSizes(outputSizes, fileSizeOutPath); err != nil {
log.Fatal(err)
}
}
blobFsBudget := parseBlobfsBudget(buildDir, FileSystemSizesJSON)
overBudget, report := generateReport(outputSizes, showBudgetOnly, ignorePerComponentBudget, blobFsBudget)
if overBudget {
log.Fatal(report)
} else {
log.Println(report)
}
}