199 lines
6.1 KiB
Go
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
|
|
}
|