refactor!: separate runtime releases from panel source
This commit is contained in:
+10
-338
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user