blob: 6fd60703ea7a43e7d4d3add61197c9c3792ba111 [file] [log] [blame]
package pinpoint
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"go.skia.org/infra/go/httputils"
"go.skia.org/infra/go/skerr"
"go.skia.org/infra/go/sklog"
"go.skia.org/infra/mcp/services/common"
"go.skia.org/infra/pinpoint/go/backends"
)
const (
// Refered to as comparison_mode for Pinpoint, one of bisect or try.
// bisect refers to culprit detection, or in other words, trying to
// find the culprit change that's caused a regression in a given range
// of commits.
BisectCommandName = "bisect"
// Try refers to the performance comparison of two static changes.
PairwiseCommandName = "try"
// Legacy Pinpoint, or Chromeperf Pinpoint instance.
LegacyPinpointUrl = "https://pinpoint-dot-chromeperf.appspot.com"
// Endpoint for new bisect/try jobs.
LegacyPinpointApiNew = "/api/new"
// New Pinpoint. The service handler is implemented in FE, so we
// target public perf instance to ensure that Chrome requests are
// handled within Chrome instances.
PinpointUrl = "https://perf.luci.app"
// Endpoint for scheduling Pinpoint workflows through Temporal.
PinpointV1Schedule = "/pinpoint/v1/schedule"
)
// Pinpoint response format for newly triggered jobs.
type PinpointJobResponse struct {
// A unique identifier for the Pinpoint job triggered.
// A hash (in legacy) and UUID (in new)
JobID string `json:"jobId"`
// URL to the job that's triggered. For new Pinpoint jobs, the
// route will return a 404 until it's complete.
// Note: New Pinpoint try jobs do not support writeback to the UI
// just yet.
JobURL string `json:"jobUrl"`
}
type PinpointConfigurationResponse struct {
Configurations []string `json:"configurations"`
}
// Lightweight client object.
type PinpointClient struct {
targetNewPinpoint bool
args map[string]any
Url string
}
// NewPinpointClient returns a client with the URL pointing to legacy
// or new Pinpoint depending on the arguments provided.
func NewPinpointClient(args map[string]any) *PinpointClient {
// default to legacy
if args == nil || args[TargetNewPinpoint] == nil {
return &PinpointClient{
targetNewPinpoint: false,
Url: LegacyPinpointUrl,
args: args,
}
}
targetVal := args[TargetNewPinpoint].(bool)
url := LegacyPinpointUrl
if targetVal {
url = PinpointUrl
}
return &PinpointClient{
targetNewPinpoint: targetVal,
Url: url,
args: args,
}
}
// legacyRequestUrl formulates the POST request URL to /api/new
// for a Pinpoint job.
func (pc *PinpointClient) legacyRequestUrl(comparisonMode string, bisectKey string) string {
params := url.Values{}
sklog.Debug(pc.args)
params.Set("comparison_mode", comparisonMode)
params.Set("name", fmt.Sprintf("[Beta] Pinpoint Job for %s", comparisonMode))
params.Set("tags", "{\"origin\":\"gemini\"}")
// legacy uses a different bisect key for try and bisect.
if pc.args[BaseGitHashFlagName] != nil {
params.Set(bisectKey, pc.args[BaseGitHashFlagName].(string))
}
if pc.args[ExperimentGitHashFlagName] != nil {
params.Set("end_git_hash", pc.args[ExperimentGitHashFlagName].(string))
}
if pc.args[BotConfigurationFlagName] != nil {
params.Set("configuration", pc.args[BotConfigurationFlagName].(string))
}
if pc.args[BenchmarkFlagName] != nil {
params.Set("benchmark", pc.args[BenchmarkFlagName].(string))
}
if pc.args[StoryFlagName] != nil {
params.Set("story", pc.args[StoryFlagName].(string))
}
if pc.args[IterationFlagName] != nil {
params.Set("iterations", pc.args[IterationFlagName].(string))
}
if pc.args[UserFlagName] != nil {
params.Set("user", pc.args[UserFlagName].(string))
}
if pc.args[BugIDFlagName] != nil {
params.Set("bug_id", pc.args[BugIDFlagName].(string))
}
// project defaults to Chromium if not provided.
if pc.args[ProjectFlagName] != nil {
params.Set("project", pc.args[ProjectFlagName].(string))
} else {
params.Set("project", "chromium")
}
if pc.args[BasePatchFlagName] != nil {
params.Set("base_patch", pc.args[BasePatchFlagName].(string))
}
if pc.args[ExperimentPatchFlagName] != nil {
params.Set("experiment_patch", pc.args[ExperimentPatchFlagName].(string))
}
url := fmt.Sprintf("%s%s?%s", pc.Url, LegacyPinpointApiNew, params.Encode())
sklog.Debugf("Target URL for Pinpoint Job: %s", url)
return url
}
// LegacyTryRequestUrl formulates the URL w/ comparison_mode: try
func (pc *PinpointClient) LegacyTryRequestUrl() string {
return pc.legacyRequestUrl(PairwiseCommandName, "base_git_hash")
}
// LegacyBisectRequestUrl formulates the URL w/ comparison_mode: bisect
func (pc *PinpointClient) LegacyBisectRequestUrl() string {
return pc.legacyRequestUrl("performance", "start_git_hash")
}
// TryJob curates the POST request to /api/new or /pinpoint/v1/schedule
// based on the arguments provided, specific to a Try Job (meaning comparison mode
// is try). Returns a PinpointResponse, containing the JobID and JobURL,
// both strings.
func (pc *PinpointClient) TryJob(ctx context.Context, c *http.Client) (*PinpointJobResponse, error) {
if pc.targetNewPinpoint {
// TODO(fill non legacy format)
return nil, errors.New("tool unsupported yet for new pinpoint")
}
reqUrl := pc.LegacyTryRequestUrl()
resp, err := httputils.PostWithContext(ctx, c, reqUrl, common.ContentType, nil)
if err != nil {
return nil, skerr.Wrapf(err, "failed to execute Pinpoint call")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed with request %d", resp.StatusCode)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, skerr.Wrapf(err, "failed to read body of response")
}
res := &PinpointJobResponse{}
err = json.Unmarshal([]byte(respBody), &res)
if err != nil {
return nil, skerr.Wrapf(err, "Failed to parse pinpoint response body.")
}
return res, nil
}
// setGitHashFromRevision is a helper function to determine whether
// git hash or revision is provided, for both base and experiment.
// if both are provided, the git hash is used.
// this function will modify args and return it.
func setGitHashFromRevision(ctx context.Context, args map[string]any, crrevClient *backends.CrrevClientImpl) (map[string]any, error) {
isEmpty := func(key string) bool {
return args[key] == nil || args[key].(string) == ""
}
// base case, where both are unset.
if args == nil ||
(isEmpty(BaseGitHashFlagName) && isEmpty(BaseRevisionFlagName)) ||
(isEmpty(ExperimentGitHashFlagName) && isEmpty(ExperimentRevisionFlagName)) {
return nil, errors.New("one of git hash or revision for both base and experiment is not set")
}
// if git hash is not set, but revision is, use crrev to figure out the hash and set it.
if isEmpty(BaseGitHashFlagName) && !isEmpty(BaseRevisionFlagName) {
resp, err := crrevClient.GetCommitInfo(ctx, args[BaseRevisionFlagName].(string))
if err != nil {
return nil, skerr.Wrapf(err, "failed to translate reivison to git hash")
}
args[BaseGitHashFlagName] = resp.GitHash
}
if isEmpty(ExperimentGitHashFlagName) && !isEmpty(ExperimentRevisionFlagName) {
resp, err := crrevClient.GetCommitInfo(ctx, args[ExperimentRevisionFlagName].(string))
if err != nil {
return nil, skerr.Wrapf(err, "failed to translate reivison to git hash")
}
args[ExperimentGitHashFlagName] = resp.GitHash
}
return args, nil
}
// Bisect curates the POST request to /api/new or /pinpoint/v1/schedule
// based on the arguments provided, specific to a Bisect (meaning comparison mode
// is bisect). Returns a PinpointResponse, containing the JobID and JobURL,
// both strings.
func (pc *PinpointClient) Bisect(ctx context.Context, c *http.Client, crrevClient *backends.CrrevClientImpl) (*PinpointJobResponse, error) {
if pc.targetNewPinpoint {
// TODO(fill non legacy format)
return nil, errors.New("tool unsupported yet for new pinpoint")
}
updatedArgs, err := setGitHashFromRevision(ctx, pc.args, crrevClient)
if err != nil {
return nil, err
}
pc.args = updatedArgs
reqUrl := pc.LegacyBisectRequestUrl()
resp, err := httputils.PostWithContext(ctx, c, reqUrl, common.ContentType, nil)
if err != nil {
return nil, skerr.Wrapf(err, "failed to execute Pinpoint call")
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed with request %d", resp.StatusCode)
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, skerr.Wrapf(err, "failed to read body of response")
}
res := &PinpointJobResponse{}
err = json.Unmarshal([]byte(respBody), &res)
if err != nil {
return nil, skerr.Wrapf(err, "Failed to parse pinpoint response body.")
}
return res, nil
}