status.go
4636 bytes
1package pages
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "io"
8 "net/http"
9 "net/url"
10 "time"
11)
12
13const (
14 buildStatusRequestBatchSize = 100
15 maxBuildStatusResponseSize = 1 << 20
16)
17
18var buildStatusHTTPClient = &http.Client{Timeout: 2 * time.Second}
19
20type repositoryBuildStatus struct {
21 Label string
22 Class string
23 Present bool
24}
25
26type workerBuildStatusRequest struct {
27 Repositories []string `json:"repositories"`
28}
29
30type workerBuildStatusResponse struct {
31 Statuses map[string]workerBuildStatus `json:"statuses"`
32}
33
34type workerBuildStatus struct {
35 SHA string `json:"sha"`
36 State string `json:"state"`
37 QueuedAt time.Time `json:"queued_at"`
38}
39
40// LoadBuildStatuses obtains current worker status without allowing an unavailable worker to fail a page.
41func LoadBuildStatuses(ctx context.Context, workerURL string, repositories []string) map[string]repositoryBuildStatus {
42 statuses := make(map[string]repositoryBuildStatus, len(repositories))
43 for _, repository := range repositories {
44 statuses[repository] = noBuildStatus()
45 }
46 if workerURL == "" || len(repositories) == 0 {
47 return statuses
48 }
49
50 endpoint, err := buildStatusEndpoint(workerURL)
51 if err != nil {
52 markBuildStatusUnavailable(statuses, repositories)
53 return statuses
54 }
55 for start := 0; start < len(repositories); start += buildStatusRequestBatchSize {
56 end := start + buildStatusRequestBatchSize
57 if end > len(repositories) {
58 end = len(repositories)
59 }
60 batch := repositories[start:end]
61 workerStatuses, err := requestBuildStatuses(ctx, endpoint, batch)
62 if err != nil {
63 markBuildStatusUnavailable(statuses, batch)
64 continue
65 }
66 for repository, status := range workerStatuses {
67 statuses[repository] = status
68 }
69 }
70 return statuses
71}
72
73func buildStatusEndpoint(rawURL string) (string, error) {
74 base, err := url.Parse(rawURL)
75 if err != nil || (base.Scheme != "http" && base.Scheme != "https") || base.Host == "" {
76 return "", ErrNotFound
77 }
78 return base.ResolveReference(&url.URL{Path: "/build-statuses"}).String(), nil
79}
80
81func requestBuildStatuses(ctx context.Context, endpoint string, repositories []string) (map[string]repositoryBuildStatus, error) {
82 body, err := json.Marshal(workerBuildStatusRequest{Repositories: repositories})
83 if err != nil {
84 return nil, err
85 }
86 request, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
87 if err != nil {
88 return nil, err
89 }
90 request.Header.Set("Content-Type", "application/json")
91 response, err := buildStatusHTTPClient.Do(request)
92 if err != nil {
93 return nil, err
94 }
95 defer response.Body.Close()
96 if response.StatusCode != http.StatusOK {
97 return nil, ErrNotFound
98 }
99
100 limitedBody := &io.LimitedReader{R: response.Body, N: maxBuildStatusResponseSize + 1}
101 decoder := json.NewDecoder(limitedBody)
102 decoder.DisallowUnknownFields()
103 var payload workerBuildStatusResponse
104 if err := decoder.Decode(&payload); err != nil || payload.Statuses == nil || ensureNoAdditionalJSON(decoder) != nil || limitedBody.N == 0 {
105 return nil, ErrNotFound
106 }
107 requested := make(map[string]struct{}, len(repositories))
108 for _, repository := range repositories {
109 requested[repository] = struct{}{}
110 }
111 statuses := make(map[string]repositoryBuildStatus, len(payload.Statuses))
112 for repository, status := range payload.Statuses {
113 if _, ok := requested[repository]; !ok || !ValidCommitHash(status.SHA) || status.QueuedAt.IsZero() {
114 return nil, ErrNotFound
115 }
116 badge, ok := buildStatusBadge(status.State)
117 if !ok {
118 return nil, ErrNotFound
119 }
120 statuses[repository] = badge
121 }
122 return statuses, nil
123}
124
125func ensureNoAdditionalJSON(decoder *json.Decoder) error {
126 var extra any
127 if err := decoder.Decode(&extra); err != io.EOF {
128 return ErrNotFound
129 }
130 return nil
131}
132
133func buildStatusBadge(state string) (repositoryBuildStatus, bool) {
134 switch state {
135 case "queued":
136 return repositoryBuildStatus{Label: "Queued", Class: "build-status-queued", Present: true}, true
137 case "running":
138 return repositoryBuildStatus{Label: "Building", Class: "build-status-running", Present: true}, true
139 case "passed":
140 return repositoryBuildStatus{Label: "Passed", Class: "build-status-passed", Present: true}, true
141 case "failed":
142 return repositoryBuildStatus{Label: "Failed", Class: "build-status-failed", Present: true}, true
143 default:
144 return repositoryBuildStatus{}, false
145 }
146}
147
148func noBuildStatus() repositoryBuildStatus {
149 return repositoryBuildStatus{}
150}
151
152func markBuildStatusUnavailable(statuses map[string]repositoryBuildStatus, repositories []string) {
153 for _, repository := range repositories {
154 statuses[repository] = repositoryBuildStatus{}
155 }
156}