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 }