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}) }