blob: b29a5f165238df634d6e350ebf1015e5dd100f16 [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"
"strings"
)
type Input struct {
AssetLimit json.Number `json:"asset_limit"`
CoreLimit json.Number `json:"core_limit"`
AssetExt []string `json:"asset_ext"`
Components []Component `json:"components"`
}
type Node struct {
fullPath string
size int64
children map[string]*Node
}
type Component struct {
Component string `json:"component"`
Limit json.Number `json:"limit"`
Src []string `json:"src"`
}
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"
BlobSizes = "blobs.json"
BlobList = "gen/build/images/blob.manifest.list"
BlobsJSON = "blobs.json"
ConfigData = "config-data"
DataPrefix = "data/"
InputJSON = "size_checker.json"
)
func newDummyNode() *Node {
return newNode("")
}
func newNode(p string) *Node {
return &Node{
fullPath: p,
children: make(map[string]*Node),
}
}
// 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))
for _, name := range fullPath {
name = strings.TrimSuffix(name, ".meta")
nodePath = filepath.Join(nodePath, name)
if _, ok := curr.children[name]; !ok {
target := newNode(nodePath)
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.
func (root *Node) find(p string) *Node {
fullPath := strings.Split(p, "/")
curr := root
for _, name := range fullPath {
next := curr.children[name]
if next == nil {
return nil
}
curr = next
}
return curr
}
// 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.")
}
// Formats a given number into human friendly string representation of bytes, rounded to 2 decimal places.
func formatSize(s int64) string {
size := float64(s)
for _, unit := range []string{"bytes", "KiB", "MiB"} {
if size < 1024 {
return fmt.Sprintf("%.2f %s", size, unit)
}
size /= 1024
}
return fmt.Sprintf("%.2f GiB", size)
}
// 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, blobList, blobSize string) (blobMap map[string]*Blob, packages []string, err error) {
blobMap = make(map[string]*Blob)
var sizeMap map[string]int64
if sizeMap, err = processBlobSizes(filepath.Join(buildDir, blobSize)); err != nil {
return
}
f, err := os.Open(filepath.Join(buildDir, blobList))
if err != nil {
return
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
pkg, err := processBlobsManifest(blobMap, sizeMap, buildDir, scanner.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 processBlobsManifest(
blobMap map[string]*Blob,
sizeMap map[string]int64,
buildDir, manifest string) ([]string, error) {
f, err := os.Open(filepath.Join(buildDir, manifest))
if err != nil {
return nil, err
}
defer f.Close()
return processManifest(blobMap, sizeMap, manifest, f), nil
}
// Similar to processBlobsManifest, except it doesn't throw an I/O error.
func processManifest(
blobMap map[string]*Blob,
sizeMap map[string]int64,
manifest string, r io.Reader) []string {
packages := []string{}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
temp := strings.Split(scanner.Text(), "=")
if blob, ok := blobMap[temp[0]]; !ok {
blob = &Blob{
dep: []string{manifest},
name: temp[1],
size: sizeMap[temp[0]],
hash: temp[0],
}
blobMap[temp[0]] = blob
// This blob is a Fuchsia package.
if strings.HasSuffix(temp[1], MetaFar) {
packages = append(packages, temp[1])
}
} else {
blob.dep = append(blob.dep, manifest)
}
}
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 processBlobSizes(src string) (map[string]int64, error) {
f, err := os.Open(src)
if err != nil {
return nil, err
}
defer f.Close()
return processSizes(f)
}
func processSizes(r io.Reader) (map[string]int64, error) {
blobs := []BlobFromSizes{}
if err := json.NewDecoder(r).Decode(&blobs); err != nil {
return nil, err
}
m := map[string]int64{}
for _, b := range blobs {
m[b.Merkle] = int64(b.Size)
}
return m, nil
}
// Process the packages extracted from blob.manifest.list and process the blobs.json file to build a
// tree of packages.
func processPackages(
buildDir string,
blobMap map[string]*Blob,
assetMap map[string]bool,
assetSize *int64,
packages []string,
root *Node) error {
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)
blobJSON := filepath.Join(buildDir, dir, BlobsJSON)
// We then parse the blobs.json
blobs := []BlobFromJSON{}
data, err := ioutil.ReadFile(blobJSON)
if err != nil {
return fmt.Errorf(readError(blobJSON, err))
}
if err := json.Unmarshal(data, &blobs); err != nil {
return fmt.Errorf(unmarshalError(blobJSON, err))
}
// Finally, we add the blob and the package to the tree.
processBlobsJSON(blobMap, assetMap, assetSize, blobs, root, dir)
}
return nil
}
// Similar to processPackages except it doesn't throw an I/O error.
func processBlobsJSON(
blobMap map[string]*Blob,
assetMap map[string]bool,
assetSize *int64,
blobs []BlobFromJSON,
root *Node,
pkgPath string) {
for _, blob := range blobs {
// If the blob is an asset, 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
if assetMap[filepath.Ext(blob.Path)] {
// 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 blobMap[blob.Merkle] != nil {
*assetSize += blobMap[blob.Merkle].size
delete(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)
}
root.add(filepath.Join(pkgPath, configPkgPath), blobMap[blob.Merkle])
}
}
}
// Processes the given input and throws an error if any component in the input is above its
// allocated space limit.
func processInput(input *Input, buildDir, blobList, blobSize string) (map[string]int64, error) {
outputSizes := map[string]int64{}
blobMap, packages, err := extractPackages(buildDir, blobList, blobSize)
if err != nil {
return outputSizes, err
}
// We create a set of extensions that should be considered as assets.
assetMap := make(map[string]bool)
for _, ext := range input.AssetExt {
assetMap[ext] = true
}
// The dummy node will have the root node as its only child.
dummy := newDummyNode()
var assetSize int64
// We process the meta.far files that were found in the blobs.manifest here.
if err := processPackages(buildDir, blobMap, assetMap, &assetSize, packages, dummy); err != nil {
return outputSizes, fmt.Errorf("error processing packages: %s", err)
}
var total int64
var noSpace = false
var report strings.Builder
root, err := dummy.getOnlyChild()
if err != nil {
return outputSizes, err
}
for _, component := range input.Components {
var size int64
for _, src := range component.Src {
if node := root.find(src); node != nil {
size += node.size
}
}
total += size
outputSizes[component.Component] = size
if s := checkLimit(component.Component, size, component.Limit); s != "" {
noSpace = true
report.WriteString(s + "\n")
}
}
const assetsName = "Assets (Fonts / Strings / Images)"
outputSizes[assetsName] = assetSize
if s := checkLimit(assetsName, assetSize, input.AssetLimit); s != "" {
noSpace = true
report.WriteString(s + "\n")
}
const coreName = "Core system+services"
outputSizes[coreName] = root.size - total
if s := checkLimit(coreName, root.size-total, input.CoreLimit); s != "" {
noSpace = true
report.WriteString(s + "\n")
}
if noSpace {
return outputSizes, fmt.Errorf(report.String())
}
return outputSizes, nil
}
// Checks a given component to see if its size is greater than its allocated space limit as defined
// in the input JSON file.
func checkLimit(name string, size int64, limit json.Number) string {
l, err := limit.Int64()
if err != nil {
log.Fatalf("failed to parse %s as an int64: %s\n", limit, err)
}
if size > l {
return fmt.Sprintf("%s (%s) has exceeded its limit of %s.", name, formatSize(size), formatSize(l))
}
return ""
}
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]int64, outPath string) error {
f, err := os.Create(outPath)
if err != nil {
return err
}
defer f.Close()
encoder := json.NewEncoder(f)
encoder.SetIndent("", " ")
if err := encoder.Encode(&sizes); err != nil {
log.Fatal("failed to encode sizes: ", err)
}
return nil
}
func main() {
flag.Usage = func() {
fmt.Fprintln(os.Stderr, `Usage: size_checker --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)> }.")
flag.Parse()
if buildDir == "" {
flag.Usage()
os.Exit(2)
}
inputJSON := filepath.Join(buildDir, InputJSON)
inputData, err := ioutil.ReadFile(inputJSON)
if err != nil {
log.Fatal(readError(inputJSON, err))
}
var input Input
if err := json.Unmarshal(inputData, &input); err != nil {
log.Fatal(unmarshalError(inputJSON, err))
}
// If there are no components, then there are no work to do. We are done.
if len(input.Components) == 0 {
os.Exit(0)
}
outputSizes, processErr := processInput(&input, buildDir, BlobList, BlobSizes)
if len(fileSizeOutPath) > 0 {
if err := writeOutputSizes(outputSizes, fileSizeOutPath); err != nil {
log.Fatal(err)
}
}
if processErr != nil {
log.Fatal(processErr)
}
}