b19ec73bb7fd1c70d5b1c53e1fafe27a28f89326

Author
TheEdgeOfRage <git@theedgeofrage.com>
Committer
TheEdgeOfRage <git@theedgeofrage.com>
Date

Message

feat: expose current build statuses

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