Files
dsh-web/internal/api/releases.go
T

199 lines
6.1 KiB
Go

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
}