package api import ( "context" "database/sql" "encoding/json" "errors" "fmt" "git.misaka.ren/m1saka/dsh-web/internal/store" "io" "net/http" "net/url" "regexp" "strings" "sync" "time" ) // Caller holds buildQueue; interrupted jobs cannot be reported as successful. func (a *API) ensureBuilds() error { if buildQueue.initialized[a.s.DB] { return nil } _, err := a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS image_builds(id TEXT PRIMARY KEY,version TEXT NOT NULL,status TEXT NOT NULL,logs TEXT NOT NULL DEFAULT '',error TEXT NOT NULL DEFAULT '',image_id TEXT NOT NULL DEFAULT '',created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS image_build_requests(user_id TEXT NOT NULL,build_id TEXT PRIMARY KEY,created_at TEXT NOT NULL); CREATE TABLE IF NOT EXISTS images(image_id TEXT PRIMARY KEY,version TEXT NOT NULL,recipe_rev TEXT,platform TEXT,tag TEXT NOT NULL DEFAULT '',status TEXT NOT NULL,built_at TEXT NOT NULL,usage_count INTEGER NOT NULL DEFAULT 0);`) if err != nil { return err } for _, q := range []string{ "CREATE INDEX IF NOT EXISTS image_builds_history ON image_builds(created_at DESC,id DESC)", "ALTER TABLE image_builds ADD COLUMN requested_by TEXT NOT NULL DEFAULT ''", "ALTER TABLE image_builds ADD COLUMN recipe_rev TEXT", "ALTER TABLE image_builds ADD COLUMN platform TEXT", "ALTER TABLE image_builds ADD COLUMN finished_at TEXT", "ALTER TABLE image_builds ADD COLUMN experimental INTEGER NOT NULL DEFAULT 0", "ALTER TABLE image_builds ADD COLUMN release_id TEXT NOT NULL DEFAULT ''", "ALTER TABLE image_builds ADD COLUMN source_repository TEXT NOT NULL DEFAULT ''", "ALTER TABLE image_builds ADD COLUMN source_commit TEXT NOT NULL DEFAULT ''", "CREATE TABLE IF NOT EXISTS image_admissions(image_id TEXT NOT NULL,version TEXT NOT NULL,recipe_rev TEXT NOT NULL,platform TEXT NOT NULL,state TEXT NOT NULL,evidence TEXT NOT NULL,admitted_by TEXT NOT NULL,admitted_at TEXT NOT NULL,PRIMARY KEY(image_id,version,recipe_rev,platform))", "ALTER TABLE images ADD COLUMN recipe_rev TEXT", "ALTER TABLE images ADD COLUMN platform TEXT", "ALTER TABLE images ADD COLUMN usage_count INTEGER NOT NULL DEFAULT 0", } { if _, e := a.s.DB.Exec(q); e != nil && !strings.Contains(strings.ToLower(e.Error()), "duplicate column") { return e } } _, err = a.s.DB.Exec("DROP INDEX IF EXISTS image_builds_version; DROP INDEX IF EXISTS image_builds_identity; CREATE UNIQUE INDEX image_builds_identity ON image_builds(version,recipe_rev,platform) WHERE release_id='' AND recipe_rev IS NOT NULL AND platform IS NOT NULL AND status IN ('queued','running','validating','success'); CREATE UNIQUE INDEX IF NOT EXISTS image_builds_release_identity ON image_builds(source_repository,source_commit,release_id,platform) WHERE release_id<>'' AND status IN ('queued','running','validating','success')") if 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 } if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed',error='build interrupted by process restart' WHERE status IN ('running','validating')"); err != nil { return err } buildQueue.initialized[a.s.DB] = true } return nil } func (a *API) builds(w http.ResponseWriter, r *http.Request) { _, _, authErr := a.user(r) if authErr != nil { fail(w, 401, "authentication required") return } if r.Method != http.MethodGet { fail(w, 405, "method not allowed") return } a.listBuilds(w, r) } var errBuildRateLimited = errors.New("build limit: three new tasks per user per hour") func (a *API) startBuildWorker() { buildQueue.Lock() defer buildQueue.Unlock() a.startBuildWorkerLocked() } // startBuildWorkerLocked requires the caller to hold buildQueue: the active map // is only safe under that lock. func (a *API) startBuildWorkerLocked() { if buildQueue.active[a.s.DB] { return } buildQueue.active[a.s.DB] = true go func() { var lastCleanupAudit time.Time var nextCleanup time.Time var recoveryErr error for { select { case <-a.s.Done(): buildQueue.Lock() delete(buildQueue.active, a.s.DB) buildQueue.Unlock() return default: } if time.Now().After(nextCleanup) { buildWorker.Lock() recovery, cancel := context.WithTimeout(context.Background(), 20*time.Second) recoveryErr = a.recoverBuildChecks(recovery) cancel() buildWorker.Unlock() if errors.Is(recoveryErr, errBuildCheckUncertain) { nextCleanup = time.Now().Add(time.Minute) } } if recoveryErr != nil { if time.Since(lastCleanupAudit) >= time.Minute { a.s.AddAudit("", "build.cleanup_error", "", sanitizeBuildLog(recoveryErr.Error())) lastCleanupAudit = time.Now() } } buildQueue.Lock() var b buildRecord 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) } if err != nil { if !errors.Is(err, sql.ErrNoRows) || recoveryErr != nil { buildQueue.Unlock() select { case <-a.s.Done(): case <-time.After(time.Second): } continue } delete(buildQueue.active, a.s.DB) buildQueue.Unlock() return } buildQueue.Unlock() a.runImageBuild(b) nextCleanup = time.Time{} } }() } func (a *API) runImageBuild(b buildRecord) { buildWorker.Lock() ctx, cancel := context.WithTimeout(context.Background(), 30*time.Minute) defer cancel() logs := "" rawLogs := "" var logErr error appendLog := func(s string) { if len(rawLogs) < buildLogLimit { remaining := buildLogLimit - len(rawLogs) if len(s) > remaining { s = s[:remaining] } rawLogs += s } logs = sanitizeBuildLog(rawLogs) if logErr == nil { _, logErr = a.s.DB.Exec("UPDATE image_builds SET logs=? WHERE id=?", logs, b.ID) } } var id string 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 { err = a.checkBuildDisk(ctx) } if err == nil { id, err = a.docker.BuildRelease(ctx, tarball, b.Version, "dsh-web:release-"+b.SourceCommit, b.ReleaseID, b.SourceRepository, b.SourceCommit, appendLog) } } if err == nil { var matches bool matches, err = a.buildImageMatches(ctx, id, b) if err == nil && !matches { err = errors.New("image template or platform mismatch") } } if err == nil { _, err = a.s.DB.Exec("UPDATE image_builds SET status='validating',image_id=? WHERE id=?", id, b.ID) } if err == nil { err = a.verifyBuiltImage(ctx, id, b.Version, b.ID) } if err == nil { err = logErr } status, detail := "success", "" if err != nil { status = "failed" detail = sanitizeBuildLog(err.Error()) appendLog(string(rune(10)) + detail) } finished := time.Now().UTC().Format(time.RFC3339Nano) buildWorker.Unlock() if err := a.persistBuildResult(b, status, logs, detail, id, finished); err != nil { go a.finishBuild(b, status, logs, detail, id, finished) } } func (a *API) verifyBuiltImage(parent context.Context, image, version string, buildIDs ...string) (result error) { ctx, cancel := context.WithTimeout(parent, 90*time.Second) defer cancel() id := store.ID() if len(buildIDs) != 0 { id = buildIDs[0] } if err := a.ensureBuildChecks(); err != nil { return err } if _, err := a.s.DB.Exec("INSERT INTO image_build_checks(build_id,image_id,created_at) VALUES(?,?,?)", id, image, time.Now().UTC().Format(time.RFC3339Nano)); err != nil { return err } var created struct { ID string `json:"Id"` } rejected := false defer func() { cleanup, c := context.WithTimeout(context.Background(), 10*time.Second) defer c() var err error if rejected { _, err = a.s.DB.Exec("DELETE FROM image_build_checks WHERE build_id=?", id) } else if created.ID != "" { err = a.deleteBuildCheck(cleanup, id, created.ID) } else { err = a.cleanupBuildCheck(cleanup, id, image) } result = errors.Join(result, err) }() config := map[string]any{"Image": image, "User": "1000:1000", "Labels": map[string]string{"dsh-mgr.role": "build-check", "dsh-mgr.build": id}, "HostConfig": map[string]any{"NetworkMode": "none", "ReadonlyRootfs": true, "CapDrop": []string{"ALL"}, "SecurityOpt": []string{"no-new-privileges:true"}, "Memory": int64(1 << 30), "NanoCpus": int64(1e9), "PidsLimit": 256, "Tmpfs": map[string]string{"/tmp": "rw,exec,nosuid,nodev,size=256m,mode=1777", "/workspace": "rw,nosuid,nodev,size=256m,uid=1000,gid=1000"}}} if err := a.docker.Do(ctx, http.MethodPost, "/containers/create?name="+url.QueryEscape(buildCheckName(id)), config, &created); err != nil { rejected = dockerCreateRejected(err) return err } if created.ID == "" { return errors.New("verification container ID missing") } if _, err := a.s.DB.Exec("UPDATE image_build_checks SET container_id=? WHERE build_id=?", created.ID, id); err != nil { return err } path := "/containers/" + url.PathEscape(created.ID) if err := a.docker.Do(ctx, http.MethodPost, path+"/start", nil, nil); err != nil { return err } script := `const end=Date.now()+60000;async function probe(){try{const r=await fetch('http://127.0.0.1:8080/',{redirect:'manual',signal:AbortSignal.timeout(2000)});if(r.status>=200&&r.status<500)process.exit(0);}catch{}if(Date.now()>end)process.exit(1);setTimeout(probe,500);}probe();` { expected, _ := json.Marshal(version) script = `const cp=require('node:child_process');const actual=cp.execFileSync('dsh',['--version'],{encoding:'utf8',timeout:20000}).trim();if(actual!==` + string(expected) + `)process.exit(2);` + script } script = `const fs=require('node:fs');for(const [k,v] of Object.entries({DSH_HOME:'/workspace',HOME:'/workspace',XDG_DATA_HOME:'/workspace/.local/share',XDG_CONFIG_HOME:'/workspace/.config'})){if(process.env[k]!==v)process.exit(3);}if(!fs.lstatSync('/data/dsh-home').isSymbolicLink()||fs.realpathSync('/data/dsh-home')!=='/workspace')process.exit(4);fs.writeFileSync('/workspace/.home-check','isolated');if(fs.readFileSync('/data/dsh-home/.home-check','utf8')!=='isolated')process.exit(5);fs.unlinkSync('/workspace/.home-check');` + script var exec struct { ID string `json:"Id"` } if err := a.docker.Do(ctx, http.MethodPost, path+"/exec", map[string]any{"Cmd": []string{"node", "-e", script}, "User": "1000:1000"}, &exec); err != nil { return err } if exec.ID == "" { return errors.New("verification exec ID missing") } ep := "/exec/" + url.PathEscape(exec.ID) if err := a.docker.Do(ctx, http.MethodPost, ep+"/start", map[string]bool{"Detach": true, "Tty": false}, nil); err != nil { return err } ticker := time.NewTicker(250 * time.Millisecond) defer ticker.Stop() for { var state struct { Running bool ExitCode int } if err := a.docker.Do(ctx, http.MethodGet, ep+"/json", nil, &state); err != nil { return err } if !state.Running { if state.ExitCode != 0 { return fmt.Errorf("image startup verification failed (exit %d)", state.ExitCode) } container, err := a.docker.Inspect(ctx, created.ID) if err != nil { return err } if !container.State.Running { return errors.New("verification container exited") } return nil } select { case <-ctx.Done(): return ctx.Err() case <-ticker.C: } } } const buildLogLimit = 64 << 10 var fixedVersion = regexp.MustCompile(`^(0|[1-9][0-9]*)[.](0|[1-9][0-9]*)[.](0|[1-9][0-9]*)(-[0-9A-Za-z-]+([.][0-9A-Za-z-]+)*)?$`) var buildSecrets = regexp.MustCompile(`(?i)(authorization["']?[[:space:]]*[:=][[:space:]]*["']?(?:bearer[[:space:]]+|basic[[:space:]]+)?|(?:token|password|passwd|secret|api[_-]?key|cookie)["']?[[:space:]]*[:=][[:space:]]*["']?)[^[:space:]"'&,;]+`) var buildURLCredentials = regexp.MustCompile(`(https?://)[^/@[:space:]]+:[^/@[:space:]]+@`) var buildQueue = struct { sync.Mutex initialized map[*sql.DB]bool active map[*sql.DB]bool }{initialized: map[*sql.DB]bool{}, active: map[*sql.DB]bool{}} var buildWorker sync.Mutex type imageRecord struct { Admission string `json:"admission"` AdmissionReason string `json:"admission_reason"` Selectable bool `json:"selectable"` ID string `json:"id"` ImageID string `json:"image_id"` Version string `json:"version"` Tag string `json:"tag,omitempty"` Status string `json:"status"` Created string `json:"created_at"` RecipeRev string `json:"recipe_rev,omitempty"` Platform string `json:"platform,omitempty"` UsageCount int `json:"usage_count"` } type buildRecord struct { ReleaseID string `json:"release_id,omitempty"` SourceRepository string `json:"source_repository,omitempty"` SourceCommit string `json:"source_commit,omitempty"` Experimental bool `json:"experimental"` ImageID string `json:"image_id,omitempty"` ID string `json:"id"` Version string `json:"version"` Status string `json:"status"` Logs string `json:"logs,omitempty"` Error string `json:"error,omitempty"` Created string `json:"created_at"` RequestedBy string `json:"requested_by,omitempty"` RecipeRev string `json:"recipe_rev,omitempty"` Platform string `json:"platform,omitempty"` Finished string `json:"finished_at,omitempty"` } func (a *API) images(w http.ResponseWriter, r *http.Request) { if r.Method == http.MethodDelete { a.deleteImage(w, r) return } 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 } ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second) defer cancel() result, err := a.imageIndex(ctx) if err != nil { fail(w, 502, "image index reconciliation failed") return } writeJSON(w, result) } 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 { return nil, err } ids := []string{} for rows.Next() { var id string if err = rows.Scan(&id); err != nil { rows.Close() return nil, err } ids = append(ids, id) } err = rows.Err() rows.Close() if err != nil { return nil, err } return ids, nil } func (a *API) verifiedImages(ctx context.Context) (map[string]string, error) { buildQueue.Lock() err := a.ensureBuilds() 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.evaluateImage(ctx, id) if err != nil { return nil, err } if im.Selectable { result[id] = im.Version } } return result, nil } func validBuildVersion(v string) bool { return len(v) <= 100 && fixedVersion.MatchString(v) } func sanitizeBuildLog(s string) string { s = buildURLCredentials.ReplaceAllString(s, "$1[REDACTED]@") s = buildSecrets.ReplaceAllString(s, "$1[REDACTED]") s = strings.ToValidUTF8(s, "") if len(s) > buildLogLimit { s = strings.ToValidUTF8(s[:buildLogLimit], "") } return s }