blob: 4f0c91a0bfa22c918846652f4c3d2506bd65ea34 [file] [log] [blame]
package main
import (
"bytes"
"context"
_ "embed"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"path"
"path/filepath"
"sort"
"strings"
"sync"
"text/template"
"go.skia.org/infra/autoroll/go/config"
"go.skia.org/infra/autoroll/go/config_vars"
"go.skia.org/infra/cd/go/cd"
"go.skia.org/infra/go/auth"
"go.skia.org/infra/go/chrome_branch"
"go.skia.org/infra/go/gerrit"
"go.skia.org/infra/go/git"
"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/kube/go/kube_conf_gen_lib"
"go.skia.org/infra/task_driver/go/td"
"golang.org/x/oauth2/google"
"golang.org/x/sync/errgroup"
"google.golang.org/protobuf/encoding/protojson"
"google.golang.org/protobuf/encoding/prototext"
)
const (
// Parent repo name for Google3 rollers.
google3ParentName = "Google3"
)
var (
// backendTemplate is the template used to generate the k8s YAML config file
// for autoroll backends.
//go:embed autoroll-be.yaml.template
backendTemplate string
// namespaceTemplate is the template used to generate the k8s YAML config
// file for autoroll namespaces.
//go:embed autoroll-ns.yaml.template
namespaceTemplate string
// protoMarshalOptions are used when writing configs in text proto format.
protoMarshalOptions = prototext.MarshalOptions{
Multiline: true,
}
// funcMap is used for executing templates.
funcMap = template.FuncMap{
"map": makeMap,
"list": makeList,
}
)
func main() {
// Flags.
privacySandboxAndroidRepoURL := flag.String("privacy_sandbox_android_repo_url", "", "Repo URL for privacy sandbox on Android.")
privacySandboxAndroidVersionsPath := flag.String("privacy_sandbox_android_versions_path", "", "Path to the file containing the versions of privacy sandbox on Android.")
srcRepo := flag.String("source-repo", "https://skia.googlesource.com/skia-autoroll-internal-config.git", "URL of the repo which triggered this run.")
srcCommit := flag.String("source-commit", "", "Commit hash which triggered this run.")
louhiExecutionID := flag.String("louhi-execution-id", "", "Execution ID of the Louhi flow.")
louhiPubsubProject := flag.String("louhi-pubsub-project", "", "GCP project used for sending Louhi pub/sub notifications.")
flag.Parse()
// We're using the task driver framework because it provides logging and
// helpful insight into what's occurring as the program runs.
fakeProjectId := ""
fakeTaskId := ""
fakeTaskName := ""
output := "-"
tdLocal := true
ctx := td.StartRun(&fakeProjectId, &fakeTaskId, &fakeTaskName, &output, &tdLocal)
defer td.EndRun(ctx)
if backendTemplate == "" {
td.Fatalf(ctx, "internal error; embedded template is empty.")
}
if *srcCommit == "" {
td.Fatalf(ctx, "--source-commit is required.")
}
// Set up auth, load config variables.
ts, err := google.DefaultTokenSource(ctx, auth.ScopeUserinfoEmail, gerrit.AuthScope)
if err != nil {
td.Fatal(ctx, err)
}
client := httputils.DefaultClientConfig().WithTokenSource(ts).With2xxOnly().Client()
reg, err := config_vars.NewRegistry(ctx, chrome_branch.NewClient(client))
if err != nil {
td.Fatal(ctx, 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 {
td.Fatal(ctx, err)
}
sort.Sort(PrivacySandboxVersionSlice(vars.PrivacySandboxVersions))
}
b, err := json.MarshalIndent(vars, "", " ")
if err != nil {
td.Fatal(ctx, err)
}
sklog.Infof("Using variables: %s", string(b))
// Walk through all files from the k8s-config repo. Read autoroll-related
// config files.
dst := gitiles.NewRepo("https://skia.googlesource.com/k8s-config.git", client)
dstBaseCommit, err := dst.ResolveRef(ctx, git.DefaultRef)
if err != nil {
td.Fatal(ctx, err)
}
dstFiles, err := dst.ListFilesRecursiveAtRef(ctx, ".", dstBaseCommit)
if err != nil {
td.Fatal(ctx, err)
}
dstExistingContents := map[string][]byte{}
for _, dstFile := range dstFiles {
if strings.HasSuffix(dstFile, "-autoroll-ns.yaml") || (strings.HasPrefix(dstFile, "autoroll-be-") && strings.HasSuffix(dstFile, ".yaml")) {
contents, err := dst.ReadFileAtRef(ctx, dstFile, dstBaseCommit)
if err != nil {
td.Fatal(ctx, err)
}
dstExistingContents[dstFile] = contents
}
}
// Walk through the autoroller config repo. Create roller configs from
// templates and convert roller configs to k8s configs.
src := gitiles.NewRepo(*srcRepo, client)
srcFiles, err := src.ListFilesRecursiveAtRef(ctx, ".", *srcCommit)
if err != nil {
td.Fatal(ctx, err)
}
generatedContents := map[string][]byte{}
for _, srcFile := range srcFiles {
if strings.HasSuffix(srcFile, ".cfg") {
sklog.Infof("Converting %s", srcFile)
cfgBytes, err := src.ReadFileAtRef(ctx, srcFile, *srcCommit)
if err != nil {
td.Fatalf(ctx, "failed to read roller config %s: %s", srcFile, err)
}
if err := convertConfig(ctx, cfgBytes, srcFile, generatedContents); err != nil {
td.Fatalf(ctx, "failed to convert config %s: %s", srcFile, err)
}
} else if strings.HasSuffix(srcFile, ".tmpl") {
sklog.Infof("Processing %s", srcFile)
tmplBytes, err := src.ReadFileAtRef(ctx, srcFile, *srcCommit)
if err != nil {
td.Fatalf(ctx, "failed to read template file %s: %s", srcFile, err)
}
generatedConfigs, err := processTemplate(ctx, srcFile, string(tmplBytes), vars)
if err != nil {
td.Fatalf(ctx, "failed to process template file %s: %s", srcFile, err)
}
for path, cfgBytes := range generatedConfigs {
if err := convertConfig(ctx, cfgBytes, path, generatedContents); err != nil {
td.Fatalf(ctx, "failed to convert config %s: %s", srcFile, err)
}
}
}
}
// Find the actual changes between the existing and the generated configs.
changes := make(map[string]string, len(generatedContents))
// First, "delete" all of the old contents, to ensure that we remove any
// no-longer-generated rollers.
for path := range dstExistingContents {
changes[path] = ""
}
// Next, overwrite the old contents with the generated contents.
for path, newContents := range generatedContents {
changes[path] = string(newContents)
}
// Finally, remove any files which didn't actually change.
for path, newContents := range generatedContents {
oldContents, ok := dstExistingContents[path]
if ok && bytes.Equal(oldContents, newContents) {
delete(changes, path)
}
}
// Upload a CL.
if len(changes) > 0 {
commitSubject := "Update autoroll k8s configs"
if err := cd.UploadCL(ctx, changes, "https://skia.googlesource.com/k8s-config.git", dstBaseCommit, commitSubject, *srcRepo, *srcCommit, *louhiPubsubProject, *louhiExecutionID); err != nil {
td.Fatalf(ctx, "Failed to create CL: %s", err)
}
}
}
// 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 convertConfig(ctx context.Context, cfgBytes []byte, relPath string, generatedContents map[string][]byte) error {
// Decode the config file.
var cfg config.Config
if err := prototext.Unmarshal(cfgBytes, &cfg); err != nil {
return skerr.Wrapf(err, "failed to parse roller config: %s", string(cfgBytes))
}
// Google3 uses a different type of backend.
if cfg.ParentDisplayName == google3ParentName {
return nil
}
// kube-conf-gen wants a JSON-ish version of the config in order to build
// the config map.
cfgJsonBytes, err := protojson.MarshalOptions{
AllowPartial: true,
EmitUnpopulated: true,
}.Marshal(&cfg)
if err != nil {
return skerr.Wrap(err)
}
cfgJson := map[string]interface{}{}
if err := json.Unmarshal(cfgJsonBytes, &cfgJson); err != nil {
return skerr.Wrap(err)
}
cfgMap := map[string]interface{}{}
if err := kube_conf_gen_lib.ParseConfigHelper(cfgJson, cfgMap, false); err != nil {
return skerr.Wrap(err)
}
// Encode the roller config file as base64.
// Note that we could re-read the config file from disk
// and base64-encode its contents. In practice, the
// behavior of the autoroll frontend and backends would
// be the same, so we consider it preferable to encode
// the parsed config, which will strip things like
// comments and whitespace that would otherwise produce
// a "different" config.
b, err := prototext.MarshalOptions{
AllowPartial: true,
EmitUnknown: true,
// This causes the emitted config to be pretty-printed. It isn't needed
// by the roller itself, but it's helpful for a human to debug issues
// related to the config. Remove this if we start getting errors about
// command lines being too long.
Indent: " ",
}.Marshal(&cfg)
if err != nil {
return skerr.Wrapf(err, "Failed to encode roller config as text proto")
}
cfgFileBase64 := base64.StdEncoding.EncodeToString(b)
cfgMap["configBase64"] = cfgFileBase64
// Run kube-conf-gen to generate the backend config file.
relDir, baseName := path.Split(relPath)
dstCfgPath := path.Join(relDir, fmt.Sprintf("autoroll-be-%s.yaml", strings.Split(baseName, ".")[0]))
rollerCfg, err := kube_conf_gen_lib.GenerateOutputFromTemplateString(backendTemplate, false, cfgMap)
if err != nil {
return skerr.Wrapf(err, "failed to write output")
}
generatedContents[dstCfgPath] = rollerCfg
// Run kube-conf-gen to generate the namespace config file. Note that we'll
// overwrite this file for every roller in the namespace, but that shouldn't
// be a problem, since the generated files will be the same.
namespace := strings.Split(cfg.ServiceAccount, "@")[0]
dstNsPath := path.Join(relDir, fmt.Sprintf("%s-ns.yaml", namespace))
nsCfg, err := kube_conf_gen_lib.GenerateOutputFromTemplateString(namespaceTemplate, false, cfgMap)
if err != nil {
return skerr.Wrapf(err, "failed to write output")
}
generatedContents[dstNsPath] = nsCfg
return nil
}
// processTemplate converts a single template into at least one config.
func processTemplate(ctx context.Context, srcPath, tmplContents string, vars *TemplateVars) (map[string][]byte, error) {
// Read and execute the template.
tmpl, err := template.New(filepath.Base(srcPath)).Funcs(funcMap).Parse(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)
}
// Parse the template as a list of Configs.
var configs config.Configs
if err := prototext.Unmarshal(buf.Bytes(), &configs); err != nil {
return nil, skerr.Wrapf(err, "failed to parse config proto from template file %q", srcPath)
}
sklog.Infof(" Found %d configs in %s", len(configs.Config), srcPath)
// Split off the template file name and the "templates" directory name.
srcPathSplit := []string{}
for _, elem := range strings.Split(srcPath, string(filepath.Separator)) {
if !strings.HasSuffix(elem, ".tmpl") && elem != "templates" {
srcPathSplit = append(srcPathSplit, elem)
}
}
srcRelPath := filepath.Join(srcPathSplit...)
changes := make(map[string][]byte, len(configs.Config))
for _, cfg := range configs.Config {
encBytes, err := protoMarshalOptions.Marshal(cfg)
if err != nil {
return nil, skerr.Wrapf(err, "failed to encode config from %q", srcPath)
}
changes[filepath.Join(srcRelPath, cfg.RollerName+".cfg")] = encBytes
}
return changes, nil
}
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
}