425 lines
15 KiB
Go
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
|
|
}
|