0.2.64 update
This commit is contained in:
@@ -0,0 +1,203 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/subtle"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const maxLogLines = 400
|
||||
|
||||
var tagPattern = regexp.MustCompile(`^v\d+\.\d+\.\d+$`)
|
||||
|
||||
type releaseRequest struct {
|
||||
Tag string `json:"tag"`
|
||||
Notes string `json:"notes"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
}
|
||||
|
||||
type releaseStatus struct {
|
||||
State string `json:"state"`
|
||||
Tag string `json:"tag,omitempty"`
|
||||
Mandatory bool `json:"mandatory"`
|
||||
StartedAt time.Time `json:"startedAt,omitempty"`
|
||||
FinishedAt time.Time `json:"finishedAt,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Logs []string `json:"logs"`
|
||||
Fallback string `json:"fallback"`
|
||||
}
|
||||
|
||||
type controller struct {
|
||||
mu sync.RWMutex
|
||||
status releaseStatus
|
||||
token []byte
|
||||
}
|
||||
|
||||
func main() {
|
||||
token, err := readSecret("/run/secrets/memby_release_publish_token")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
c := &controller{token: token, status: releaseStatus{
|
||||
State: "idle", Logs: []string{},
|
||||
Fallback: "docker compose run --rm --build memby-builder release",
|
||||
}}
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
mux.Handle("GET /v1/status", c.authorise(http.HandlerFunc(c.handleStatus)))
|
||||
mux.Handle("POST /v1/releases", c.authorise(http.HandlerFunc(c.handleRelease)))
|
||||
server := &http.Server{Addr: ":8090", Handler: mux, ReadHeaderTimeout: 5 * time.Second, IdleTimeout: 30 * time.Second}
|
||||
log.Printf("Memby release controller listening on %s", server.Addr)
|
||||
log.Fatal(server.ListenAndServe())
|
||||
}
|
||||
|
||||
func readSecret(path string) ([]byte, error) {
|
||||
value, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("release controller token: %w", err)
|
||||
}
|
||||
value = []byte(strings.TrimSpace(string(value)))
|
||||
if len(value) == 0 {
|
||||
return nil, errors.New("release controller token is empty")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (c *controller) authorise(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
presented := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer "))
|
||||
if subtle.ConstantTimeCompare([]byte(presented), c.token) != 1 {
|
||||
writeError(w, http.StatusUnauthorized, "invalid release token")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *controller) handleStatus(w http.ResponseWriter, _ *http.Request) {
|
||||
c.mu.RLock()
|
||||
status := c.status
|
||||
// Start with a non-nil slice so an idle controller emits `[]`, not `null`. The Admin
|
||||
// Console is still defensive for compatibility with already-deployed controllers.
|
||||
status.Logs = append([]string{}, c.status.Logs...)
|
||||
c.mu.RUnlock()
|
||||
writeJSON(w, http.StatusOK, status)
|
||||
}
|
||||
|
||||
func (c *controller) handleRelease(w http.ResponseWriter, r *http.Request) {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 16<<10)
|
||||
var request releaseRequest
|
||||
decoder := json.NewDecoder(r.Body)
|
||||
decoder.DisallowUnknownFields()
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "invalid release request")
|
||||
return
|
||||
}
|
||||
request.Tag = strings.TrimSpace(request.Tag)
|
||||
request.Notes = strings.TrimSpace(request.Notes)
|
||||
if request.Tag != "" && !tagPattern.MatchString(request.Tag) {
|
||||
writeError(w, http.StatusBadRequest, "tag must be blank or look like v0.2.64")
|
||||
return
|
||||
}
|
||||
if len(request.Notes) > 4000 {
|
||||
writeError(w, http.StatusBadRequest, "release notes are too long")
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
if c.status.State == "running" {
|
||||
c.mu.Unlock()
|
||||
writeError(w, http.StatusConflict, "a Memby release is already running")
|
||||
return
|
||||
}
|
||||
c.status = releaseStatus{
|
||||
State: "running", Tag: request.Tag, Mandatory: request.Mandatory,
|
||||
StartedAt: time.Now().UTC(), Message: "Preparing the Android release builder", Logs: []string{},
|
||||
Fallback: "docker compose run --rm --build memby-builder release",
|
||||
}
|
||||
status := c.status
|
||||
c.mu.Unlock()
|
||||
go c.run(request)
|
||||
writeJSON(w, http.StatusAccepted, status)
|
||||
}
|
||||
|
||||
func (c *controller) run(request releaseRequest) {
|
||||
command := exec.Command("/usr/local/bin/memby-builder", "release")
|
||||
command.Env = append(os.Environ(),
|
||||
"MEMBY_RELEASE_TAG="+request.Tag,
|
||||
"MEMBY_RELEASE_NOTES="+request.Notes,
|
||||
fmt.Sprintf("MEMBY_RELEASE_MANDATORY=%t", request.Mandatory),
|
||||
)
|
||||
stdout, err := command.StdoutPipe()
|
||||
if err != nil {
|
||||
c.finish(err)
|
||||
return
|
||||
}
|
||||
command.Stderr = command.Stdout
|
||||
if err := command.Start(); err != nil {
|
||||
c.finish(err)
|
||||
return
|
||||
}
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(stdout)
|
||||
scanner.Buffer(make([]byte, 64*1024), 1024*1024)
|
||||
for scanner.Scan() {
|
||||
c.appendLog(scanner.Text())
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
c.appendLog("Could not read complete build output: " + err.Error())
|
||||
}
|
||||
close(done)
|
||||
}()
|
||||
err = command.Wait()
|
||||
<-done
|
||||
c.finish(err)
|
||||
}
|
||||
|
||||
func (c *controller) appendLog(line string) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
return
|
||||
}
|
||||
c.status.Logs = append(c.status.Logs, line)
|
||||
if len(c.status.Logs) > maxLogLines {
|
||||
c.status.Logs = append([]string(nil), c.status.Logs[len(c.status.Logs)-maxLogLines:]...)
|
||||
}
|
||||
c.status.Message = line
|
||||
}
|
||||
|
||||
func (c *controller) finish(err error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.status.FinishedAt = time.Now().UTC()
|
||||
if err != nil {
|
||||
c.status.State = "failed"
|
||||
c.status.Message = "Release failed: " + err.Error()
|
||||
return
|
||||
}
|
||||
c.status.State = "succeeded"
|
||||
c.status.Message = "Release built, verified and published"
|
||||
}
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, value any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(value)
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
writeJSON(w, status, map[string]string{"error": message})
|
||||
}
|
||||
Reference in New Issue
Block a user