feat: build runtime releases from pinned Git branch commits
This commit is contained in:
@@ -89,6 +89,11 @@ func (a *API) evaluateImage(ctx context.Context, id string) (imageRecord, error)
|
||||
if count == 0 {
|
||||
return im, nil
|
||||
}
|
||||
var trustedRelease int
|
||||
err = a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM image_builds WHERE image_id=? AND version=? AND recipe_rev=? AND platform=? AND status='success' AND release_id<>'' AND release_id=? AND source_repository=? AND source_commit=?", id, im.Version, im.RecipeRev, im.Platform, actual.Config.Labels["dsh-mgr.release"], actual.Config.Labels["dsh-mgr.source"], actual.Config.Labels["dsh-mgr.commit"]).Scan(&trustedRelease)
|
||||
if err != nil {
|
||||
return im, err
|
||||
}
|
||||
im.Admission = defaultAdmission(im.Version, im.RecipeRev, im.Platform)
|
||||
im.AdmissionReason = "no accepted compatibility evidence for this tuple"
|
||||
if positiveAdmission(im.Admission) {
|
||||
@@ -101,6 +106,10 @@ func (a *API) evaluateImage(ctx context.Context, id string) (imageRecord, error)
|
||||
} else {
|
||||
im.Status = "existing_only"
|
||||
}
|
||||
if trustedRelease > 0 {
|
||||
im.Admission = "compatible"
|
||||
im.AdmissionReason = "trusted pinned release passed exact image startup validation"
|
||||
}
|
||||
if state != "" {
|
||||
im.Admission = state
|
||||
im.AdmissionReason = evidence
|
||||
|
||||
@@ -72,6 +72,8 @@ func (a *API) Handler() http.Handler {
|
||||
m.HandleFunc("/api/images/admission", a.imageAdmission)
|
||||
m.HandleFunc("/api/compatibility", a.compatibility)
|
||||
m.HandleFunc("/api/latest", a.latest)
|
||||
m.HandleFunc("/api/releases/latest", a.latestRelease)
|
||||
m.HandleFunc("/api/releases/build", a.releaseBuild)
|
||||
m.HandleFunc("/api/builds", a.builds)
|
||||
m.HandleFunc("/api/builds/", a.buildDetail)
|
||||
m.HandleFunc("/api/intents", a.intents)
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
)
|
||||
|
||||
// Summary queries never load log bodies; history cannot inflate a page response.
|
||||
const buildSummaryColumns = "id,version,status,error,image_id,created_at,requested_by,COALESCE(recipe_rev,''),COALESCE(platform,''),COALESCE(finished_at,''),experimental"
|
||||
const buildSummaryColumns = "id,version,status,error,image_id,created_at,requested_by,COALESCE(recipe_rev,''),COALESCE(platform,''),COALESCE(finished_at,''),experimental,release_id,source_repository,source_commit"
|
||||
|
||||
func scanBuildSummary(row interface{ Scan(...any) error }, b *buildRecord) error {
|
||||
return row.Scan(&b.ID, &b.Version, &b.Status, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &b.RecipeRev, &b.Platform, &b.Finished, &b.Experimental)
|
||||
return row.Scan(&b.ID, &b.Version, &b.Status, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &b.RecipeRev, &b.Platform, &b.Finished, &b.Experimental, &b.ReleaseID, &b.SourceRepository, &b.SourceCommit)
|
||||
}
|
||||
|
||||
func (a *API) listBuilds(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -97,7 +97,7 @@ func (a *API) buildDetail(w http.ResponseWriter, r *http.Request) {
|
||||
buildQueue.Unlock()
|
||||
var b buildRecord
|
||||
if err == nil {
|
||||
err = a.s.DB.QueryRowContext(ctx, "SELECT "+buildSummaryColumns+",logs FROM image_builds WHERE id=?", id).Scan(&b.ID, &b.Version, &b.Status, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &b.RecipeRev, &b.Platform, &b.Finished, &b.Experimental, &b.Logs)
|
||||
err = a.s.DB.QueryRowContext(ctx, "SELECT "+buildSummaryColumns+",logs FROM image_builds WHERE id=?", id).Scan(&b.ID, &b.Version, &b.Status, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &b.RecipeRev, &b.Platform, &b.Finished, &b.Experimental, &b.ReleaseID, &b.SourceRepository, &b.SourceCommit, &b.Logs)
|
||||
}
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
fail(w, 404, "build not found")
|
||||
|
||||
+37
-20
@@ -35,6 +35,9 @@ func (a *API) ensureBuilds() error {
|
||||
"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",
|
||||
@@ -44,11 +47,11 @@ func (a *API) ensureBuilds() error {
|
||||
return e
|
||||
}
|
||||
}
|
||||
_, err = a.s.DB.Exec("DROP INDEX IF EXISTS image_builds_version; CREATE UNIQUE INDEX IF NOT EXISTS image_builds_identity ON image_builds(version,recipe_rev,platform) WHERE recipe_rev IS NOT NULL AND platform IS NOT NULL AND status IN ('queued','running','validating','success')")
|
||||
_, 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 _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed',error='version requires administrator experimental build' WHERE status='queued' AND experimental=0 AND version NOT IN ('0.2.0-rc.1','0.2.0-rc.2')"); err != nil {
|
||||
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] {
|
||||
@@ -117,7 +120,7 @@ var errBuildRateLimited = errors.New("build limit: three new tasks per user per
|
||||
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 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)
|
||||
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
|
||||
@@ -280,7 +283,8 @@ func (a *API) startBuildWorkerLocked() {
|
||||
}
|
||||
buildQueue.Lock()
|
||||
var b buildRecord
|
||||
err := a.s.DB.QueryRow("SELECT id,version,experimental,requested_by FROM image_builds WHERE status='queued' ORDER BY created_at,id LIMIT 1").Scan(&b.ID, &b.Version, &b.Experimental, &b.RequestedBy)
|
||||
row := a.s.DB.QueryRow("SELECT " + buildSummaryColumns + " FROM image_builds WHERE status='queued' 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)
|
||||
}
|
||||
@@ -345,7 +349,7 @@ func (a *API) runImageBuild(b buildRecord) {
|
||||
}
|
||||
}
|
||||
tarball, err := imageBuildContext()
|
||||
if !b.Experimental && !normalBuildAllowed(b.Version) {
|
||||
if b.ReleaseID == "" && !b.Experimental && !normalBuildAllowed(b.Version) {
|
||||
err = errBuildUnsupported
|
||||
}
|
||||
if b.Experimental {
|
||||
@@ -355,7 +359,10 @@ func (a *API) runImageBuild(b buildRecord) {
|
||||
}
|
||||
}
|
||||
var id string
|
||||
if err == nil {
|
||||
if b.ReleaseID != "" {
|
||||
tarball, err = a.releaseBuildContext(ctx, b)
|
||||
}
|
||||
if err == nil && b.ReleaseID == "" {
|
||||
id, err = a.localBuildImage(ctx, b.Version, "")
|
||||
}
|
||||
if err == nil && id == "" {
|
||||
@@ -365,11 +372,15 @@ func (a *API) runImageBuild(b buildRecord) {
|
||||
}
|
||||
}
|
||||
if err == nil && id == "" {
|
||||
id, err = a.docker.Build(ctx, tarball, b.Version, "dsh-web:"+b.Version+"-"+imageRecipeRev, appendLog)
|
||||
if b.ReleaseID == "" {
|
||||
id, err = a.docker.Build(ctx, tarball, b.Version, "dsh-web:"+b.Version+"-"+imageRecipeRev, appendLog)
|
||||
} else {
|
||||
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.imageMatches(ctx, id, b.Version)
|
||||
matches, err = a.buildImageMatches(ctx, id, b)
|
||||
if err == nil && !matches {
|
||||
err = errors.New("image template or platform mismatch")
|
||||
}
|
||||
@@ -517,18 +528,21 @@ type imageRecord struct {
|
||||
UsageCount int `json:"usage_count"`
|
||||
}
|
||||
type buildRecord struct {
|
||||
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"`
|
||||
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) {
|
||||
@@ -721,6 +735,9 @@ func (a *API) localBuildImage(ctx context.Context, version, preferred string) (s
|
||||
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 {
|
||||
|
||||
@@ -240,7 +240,7 @@ func (a *API) insertBuildRequest(b buildRecord, user string) error {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.Exec("INSERT INTO image_builds(id,version,status,created_at,requested_by,recipe_rev,platform,experimental) VALUES(?,?,?,?,?,?,?,?)", b.ID, b.Version, b.Status, b.Created, b.RequestedBy, b.RecipeRev, b.Platform, b.Experimental); err != nil {
|
||||
if _, err = tx.Exec("INSERT INTO image_builds(id,version,status,created_at,requested_by,recipe_rev,platform,experimental,release_id,source_repository,source_commit) VALUES(?,?,?,?,?,?,?,?,?,?,?)", b.ID, b.Version, b.Status, b.Created, b.RequestedBy, b.RecipeRev, b.Platform, b.Experimental, b.ReleaseID, b.SourceRepository, b.SourceCommit); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec("INSERT INTO image_build_requests(user_id,build_id,created_at) VALUES(?,?,?)", user, b.ID, b.Created); err != nil {
|
||||
@@ -261,7 +261,7 @@ func (a *API) reconcileBuilds(parent context.Context, records []buildRecord) err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actual == nil || actual.ID != b.ImageID || actual.Config.Labels["dsh-mgr.role"] != "runtime" || actual.Config.Labels["dsh-mgr.version"] != b.Version || actual.Config.Labels["dsh-mgr.recipe"] != b.RecipeRev || actual.platform() != b.Platform {
|
||||
if actual == nil || actual.ID != b.ImageID || actual.Config.Labels["dsh-mgr.role"] != "runtime" || actual.Config.Labels["dsh-mgr.version"] != b.Version || actual.Config.Labels["dsh-mgr.recipe"] != b.RecipeRev || actual.platform() != b.Platform || !releaseLabelsMatch(actual, *b) {
|
||||
b.Status, b.Error = "missing", "image no longer available"
|
||||
if _, err := a.s.DB.Exec("UPDATE image_builds SET status=?,error=? WHERE id=? AND status='success'", b.Status, b.Error, b.ID); err != nil {
|
||||
return err
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
var fetchRelease = readRelease
|
||||
|
||||
func (a *API) latestRelease(w http.ResponseWriter, r *http.Request) {
|
||||
u, _, err := a.user(r)
|
||||
if err != nil {
|
||||
fail(w, 401, "authentication required")
|
||||
return
|
||||
}
|
||||
if !u.Admin {
|
||||
fail(w, 403, "administrator required")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodGet {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
repository, branch, err := releaseSource()
|
||||
if err != nil {
|
||||
fail(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
info, _, err := fetchRelease(r.Context(), repository, "refs/heads/"+branch)
|
||||
if err != nil && !errors.Is(err, errReleaseIncompatible) {
|
||||
fail(w, 502, sanitizeBuildLog(err.Error()))
|
||||
return
|
||||
}
|
||||
compatible := err == nil
|
||||
reason := "trusted release supports current layout and platform"
|
||||
if !compatible {
|
||||
reason = errReleaseIncompatible.Error()
|
||||
}
|
||||
buildQueue.Lock()
|
||||
err = a.ensureBuilds()
|
||||
buildQueue.Unlock()
|
||||
if err != nil {
|
||||
fail(w, 500, "database error")
|
||||
return
|
||||
}
|
||||
var b buildRecord
|
||||
err = scanBuildSummary(a.s.DB.QueryRow("SELECT "+buildSummaryColumns+" FROM image_builds WHERE source_repository=? AND source_commit=? AND release_id=? AND platform=? ORDER BY created_at DESC,id DESC LIMIT 1", repository, info.Commit, info.ReleaseID, imagePlatform), &b)
|
||||
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
fail(w, 500, "database error")
|
||||
return
|
||||
}
|
||||
result := map[string]any{"release_id": info.ReleaseID, "version": info.Version, "commit": info.Commit, "repository": repository, "branch": branch, "recipe_rev": strconv.Itoa(info.DeploymentSchema), "platforms": info.Platforms, "compatible": compatible, "reason": reason}
|
||||
if err == nil {
|
||||
result["build"] = b
|
||||
}
|
||||
writeJSON(w, result)
|
||||
}
|
||||
|
||||
func (a *API) releaseBuild(w http.ResponseWriter, r *http.Request) {
|
||||
u, _, err := a.user(r)
|
||||
if err != nil {
|
||||
fail(w, 401, "authentication required")
|
||||
return
|
||||
}
|
||||
if !u.Admin {
|
||||
fail(w, 403, "administrator required")
|
||||
return
|
||||
}
|
||||
if r.Method != http.MethodPost {
|
||||
fail(w, 405, "method not allowed")
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
Commit string `json:"commit"`
|
||||
}
|
||||
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
|
||||
dec.DisallowUnknownFields()
|
||||
if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || !releaseCommit.MatchString(in.Commit) {
|
||||
fail(w, 400, "fixed commit required")
|
||||
return
|
||||
}
|
||||
repository, branch, err := releaseSource()
|
||||
if err != nil {
|
||||
fail(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
info, _, err := fetchRelease(r.Context(), repository, "refs/heads/"+branch)
|
||||
if err != nil {
|
||||
fail(w, 502, sanitizeBuildLog(err.Error()))
|
||||
return
|
||||
}
|
||||
if info.Commit != in.Commit {
|
||||
fail(w, 409, "release changed; check the latest release again")
|
||||
return
|
||||
}
|
||||
b, err := a.enqueueRelease(u.ID, info)
|
||||
if err != nil {
|
||||
status := 500
|
||||
if errors.Is(err, errBuildRateLimited) {
|
||||
status = 429
|
||||
}
|
||||
fail(w, status, sanitizeBuildLog(err.Error()))
|
||||
return
|
||||
}
|
||||
a.startBuildWorker()
|
||||
w.WriteHeader(http.StatusAccepted)
|
||||
writeJSON(w, b)
|
||||
}
|
||||
|
||||
func (a *API) enqueueRelease(uid string, info releaseInfo) (buildRecord, error) {
|
||||
buildQueue.Lock()
|
||||
defer buildQueue.Unlock()
|
||||
var b buildRecord
|
||||
if err := a.ensureBuilds(); err != nil {
|
||||
return b, err
|
||||
}
|
||||
err := scanBuildSummary(a.s.DB.QueryRow("SELECT "+buildSummaryColumns+" FROM image_builds WHERE source_repository=? AND source_commit=? AND release_id=? AND platform=? AND status IN ('queued','running','validating','success')", info.Repository, info.Commit, info.ReleaseID, imagePlatform), &b)
|
||||
if err == nil {
|
||||
if b.Status == "success" {
|
||||
buildQueue.Unlock()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
matches, verifyErr := a.buildImageMatches(ctx, b.ImageID, b)
|
||||
cancel()
|
||||
buildQueue.Lock()
|
||||
if verifyErr != nil {
|
||||
return b, verifyErr
|
||||
}
|
||||
if !matches {
|
||||
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 {
|
||||
return b, err
|
||||
}
|
||||
buildQueue.Unlock()
|
||||
b, err = a.enqueueRelease(uid, info)
|
||||
buildQueue.Lock()
|
||||
return b, err
|
||||
}
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
if !errors.Is(err, sql.ErrNoRows) {
|
||||
return b, err
|
||||
}
|
||||
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 b, err
|
||||
}
|
||||
if count >= 3 {
|
||||
return b, errBuildRateLimited
|
||||
}
|
||||
b = buildRecord{ID: store.ID(), Version: info.Version, Status: "queued", Created: time.Now().UTC().Format(time.RFC3339Nano), RequestedBy: uid, RecipeRev: imageRecipeRev, Platform: imagePlatform, ReleaseID: info.ReleaseID, SourceRepository: info.Repository, SourceCommit: info.Commit}
|
||||
return b, a.insertBuildRequest(b, uid)
|
||||
}
|
||||
|
||||
func (a *API) releaseBuildContext(ctx context.Context, b buildRecord) (io.Reader, error) {
|
||||
repository, _, err := releaseSource()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if repository != b.SourceRepository || !releaseCommit.MatchString(b.SourceCommit) {
|
||||
return nil, errors.New("queued release source is no longer trusted")
|
||||
}
|
||||
u, err := a.s.UserByID(b.RequestedBy)
|
||||
if err != nil || !u.Admin {
|
||||
return nil, errors.New("release requester is not an administrator")
|
||||
}
|
||||
info, archive, err := fetchRelease(ctx, repository, b.SourceCommit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Commit != b.SourceCommit || info.ReleaseID != b.ReleaseID || info.Version != b.Version {
|
||||
return nil, errors.New("pinned release manifest mismatch")
|
||||
}
|
||||
return archive, nil
|
||||
}
|
||||
func releaseLabelsMatch(im *inspectedImage, b buildRecord) bool {
|
||||
if b.ReleaseID == "" {
|
||||
return im.Config.Labels["dsh-mgr.release"] == ""
|
||||
}
|
||||
return im.Config.Labels["dsh-mgr.release"] == b.ReleaseID && im.Config.Labels["dsh-mgr.source"] == b.SourceRepository && im.Config.Labels["dsh-mgr.commit"] == b.SourceCommit
|
||||
}
|
||||
func (a *API) buildImageMatches(ctx context.Context, id string, b buildRecord) (bool, error) {
|
||||
matches, err := a.imageMatches(ctx, id, b.Version)
|
||||
if err != nil || !matches {
|
||||
return matches, err
|
||||
}
|
||||
im, err := a.inspectLocalImage(ctx, id)
|
||||
if err != nil || im == nil {
|
||||
return false, err
|
||||
}
|
||||
return releaseLabelsMatch(im, b), nil
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"regexp"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
type releaseManifest struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ReleaseID string `json:"release_id"`
|
||||
Version string `json:"version"`
|
||||
DeploymentSchema int `json:"deployment_schema"`
|
||||
Platforms []string `json:"platforms"`
|
||||
}
|
||||
type releaseInfo struct {
|
||||
releaseManifest
|
||||
Repository string `json:"source_repository"`
|
||||
Commit string `json:"source_commit"`
|
||||
}
|
||||
|
||||
var errReleaseIncompatible = errors.New("release layout or platform is incompatible")
|
||||
|
||||
var releaseCommit = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||
var releaseID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||
|
||||
const releaseLimit = 32 << 20
|
||||
|
||||
func releaseSource() (string, string, error) {
|
||||
repository := os.Getenv("DSH_RELEASE_REPOSITORY")
|
||||
if repository == "" {
|
||||
repository = "https://git.misaka.ren/m1saka/dsh-web.git"
|
||||
}
|
||||
branch := os.Getenv("DSH_RELEASE_BRANCH")
|
||||
if branch == "" {
|
||||
branch = "dockerfile"
|
||||
}
|
||||
u, err := url.Parse(repository)
|
||||
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.ContainsAny(repository, "\r\n") || !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`).MatchString(branch) || strings.Contains(branch, "..") {
|
||||
return "", "", errors.New("invalid trusted release source configuration")
|
||||
}
|
||||
return repository, branch, nil
|
||||
}
|
||||
|
||||
type boundedReleaseBuffer struct{ bytes.Buffer }
|
||||
|
||||
func (b *boundedReleaseBuffer) Write(p []byte) (int, error) {
|
||||
if b.Len()+len(p) > releaseLimit {
|
||||
return 0, errors.New("release exceeds size limit")
|
||||
}
|
||||
return b.Buffer.Write(p)
|
||||
}
|
||||
func gitReleaseCommand(ctx context.Context, dir string, args ...string) ([]byte, error) {
|
||||
gitArgs := append([]string{"-c", "credential.helper=", "-c", "http.followRedirects=false", "-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "fetch.unpackLimit=1"}, args...)
|
||||
// The inherited file-size limit also bounds Git's on-disk pack, not just stdout.
|
||||
cmd := exec.CommandContext(ctx, "/bin/sh", append([]string{"-c", "ulimit -f 32768; exec git \"$@\"", "git"}, gitArgs...)...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
|
||||
cmd.Dir = dir
|
||||
cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0", "GIT_LFS_SKIP_SMUDGE=1"}
|
||||
cmd.WaitDelay = time.Second
|
||||
var out boundedReleaseBuffer
|
||||
cmd.Stdout = &out
|
||||
if err := cmd.Run(); err != nil {
|
||||
return nil, errors.New("trusted release Git operation failed")
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
var releaseFetchSlot = make(chan struct{}, 1)
|
||||
|
||||
func readRelease(ctx context.Context, repository, ref string) (releaseInfo, io.Reader, error) {
|
||||
var info releaseInfo
|
||||
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||
defer cancel()
|
||||
select {
|
||||
case releaseFetchSlot <- struct{}{}:
|
||||
defer func() { <-releaseFetchSlot }()
|
||||
case <-ctx.Done():
|
||||
return info, nil, ctx.Err()
|
||||
}
|
||||
dir, err := os.MkdirTemp("", "dsh-release-")
|
||||
if err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
if _, err = gitReleaseCommand(ctx, dir, "init", "--quiet"); err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
if _, err = gitReleaseCommand(ctx, dir, "fetch", "--quiet", "--depth=1", "--no-tags", repository, ref); err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
commit, err := gitReleaseCommand(ctx, dir, "rev-parse", "FETCH_HEAD^{commit}")
|
||||
if err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
info.Commit = strings.TrimSpace(string(commit))
|
||||
info.Repository = repository
|
||||
if !releaseCommit.MatchString(info.Commit) || (releaseCommit.MatchString(ref) && info.Commit != ref) {
|
||||
return info, nil, errors.New("release commit mismatch")
|
||||
}
|
||||
archive, err := gitReleaseCommand(ctx, dir, "archive", "--format=tar", info.Commit, "--", "release.json", "image")
|
||||
if err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
return parseReleaseArchive(info, archive)
|
||||
}
|
||||
|
||||
func parseReleaseArchive(info releaseInfo, archive []byte) (releaseInfo, io.Reader, error) {
|
||||
var manifest []byte
|
||||
tr := tar.NewReader(bytes.NewReader(archive))
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
if h.Name == "release.json" && h.Typeflag == tar.TypeReg {
|
||||
if h.Size > 16384 {
|
||||
return info, nil, errors.New("release manifest too large")
|
||||
}
|
||||
manifest, err = io.ReadAll(tr)
|
||||
if err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
dec := json.NewDecoder(bytes.NewReader(manifest))
|
||||
dec.DisallowUnknownFields()
|
||||
if dec.Decode(&info.releaseManifest) != nil || dec.Decode(new(any)) != io.EOF {
|
||||
return info, nil, errors.New("invalid release manifest")
|
||||
}
|
||||
m := info.releaseManifest
|
||||
compatible := false
|
||||
for _, p := range m.Platforms {
|
||||
if p == imagePlatform {
|
||||
compatible = true
|
||||
}
|
||||
}
|
||||
if m.SchemaVersion != 1 || !releaseID.MatchString(m.ReleaseID) || !validBuildVersion(m.Version) || m.DeploymentSchema < 1 || len(m.Platforms) == 0 {
|
||||
return info, nil, errors.New("unsupported release manifest or platform")
|
||||
}
|
||||
if m.DeploymentSchema != 7 || !compatible {
|
||||
return info, nil, errReleaseIncompatible
|
||||
}
|
||||
var out bytes.Buffer
|
||||
tw := tar.NewWriter(&out)
|
||||
tr = tar.NewReader(bytes.NewReader(archive))
|
||||
dockerfile := false
|
||||
for {
|
||||
h, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
name := h.Name
|
||||
if !strings.HasPrefix(name, "image/") {
|
||||
continue
|
||||
}
|
||||
name = strings.TrimPrefix(name, "image/")
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if path.IsAbs(name) || path.Clean(strings.TrimSuffix(name, "/")) != strings.TrimSuffix(name, "/") || name == ".." || strings.HasPrefix(name, "../") {
|
||||
return info, nil, errors.New("unsafe release context path")
|
||||
}
|
||||
excluded := false
|
||||
for _, part := range strings.Split(name, "/") {
|
||||
if part == ".git" || part == ".env" || strings.HasPrefix(part, ".env.") || part == ".netrc" || part == ".npmrc" || part == ".ssh" || part == ".git-credentials" {
|
||||
excluded = true
|
||||
}
|
||||
}
|
||||
if excluded {
|
||||
continue
|
||||
}
|
||||
if h.Typeflag != tar.TypeReg && h.Typeflag != tar.TypeDir {
|
||||
return info, nil, errors.New("release context links and special files are not supported")
|
||||
}
|
||||
h.Name = name
|
||||
h.PAXRecords = nil
|
||||
h.Uname = ""
|
||||
h.Gname = ""
|
||||
h.Uid = 0
|
||||
h.Gid = 0
|
||||
if name == "Dockerfile" && h.Typeflag == tar.TypeReg {
|
||||
dockerfile = true
|
||||
}
|
||||
if err = tw.WriteHeader(h); err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
if _, err = io.Copy(tw, tr); err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
}
|
||||
if !dockerfile {
|
||||
return info, nil, errors.New("release context Dockerfile missing")
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
return info, nil, err
|
||||
}
|
||||
return info, bytes.NewReader(out.Bytes()), nil
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func testReleaseArchive(t *testing.T, m string, files map[string]string) []byte {
|
||||
t.Helper()
|
||||
var b bytes.Buffer
|
||||
tw := tar.NewWriter(&b)
|
||||
files["release.json"] = m
|
||||
for name, data := range files {
|
||||
if err := tw.WriteHeader(&tar.Header{Name: name, Size: int64(len(data)), Mode: 0644}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := tw.Write([]byte(data)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := tw.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return b.Bytes()
|
||||
}
|
||||
func releaseFixture() releaseInfo {
|
||||
return releaseInfo{releaseManifest: releaseManifest{SchemaVersion: 1, ReleaseID: "runtime-7.1", Version: "0.2.0-rc.3", DeploymentSchema: 7, Platforms: []string{imagePlatform}}, Repository: "https://git.misaka.ren/m1saka/dsh-web.git", Commit: strings.Repeat("a", 40)}
|
||||
}
|
||||
func TestReleaseManifestAndContext(t *testing.T) {
|
||||
info := releaseFixture()
|
||||
data, _ := json.Marshal(info.releaseManifest)
|
||||
for _, tc := range []struct {
|
||||
name, manifest string
|
||||
files map[string]string
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid", string(data), map[string]string{"image/Dockerfile": "FROM scratch", "image/nested/helper": "complete", "image/.git/config": "secret", "image/.env": "secret"}, false},
|
||||
{"malformed", "{", map[string]string{"image/Dockerfile": "FROM scratch"}, true},
|
||||
{"unknown", strings.Replace(string(data), "schema_version", "unknown", 1), map[string]string{"image/Dockerfile": "FROM scratch"}, true},
|
||||
{"incompatible", strings.Replace(string(data), "deployment_schema\":7", "deployment_schema\":8", 1), map[string]string{"image/Dockerfile": "FROM scratch"}, true},
|
||||
{"missing Dockerfile", string(data), map[string]string{"image/helper": "x"}, true},
|
||||
{"unsafe path", string(data), map[string]string{"image/Dockerfile": "FROM scratch", "image/../escape": "x"}, true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, r, err := parseReleaseArchive(info, testReleaseArchive(t, tc.manifest, tc.files))
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tr := tar.NewReader(r)
|
||||
seen := map[string]bool{}
|
||||
for {
|
||||
h, e := tr.Next()
|
||||
if e == io.EOF {
|
||||
break
|
||||
}
|
||||
if e != nil {
|
||||
t.Fatal(e)
|
||||
}
|
||||
seen[h.Name] = true
|
||||
}
|
||||
if !seen["nested/helper"] || seen[".env"] || seen[".git/config"] {
|
||||
t.Fatal(seen)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func TestReleaseQueueIdentityRecoveryAndFailure(t *testing.T) {
|
||||
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) })
|
||||
info := releaseFixture()
|
||||
b, err := a.enqueueRelease("u", info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := a.enqueueRelease("u", info)
|
||||
if err != nil || again.ID != b.ID {
|
||||
t.Fatalf("repeat %v %v", again, err)
|
||||
}
|
||||
info.Commit = strings.Repeat("b", 40)
|
||||
info.ReleaseID = "runtime-7.2"
|
||||
next, err := a.enqueueRelease("u", info)
|
||||
if err != nil || next.ID == b.ID || next.Version != b.Version {
|
||||
t.Fatalf("next %v %v", next, err)
|
||||
}
|
||||
buildQueue.Lock()
|
||||
delete(buildQueue.initialized, a.s.DB)
|
||||
err = a.ensureBuilds()
|
||||
buildQueue.Unlock()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var recovered buildRecord
|
||||
if err = scanBuildSummary(a.s.DB.QueryRow("SELECT "+buildSummaryColumns+" FROM image_builds WHERE id=?", next.ID), &recovered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recovered.Status != "queued" || recovered.SourceCommit != info.Commit {
|
||||
t.Fatal(recovered)
|
||||
}
|
||||
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed' WHERE id=?", next.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
retry, err := a.enqueueRelease("u", info)
|
||||
if err != nil || retry.ID == next.ID {
|
||||
t.Fatalf("retry %v %v", retry, err)
|
||||
}
|
||||
}
|
||||
func TestReleaseEndpointsAuthorizationFailureAndMismatch(t *testing.T) {
|
||||
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) })
|
||||
if w := imageRequest(a.latestRelease, sid, "GET", ""); w.Code != 403 {
|
||||
t.Fatal(w.Code)
|
||||
}
|
||||
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='u'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := fetchRelease
|
||||
t.Cleanup(func() { fetchRelease = old })
|
||||
fetchRelease = func(context.Context, string, string) (releaseInfo, io.Reader, error) {
|
||||
return releaseInfo{}, nil, errors.New("network failure")
|
||||
}
|
||||
if w := imageRequest(a.latestRelease, sid, "GET", ""); w.Code != 502 {
|
||||
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
fetchRelease = func(context.Context, string, string) (releaseInfo, io.Reader, error) {
|
||||
return releaseFixture(), nil, nil
|
||||
}
|
||||
if w := imageRequest(a.releaseBuild, sid, "POST", `{"commit":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`); w.Code != 409 {
|
||||
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
fetchRelease = func(context.Context, string, string) (releaseInfo, io.Reader, error) {
|
||||
return releaseFixture(), nil, errReleaseIncompatible
|
||||
}
|
||||
if w := imageRequest(a.latestRelease, sid, "GET", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"compatible":false`) {
|
||||
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
func TestReleaseGitEnvironmentAndFailure(t *testing.T) {
|
||||
out, err := gitReleaseCommand(context.Background(), t.TempDir(), "--version")
|
||||
if err != nil || !strings.HasPrefix(string(out), "git version") {
|
||||
t.Fatalf("%q %v", out, err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
if _, err = gitReleaseCommand(ctx, t.TempDir(), "--version"); err == nil {
|
||||
t.Fatal("cancel ignored")
|
||||
}
|
||||
for _, repository := range []string{"http://example.test/repo", "https://user:[email protected]/repo", "https://example.test/repo?token=secret"} {
|
||||
t.Setenv("DSH_RELEASE_REPOSITORY", repository)
|
||||
if _, _, err = releaseSource(); err == nil {
|
||||
t.Fatal("accepted unsafe source")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedReleaseWorkerAdmissionAndBlock(t *testing.T) {
|
||||
info := releaseFixture()
|
||||
labels := map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": info.Version, "dsh-mgr.recipe": "7", "dsh-mgr.release": info.ReleaseID, "dsh-mgr.source": info.Repository, "dsh-mgr.commit": info.Commit}
|
||||
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1.47/build":
|
||||
var got map[string]string
|
||||
if err := json.Unmarshal([]byte(r.URL.Query().Get("labels")), &got); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
if got["dsh-mgr.commit"] != info.Commit || r.URL.Query().Get("t") != "dsh-web:release-"+info.Commit {
|
||||
t.Error("missing provenance", r.URL)
|
||||
}
|
||||
data, _ := io.ReadAll(r.Body)
|
||||
if !bytes.Contains(data, []byte("pinned context")) {
|
||||
t.Error("wrong build context")
|
||||
}
|
||||
io.WriteString(w, `{"aux":{"ID":"sha256:release"}}`)
|
||||
case "/v1.47/images/sha256:release/json":
|
||||
parts := strings.Split(imagePlatform, "/")
|
||||
writeJSON(w, map[string]any{"Id": "sha256:release", "Os": parts[0], "Architecture": parts[1], "Config": map[string]any{"Labels": labels}})
|
||||
case "/v1.47/containers/create":
|
||||
io.WriteString(w, `{"Id":"check"}`)
|
||||
case "/v1.47/containers/check/start", "/v1.47/exec/probe/start", "/v1.47/containers/check":
|
||||
w.WriteHeader(204)
|
||||
case "/v1.47/containers/check/exec":
|
||||
io.WriteString(w, `{"Id":"probe"}`)
|
||||
case "/v1.47/exec/probe/json":
|
||||
io.WriteString(w, `{"Running":false,"ExitCode":0}`)
|
||||
case "/v1.47/containers/check/json":
|
||||
io.WriteString(w, `{"State":{"Running":true}}`)
|
||||
default:
|
||||
t.Errorf("unexpected %s", r.URL)
|
||||
w.WriteHeader(500)
|
||||
}
|
||||
})
|
||||
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='u'"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
npmBuildClient = &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"version":"0.2.0-rc.3"}`)), Header: make(http.Header)}, nil
|
||||
})}
|
||||
old := fetchRelease
|
||||
t.Cleanup(func() { fetchRelease = old })
|
||||
fetchRelease = func(ctx context.Context, repository, ref string) (releaseInfo, io.Reader, error) {
|
||||
if repository != info.Repository || ref != info.Commit {
|
||||
t.Error("not pinned", repository, ref)
|
||||
}
|
||||
return info, strings.NewReader("pinned context"), nil
|
||||
}
|
||||
b, err := a.enqueueRelease("u", info)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.runImageBuild(b)
|
||||
if err = scanBuildSummary(a.s.DB.QueryRow("SELECT "+buildSummaryColumns+" FROM image_builds WHERE id=?", b.ID), &b); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if b.Status != "success" {
|
||||
t.Fatalf("%+v", b)
|
||||
}
|
||||
im, err := a.evaluateImage(context.Background(), b.ImageID)
|
||||
if err != nil || !im.Selectable {
|
||||
t.Fatalf("%+v %v", im, err)
|
||||
}
|
||||
if _, err = a.s.DB.Exec("INSERT INTO image_admissions(image_id,version,recipe_rev,platform,state,evidence,admitted_by,admitted_at) VALUES(?,?,?,?,?,?,?,?)", b.ImageID, b.Version, "7", imagePlatform, "blocked", "explicit block", "u", "now"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
im, err = a.evaluateImage(context.Background(), b.ImageID)
|
||||
if err != nil || im.Selectable || im.Admission != "blocked" {
|
||||
t.Fatalf("%+v %v", im, err)
|
||||
}
|
||||
}
|
||||
@@ -277,7 +277,11 @@ func (a *API) persistBuildResult(b buildRecord, status, logs, detail, image, fin
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if status == "success" && image != "" {
|
||||
_, err = tx.Exec("INSERT INTO images(image_id,version,recipe_rev,platform,tag,status,built_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(image_id) DO UPDATE SET version=excluded.version,recipe_rev=excluded.recipe_rev,platform=excluded.platform,tag=excluded.tag,status=excluded.status,built_at=excluded.built_at", image, b.Version, imageRecipeRev, imagePlatform, "dsh-web:"+b.Version+"-"+imageRecipeRev, "unverified", finished)
|
||||
tag := "dsh-web:" + b.Version + "-" + imageRecipeRev
|
||||
if b.ReleaseID != "" {
|
||||
tag = "dsh-web:release-" + b.SourceCommit
|
||||
}
|
||||
_, err = tx.Exec("INSERT INTO images(image_id,version,recipe_rev,platform,tag,status,built_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(image_id) DO UPDATE SET version=excluded.version,recipe_rev=excluded.recipe_rev,platform=excluded.platform,tag=excluded.tag,status=excluded.status,built_at=excluded.built_at", image, b.Version, imageRecipeRev, imagePlatform, tag, "unverified", finished)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user