feat: build runtime releases from pinned Git branch commits
This commit is contained in:
@@ -0,0 +1,198 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var fetchRelease = readRelease
|
||||
|
||||
func (a *API) latestRelease(w http.ResponseWriter, r *http.Request) {
|
||||
u, _, err := a.user(r)
|
||||
if err != nil {
|
||||
fail(w, 401, "authentication required")
|
||||
return
|
||||
}
|
||||
if !u.Admin {
|
||||
fail(w, 403, "administrator required")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
repository, branch, err := releaseSource()
|
||||
if err != nil {
|
||||
fail(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
info, _, err := fetchRelease(r.Context(), repository, "refs/heads/"+branch)
|
||||
if err != nil && !errors.Is(err, errReleaseIncompatible) {
|
||||
fail(w, 502, sanitizeBuildLog(err.Error()))
|
||||
return
|
||||
}
|
||||
compatible := err == nil
|
||||
reason := "trusted release supports current layout and platform"
|
||||
if !compatible {
|
||||
reason = errReleaseIncompatible.Error()
|
||||
}
|
||||
buildQueue.Lock()
|
||||
err = a.ensureBuilds()
|
||||
buildQueue.Unlock()
|
||||
if err != nil {
|
||||
fail(w, 500, "database error")
|
||||
return
|
||||
}
|
||||
var b buildRecord
|
||||
err = scanBuildSummary(a.s.DB.QueryRow("SELECT "+buildSummaryColumns+" FROM image_builds WHERE source_repository=? AND source_commit=? AND release_id=? AND platform=? ORDER BY created_at DESC,id DESC LIMIT 1", repository, info.Commit, info.ReleaseID, imagePlatform), &b)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
fail(w, 500, "database error")
|
||||
return
|
||||
}
|
||||
result := map[string]any{"release_id": info.ReleaseID, "version": info.Version, "commit": info.Commit, "repository": repository, "branch": branch, "recipe_rev": strconv.Itoa(info.DeploymentSchema), "platforms": info.Platforms, "compatible": compatible, "reason": reason}
|
||||
if err == nil {
|
||||
result["build"] = b
|
||||
}
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (a *API) releaseBuild(w http.ResponseWriter, r *http.Request) {
|
||||
u, _, err := a.user(r)
|
||||
if err != nil {
|
||||
fail(w, 401, "authentication required")
|
||||
return
|
||||
}
|
||||
if !u.Admin {
|
||||
fail(w, 403, "administrator required")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
|
||||
dec.DisallowUnknownFields()
|
||||
if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || !releaseCommit.MatchString(in.Commit) {
|
||||
fail(w, 400, "fixed commit required")
|
||||
return
|
||||
}
|
||||
repository, branch, err := releaseSource()
|
||||
if err != nil {
|
||||
fail(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
info, _, err := fetchRelease(r.Context(), repository, "refs/heads/"+branch)
|
||||
if err != nil {
|
||||
fail(w, 502, sanitizeBuildLog(err.Error()))
|
||||
return
|
||||
}
|
||||
if info.Commit != in.Commit {
|
||||
fail(w, 409, "release changed; check the latest release again")
|
||||
return
|
||||
}
|
||||
b, err := a.enqueueRelease(u.ID, info)
|
||||
if err != nil {
|
||||
status := 500
|
||||
if errors.Is(err, errBuildRateLimited) {
|
||||
status = 429
|
||||
}
|
||||
fail(w, status, sanitizeBuildLog(err.Error()))
|
||||
return
|
||||
}
|
||||
a.startBuildWorker()
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
writeJSON(w, b)
|
||||
}
|
||||
|
||||
func (a *API) enqueueRelease(uid string, info releaseInfo) (buildRecord, error) {
|
||||
buildQueue.Lock()
|
||||
defer buildQueue.Unlock()
|
||||
var b buildRecord
|
||||
if err := a.ensureBuilds(); err != nil {
|
||||
return b, err
|
||||
}
|
||||
err := scanBuildSummary(a.s.DB.QueryRow("SELECT "+buildSummaryColumns+" FROM image_builds WHERE source_repository=? AND source_commit=? AND release_id=? AND platform=? AND status IN ('queued','running','validating','success')", info.Repository, info.Commit, info.ReleaseID, imagePlatform), &b)
|
||||
if err == nil {
|
||||
if b.Status == "success" {
|
||||
buildQueue.Unlock()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
matches, verifyErr := a.buildImageMatches(ctx, b.ImageID, b)
|
||||
cancel()
|
||||
buildQueue.Lock()
|
||||
if verifyErr != nil {
|
||||
return b, verifyErr
|
||||
}
|
||||
if !matches {
|
||||
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='missing',error='image no longer available' WHERE id=? AND status='success'", b.ID); err != nil {
|
||||
return b, err
|
||||
}
|
||||
buildQueue.Unlock()
|
||||
b, err = a.enqueueRelease(uid, info)
|
||||
buildQueue.Lock()
|
||||
return b, err
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return b, err
|
||||
}
|
||||
var count int
|
||||
if err = a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests WHERE user_id=? AND created_at>?", uid, time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)).Scan(&count); err != nil {
|
||||
return b, err
|
||||
}
|
||||
if count >= 3 {
|
||||
return b, errBuildRateLimited
|
||||
}
|
||||
b = buildRecord{ID: store.ID(), Version: info.Version, Status: "queued", Created: time.Now().UTC().Format(time.RFC3339Nano), RequestedBy: uid, RecipeRev: imageRecipeRev, Platform: imagePlatform, ReleaseID: info.ReleaseID, SourceRepository: info.Repository, SourceCommit: info.Commit}
|
||||
return b, a.insertBuildRequest(b, uid)
|
||||
}
|
||||
|
||||
func (a *API) releaseBuildContext(ctx context.Context, b buildRecord) (io.Reader, error) {
|
||||
repository, _, err := releaseSource()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if repository != b.SourceRepository || !releaseCommit.MatchString(b.SourceCommit) {
|
||||
return nil, errors.New("queued release source is no longer trusted")
|
||||
}
|
||||
u, err := a.s.UserByID(b.RequestedBy)
|
||||
if err != nil || !u.Admin {
|
||||
return nil, errors.New("release requester is not an administrator")
|
||||
}
|
||||
info, archive, err := fetchRelease(ctx, repository, b.SourceCommit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Commit != b.SourceCommit || info.ReleaseID != b.ReleaseID || info.Version != b.Version {
|
||||
return nil, errors.New("pinned release manifest mismatch")
|
||||
}
|
||||
return archive, nil
|
||||
}
|
||||
func releaseLabelsMatch(im *inspectedImage, b buildRecord) bool {
|
||||
if b.ReleaseID == "" {
|
||||
return im.Config.Labels["dsh-mgr.release"] == ""
|
||||
}
|
||||
return im.Config.Labels["dsh-mgr.release"] == b.ReleaseID && im.Config.Labels["dsh-mgr.source"] == b.SourceRepository && im.Config.Labels["dsh-mgr.commit"] == b.SourceCommit
|
||||
}
|
||||
func (a *API) buildImageMatches(ctx context.Context, id string, b buildRecord) (bool, error) {
|
||||
matches, err := a.imageMatches(ctx, id, b.Version)
|
||||
if err != nil || !matches {
|
||||
return matches, err
|
||||
}
|
||||
im, err := a.inspectLocalImage(ctx, id)
|
||||
if err != nil || im == nil {
|
||||
return false, err
|
||||
}
|
||||
return releaseLabelsMatch(im, b), nil
|
||||
}
|
||||
Reference in New Issue
Block a user