Files

425 lines
15 KiB
Go

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
}