golang.org/x/build@v0.0.0-20240506185731-218518f32b70/cmd/relui/main.go (about)

     1  // Copyright 2020 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  // relui is a web interface for managing the release process of Go.
     6  package main
     7  
     8  import (
     9  	"bytes"
    10  	"context"
    11  	"crypto/hmac"
    12  	"crypto/md5"
    13  	"encoding/json"
    14  	"flag"
    15  	"fmt"
    16  	"io"
    17  	"log"
    18  	"math/rand"
    19  	"net/http"
    20  	"net/mail"
    21  	"net/url"
    22  	"strings"
    23  	"time"
    24  
    25  	cloudbuild "cloud.google.com/go/cloudbuild/apiv1/v2"
    26  	"cloud.google.com/go/compute/metadata"
    27  	"cloud.google.com/go/storage"
    28  	"github.com/google/go-github/github"
    29  	"github.com/jackc/pgx/v4/pgxpool"
    30  	"github.com/shurcooL/githubv4"
    31  	"go.chromium.org/luci/auth"
    32  	pb "go.chromium.org/luci/buildbucket/proto"
    33  	"go.chromium.org/luci/grpc/prpc"
    34  	"go.chromium.org/luci/swarming/client/swarming"
    35  	"go.opencensus.io/plugin/ochttp"
    36  	"golang.org/x/build/buildlet"
    37  	"golang.org/x/build/gerrit"
    38  	"golang.org/x/build/internal/access"
    39  	"golang.org/x/build/internal/https"
    40  	"golang.org/x/build/internal/metrics"
    41  	"golang.org/x/build/internal/relui"
    42  	"golang.org/x/build/internal/relui/db"
    43  	"golang.org/x/build/internal/relui/protos"
    44  	"golang.org/x/build/internal/relui/sign"
    45  	"golang.org/x/build/internal/secret"
    46  	"golang.org/x/build/internal/task"
    47  	"golang.org/x/build/repos"
    48  	"golang.org/x/oauth2"
    49  	"golang.org/x/oauth2/google"
    50  	"google.golang.org/grpc"
    51  )
    52  
    53  var (
    54  	baseURL       = flag.String("base-url", "", "Prefix URL for routing and links.")
    55  	siteTitle     = flag.String("site-title", "Go Releases", "Site title.")
    56  	siteHeaderCSS = flag.String("site-header-css", "", "Site header CSS class name. Can be used to pick a look for the header.")
    57  
    58  	downUp      = flag.Bool("migrate-down-up", false, "Run all Up migration steps, then the last down migration step, followed by the final up migration. Exits after completion.")
    59  	migrateOnly = flag.Bool("migrate-only", false, "Exit after running migrations. Migrations are run by default.")
    60  	pgConnect   = flag.String("pg-connect", "", "Postgres connection string or URI. If empty, libpq connection defaults are used.")
    61  
    62  	scratchFilesBase = flag.String("scratch-files-base", "", "Storage for scratch files. gs://bucket/path or file:///path/to/scratch.")
    63  	signedFilesBase  = flag.String("signed-files-base", "", "Storage for signed files. gs://bucket/path or file:///path/to/signed.")
    64  	servingFilesBase = flag.String("serving-files-base", "", "Storage for serving files. gs://bucket/path or file:///path/to/serving.")
    65  	edgeCacheURL     = flag.String("edge-cache-url", "", "URL release files appear at when published to the CDN, e.g. https://dl.google.com/go.")
    66  	websiteUploadURL = flag.String("website-upload-url", "", "URL to POST website file data to, e.g. https://go.dev/dl/upload.")
    67  
    68  	cloudBuildProject = flag.String("cloud-build-project", "", "GCP project to run miscellaneous Cloud Build tasks")
    69  	cloudBuildAccount = flag.String("cloud-build-account", "", "Service account to run miscellaneous Cloud Build tasks")
    70  
    71  	swarmingURL     = flag.String("swarming-url", "", "Swarming service to use for tasks")
    72  	swarmingAccount = flag.String("swarming-account", "", "Service account to use for Swarming tasks")
    73  	swarmingPool    = flag.String("swarming-pool", "", "Swarming pool to run tasks in")
    74  	swarmingRealm   = flag.String("swarming-realm", "", "Swarming realm to run tasks in")
    75  )
    76  
    77  func main() {
    78  	rand.Seed(time.Now().Unix())
    79  	if err := secret.InitFlagSupport(context.Background()); err != nil {
    80  		log.Fatalln(err)
    81  	}
    82  	sendgridAPIKey := secret.Flag("sendgrid-api-key", "SendGrid API key for workflows involving sending email.")
    83  	var annMail task.MailHeader
    84  	addressVarFlag(&annMail.From, "announce-mail-from", "The From address to use for the (pre-)announcement mail.")
    85  	addressVarFlag(&annMail.To, "announce-mail-to", "The To address to use for the (pre-)announcement mail.")
    86  	addressListVarFlag(&annMail.BCC, "announce-mail-bcc", "The BCC address list to use for the (pre-)announcement mail.")
    87  	var schedMail task.MailHeader
    88  	addressVarFlag(&schedMail.From, "schedule-mail-from", "The From address to use for the scheduled workflow failure mail.")
    89  	addressVarFlag(&schedMail.To, "schedule-mail-to", "The To address to use for the scheduled workflow failure mail.")
    90  	addressListVarFlag(&schedMail.BCC, "schedule-mail-bcc", "The BCC address list to use for the scheduled workflow failure mail.")
    91  	var twitterAPI secret.TwitterCredentials
    92  	secret.JSONVarFlag(&twitterAPI, "twitter-api-secret", "Twitter API secret to use for workflows involving tweeting.")
    93  	var mastodonAPI secret.MastodonCredentials
    94  	secret.JSONVarFlag(&mastodonAPI, "mastodon-api-secret", "Mastodon API secret to use for workflows involving posting.")
    95  	masterKey := secret.Flag("builder-master-key", "Builder master key")
    96  	githubToken := secret.Flag("github-token", "GitHub API token")
    97  	https.RegisterFlags(flag.CommandLine)
    98  	flag.Parse()
    99  
   100  	ctx := context.Background()
   101  	if err := relui.InitDB(ctx, *pgConnect); err != nil {
   102  		log.Fatalf("relui.InitDB() = %v", err)
   103  	}
   104  	if *migrateOnly {
   105  		return
   106  	}
   107  	if *downUp {
   108  		if err := relui.MigrateDB(*pgConnect, true); err != nil {
   109  			log.Fatalf("relui.MigrateDB() = %v", err)
   110  		}
   111  		return
   112  	}
   113  
   114  	// Define the site header and external service configuration.
   115  	// The site header communicates to humans what will happen
   116  	// when workflows run.
   117  	// Keep these appropriately in sync.
   118  	siteHeader := relui.SiteHeader{
   119  		Title:    *siteTitle,
   120  		CSSClass: *siteHeaderCSS,
   121  	}
   122  	creds, err := google.FindDefaultCredentials(ctx, gerrit.OAuth2Scopes...)
   123  	if err != nil {
   124  		log.Fatalf("reading GCP credentials: %v", err)
   125  	}
   126  	gerritClient := &task.RealGerritClient{
   127  		Gitiles: "https://go.googlesource.com",
   128  		Client:  gerrit.NewClient("https://go-review.googlesource.com", gerrit.OAuth2Auth(creds.TokenSource)),
   129  	}
   130  	privateGerritClient := &task.RealGerritClient{
   131  		Gitiles: "https://go-internal.googlesource.com",
   132  		Client:  gerrit.NewClient("https://go-internal-review.googlesource.com", gerrit.OAuth2Auth(creds.TokenSource)),
   133  	}
   134  	gitClient := &task.Git{}
   135  	gitClient.UseOAuth2Auth(creds.TokenSource)
   136  	mailFunc := task.NewSendGridMailClient(*sendgridAPIKey).SendMail
   137  	mastodonClient, err := task.NewMastodonClient(mastodonAPI)
   138  	if err != nil {
   139  		log.Fatalln(err)
   140  	}
   141  	commTasks := task.CommunicationTasks{
   142  		AnnounceMailTasks: task.AnnounceMailTasks{
   143  			SendMail:           mailFunc,
   144  			AnnounceMailHeader: annMail,
   145  		},
   146  		SocialMediaTasks: task.SocialMediaTasks{
   147  			TwitterClient:  task.NewTwitterClient(twitterAPI),
   148  			MastodonClient: mastodonClient,
   149  		},
   150  	}
   151  	dh := relui.NewDefinitionHolder()
   152  	userPassAuth := buildlet.UserPass{
   153  		Username: "user-relui",
   154  		Password: key(*masterKey, "user-relui"),
   155  	}
   156  	gcsClient, err := storage.NewClient(ctx)
   157  	if err != nil {
   158  		log.Fatalf("Could not connect to GCS: %v", err)
   159  	}
   160  	cbClient, err := cloudbuild.NewClient(ctx)
   161  	if err != nil {
   162  		log.Fatalf("Could not connect to Cloud Build: %v", err)
   163  	}
   164  	cloudBuildClient := &task.RealCloudBuildClient{
   165  		BuildClient:   cbClient,
   166  		StorageClient: gcsClient,
   167  		ScriptProject: *cloudBuildProject,
   168  		ScriptAccount: *cloudBuildAccount,
   169  		ScratchURL:    *scratchFilesBase + "/build-outputs",
   170  	}
   171  	swarmingClient, err := swarming.NewClient(ctx, swarming.ClientOptions{
   172  		ServiceURL: *swarmingURL,
   173  		Auth: auth.Options{
   174  			GCEAllowAsDefault: true,
   175  		},
   176  	})
   177  	if err != nil {
   178  		log.Fatal(err)
   179  	}
   180  	luciHTTPClient, err := auth.NewAuthenticator(ctx, auth.SilentLogin, auth.Options{GCEAllowAsDefault: true}).Client()
   181  	if err != nil {
   182  		log.Fatal(err)
   183  	}
   184  	buildsClient := pb.NewBuildsClient(&prpc.Client{
   185  		C:    luciHTTPClient,
   186  		Host: "cr-buildbucket.appspot.com",
   187  	})
   188  	buildersClient := pb.NewBuildersClient(&prpc.Client{
   189  		C:    luciHTTPClient,
   190  		Host: "cr-buildbucket.appspot.com",
   191  	})
   192  	buildBucketClient := &task.RealBuildBucketClient{
   193  		BuildersClient: buildersClient,
   194  		BuildsClient:   buildsClient,
   195  	}
   196  
   197  	var dbPool db.PGDBTX
   198  	dbPool, err = pgxpool.Connect(ctx, *pgConnect)
   199  	if err != nil {
   200  		log.Fatal(err)
   201  	}
   202  	defer dbPool.Close()
   203  	dbPool = &relui.MetricsDB{dbPool}
   204  
   205  	var gr *metrics.MonitoredResource
   206  	if metadata.OnGCE() {
   207  		gr, err = metrics.GKEResource("relui-deployment")
   208  		if err != nil {
   209  			log.Println("metrics.GKEResource:", err)
   210  		}
   211  	}
   212  	ms, err := metrics.NewService(gr, relui.Views)
   213  	if err != nil {
   214  		log.Println("failed to initialize metrics:", err)
   215  	} else {
   216  		defer ms.Stop()
   217  	}
   218  	grpcServer := grpc.NewServer(grpc.UnaryInterceptor(access.RequireIAPAuthUnaryInterceptor(access.IAPSkipAudienceValidation)),
   219  		grpc.StreamInterceptor(access.RequireIAPAuthStreamInterceptor(access.IAPSkipAudienceValidation)))
   220  	signServer := sign.NewServer()
   221  	protos.RegisterReleaseServiceServer(grpcServer, signServer)
   222  	buildTasks := &relui.BuildReleaseTasks{
   223  		GerritClient:        gerritClient,
   224  		GerritProject:       "go",
   225  		GerritHTTPClient:    oauth2.NewClient(ctx, creds.TokenSource),
   226  		PrivateGerritClient: privateGerritClient,
   227  		SignService:         signServer,
   228  		GCSClient:           gcsClient,
   229  		ScratchFS: &task.ScratchFS{
   230  			BaseURL: *scratchFilesBase,
   231  			GCS:     gcsClient,
   232  		},
   233  		SignedURL:         *signedFilesBase,
   234  		ServingURL:        *servingFilesBase,
   235  		DownloadURL:       *edgeCacheURL,
   236  		ProxyPrefix:       "https://proxy.golang.org/golang.org/toolchain/@v",
   237  		CloudBuildClient:  cloudBuildClient,
   238  		BuildBucketClient: buildBucketClient,
   239  		SwarmingClient: &task.RealSwarmingClient{
   240  			SwarmingClient: swarmingClient,
   241  			SwarmingURL:    *swarmingURL,
   242  			ServiceAccount: *swarmingAccount,
   243  			Realm:          *swarmingRealm,
   244  			Pool:           *swarmingPool,
   245  		},
   246  		GoogleDockerBuildProject: "symbolic-datum-552",
   247  		GoogleDockerBuildTrigger: "golang-publish-internal-boringcrypto",
   248  		PublishFile: func(f task.WebsiteFile) error {
   249  			return publishFile(*websiteUploadURL, userPassAuth, f)
   250  		},
   251  		ApproveAction: relui.ApproveActionDep(dbPool),
   252  	}
   253  	githubHTTPClient := oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{AccessToken: *githubToken}))
   254  	milestoneTasks := &task.MilestoneTasks{
   255  		Client: &task.GitHubClient{
   256  			V3: github.NewClient(githubHTTPClient),
   257  			V4: githubv4.NewClient(githubHTTPClient),
   258  		},
   259  		RepoOwner:     "golang",
   260  		RepoName:      "go",
   261  		ApproveAction: relui.ApproveActionDep(dbPool),
   262  	}
   263  	versionTasks := &task.VersionTasks{
   264  		Gerrit:     gerritClient,
   265  		CloudBuild: cloudBuildClient,
   266  		GoProject:  "go",
   267  		UpdateProxyTestRepoTasks: task.UpdateProxyTestRepoTasks{
   268  			Git:       gitClient,
   269  			GerritURL: "https://golang-modproxy-test.googlesource.com/latest-go-version",
   270  			Branch:    "main",
   271  		},
   272  	}
   273  	if err := relui.RegisterReleaseWorkflows(ctx, dh, buildTasks, milestoneTasks, versionTasks, commTasks); err != nil {
   274  		log.Fatalf("RegisterReleaseWorkflows: %v", err)
   275  	}
   276  
   277  	ignoreProjects := map[string]bool{}
   278  	for p, r := range repos.ByGerritProject {
   279  		ignoreProjects[p] = !r.ShowOnDashboard()
   280  	}
   281  	tagTasks := &task.TagXReposTasks{
   282  		IgnoreProjects: ignoreProjects,
   283  		Gerrit:         gerritClient,
   284  		CloudBuild:     cloudBuildClient,
   285  		BuildBucket:    buildBucketClient,
   286  	}
   287  	dh.RegisterDefinition("Tag x/ repos", tagTasks.NewDefinition())
   288  	dh.RegisterDefinition("Tag a single x/ repo", tagTasks.NewSingleDefinition())
   289  
   290  	bundleTasks := &task.BundleNSSRootsTask{
   291  		Gerrit:     gerritClient,
   292  		CloudBuild: cloudBuildClient,
   293  	}
   294  	dh.RegisterDefinition("Update x/crypto NSS root bundle", bundleTasks.NewDefinition())
   295  
   296  	tagTelemetryTasks := &task.TagTelemetryTasks{
   297  		Gerrit:     gerritClient,
   298  		CloudBuild: cloudBuildClient,
   299  	}
   300  	dh.RegisterDefinition("Tag a new version of x/telemetry/config (if necessary)", tagTelemetryTasks.NewDefinition())
   301  
   302  	privateSyncTask := &task.PrivateMasterSyncTask{
   303  		Git:              gitClient,
   304  		PrivateGerritURL: "https://go-internal.googlesource.com/golang/go-private",
   305  		Ref:              "public",
   306  	}
   307  	dh.RegisterDefinition("Sync go-private master branch with public", privateSyncTask.NewDefinition())
   308  
   309  	privateXPatchTask := &task.PrivXPatch{
   310  		Git:           gitClient,
   311  		PublicGerrit:  gerritClient,
   312  		PrivateGerrit: privateGerritClient,
   313  		PublicRepoURL: func(repo string) string {
   314  			return "https://go.googlesource.com/" + repo
   315  		},
   316  		ApproveAction:      relui.ApproveActionDep(dbPool),
   317  		SendMail:           mailFunc,
   318  		AnnounceMailHeader: annMail,
   319  	}
   320  	dh.RegisterDefinition("Publish a private patch to a x/ repo", privateXPatchTask.NewDefinition(tagTasks))
   321  
   322  	var base *url.URL
   323  	if *baseURL != "" {
   324  		base, err = url.Parse(*baseURL)
   325  		if err != nil {
   326  			log.Fatalf("url.Parse(%q) = %v, %v", *baseURL, base, err)
   327  		}
   328  	}
   329  	l := &relui.PGListener{
   330  		DB:                        dbPool,
   331  		BaseURL:                   base,
   332  		ScheduleFailureMailHeader: schedMail,
   333  		SendMail:                  mailFunc,
   334  	}
   335  	w := relui.NewWorker(dh, dbPool, l)
   336  	go w.Run(ctx)
   337  	if err := w.ResumeAll(ctx); err != nil {
   338  		log.Printf("w.ResumeAll() = %v", err)
   339  	}
   340  	var h http.Handler = relui.NewServer(dbPool, w, base, siteHeader, ms)
   341  	if metadata.OnGCE() {
   342  		project, err := metadata.ProjectID()
   343  		if err != nil {
   344  			log.Fatal("failed to read project ID from metadata server")
   345  		}
   346  		if project == "symbolic-datum-552" {
   347  			h = access.RequireIAPAuthHandler(h, access.IAPSkipAudienceValidation)
   348  		}
   349  	}
   350  	log.Fatalln(https.ListenAndServe(ctx, &ochttp.Handler{Handler: GRPCHandler(grpcServer, h)}))
   351  }
   352  
   353  // GRPCHandler creates handler which intercepts requests intended for a GRPC server and directs the calls to the server.
   354  // All other requests are directed toward the passed in handler.
   355  func GRPCHandler(gs *grpc.Server, h http.Handler) http.Handler {
   356  	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
   357  		if r.ProtoMajor == 2 && strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
   358  			gs.ServeHTTP(w, r)
   359  			return
   360  		}
   361  		h.ServeHTTP(w, r)
   362  	})
   363  }
   364  
   365  func key(masterKey, principal string) string {
   366  	h := hmac.New(md5.New, []byte(masterKey))
   367  	io.WriteString(h, principal)
   368  	return fmt.Sprintf("%x", h.Sum(nil))
   369  }
   370  
   371  func publishFile(uploadURL string, auth buildlet.UserPass, f task.WebsiteFile) error {
   372  	req, err := json.Marshal(f)
   373  	if err != nil {
   374  		return err
   375  	}
   376  	u, err := url.Parse(uploadURL)
   377  	if err != nil {
   378  		return fmt.Errorf("invalid website upload URL %q: %v", *websiteUploadURL, err)
   379  	}
   380  	q := u.Query()
   381  	q.Set("user", strings.TrimPrefix(auth.Username, "user-"))
   382  	q.Set("key", auth.Password)
   383  	u.RawQuery = q.Encode()
   384  	resp, err := http.Post(u.String(), "application/json", bytes.NewReader(req))
   385  	if err != nil {
   386  		return err
   387  	}
   388  	defer resp.Body.Close()
   389  	if resp.StatusCode != http.StatusOK {
   390  		b, _ := io.ReadAll(resp.Body)
   391  		return fmt.Errorf("upload failed to %q: %v\n%s", uploadURL, resp.Status, b)
   392  	}
   393  	return nil
   394  }
   395  
   396  // addressVarFlag defines an address flag with specified name and usage string.
   397  // The argument p points to a mail.Address variable in which to store the value of the flag.
   398  func addressVarFlag(p *mail.Address, name, usage string) {
   399  	flag.Func(name, usage, func(s string) error {
   400  		a, err := mail.ParseAddress(s)
   401  		if err != nil {
   402  			return err
   403  		}
   404  		*p = *a
   405  		return nil
   406  	})
   407  }
   408  
   409  // addressListVarFlag defines an address list flag with specified name and usage string.
   410  // The argument p points to a []mail.Address variable in which to store the value of the flag.
   411  func addressListVarFlag(p *[]mail.Address, name, usage string) {
   412  	flag.Func(name, usage, func(s string) error {
   413  		as, err := mail.ParseAddressList(s)
   414  		if err != nil {
   415  			return err
   416  		}
   417  		*p = nil // Clear out the list before appending.
   418  		for _, a := range as {
   419  			*p = append(*p, *a)
   420  		}
   421  		return nil
   422  	})
   423  }