199 lines
6.8 KiB
Go
199 lines
6.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Admission writes serialize with new runtime operations, including their Docker side effects.
|
|
var admissionMu sync.RWMutex
|
|
|
|
func defaultAdmission(version, recipe, platform string) string {
|
|
if recipe == "7" && platform == "linux/amd64" {
|
|
switch version {
|
|
case "0.2.0-rc.1":
|
|
return "compatible"
|
|
case "0.2.0-rc.2":
|
|
return "recommended"
|
|
}
|
|
}
|
|
return "existing_only"
|
|
}
|
|
func supportedLayout(recipe string) bool {
|
|
switch recipe {
|
|
case "3", "4", "5", "6", "7":
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
func positiveAdmission(state string) bool { return state == "recommended" || state == "compatible" }
|
|
|
|
func (a *API) evaluateImage(ctx context.Context, id string) (imageRecord, error) {
|
|
im := imageRecord{ID: id, ImageID: id, Status: "missing", Admission: "existing_only", AdmissionReason: "local image missing"}
|
|
actual, err := a.inspectLocalImage(ctx, id)
|
|
if err != nil || actual == nil {
|
|
return im, err
|
|
}
|
|
im.Version = actual.Config.Labels["dsh-mgr.version"]
|
|
im.RecipeRev = actual.Config.Labels["dsh-mgr.recipe"]
|
|
im.Platform = actual.platform()
|
|
im.Created = actual.Created
|
|
im.Status = "unverified"
|
|
im.AdmissionReason = "successful exact image identity verification required"
|
|
if actual.ID != id || actual.Config.Labels["dsh-mgr.role"] != "runtime" {
|
|
im.Version = ""
|
|
return im, nil
|
|
}
|
|
var count, experimental int
|
|
err = a.s.DB.QueryRowContext(ctx, "SELECT count(*),COALESCE(min(experimental),0) FROM image_builds WHERE image_id=? AND version=? AND recipe_rev=? AND platform=? AND status='success'", id, im.Version, im.RecipeRev, im.Platform).Scan(&count, &experimental)
|
|
if err != nil {
|
|
return im, err
|
|
}
|
|
var state, evidence string
|
|
err = a.s.DB.QueryRowContext(ctx, "SELECT state,evidence FROM image_admissions WHERE image_id=? AND version=? AND recipe_rev=? AND platform=?", id, im.Version, im.RecipeRev, im.Platform).Scan(&state, &evidence)
|
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
|
return im, err
|
|
}
|
|
if state == "blocked" {
|
|
im.Admission = state
|
|
im.Status = state
|
|
im.AdmissionReason = evidence
|
|
return im, nil
|
|
}
|
|
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) {
|
|
im.AdmissionReason = "built-in exact tuple compatibility"
|
|
}
|
|
if experimental != 0 {
|
|
im.Admission = "existing_only"
|
|
im.Status = "experimental"
|
|
im.AdmissionReason = "experimental validation succeeded; administrator acceptance required"
|
|
} 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
|
|
im.Status = state
|
|
}
|
|
if !supportedLayout(im.RecipeRev) || im.Platform != imagePlatform {
|
|
im.Admission = "existing_only"
|
|
im.Status = "existing_only"
|
|
im.AdmissionReason = "historical validation retained; current mount layout or platform not supported"
|
|
}
|
|
im.Selectable = positiveAdmission(im.Admission)
|
|
if im.Selectable {
|
|
im.Status = "available"
|
|
}
|
|
return im, nil
|
|
}
|
|
|
|
func (a *API) imageAdmission(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 {
|
|
ImageID string `json:"image_id"`
|
|
State string `json:"state"`
|
|
Evidence string `json:"evidence"`
|
|
}
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 8192))
|
|
dec.DisallowUnknownFields()
|
|
if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || !runtimeImageID.MatchString(in.ImageID) || strings.TrimSpace(in.Evidence) == "" || (!positiveAdmission(in.State) && in.State != "existing_only" && in.State != "blocked") {
|
|
fail(w, 400, "image_id, valid state and evidence required")
|
|
return
|
|
}
|
|
if len(in.Evidence) > 4096 {
|
|
fail(w, 400, "evidence must not exceed 4096 UTF-8 bytes")
|
|
return
|
|
}
|
|
admissionMu.Lock()
|
|
defer admissionMu.Unlock()
|
|
buildQueue.Lock()
|
|
err = a.ensureBuilds()
|
|
buildQueue.Unlock()
|
|
if err != nil {
|
|
fail(w, 500, "database error")
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
|
|
defer cancel()
|
|
im, err := a.evaluateImage(ctx, in.ImageID)
|
|
if err != nil {
|
|
fail(w, 502, "image inspection failed")
|
|
return
|
|
}
|
|
if im.Status == "missing" || im.Version == "" || im.RecipeRev == "" || im.Platform == "" {
|
|
fail(w, 400, "real runtime image identity required")
|
|
return
|
|
}
|
|
if positiveAdmission(in.State) {
|
|
var count 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'", im.ImageID, im.Version, im.RecipeRev, im.Platform).Scan(&count)
|
|
if err != nil || count == 0 || !supportedLayout(im.RecipeRev) || im.Platform != imagePlatform {
|
|
fail(w, 400, "successful exact validation and supported mount layout/platform required")
|
|
return
|
|
}
|
|
}
|
|
buildQueue.Lock()
|
|
defer buildQueue.Unlock()
|
|
tx, err := a.s.DB.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
fail(w, 500, "database error")
|
|
return
|
|
}
|
|
defer tx.Rollback()
|
|
_, err = tx.ExecContext(ctx, "INSERT INTO image_admissions(image_id,version,recipe_rev,platform,state,evidence,admitted_by,admitted_at) VALUES(?,?,?,?,?,?,?,?) ON CONFLICT(image_id,version,recipe_rev,platform) DO UPDATE SET state=excluded.state,evidence=excluded.evidence,admitted_by=excluded.admitted_by,admitted_at=excluded.admitted_at", im.ImageID, im.Version, im.RecipeRev, im.Platform, in.State, strings.TrimSpace(in.Evidence), u.ID, time.Now().UTC().Format(time.RFC3339Nano))
|
|
if err != nil {
|
|
fail(w, 500, "database error")
|
|
return
|
|
}
|
|
if _, err = tx.ExecContext(ctx, "INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", u.ID, "image.admission", im.ImageID, in.State+": "+in.Evidence, time.Now().Unix()); err != nil {
|
|
fail(w, 500, "audit persistence failed")
|
|
return
|
|
}
|
|
if err = tx.Commit(); err != nil {
|
|
fail(w, 500, "database error")
|
|
return
|
|
}
|
|
a.imageDeleteGeneration++
|
|
im.Admission = in.State
|
|
im.AdmissionReason = strings.TrimSpace(in.Evidence)
|
|
im.Selectable = positiveAdmission(in.State)
|
|
im.Status = in.State
|
|
if im.Selectable {
|
|
im.Status = "available"
|
|
}
|
|
writeJSON(w, im)
|
|
}
|