b19ec73bb7fd1c70d5b1c53e1fafe27a28f89326
- Author
- TheEdgeOfRage <git@theedgeofrage.com>
- Committer
- TheEdgeOfRage <git@theedgeofrage.com>
- Date
Message
Diff
This diff is truncated to protect this page.
1diff --git a/config.go b/config.go
2new file mode 100644
3index 0000000000000000000000000000000000000000..5ff3ea8fb2348962f0398d21094424d0daaf4047
4--- /dev/null
5+++ b/config.go
6@@ -0,0 +1,36 @@
7+package main
8+
9+import (
10+ "fmt"
11+ "os"
12+)
13+
14+type config struct {
15+ registry string
16+ buildkitAddress string
17+}
18+
19+func loadConfig() (config, error) {
20+ registry, err := requiredEnvironment("REGISTRY")
21+ if err != nil {
22+ return config{}, err
23+ }
24+
25+ buildkitAddress, err := requiredEnvironment("BUILDKIT_ADDR")
26+ if err != nil {
27+ return config{}, err
28+ }
29+
30+ return config{
31+ registry: registry,
32+ buildkitAddress: buildkitAddress,
33+ }, nil
34+}
35+
36+func requiredEnvironment(name string) (string, error) {
37+ value := os.Getenv(name)
38+ if value == "" {
39+ return "", fmt.Errorf("%s is required", name)
40+ }
41+ return value, nil
42+}
43diff --git a/http.go b/http.go
44new file mode 100644
45index 0000000000000000000000000000000000000000..fdce67231949212b38ca46dc7ad91b0aa62a53e4
46--- /dev/null
47+++ b/http.go
48@@ -0,0 +1,184 @@
49+package main
50+
51+import (
52+ "encoding/json"
53+ "errors"
54+ "io"
55+ "log/slog"
56+ "net/http"
57+ "path/filepath"
58+ "regexp"
59+ "strings"
60+ "sync"
61+ "time"
62+)
63+
64+const (
65+ maxRequestBodySize = 1024
66+ maxStatusRequestBodySize = 4096
67+ maxStatusRepositories = 100
68+)
69+
70+var commitSHA = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`)
71+
72+type buildRequest struct {
73+ Repository string `json:"repository"`
74+ SHA string `json:"sha"`
75+}
76+
77+type buildStatus struct {
78+ SHA string `json:"sha"`
79+ State string `json:"state"`
80+ QueuedAt time.Time `json:"queued_at"`
81+}
82+
83+type buildStatusRequest struct {
84+ Repositories []string `json:"repositories"`
85+}
86+
87+type buildStatusResponse struct {
88+ Statuses map[string]buildStatus `json:"statuses"`
89+}
90+
91+type buildStatuses struct {
92+ mu sync.RWMutex
93+ current map[string]buildStatus
94+ buildIDs map[string]string
95+}
96+
97+func newBuildStatuses() *buildStatuses {
98+ return &buildStatuses{
99+ current: make(map[string]buildStatus),
100+ buildIDs: make(map[string]string),
101+ }
102+}
103+
104+func (statuses *buildStatuses) queue(job buildJob) {
105+ statuses.mu.Lock()
106+ defer statuses.mu.Unlock()
107+ statuses.buildIDs[job.Request.Repository] = job.ID
108+ statuses.current[job.Request.Repository] = buildStatus{
109+ SHA: job.Request.SHA,
110+ State: "queued",
111+ QueuedAt: time.Now().UTC(),
112+ }
113+}
114+
115+func (statuses *buildStatuses) update(job buildJob, state string) bool {
116+ statuses.mu.Lock()
117+ defer statuses.mu.Unlock()
118+ if statuses.buildIDs[job.Request.Repository] != job.ID {
119+ return false
120+ }
121+ status := statuses.current[job.Request.Repository]
122+ status.State = state
123+ statuses.current[job.Request.Repository] = status
124+ return true
125+}
126+
127+func (statuses *buildStatuses) requested(repositories []string) map[string]buildStatus {
128+ statuses.mu.RLock()
129+ defer statuses.mu.RUnlock()
130+ result := make(map[string]buildStatus, len(repositories))
131+ for _, repository := range repositories {
132+ if status, ok := statuses.current[repository]; ok {
133+ result[repository] = status
134+ }
135+ }
136+ return result
137+}
138+
139+func buildHandler(jobs chan<- buildJob, statuses *buildStatuses) http.HandlerFunc {
140+ return func(writer http.ResponseWriter, request *http.Request) {
141+ request.Body = http.MaxBytesReader(writer, request.Body, maxRequestBodySize)
142+ defer func() {
143+ if err := request.Body.Close(); err != nil {
144+ slog.Warn("close build request", "error", err)
145+ }
146+ }()
147+
148diff --git a/main.go b/main.go
149index 65f4f6b9115da07aebd211e966f2031ab1d1b3b3..e05bbd20ad46fe320151811252c4ee277edb5b9b 100644
150--- a/main.go
151+++ b/main.go
152@@ -1,34 +1,11 @@
153 package main
154
155 import (
156- "context"
157- "encoding/json"
158- "errors"
159- "fmt"
160- "io"
161 "log/slog"
162 "net/http"
163 "os"
164- "os/exec"
165- "path/filepath"
166- "regexp"
167- "strings"
168 )
169
170-const maxRequestBodySize = 1024
171-
172-var commitSHA = regexp.MustCompile(`^[0-9a-f]{40}([0-9a-f]{24})?$`)
173-
174-type config struct {
175- registry string
176- buildkitAddress string
177-}
178-
179-type buildRequest struct {
180- Repository string `json:"repository"`
181- SHA string `json:"sha"`
182-}
183-
184 func main() {
185 slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, nil)))
186
187@@ -38,179 +15,16 @@ func main() {
188 os.Exit(1)
189 }
190
191- jobs := make(chan buildRequest, 100)
192- go runWorker(config, jobs)
193+ statuses := newBuildStatuses()
194+ jobs := make(chan buildJob, 100)
195+ go runWorker(config, jobs, statuses)
196
197- http.HandleFunc("POST /build", buildHandler(jobs))
198+ server := http.NewServeMux()
199+ server.HandleFunc("POST /build", buildHandler(jobs, statuses))
200+ server.HandleFunc("POST /build-statuses", buildStatusHandler(statuses))
201 slog.Info("worker started", "address", ":8080", "registry", config.registry, "buildkit_address", config.buildkitAddress)
202- if err := http.ListenAndServe(":8080", nil); err != nil {
203+ if err := http.ListenAndServe(":8080", server); err != nil {
204 slog.Error("serve HTTP", "error", err)
205 os.Exit(1)
206 }
207 }
208-
209-func loadConfig() (config, error) {
210- registry, err := requiredEnvironment("REGISTRY")
211- if err != nil {
212- return config{}, err
213- }
214-
215- buildkitAddress, err := requiredEnvironment("BUILDKIT_ADDR")
216- if err != nil {
217- return config{}, err
218- }
219-
220- return config{
221- registry: registry,
222- buildkitAddress: buildkitAddress,
223- }, nil
224-}
225-
226-func requiredEnvironment(name string) (string, error) {
227- value := os.Getenv(name)
228- if value == "" {
229- return "", fmt.Errorf("%s is required", name)
230- }
231- return value, nil
232-}
233-
234-func validRepository(repository string) bool {
235- cleaned := filepath.Clean(repository)
236- return repository != "." && !filepath.IsAbs(repository) && cleaned == repository && cleaned != ".."
237-}
238-
239-func buildHandler(jobs chan<- buildRequest) http.HandlerFunc {
240- return func(writer http.ResponseWriter, request *http.Request) {
241- request.Body = http.MaxBytesReader(writer, request.Body, maxRequestBodySize)
242- defer func() {
243- if err := request.Body.Close(); err != nil {
244- slog.Warn("close build request", "error", err)
245- }
246- }()
247-
248- var buildRequest buildRequest
249- decoder := json.NewDecoder(request.Body)
250- decoder.DisallowUnknownFields()
251- if err := decoder.Decode(&buildRequest); err != nil || !validBuildRequest(buildRequest) {
252diff --git a/worker.go b/worker.go
253new file mode 100644
254index 0000000000000000000000000000000000000000..2de0d96b143920ea4a9f030ba50b0c261941e919
255--- /dev/null
256+++ b/worker.go
257@@ -0,0 +1,130 @@
258+package main
259+
260+import (
261+ "context"
262+ "crypto/rand"
263+ "encoding/hex"
264+ "fmt"
265+ "log/slog"
266+ "os"
267+ "os/exec"
268+ "path/filepath"
269+ "strings"
270+)
271+
272+type buildJob struct {
273+ ID string
274+ Request buildRequest
275+ ready chan struct{}
276+}
277+
278+func newBuildJob(request buildRequest) (buildJob, error) {
279+ id, err := newBuildID()
280+ if err != nil {
281+ return buildJob{}, err
282+ }
283+ return buildJob{ID: id, Request: request, ready: make(chan struct{})}, nil
284+}
285+
286+func newBuildID() (string, error) {
287+ bytes := make([]byte, 16)
288+ if _, err := rand.Read(bytes); err != nil {
289+ return "", fmt.Errorf("generate build ID: %w", err)
290+ }
291+ return hex.EncodeToString(bytes), nil
292+}
293+
294+func runWorker(config config, jobs <-chan buildJob, statuses *buildStatuses) {
295+ for job := range jobs {
296+ <-job.ready
297+ request := job.Request
298+ image := imageName(config.registry, request.Repository)
299+ statuses.update(job, "running")
300+ slog.Info("build started", "repository", request.Repository, "sha", request.SHA, "image", image)
301+ if err := build(context.Background(), config, request); err != nil {
302+ statuses.update(job, "failed")
303+ slog.Error("build failed", "repository", request.Repository, "sha", request.SHA, "image", image, "error", err)
304+ continue
305+ }
306+ statuses.update(job, "passed")
307+ slog.Info("build completed", "repository", request.Repository, "sha", request.SHA, "image", image)
308+ }
309+}
310+
311+func build(ctx context.Context, config config, request buildRequest) error {
312+ repositoryPath := filepath.Join("/soft-serve/repos", request.Repository+".git")
313+ if err := run(ctx, "git", "--git-dir", repositoryPath, "rev-parse", "--verify", request.SHA+"^{commit}"); err != nil {
314+ return fmt.Errorf("verify commit: %w", err)
315+ }
316+
317+ contextPath, err := os.MkdirTemp("", "build-context-")
318+ if err != nil {
319+ return fmt.Errorf("create build context: %w", err)
320+ }
321+ defer func() {
322+ if err := os.RemoveAll(contextPath); err != nil {
323+ slog.Warn("remove build context", "path", contextPath, "error", err)
324+ }
325+ }()
326+
327+ if err := extractArchive(ctx, repositoryPath, request.SHA, contextPath); err != nil {
328+ return err
329+ }
330+
331+ image := imageName(config.registry, request.Repository)
332+ output := fmt.Sprintf("type=image,\"name=%s:%s,%s:main\",push=true", image, request.SHA, image)
333+ if err := run(
334+ ctx,
335+ "buildctl", "--addr", config.buildkitAddress,
336+ "build", "--frontend", "dockerfile.v0",
337+ "--local", "context="+contextPath,
338+ "--local", "dockerfile="+contextPath,
339+ "--output", output,
340+ ); err != nil {
341+ return fmt.Errorf("build image: %w", err)
342+ }
343+
344+ return nil
345+}
346+
347+func extractArchive(ctx context.Context, repositoryPath, sha, contextPath string) error {
348+ archive := exec.CommandContext(ctx, "git", "--git-dir", repositoryPath, "archive", sha)
349+ tar := exec.CommandContext(ctx, "tar", "-x", "-C", contextPath)
350+
351+ archiveOutput, err := archive.StdoutPipe()
352+ if err != nil {
353+ return fmt.Errorf("create archive pipe: %w", err)
354+ }
355+ tar.Stdin = archiveOutput
356+ tar.Stdout = os.Stdout