blob: 458099c785af9e745f719c2fec9251bc6eb1fd69 [file] [log] [blame]
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"io/fs"
"net/http"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"sync"
"text/template"
"github.com/protocolbuffers/txtpbfmt/parser"
"github.com/urfave/cli/v2"
"go.skia.org/infra/autoroll/go/config_vars"
"go.skia.org/infra/go/auth"
"go.skia.org/infra/go/chrome_branch"
"go.skia.org/infra/go/gitiles"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/go/util"
"golang.org/x/oauth2/google"
"golang.org/x/sync/errgroup"
)
var (
// FuncMap is used for executing templates.
FuncMap = template.FuncMap{
"map": makeMap,
"list": makeList,
"sanitize": sanitize,
}
)
func main() {
const (
flagTmplFlagsFile = "vars-file"
flagDir = "in"
flagPrivacySandboxAndroidRepoURL = "privacy-sandbox-android-repo-url"
flagPrivacySandboxAndroidVersionsPath = "privacy-sandbox-android-versions-path"
)
app := &cli.App{
Name: "autoroll-config-generator",
Description: `autoroll-config-generator generates autoroll configs from templates.`,
Commands: []*cli.Command{
{
Name: "generate",
Description: "Generate autoroll configs.",
Usage: "generate <options>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: flagTmplFlagsFile,
Usage: "File containing the template input variables.",
Required: true,
},
&cli.StringFlag{
Name: flagDir,
Usage: "Directory in which to search for templates.",
Required: true,
},
},
Action: func(ctx *cli.Context) error {
return generate(ctx.Context, ctx.String(flagTmplFlagsFile), ctx.String(flagDir))
},
},
{
Name: "update-inputs",
Description: "Update the saved input variables.",
Usage: "update-inputs <options>",
Flags: []cli.Flag{
&cli.StringFlag{
Name: flagTmplFlagsFile,
Usage: "File containing the template input variables.",
Required: true,
},
&cli.StringFlag{
Name: flagPrivacySandboxAndroidRepoURL,
Usage: "Repo URL for privacy sandbox on Android.",
Required: true,
},
&cli.StringFlag{
Name: flagPrivacySandboxAndroidVersionsPath,
Usage: "Path to the file containing the versions of privacy sandbox on Android.",
Required: true,
},
},
Action: func(ctx *cli.Context) error {
return updateInputs(ctx.Context, ctx.String(flagTmplFlagsFile), ctx.String(flagPrivacySandboxAndroidRepoURL), ctx.String(flagPrivacySandboxAndroidVersionsPath))
},
},
},
Usage: "autoroll-config-generator <subcommand>",
}
if err := app.RunContext(context.Background(), os.Args); err != nil {
sklog.Fatal(err)
}
}
func generate(ctx context.Context, tmplVarsFile, dir string) error {
// Load config variables.
var vars templateVars
if err := util.WithReadFile(tmplVarsFile, func(f io.Reader) error {
return json.NewDecoder(f).Decode(&vars)
}); err != nil {
return skerr.Wrap(err)
}
// Walk through the directory looking for templates and previously-generated
// configs.
templates := []string{}
oldConfigs := []string{}
fsys := os.DirFS(dir)
if err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() {
return nil
}
if strings.HasSuffix(d.Name(), ".tmpl") {
templates = append(templates, path)
}
if strings.Contains(path, string(filepath.Separator)+"generated"+string(filepath.Separator)) {
oldConfigs = append(oldConfigs, path)
}
return nil
}); err != nil {
return skerr.Wrap(err)
}
for _, oldConfig := range oldConfigs {
fmt.Printf("Deleting old config %s\n", oldConfig)
if err := os.Remove(oldConfig); err != nil {
return skerr.Wrapf(err, "failed to remove old config %s", oldConfig)
}
}
for _, tmplPath := range templates {
fmt.Printf("Processing %s\n", tmplPath)
generatedConfigs, err := processTemplate(tmplPath, &vars)
if err != nil {
return skerr.Wrapf(err, "failed to process template file %s", tmplPath)
}
for path, cfgBytes := range generatedConfigs {
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, os.ModePerm); err != nil {
return skerr.Wrapf(err, "failed to create dir %s", dir)
}
if err := os.WriteFile(path, cfgBytes, os.ModePerm); err != nil {
return skerr.Wrapf(err, "failed to write %s", path)
}
}
}
return nil
}
func updateInputs(ctx context.Context, tmplVarsFile, privacySandboxAndroidRepoURL, privacySandboxAndroidVersionsPath string) error {
// Set up auth, load config variables.
ts, err := google.DefaultTokenSource(ctx, auth.ScopeUserinfoEmail)
if err != nil {
return skerr.Wrap(err)
}
client := httputils.DefaultClientConfig().WithTokenSource(ts).With2xxOnly().Client()
vars, err := createTemplateVars(ctx, client, privacySandboxAndroidRepoURL, privacySandboxAndroidVersionsPath)
if err != nil {
return skerr.Wrap(err)
}
// Write the template variables to the destination file.
return util.WithWriteFile(tmplVarsFile, func(f io.Writer) error {
enc := json.NewEncoder(f)
enc.SetIndent("", " ")
return enc.Encode(vars)
})
}
var rollerNameRegex = regexp.MustCompile(`(?m)^\s*roller_name:\s*"(\S+)"`)
// processTemplate converts a single template into at least one config.
func processTemplate(srcPath string, vars *templateVars) (map[string][]byte, error) {
// Read and execute the template.
tmplContents, err := os.ReadFile(srcPath)
if err != nil {
return nil, skerr.Wrapf(err, "failed to read template file %s", srcPath)
}
tmpl, err := template.New(filepath.Base(srcPath)).Funcs(FuncMap).Parse(string(tmplContents))
if err != nil {
return nil, skerr.Wrapf(err, "failed to parse template file %q", srcPath)
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, vars); err != nil {
return nil, skerr.Wrapf(err, "failed to execute template file %q", srcPath)
}
// Split the resulting contents into one file per config.
configsBytes := splitConfigs(buf.Bytes())
fmt.Printf("Found %d configs in %s\n", len(configsBytes), srcPath)
// Split off the template file name and the "templates" directory name.
srcPathSplit := []string{}
for _, elem := range strings.Split(srcPath, string(filepath.Separator)) {
if elem == "templates" {
srcPathSplit = append(srcPathSplit, "generated")
} else if !strings.HasSuffix(elem, ".tmpl") {
srcPathSplit = append(srcPathSplit, elem)
}
}
srcRelPath := filepath.Join(srcPathSplit...)
// Find the names of the rollers.
changes := make(map[string][]byte, len(configsBytes))
for _, configBytes := range configsBytes {
matches := rollerNameRegex.FindSubmatch(configBytes)
if len(matches) != 2 {
return nil, skerr.Fmt("failed to find roller_name in %s:\n%s", srcPath, string(configBytes))
}
dstFile := filepath.Join(srcRelPath, string(matches[1])+".cfg")
if strings.HasPrefix(srcPath, string(filepath.Separator)) && !strings.HasPrefix(dstFile, string(filepath.Separator)) {
dstFile = string(filepath.Separator) + dstFile
}
if _, ok := changes[dstFile]; ok {
return nil, skerr.Fmt("multiple templates produced %s", dstFile)
}
configBytes, err = parser.FormatWithConfig(configBytes, parser.Config{
ExpandAllChildren: true,
SkipAllColons: true,
SortFieldsByFieldName: false,
SortRepeatedFieldsByContent: false,
SortRepeatedFieldsBySubfield: nil,
RemoveDuplicateValuesForRepeatedFields: false,
AllowTripleQuotedStrings: false,
WrapStringsAtColumn: 0,
WrapHTMLStrings: false,
WrapStringsAfterNewlines: false,
PreserveAngleBrackets: false,
SmartQuotes: false,
})
if err != nil {
return nil, skerr.Wrap(err)
}
changes[dstFile] = configBytes
}
return changes, nil
}
var configStartRegex = regexp.MustCompile(`(?m)^\s*config\s*\{`)
var configEndRegex = regexp.MustCompile(`\s*\}\s*$`)
// splitConfigs takes the template results containing multiple config
// definitions and splits into individual config definitions.
func splitConfigs(allConfigs []byte) [][]byte {
splitIndexes := configStartRegex.FindAllIndex(allConfigs, -1)
configsBytes := make([][]byte, 0, len(splitIndexes))
for i, splitIndex := range splitIndexes {
startIndex := splitIndex[1]
endIndex := len(allConfigs)
if i < len(splitIndexes)-1 {
endIndex = splitIndexes[i+1][0]
}
configBytes := allConfigs[startIndex:endIndex]
configBytes = configEndRegex.ReplaceAll(configBytes, []byte(""))
configsBytes = append(configsBytes, configBytes)
}
return configsBytes
}
// createTemplateVars reads data from multiple sources to produce variables used
// as input to templates.
func createTemplateVars(ctx context.Context, client *http.Client, privacySandboxAndroidRepoURL, privacySandboxAndroidVersionsPath string) (*templateVars, error) {
reg, err := config_vars.NewRegistry(ctx, chrome_branch.NewClient(client))
if err != nil {
return nil, skerr.Wrap(err)
}
vars := &templateVars{
Vars: reg.Vars(),
}
// Load the privacy sandbox versions for each of the active milestones.
if privacySandboxAndroidRepoURL != "" && privacySandboxAndroidVersionsPath != "" {
var eg errgroup.Group
repo := gitiles.NewRepo(privacySandboxAndroidRepoURL, client)
var mtx sync.Mutex
milestones := append(vars.Branches.ActiveMilestones, vars.Branches.Chromium.Main)
for _, m := range milestones {
m := m // https://golang.org/doc/faq#closures_and_goroutines
eg.Go(func() error {
branchName := fmt.Sprintf("m%d", m.Milestone)
ref := fmt.Sprintf("refs/heads/chromium/%d", m.Number)
bucket := fmt.Sprintf("luci.chrome-m%d.try", m.Milestone)
if m.Number == 0 {
branchName = "main"
ref = "refs/heads/main"
bucket = "luci.chrome.try"
}
sklog.Infof("Reading privacy sandbox versions at milestone: %+v", m)
contents, err := repo.ReadFileAtRef(ctx, privacySandboxAndroidVersionsPath, ref)
if err != nil {
if strings.Contains(err.Error(), "NOT_FOUND") {
sklog.Warningf("%s not found in %s", privacySandboxAndroidVersionsPath, ref)
return nil
}
return skerr.Wrapf(err, "failed to load privacy sandbox version for %s", ref)
}
var psVersions []*privacySandboxVersion
if err := json.Unmarshal(contents, &psVersions); err != nil {
return skerr.Wrapf(err, "failed to parse privacy sandbox version for %s from %s", ref, string(contents))
}
for _, v := range psVersions {
v.BranchName = branchName
v.Ref = ref
v.Bucket = bucket
}
mtx.Lock()
defer mtx.Unlock()
vars.PrivacySandboxVersions = append(vars.PrivacySandboxVersions, psVersions...)
return nil
})
}
if err := eg.Wait(); err != nil {
return nil, skerr.Wrap(err)
}
sort.Sort(privacySandboxVersionSlice(vars.PrivacySandboxVersions))
}
return vars, nil
}
// privacySandboxVersion tracks a single version of the privacy sandbox.
type privacySandboxVersion struct {
BranchName string `json:"BranchName"`
Ref string `json:"Ref"`
Bucket string `json:"Bucket"`
PylFile string `json:"PylFile"`
PylTargetPath string `json:"PylTargetPath"`
CipdPackage string `json:"CipdPackage"`
CipdTag string `json:"CipdTag"`
}
// privacySandboxVersionSlice implements sort.Interface.
type privacySandboxVersionSlice []*privacySandboxVersion
// Len implements sort.Interface.
func (s privacySandboxVersionSlice) Len() int {
return len(s)
}
func sortHelper(a, b string) (bool, bool) {
if a != b {
return true, a < b
}
return false, false
}
// Less implements sort.Interface.
func (s privacySandboxVersionSlice) Less(i, j int) bool {
a := s[i]
b := s[j]
if diff, less := sortHelper(a.BranchName, b.BranchName); diff {
return less
}
if diff, less := sortHelper(a.Ref, b.Ref); diff {
return less
}
if diff, less := sortHelper(a.Bucket, b.Bucket); diff {
return less
}
if diff, less := sortHelper(a.CipdPackage, b.CipdPackage); diff {
return less
}
if diff, less := sortHelper(a.CipdTag, b.CipdTag); diff {
return less
}
if diff, less := sortHelper(a.PylFile, b.PylFile); diff {
return less
}
if diff, less := sortHelper(a.PylTargetPath, b.PylTargetPath); diff {
return less
}
return false
}
// Swap implements sort.Interface.
func (s privacySandboxVersionSlice) Swap(i, j int) {
s[i], s[j] = s[j], s[i]
}
type templateVars struct {
*config_vars.Vars
PrivacySandboxVersions []*privacySandboxVersion
}
func makeMap(elems ...interface{}) (map[string]interface{}, error) {
if len(elems)%2 != 0 {
return nil, skerr.Fmt("Requires an even number of elements, not %d", len(elems))
}
rv := make(map[string]interface{}, len(elems)/2)
for i := 0; i < len(elems); i += 2 {
key, ok := elems[i].(string)
if !ok {
return nil, skerr.Fmt("Map keys must be strings, not %v", elems[i])
}
rv[key] = elems[i+1]
}
return rv, nil
}
func makeList(args ...interface{}) []interface{} {
return args
}
func sanitize(v string) string {
re1 := regexp.MustCompile(`[^a-zA-Z0-9-]+`)
v = re1.ReplaceAllString(v, "-")
re2 := regexp.MustCompile(`--+`)
v = re2.ReplaceAllString(v, "-")
return v
}