|
| 1 | +// Copyright 2024 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 | +// Package lucipoll implements a simple polling LUCI client |
| 6 | +// for the possibly-short-term needs of the build dashboard. |
| 7 | +package lucipoll |
| 8 | + |
| 9 | +import ( |
| 10 | + "context" |
| 11 | + "encoding/json" |
| 12 | + "fmt" |
| 13 | + "log" |
| 14 | + "runtime/debug" |
| 15 | + "slices" |
| 16 | + "strings" |
| 17 | + "sync" |
| 18 | + "time" |
| 19 | + |
| 20 | + bbpb "go.chromium.org/luci/buildbucket/proto" |
| 21 | + "go.chromium.org/luci/grpc/prpc" |
| 22 | + "golang.org/x/build/maintner/maintnerd/apipb" |
| 23 | + "golang.org/x/build/repos" |
| 24 | + "google.golang.org/grpc" |
| 25 | + "google.golang.org/protobuf/types/known/fieldmaskpb" |
| 26 | +) |
| 27 | + |
| 28 | +// maintnerClient is a subset of apipb.MaintnerServiceClient. |
| 29 | +type maintnerClient interface { |
| 30 | + // GetDashboard is extracted from apipb.MaintnerServiceClient. |
| 31 | + GetDashboard(ctx context.Context, in *apipb.DashboardRequest, opts ...grpc.CallOption) (*apipb.DashboardResponse, error) |
| 32 | +} |
| 33 | + |
| 34 | +type Builder struct { |
| 35 | + Name string |
| 36 | + *BuilderConfigProperties |
| 37 | +} |
| 38 | + |
| 39 | +type BuilderConfigProperties struct { |
| 40 | + Repo string `json:"project,omitempty"` |
| 41 | + GoBranch string `json:"go_branch,omitempty"` |
| 42 | + Target struct { |
| 43 | + GOOS string `json:"goos,omitempty"` |
| 44 | + GOARCH string `json:"goarch,omitempty"` |
| 45 | + } `json:"target"` |
| 46 | + KnownIssue int `json:"known_issue,omitempty"` |
| 47 | +} |
| 48 | + |
| 49 | +type Build struct { |
| 50 | + ID int64 |
| 51 | + BuilderName string |
| 52 | + Status bbpb.Status |
| 53 | +} |
| 54 | + |
| 55 | +func NewService(maintCl maintnerClient) *service { |
| 56 | + const crBuildBucketHost = "cr-buildbucket.appspot.com" |
| 57 | + |
| 58 | + s := &service{ |
| 59 | + maintCl: maintCl, |
| 60 | + buildersCl: bbpb.NewBuildersPRPCClient(&prpc.Client{Host: crBuildBucketHost}), |
| 61 | + buildsCl: bbpb.NewBuildsPRPCClient(&prpc.Client{Host: crBuildBucketHost}), |
| 62 | + } |
| 63 | + go s.pollLoop() |
| 64 | + return s |
| 65 | +} |
| 66 | + |
| 67 | +type service struct { |
| 68 | + maintCl maintnerClient |
| 69 | + |
| 70 | + buildersCl bbpb.BuildersClient |
| 71 | + buildsCl bbpb.BuildsClient |
| 72 | + |
| 73 | + mu sync.RWMutex |
| 74 | + cached Snapshot |
| 75 | +} |
| 76 | + |
| 77 | +// A Snapshot is a consistent snapshot in time holding LUCI post-submit state. |
| 78 | +type Snapshot struct { |
| 79 | + Builders map[string]Builder // Map key is builder name. |
| 80 | + RepoCommitBuilds map[string]map[string]map[string]Build // Map keys are repo, commit ID, builder name. |
| 81 | +} |
| 82 | + |
| 83 | +// PostSubmitSnapshot returns a cached snapshot. |
| 84 | +func (s *service) PostSubmitSnapshot() Snapshot { |
| 85 | + s.mu.RLock() |
| 86 | + defer s.mu.RUnlock() |
| 87 | + return s.cached |
| 88 | +} |
| 89 | + |
| 90 | +func (s *service) pollLoop() { |
| 91 | + ticker := time.NewTicker(2 * time.Minute) |
| 92 | + for { |
| 93 | + builders, builds, err := runOnce(context.Background(), s.maintCl, s.buildersCl, s.buildsCl) |
| 94 | + if err != nil { |
| 95 | + log.Println("lucipoll:", err) |
| 96 | + // Sleep a bit and retry. |
| 97 | + time.Sleep(30 * time.Second) |
| 98 | + continue |
| 99 | + } |
| 100 | + s.mu.Lock() |
| 101 | + s.cached = Snapshot{builders, builds} |
| 102 | + s.mu.Unlock() |
| 103 | + <-ticker.C // Limit how often we're willing to poll. |
| 104 | + } |
| 105 | +} |
| 106 | + |
| 107 | +func runOnce( |
| 108 | + ctx context.Context, |
| 109 | + maintCl maintnerClient, buildersCl bbpb.BuildersClient, buildsCl bbpb.BuildsClient, |
| 110 | +) (_ map[string]Builder, _ map[string]map[string]map[string]Build, err error) { |
| 111 | + defer func() { |
| 112 | + if e := recover(); e != nil { |
| 113 | + err = fmt.Errorf("internal panic: %v\n\n%s", e, debug.Stack()) |
| 114 | + } |
| 115 | + }() |
| 116 | + |
| 117 | + // Fetch all current completed LUCI builders. |
| 118 | + // |
| 119 | + // TODO: It would be possible to cache initially fetched builders and then fetch |
| 120 | + // additional individual builders when seeing a build referencing an unknown one. |
| 121 | + // But that would need to take into account that a builder may be intentionally |
| 122 | + // removed from the LUCI dashboard. It adds more complexity, so for now do the |
| 123 | + // simple thing and save caching as an optional enhancement. |
| 124 | + builderList, err := listBuilders(ctx, buildersCl) |
| 125 | + if err != nil { |
| 126 | + return nil, nil, err |
| 127 | + } |
| 128 | + var builders = make(map[string]Builder) |
| 129 | + for _, b := range builderList { |
| 130 | + if _, ok := builders[b.Name]; ok { |
| 131 | + return nil, nil, fmt.Errorf("duplicate builder name %q", b.Name) |
| 132 | + } |
| 133 | + if b.KnownIssue != 0 { |
| 134 | + // Skip LUCI builders with a known issue at this time. |
| 135 | + // This also means builds from these builders are skipped below as well. |
| 136 | + // Such builders&builds can be included when the callers deem it useful. |
| 137 | + continue |
| 138 | + } |
| 139 | + builders[b.Name] = b |
| 140 | + } |
| 141 | + |
| 142 | + // Fetch LUCI builds for the builders, repositories, and their commits |
| 143 | + // that are deemed relevant to the callers of this package. |
| 144 | + // |
| 145 | + // TODO: It would be possible to cache the last GetDashboard response |
| 146 | + // and if didn't change since the last, only fetch new LUCI builds |
| 147 | + // since then. Similarly, builds that were earlier for commits that |
| 148 | + // still show up in the response can be reused instead of refetched. |
| 149 | + // Furthermore, builds can be sorted according to how complete/useful |
| 150 | + // they are. These known enhancements are left for later as needed. |
| 151 | + var builds = make(map[string]map[string]map[string]Build) |
| 152 | + dashResp, err := maintCl.GetDashboard(ctx, &apipb.DashboardRequest{MaxCommits: 30}) |
| 153 | + if err != nil { |
| 154 | + return nil, nil, err |
| 155 | + } |
| 156 | + var used, total int |
| 157 | + t0 := time.Now() |
| 158 | + // Fetch builds for Go repo commits. |
| 159 | + for _, c := range dashResp.Commits { |
| 160 | + repo, commit := "go", c.Commit |
| 161 | + buildList, err := fetchBuildsForCommit(ctx, buildsCl, repo, commit, "id", "builder.builder", "status", "input.gitiles_commit", "input.properties") |
| 162 | + if err != nil { |
| 163 | + return nil, nil, err |
| 164 | + } |
| 165 | + total += len(buildList) |
| 166 | + for _, b := range buildList { |
| 167 | + builder, ok := builders[b.GetBuilder().GetBuilder()] |
| 168 | + if !ok { |
| 169 | + // A build that isn't associated with a current builder we're tracking. |
| 170 | + // It might've been removed, or has a known issue. Skip this build too. |
| 171 | + continue |
| 172 | + } |
| 173 | + c := b.GetInput().GetGitilesCommit() |
| 174 | + if c.Project != builder.Repo { |
| 175 | + // A build that was triggered from a different project than the builder is for. |
| 176 | + // If the build hasn't completed, the exact repo commit hasn't been chosen yet. |
| 177 | + // For now such builds are not represented in the simple model of this package, |
| 178 | + // so skip it. |
| 179 | + continue |
| 180 | + } |
| 181 | + if builds[builder.Repo] == nil { |
| 182 | + builds[builder.Repo] = make(map[string]map[string]Build) |
| 183 | + } |
| 184 | + if builds[builder.Repo][c.Id] == nil { |
| 185 | + builds[builder.Repo][c.Id] = make(map[string]Build) |
| 186 | + } |
| 187 | + builds[builder.Repo][c.Id][b.GetBuilder().GetBuilder()] = Build{ |
| 188 | + ID: b.GetId(), |
| 189 | + BuilderName: b.GetBuilder().GetBuilder(), |
| 190 | + Status: b.GetStatus(), |
| 191 | + } |
| 192 | + used++ |
| 193 | + } |
| 194 | + } |
| 195 | + // Fetch builds for a single commit in each golang.org/x repo. |
| 196 | + for _, rh := range dashResp.RepoHeads { |
| 197 | + if rh.GerritProject == "go" { |
| 198 | + continue |
| 199 | + } |
| 200 | + if r, ok := repos.ByGerritProject[rh.GerritProject]; !ok || !r.ShowOnDashboard() { |
| 201 | + // Not a golang.org/x repository that's marked visible on the dashboard. |
| 202 | + // Skip it. |
| 203 | + continue |
| 204 | + } |
| 205 | + repo, commit := rh.GerritProject, rh.Commit.Commit |
| 206 | + buildList, err := fetchBuildsForCommit(ctx, buildsCl, repo, commit, "id", "builder.builder", "status", "input.gitiles_commit", "input.properties") |
| 207 | + if err != nil { |
| 208 | + return nil, nil, err |
| 209 | + } |
| 210 | + total += len(buildList) |
| 211 | + for _, b := range buildList { |
| 212 | + builder, ok := builders[b.GetBuilder().GetBuilder()] |
| 213 | + if !ok { |
| 214 | + // A build that isn't associated with a current builder we're tracking. |
| 215 | + // It might've been removed, or has a known issue. Skip this build too. |
| 216 | + continue |
| 217 | + } |
| 218 | + c := b.GetInput().GetGitilesCommit() |
| 219 | + if c.Project != builder.Repo { |
| 220 | + // When fetching builds for commits in x/ repos, it's expected |
| 221 | + // that build repo will always match builder repo. This isn't |
| 222 | + // true for the main Go repo because it triggers builds for x/ |
| 223 | + // repos. But x/ repo builds don't trigger builds elsewhere. |
| 224 | + return nil, nil, fmt.Errorf("internal error: build repo %q doesn't match builder repo %q", b.GetInput().GetGitilesCommit().GetProject(), builder.Repo) |
| 225 | + } |
| 226 | + if builds[builder.Repo] == nil { |
| 227 | + builds[builder.Repo] = make(map[string]map[string]Build) |
| 228 | + } |
| 229 | + if builds[builder.Repo][c.Id] == nil { |
| 230 | + builds[builder.Repo][c.Id] = make(map[string]Build) |
| 231 | + } |
| 232 | + builds[builder.Repo][c.Id][b.GetBuilder().GetBuilder()] = Build{ |
| 233 | + ID: b.GetId(), |
| 234 | + BuilderName: b.GetBuilder().GetBuilder(), |
| 235 | + Status: b.GetStatus(), |
| 236 | + } |
| 237 | + used++ |
| 238 | + } |
| 239 | + } |
| 240 | + fmt.Printf("runOnce: aggregate GetBuildsForCommit calls fetched %d builds in %v\n", total, time.Since(t0)) |
| 241 | + fmt.Printf("runOnce: used %d of those %d builds\n", used, total) |
| 242 | + |
| 243 | + return builders, builds, nil |
| 244 | +} |
| 245 | + |
| 246 | +// listBuilders lists post-submit LUCI builders. |
| 247 | +func listBuilders(ctx context.Context, buildersCl bbpb.BuildersClient) (builders []Builder, _ error) { |
| 248 | + var pageToken string |
| 249 | +nextPage: |
| 250 | + resp, err := buildersCl.ListBuilders(ctx, &bbpb.ListBuildersRequest{ |
| 251 | + Project: "golang", Bucket: "ci", |
| 252 | + PageSize: 1000, |
| 253 | + PageToken: pageToken, |
| 254 | + }) |
| 255 | + if err != nil { |
| 256 | + return nil, err |
| 257 | + } |
| 258 | + for _, b := range resp.GetBuilders() { |
| 259 | + var p BuilderConfigProperties |
| 260 | + if err := json.Unmarshal([]byte(b.GetConfig().GetProperties()), &p); err != nil { |
| 261 | + return nil, err |
| 262 | + } |
| 263 | + builders = append(builders, Builder{b.GetId().GetBuilder(), &p}) |
| 264 | + } |
| 265 | + if resp.GetNextPageToken() != "" { |
| 266 | + pageToken = resp.GetNextPageToken() |
| 267 | + goto nextPage |
| 268 | + } |
| 269 | + slices.SortFunc(builders, func(a, b Builder) int { |
| 270 | + return strings.Compare(a.Name, b.Name) |
| 271 | + }) |
| 272 | + return builders, nil |
| 273 | +} |
| 274 | + |
| 275 | +// fetchBuildsForCommit fetches builds from all post-submit LUCI builders for a specific commit. |
| 276 | +func fetchBuildsForCommit(ctx context.Context, buildsCl bbpb.BuildsClient, repo, commit string, maskPaths ...string) (builds []*bbpb.Build, _ error) { |
| 277 | + mask, err := fieldmaskpb.New((*bbpb.Build)(nil), maskPaths...) |
| 278 | + if err != nil { |
| 279 | + return nil, err |
| 280 | + } |
| 281 | + var pageToken string |
| 282 | +nextPage: |
| 283 | + resp, err := buildsCl.SearchBuilds(ctx, &bbpb.SearchBuildsRequest{ |
| 284 | + Predicate: &bbpb.BuildPredicate{ |
| 285 | + Builder: &bbpb.BuilderID{Project: "golang", Bucket: "ci"}, |
| 286 | + Tags: []*bbpb.StringPair{ |
| 287 | + {Key: "buildset", Value: fmt.Sprintf("commit/gitiles/go.googlesource.com/%s/+/%s", repo, commit)}, |
| 288 | + }, |
| 289 | + }, |
| 290 | + Mask: &bbpb.BuildMask{Fields: mask}, |
| 291 | + PageSize: 1000, |
| 292 | + PageToken: pageToken, |
| 293 | + }) |
| 294 | + if err != nil { |
| 295 | + return nil, err |
| 296 | + } |
| 297 | + builds = append(builds, resp.GetBuilds()...) |
| 298 | + if resp.GetNextPageToken() != "" { |
| 299 | + pageToken = resp.GetNextPageToken() |
| 300 | + goto nextPage |
| 301 | + } |
| 302 | + return builds, nil |
| 303 | +} |
0 commit comments