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 }