refactor!: separate runtime releases from panel source

This commit is contained in:
2026-10-14 15:05:10 +08:00
parent 20635ffaec
commit a2dd6ee27d
51 changed files with 221 additions and 2017 deletions
+10 -338
View File
@@ -1,14 +1,11 @@
package api
import (
"archive/tar"
"bytes"
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
imageassets "git.misaka.ren/m1saka/dsh-web/image"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"io"
"net/http"
@@ -51,9 +48,6 @@ func (a *API) ensureBuilds() error {
if err != nil {
return err
}
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed',error='version requires administrator experimental build' WHERE status='queued' AND release_id='' AND experimental=0 AND version NOT IN ('0.2.0-rc.1','0.2.0-rc.2')"); err != nil {
return err
}
if !buildQueue.initialized[a.s.DB] {
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed',error='image template identity is unknown' WHERE status='queued' AND (recipe_rev IS NULL OR recipe_rev<>? OR platform IS NULL OR platform<>?)", imageRecipeRev, imagePlatform); err != nil {
return err
@@ -66,179 +60,20 @@ func (a *API) ensureBuilds() error {
return nil
}
func (a *API) builds(w http.ResponseWriter, r *http.Request) {
u, _, authErr := a.user(r)
_, _, authErr := a.user(r)
if authErr != nil {
fail(w, 401, "authentication required")
return
}
if r.Method != http.MethodGet && r.Method != http.MethodPost {
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
var in struct {
Version string `json:"version"`
Experimental bool `json:"experimental"`
}
if r.Method == http.MethodPost {
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
dec.DisallowUnknownFields()
if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || !validBuildVersion(in.Version) {
fail(w, 400, "a fixed version is required")
return
}
}
if r.Method == http.MethodGet {
a.listBuilds(w, r)
return
}
if in.Experimental && !u.Admin {
fail(w, 403, "administrator required")
return
}
b, err := a.enqueueBuild(r.Context(), u.ID, in.Version, in.Experimental)
if err != nil {
status := 502
if errors.Is(err, errBuildUnsupported) {
status = 400
}
if errors.Is(err, errBuildRateLimited) {
status = 429
w.Header().Set("Retry-After", "3600")
}
fail(w, status, sanitizeBuildLog(err.Error()))
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
writeJSON(w, b)
a.listBuilds(w, r)
}
var errBuildRateLimited = errors.New("build limit: three new tasks per user per hour")
// lookupActiveBuild returns the active or successful record for a version; it
// performs no I/O and must be called with the build queue held.
func (a *API) lookupActiveBuild(version string) (buildRecord, bool, error) {
var b buildRecord
var finished, recipe, platform sql.NullString
err := a.s.DB.QueryRow("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at,experimental FROM image_builds WHERE release_id='' AND version=? AND recipe_rev=? AND platform=? AND status IN ('queued','running','validating','success')", version, imageRecipeRev, imagePlatform).Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished, &b.Experimental)
b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String
if errors.Is(err, sql.ErrNoRows) {
return b, false, nil
}
if err != nil {
return b, false, err
}
return b, true, nil
}
func (a *API) enqueueBuild(ctx context.Context, uid, version string, experiment ...bool) (buildRecord, error) {
experimental := len(experiment) > 0 && experiment[0]
if experimental {
u, err := a.s.UserByID(uid)
if err != nil || !u.Admin {
return buildRecord{}, errors.New("administrator required")
}
}
if !experimental && !normalBuildAllowed(version) {
return buildRecord{}, errBuildUnsupported
}
if !validBuildVersion(version) {
return buildRecord{}, errors.New("a fixed version is required")
}
buildQueue.Lock()
err := a.ensureBuilds()
var b buildRecord
var found bool
if err == nil {
b, found, err = a.lookupActiveBuild(version)
}
buildQueue.Unlock()
if err != nil {
return b, err
}
if found && !experimental && b.Experimental && b.Status != "success" {
return b, errBuildUnsupported
}
if found && b.Status != "success" {
a.startBuildWorker()
return b, nil
}
if found {
// Verifying a previously built image talks to Docker; keep that I/O
// out of the queue lock shared with instance creation and upgrades.
verify, cancel := context.WithTimeout(ctx, 15*time.Second)
id, lookupErr := a.localBuildImage(verify, b.Version, b.ImageID)
cancel()
if lookupErr != nil {
return b, lookupErr
}
if id != "" && id == b.ImageID {
if !experimental {
im, e := a.evaluateImage(ctx, id)
if e != nil {
return b, e
}
if !im.Selectable {
return b, errBuildUnsupported
}
}
a.startBuildWorker()
return b, nil
}
buildQueue.Lock()
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 {
// Another request may have queued a replacement while the image
// was being verified; honor it instead of inserting a duplicate.
b, found, err = a.lookupActiveBuild(version)
}
if err == nil && !found {
b, err = a.insertBuild(uid, version, experimental)
}
buildQueue.Unlock()
if err != nil {
return b, err
}
if !experimental && found && b.Experimental {
return b, errBuildUnsupported
}
a.startBuildWorker()
return b, nil
}
buildQueue.Lock()
defer buildQueue.Unlock()
// Re-check after the unlocked phases: another request may have inserted.
b, found, err = a.lookupActiveBuild(version)
if err != nil {
return b, err
}
if found {
if !experimental && b.Experimental {
return b, errBuildUnsupported
}
return b, nil
}
b, err = a.insertBuild(uid, version, experimental)
if err != nil {
return b, err
}
a.startBuildWorkerLocked()
return b, nil
}
// insertBuild applies the per-user rate limit and inserts a queued record;
// call with the build queue held.
func (a *API) insertBuild(uid, version string, experiment ...bool) (buildRecord, error) {
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 buildRecord{}, err
}
if count >= 3 {
return buildRecord{}, errBuildRateLimited
}
b := buildRecord{ID: store.ID(), Version: version, Status: "queued", Created: time.Now().UTC().Format(time.RFC3339Nano), RequestedBy: uid, RecipeRev: imageRecipeRev, Platform: imagePlatform}
b.Experimental = len(experiment) > 0 && experiment[0]
return b, a.insertBuildRequest(b, uid)
}
func (a *API) startBuildWorker() {
buildQueue.Lock()
defer buildQueue.Unlock()
@@ -283,7 +118,7 @@ func (a *API) startBuildWorkerLocked() {
}
buildQueue.Lock()
var b buildRecord
row := a.s.DB.QueryRow("SELECT " + buildSummaryColumns + " FROM image_builds WHERE status='queued' ORDER BY created_at,id LIMIT 1")
row := a.s.DB.QueryRow("SELECT " + buildSummaryColumns + " FROM image_builds WHERE status='queued' AND release_id<>'' ORDER BY created_at,id LIMIT 1")
err := scanBuildSummary(row, &b)
if err == nil {
_, err = a.s.DB.Exec("UPDATE image_builds SET status='running' WHERE id=?", b.ID)
@@ -307,27 +142,6 @@ func (a *API) startBuildWorkerLocked() {
}
}()
}
func imageBuildContext() (io.Reader, error) {
var buf bytes.Buffer
tw := tar.NewWriter(&buf)
for _, name := range []string{"Dockerfile", "Caddyfile", "entrypoint.sh", "install-mobile.mjs", "install-free-search.mjs", "install-workspace.mjs", "init-profile.mjs"} {
dataBytes, err := imageassets.Files.ReadFile(name)
if err != nil {
return nil, err
}
data := string(dataBytes)
if err := tw.WriteHeader(&tar.Header{Name: name, Mode: 0755, Size: int64(len(data))}); err != nil {
return nil, err
}
if _, err := tw.Write([]byte(data)); err != nil {
return nil, err
}
}
if err := tw.Close(); err != nil {
return nil, err
}
return &buf, nil
}
func (a *API) runImageBuild(b buildRecord) {
buildWorker.Lock()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute)
@@ -348,33 +162,17 @@ func (a *API) runImageBuild(b buildRecord) {
_, logErr = a.s.DB.Exec("UPDATE image_builds SET logs=? WHERE id=?", logs, b.ID)
}
}
tarball, err := imageBuildContext()
if b.ReleaseID == "" && !b.Experimental && !normalBuildAllowed(b.Version) {
err = errBuildUnsupported
}
if b.Experimental {
u, e := a.s.UserByID(b.RequestedBy)
if e != nil || !u.Admin {
err = errors.New("experimental build requester is not an administrator")
}
}
var id string
if b.ReleaseID != "" {
var err error
if b.ReleaseID == "" {
err = errors.New("Git release identity required")
} else {
var tarball io.Reader
tarball, err = a.releaseBuildContext(ctx, b)
}
if err == nil && b.ReleaseID == "" {
id, err = a.localBuildImage(ctx, b.Version, "")
}
if err == nil && id == "" {
_, err = fetchNPMVersion(ctx, npmBuildClient, b.Version)
if err == nil {
err = a.checkBuildDisk(ctx)
}
}
if err == nil && id == "" {
if b.ReleaseID == "" {
id, err = a.docker.Build(ctx, tarball, b.Version, "dsh-web:"+b.Version+"-"+imageRecipeRev, appendLog)
} else {
if err == nil {
id, err = a.docker.BuildRelease(ctx, tarball, b.Version, "dsh-web:release-"+b.SourceCommit, b.ReleaseID, b.SourceRepository, b.SourceCommit, appendLog)
}
}
@@ -568,38 +366,6 @@ func (a *API) images(w http.ResponseWriter, r *http.Request) {
writeJSON(w, result)
}
// verifiedImagesTTL bounds how long a cached admission evaluation is reused
// by the one-second intent poller.
var verifiedImagesTTL = 5 * time.Second
type evalCacheEntry struct {
im imageRecord
at time.Time
}
// evaluateImageCached reuses recent per-image evaluations for the intent
// poller so repeated ticks do not hammer Docker. Deletions and admission
// writes bump imageDeleteGeneration and invalidate every entry.
func (a *API) evaluateImageCached(ctx context.Context, gen uint64, id string) (imageRecord, error) {
a.evalCacheMu.Lock()
if entry, ok := a.evalCache[id]; ok && a.evalCacheGen == gen && time.Since(entry.at) < verifiedImagesTTL {
a.evalCacheMu.Unlock()
return entry.im, nil
}
a.evalCacheMu.Unlock()
im, err := a.evaluateImage(ctx, id)
if err != nil {
return im, err
}
a.evalCacheMu.Lock()
if a.evalCache == nil || a.evalCacheGen != gen {
a.evalCache, a.evalCacheGen = make(map[string]evalCacheEntry), gen
}
a.evalCache[id] = evalCacheEntry{im: im, at: time.Now()}
a.evalCacheMu.Unlock()
return im, nil
}
func (a *API) successfulImageIDs(ctx context.Context) ([]string, error) {
rows, err := a.s.DB.QueryContext(ctx, "SELECT DISTINCT image_id FROM image_builds WHERE status='success' AND image_id<>''")
if err != nil {
@@ -646,74 +412,6 @@ func (a *API) verifiedImages(ctx context.Context) (map[string]string, error) {
return result, nil
}
// verifiedImagesPoll is the intent poller's view: build rows are reread every
// tick, but per-image Docker evaluation is cached briefly so a stuck waiting
// intent cannot turn the one-second loop into Docker hammering.
func (a *API) verifiedImagesPoll(ctx context.Context) (map[string]string, error) {
buildQueue.Lock()
err := a.ensureBuilds()
gen := a.imageDeleteGeneration
buildQueue.Unlock()
if err != nil {
return nil, err
}
ids, err := a.successfulImageIDs(ctx)
if err != nil {
return nil, err
}
result := map[string]string{}
for _, id := range ids {
im, err := a.evaluateImageCached(ctx, gen, id)
if err != nil {
return nil, err
}
if im.Selectable {
result[id] = im.Version
}
}
return result, nil
}
func (a *API) latest(w http.ResponseWriter, r *http.Request) {
if _, _, err := a.user(r); err != nil {
fail(w, 401, "authentication required")
return
}
if r.Method != http.MethodGet {
fail(w, 405, "method not allowed")
return
}
v, err := fetchLatest(r.Context(), npmBuildClient)
if err != nil {
fail(w, 502, "npm version lookup failed")
return
}
writeJSON(w, map[string]any{"version": v, "checked_at": time.Now().UTC().Format(time.RFC3339), "supported": normalBuildAllowed(v), "build_allowed": normalBuildAllowed(v), "admission": defaultAdmission(v, imageRecipeRev, imagePlatform), "admission_reason": "normal builds require a built-in tested tuple; unknown versions require administrator experimental validation and admission"})
}
func fetchLatest(ctx context.Context, client *http.Client) (string, error) {
return fetchNPMVersion(ctx, client, "latest")
}
func fetchNPMVersion(ctx context.Context, client *http.Client, version string) (string, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://registry.npmjs.org/@deepseek-ai%2Fdsh/"+url.PathEscape(version), nil)
if err != nil {
return "", err
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
return "", fmt.Errorf("npm status %d", resp.StatusCode)
}
var v struct{ Version string }
if err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&v); err != nil {
return "", err
}
if !validBuildVersion(v.Version) || (version != "latest" && v.Version != version) {
return "", errors.New("invalid npm version")
}
return v.Version, nil
}
func validBuildVersion(v string) bool { return len(v) <= 100 && fixedVersion.MatchString(v) }
func sanitizeBuildLog(s string) string {
s = buildURLCredentials.ReplaceAllString(s, "$1[REDACTED]@")
@@ -724,29 +422,3 @@ func sanitizeBuildLog(s string) string {
}
return s
}
func (a *API) localBuildImage(ctx context.Context, version, preferred string) (string, error) {
filters, _ := json.Marshal(map[string][]string{"label": {"dsh-mgr.role=runtime", "dsh-mgr.version=" + version}})
var images []struct {
ID string
Labels map[string]string
}
if err := a.docker.Do(ctx, http.MethodGet, "/images/json?filters="+url.QueryEscape(string(filters)), nil, &images); err != nil {
return "", err
}
for _, im := range images {
if preferred == "" && im.Labels["dsh-mgr.release"] != "" {
continue
}
if im.ID != "" && im.Labels["dsh-mgr.role"] == "runtime" && im.Labels["dsh-mgr.version"] == version && im.Labels["dsh-mgr.recipe"] == imageRecipeRev && (preferred == "" || im.ID == preferred) {
ok, err := a.imageMatches(ctx, im.ID, version)
if err != nil {
return "", err
}
if ok {
return im.ID, nil
}
}
}
return "", nil
}