// Copyright 2020 The Go Authors. All rights reserved. // Use of this source code is governed by a BSD-style // license that can be found in the LICENSE file. //go:build go1.16 && (linux || darwin) // +build go1.16 // +build linux darwin // Code related to the Build Results API. package main import ( "context" "encoding/json" "io/ioutil" "log" "net/http" "net/url" "strings" "golang.org/x/build/cmd/coordinator/protos" "google.golang.org/grpc/codes" "google.golang.org/grpc/metadata" grpcstatus "google.golang.org/grpc/status" ) type gRPCServer struct { // embed an UnimplementedCoordinatorServer to avoid errors when adding new RPCs to the proto. *protos.UnimplementedCoordinatorServer // dashboardURL is the base URL of the Dashboard service (https://build.golang.org) dashboardURL string } // ClearResults implements the ClearResults RPC call from the CoordinatorService. // // It currently hits the build Dashboard service to clear a result. // TODO(golang.org/issue/34744) - Change to wipe build status from the Coordinator itself after findWork // starts using maintner. func (g *gRPCServer) ClearResults(ctx context.Context, req *protos.ClearResultsRequest) (*protos.ClearResultsResponse, error) { key, err := keyFromContext(ctx) if err != nil { return nil, err } if req.GetBuilder() == "" || req.GetHash() == "" { return nil, grpcstatus.Error(codes.InvalidArgument, "Builder and Hash must be provided") } if err := g.clearFromDashboard(ctx, req.GetBuilder(), req.GetHash(), key); err != nil { return nil, err } return &protos.ClearResultsResponse{}, nil } // clearFromDashboard calls the dashboard API to remove a build. // TODO(golang.org/issue/34744) - Remove after switching to wiping in the Coordinator. func (g *gRPCServer) clearFromDashboard(ctx context.Context, builder, hash, key string) error { u, err := url.Parse(g.dashboardURL) if err != nil { log.Printf("gRPCServer.ClearResults: Error parsing dashboardURL %q: %v", g.dashboardURL, err) return grpcstatus.Error(codes.Internal, codes.Internal.String()) } u.Path = "/clear-results" form := url.Values{ "builder": {builder}, "hash": {hash}, "key": {key}, } u.RawQuery = form.Encode() // The Dashboard API does not read the POST body. clearReq, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), nil) if err != nil { log.Printf("gRPCServer.ClearResults: error creating http request: %v", err) return grpcstatus.Error(codes.Internal, codes.Internal.String()) } resp, err := http.DefaultClient.Do(clearReq) if err != nil { log.Printf("gRPCServer.ClearResults: error performing wipe for %q/%q: %v", builder, hash, err) return grpcstatus.Error(codes.Internal, codes.Internal.String()) } body, err := ioutil.ReadAll(resp.Body) resp.Body.Close() if err != nil { log.Printf("gRPCServer.ClearResults: error reading response body for %q/%q: %v", builder, hash, err) return grpcstatus.Error(codes.Internal, codes.Internal.String()) } if resp.StatusCode != http.StatusOK { log.Printf("gRPCServer.ClearResults: bad status from dashboard: %v (%q)", resp.StatusCode, resp.Status) code, ok := statusToCode[resp.StatusCode] if !ok { code = codes.Internal } return grpcstatus.Error(code, code.String()) } if len(body) == 0 { return nil } dr := new(dashboardResponse) if err := json.Unmarshal(body, dr); err != nil { log.Printf("gRPCServer.ClearResults: error parsing response body for %q/%q: %v", builder, hash, err) return grpcstatus.Error(codes.Internal, codes.Internal.String()) } if dr.Error == "datastore: concurrent transaction" { return grpcstatus.Error(codes.Aborted, dr.Error) } if dr.Error != "" { return grpcstatus.Error(codes.FailedPrecondition, dr.Error) } return nil } // dashboardResponse mimics the dashResponse struct from app/appengine. // TODO(golang.org/issue/34744) - Remove after switching to wiping in the Coordinator. type dashboardResponse struct { // Error is an error string describing the API response. The dashboard API semantics are to always return a // 200, and populate this field with details. Error string `json:"Error"` // Response a human friendly response from the API. It is not populated for build status clear responses. Response string `json:"Response"` } // statusToCode maps HTTP status codes to gRPC codes. It purposefully only contains statuses we care to map. // TODO(golang.org/issue/34744) - Move to shared file or library. var statusToCode = map[int]codes.Code{ http.StatusOK: codes.OK, http.StatusBadRequest: codes.InvalidArgument, http.StatusUnauthorized: codes.Unauthenticated, http.StatusForbidden: codes.PermissionDenied, http.StatusNotFound: codes.NotFound, http.StatusConflict: codes.Aborted, http.StatusGone: codes.DataLoss, http.StatusTooManyRequests: codes.ResourceExhausted, http.StatusInternalServerError: codes.Internal, http.StatusNotImplemented: codes.Unimplemented, http.StatusServiceUnavailable: codes.Unavailable, http.StatusGatewayTimeout: codes.DeadlineExceeded, } // keyFromContext loads a builder key from request metadata. // // TODO(golang.org/issue/34744) - Move to shared file or library. This would make a nice UnaryServerInterceptor. // TODO(golang.org/issue/34744) - Currently allows the Build Dashboard to validate tokens, but we should validate here. func keyFromContext(ctx context.Context) (string, error) { md, ok := metadata.FromIncomingContext(ctx) if !ok { return "", grpcstatus.Error(codes.Internal, codes.Internal.String()) } auth := md.Get("coordinator-authorization") if len(auth) == 0 || len(auth[0]) < 9 || !strings.HasPrefix(auth[0], "builder ") { return "", grpcstatus.Error(codes.Unauthenticated, codes.Unauthenticated.String()) } key := auth[0][8:len(auth[0])] return key, nil }