blob: e53e92c9a96408d88b72322febe0579f5557bc8e [file] [log] [blame]
// 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.
//
// The metadata format is prefixed with "builder " to avoid collisions with OAuth:
// authorization: builder MYKEY
//
// 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("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
}