blob: 0f4111e6ea9e0bba6c409c66bfb78749d1ed9b46 [file]
// Copyright 2016 The Chromium 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 (
"bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
"reflect"
"sort"
"strconv"
"github.com/golang/protobuf/proto"
"github.com/maruel/subcommands"
"github.com/luci/luci-go/buildbucket/client/cmd/buildbucket/proto"
"github.com/luci/luci-go/common/cli"
"github.com/luci/luci-go/common/data/text/indented"
)
var cmdConvertBuilders = &subcommands.Command{
UsageLine: "convert-builders",
ShortDesc: "converts builders.pyl to swarmbucket definitions",
LongDesc: "Reads builders.pyl file from stdin, converts them to swarmbucket definitions and prints to stdout",
Advanced: true,
CommandRun: func() subcommands.CommandRun {
r := &convertBuildersRun{}
r.Flags.StringVar(&r.recipeRepo,
"recipe-repository",
"https://chromium.googlesource.com/chromium/tools/build",
"repository that contains recipes")
r.Flags.StringVar(&r.builderSuffix, "builder-suffix", " (Swarming)", "builder name suffix")
r.Flags.StringVar(&r.poolDim, "pool", "Chrome", "pool dimension to use")
return r
},
}
type convertBuildersRun struct {
baseCommandRun
recipeRepo string
builderSuffix string
poolDim string
}
func (r *convertBuildersRun) Run(a subcommands.Application, args []string, env subcommands.Env) int {
ctx := cli.GetContext(a, r, env)
if len(args) > 1 {
return r.done(ctx, fmt.Errorf("unexpected arguments; pipe builders.pyl file into my stdin"))
}
if err := r.convertStreams(os.Stdin, os.Stderr); err != nil {
return r.done(ctx, err)
}
return 0
}
func (r *convertBuildersRun) convertStreams(in io.Reader, out io.Writer) error {
var input buildersFile
if err := parsePyl(in, &input); err != nil {
return fmt.Errorf("could not parse stdin: %s", err)
}
output, err := r.convert(&input)
if err != nil {
return err
}
return writeConfig(out, output)
}
func (r *convertBuildersRun) convert(in *buildersFile) (*buildbucket.Swarming, error) {
if len(in.Builders) == 0 {
return nil, fmt.Errorf("no builders")
}
commonDims, err := in.commonDimensions()
if err != nil {
return nil, err
}
commonDims["pool"] = r.poolDim
commonProps := in.commonProperties()
commonProperties, commonPropertiesJ := encodeProperties(commonProps)
out := &buildbucket.Swarming{
BuilderDefaults: &buildbucket.Swarming_Builder{
Recipe: &buildbucket.Swarming_Recipe{
Name: proto.String(in.commonRecipe()),
Properties: commonProperties,
PropertiesJ: commonPropertiesJ,
Repository: &r.recipeRepo,
},
Dimensions: mapToList(commonDims),
SwarmingTags: []string{"allow_milo:1"},
},
}
for name, b := range in.Builders {
pool, err := in.pool(b)
if err != nil {
panic(err)
}
dims, err := pool.dimensions()
if err != nil {
panic(err)
}
dims = mapDiff(dims, commonDims)
delete(dims, "pool")
props := mapDiff(b.Properties, commonProps)
properties, propertiesJ := encodeProperties(props)
recipeName := b.Recipe
if recipeName == out.BuilderDefaults.Recipe.GetName() {
recipeName = ""
}
out.Builders = append(out.Builders, &buildbucket.Swarming_Builder{
Category: &b.Category,
Dimensions: mapToList(dims),
ExecutionTimeoutSecs: proto.Uint32(uint32(b.ExecutionTimeoutSecs)),
Name: proto.String(name + r.builderSuffix),
Recipe: &buildbucket.Swarming_Recipe{
Name: &recipeName,
Properties: properties,
PropertiesJ: propertiesJ,
},
})
}
sort.Sort(byCategoryThenName(out.Builders))
return out, nil
}
var pylToJSONScript = `
import ast, json, sys
d = ast.literal_eval(sys.stdin.read())
json.dump(d, sys.stdout)
`
func parsePyl(r io.Reader, v interface{}) error {
cmd := exec.Command("python", "-u", "-c", pylToJSONScript)
cmd.Stdin = r
out, err := cmd.CombinedOutput()
if err != nil {
return fmt.Errorf("could not parse pyl format: %s. Output: %s", err, out)
}
err = json.Unmarshal(out, v)
if err != nil {
err = fmt.Errorf("could not convert PYL to JSON: %s", err)
}
return err
}
// buildersFile is a structure for portions of builders.pyl file that we need.
// The format is documented in
// https://chromium.googlesource.com/infra/infra/+/ce976a0130570d5a66891bb8671f0b73ffc95c4d/doc/users/services/buildbot/builders.pyl.md
type buildersFile struct {
Builders map[string]*builder `json:"builders"`
SlavePools map[string]*slavePool `json:"slave_pools"`
}
type builder struct {
Category string `json:"category"`
Recipe string `json:"recipe"`
Properties map[string]interface{} `json:"properties"`
SlavePoolNames []string `json:"slave_pools"`
ExecutionTimeoutSecs intOrString `json:"builder_timeout_s"`
}
type slavePool struct {
slaveData `json:"slave_data"`
// there is also "slaves" property, but we don't need it.
}
type slaveData struct {
Bitness intOrString `json:"bits"`
OS string `json:"os"`
OSVersion string `json:"version"`
}
// intOrString is an integer that supports JSON unmarshaling from a string
type intOrString int
// UnmarshalJSON unmarshals data into n.
// data is expected to be a JSON string. If the string
// fails to parse to an integer, UnmarshalJSON returns
// an error.
func (n *intOrString) UnmarshalJSON(data []byte) error {
data = bytes.Trim(data, `"`)
num, err := strconv.Atoi(string(data))
if err != nil {
return err
}
*n = intOrString(num)
return nil
}
func (f *buildersFile) pool(b *builder) (*slavePool, error) {
if len(b.SlavePoolNames) != 1 {
return nil, fmt.Errorf("builders with multiple slave pools are not supported")
}
slavePool := f.SlavePools[b.SlavePoolNames[0]]
if slavePool == nil {
return nil, fmt.Errorf("slave pool %q is not found", slavePool)
}
return slavePool, nil
}
func (f *buildersFile) commonProperties() map[string]interface{} {
props := make([]map[string]interface{}, 0, len(f.Builders))
for _, b := range f.Builders {
props = append(props, b.Properties)
}
return frequentValues(props)
}
func (f *buildersFile) commonRecipe() string {
counts := make(map[interface{}]int, len(f.Builders))
for _, b := range f.Builders {
counts[b.Recipe]++
}
if result, ok := frequentValue(counts); ok {
return result.(string)
}
return ""
}
func (f *buildersFile) commonDimensions() (map[string]interface{}, error) {
allDims := make([]map[string]interface{}, 0, len(f.Builders))
for name, b := range f.Builders {
pool, err := f.pool(b)
if err != nil {
return nil, fmt.Errorf("builder %q: %s", name, err)
}
dims, err := pool.dimensions()
if err != nil {
return nil, fmt.Errorf("builder %q: %s", name, err)
}
allDims = append(allDims, dims)
}
return frequentValues(allDims), nil
}
func (s *slavePool) dimensions() (map[string]interface{}, error) {
var os string
switch s.OS {
case "linux":
switch s.OSVersion {
case "":
os = "Linux"
case "precise":
os = "Ubuntu-12.04"
case "trusty":
os = "Ubuntu-14.04"
}
case "mac":
switch s.OSVersion {
case "":
os = "Mac"
case "10.9", "10.10", "10.11":
os = "Mac-" + s.OSVersion
}
case "win":
switch s.OSVersion {
case "":
os = "Windows"
case "win7":
os = "Windows-7-SP1"
case "win8":
os = "Windows-8.1-SP0"
case "win10":
os = "Windows-10-10586"
case "2008":
os = "Windows-2008ServerR2-SP1"
}
}
if os == "" {
return nil, fmt.Errorf("unsupported OS %q version %q", s.OS, s.OSVersion)
}
var cpu string
switch s.Bitness {
case 0:
cpu = "x86"
case 32:
cpu = "x86-32"
case 64:
cpu = "x86-64"
default:
return nil, fmt.Errorf("unsupported bitness: %d", s.Bitness)
}
return map[string]interface{}{
"os": os,
"cpu": cpu,
}, nil
}
func encodeProperties(props map[string]interface{}) (properties, propertiesJ []string) {
for k, v := range props {
if s, ok := v.(string); ok {
properties = append(properties, fmt.Sprintf("%s:%s", k, s))
} else {
marshaled, err := json.Marshal(v)
if err != nil {
panic(err)
}
propertiesJ = append(propertiesJ, fmt.Sprintf("%s:%s", k, marshaled))
}
}
sort.Strings(properties)
sort.Strings(propertiesJ)
return
}
func writeConfig(w io.Writer, cfg *buildbucket.Swarming) error {
indented := &indented.Writer{Writer: w, UseSpaces: true}
var writeError error
p := func(f string, a ...interface{}) {
if writeError == nil {
_, writeError = fmt.Fprintf(indented, f, a...)
fmt.Fprint(indented, "\n")
}
}
pstr := func(field, value string) {
if value != "" {
p("%s: %q", field, value)
}
}
pstrlist := func(field string, values []string) {
for _, v := range values {
pstr(field, v)
}
}
printRecipe := func(r *buildbucket.Swarming_Recipe) {
pstr("repository", r.GetRepository())
pstr("name", r.GetName())
var properties []string
fieldName := make(map[string]string, len(r.Properties)+len(r.PropertiesJ))
for _, p := range r.Properties {
properties = append(properties, p)
fieldName[p] = "properties"
}
for _, p := range r.PropertiesJ {
properties = append(properties, p)
fieldName[p] = "properties_j"
}
sort.Strings(properties)
for _, prop := range properties {
pstr(fieldName[prop], prop)
}
}
printBuilder := func(b *buildbucket.Swarming_Builder) {
pstr("category", b.GetCategory())
pstr("name", b.GetName())
pstrlist("swarming_tags", b.SwarmingTags)
pstrlist("dimensions", b.Dimensions)
if b.GetExecutionTimeoutSecs() > 0 {
p("execution_timeout_secs: %d", b.GetExecutionTimeoutSecs())
}
if b.Recipe.GetRepository() != "" || b.Recipe.GetName() != "" ||
len(b.Recipe.Properties) > 0 || len(b.Recipe.PropertiesJ) > 0 {
p("recipe {")
indented.Level += 2
printRecipe(b.Recipe)
indented.Level -= 2
p("}")
}
}
p("builder_defaults {")
indented.Level += 2
printBuilder(cfg.BuilderDefaults)
indented.Level -= 2
p("}")
p("\n# Keep builders sorted by category, then name.")
builders := make(byCategoryThenName, len(cfg.Builders))
copy(builders, cfg.Builders)
sort.Sort(builders)
for _, b := range builders {
p("\nbuilders {")
indented.Level += 2
printBuilder(b)
indented.Level -= 2
p("}")
}
return writeError
}
// freqValues returns frequently used values
func frequentValues(maps []map[string]interface{}) map[string]interface{} {
freq := map[string]map[interface{}]int{}
// find all keys
for _, m := range maps {
for k := range m {
if freq[k] == nil {
freq[k] = make(map[interface{}]int, len(maps))
}
}
}
// for each key count occurrences of values, including unset values.
for k, counts := range freq {
for _, m := range maps {
counts[m[k]]++
}
}
result := map[string]interface{}{}
for k, counts := range freq {
if v, ok := frequentValue(counts); ok && v != nil {
result[k] = v
}
}
return result
}
func frequentValue(counts map[interface{}]int) (interface{}, bool) {
total := 0
for _, c := range counts {
total += c
}
for v, c := range counts {
fraction := float64(c) / float64(total)
if fraction >= 0.7 {
return v, true
}
}
return nil, false
}
func mapToList(m map[string]interface{}) []string {
var result []string
for k, v := range m {
result = append(result, fmt.Sprintf("%s:%s", k, v))
}
sort.Strings(result)
return result
}
// mapDiff returns a map with values different from defaults.
func mapDiff(values, defaults map[string]interface{}) map[string]interface{} {
result := make(map[string]interface{}, len(values))
// include values that are different from defaults
for k, v := range values {
if dv, ok := defaults[k]; !ok || !reflect.DeepEqual(v, dv) {
result[k] = v
}
}
// negate defaults that were not explicitly specified in values
for k, dv := range defaults {
zero := reflect.Zero(reflect.TypeOf(dv)).Interface()
if _, ok := values[k]; !ok && !reflect.DeepEqual(zero, dv) {
result[k] = zero
}
}
return result
}
type byCategoryThenName []*buildbucket.Swarming_Builder
func (a byCategoryThenName) Len() int { return len(a) }
func (a byCategoryThenName) Swap(i, j int) { a[i], a[j] = a[j], a[i] }
func (a byCategoryThenName) Less(i, j int) bool {
switch {
case a[i].GetCategory() < a[j].GetCategory():
return true
case a[i].GetCategory() > a[j].GetCategory():
return false
default:
return a[i].GetName() < a[j].GetName()
}
}