132 lines
4.8 KiB
Go
132 lines
4.8 KiB
Go
package preflight
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"path/filepath"
|
|
"regexp"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
|
)
|
|
|
|
// Manifest is the publication/consumer contract. Each platform maps to a single-image digest,
|
|
// not a mutable tag or multi-platform index. Maintenance roles use Images["core"].
|
|
type Manifest struct {
|
|
SchemaVersion int `json:"schema_version"`
|
|
Version string `json:"version"`
|
|
Revision string `json:"revision"`
|
|
ValidatorProtocol int `json:"validator_protocol"`
|
|
Requirements Requirements `json:"requirements"`
|
|
Components []string `json:"components"`
|
|
Images map[string]map[string]string `json:"images"`
|
|
Files map[string]string `json:"files"`
|
|
Compose []string `json:"compose"`
|
|
}
|
|
|
|
var hex256 = regexp.MustCompile(`^[a-f0-9]{64}$`)
|
|
var imageReference = regexp.MustCompile(`^docker\.io/[a-z0-9][a-z0-9._/-]*@sha256:[a-f0-9]{64}$`)
|
|
|
|
func invalidRelease() error {
|
|
return errors.New("release manifest or packaged files are incomplete, incompatible or invalid")
|
|
}
|
|
func (m Manifest) Validate() error {
|
|
if m.SchemaVersion != 1 || m.ValidatorProtocol != Protocol || !regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9.-]+)?$`).MatchString(m.Version) || !regexp.MustCompile(`^[a-f0-9]{40}$`).MatchString(m.Revision) {
|
|
return invalidRelease()
|
|
}
|
|
if m.Requirements.CPUs < 2 || m.Requirements.MemoryBytes < 4<<30 || m.Requirements.DiskBytes < 10<<30 {
|
|
return invalidRelease()
|
|
}
|
|
for _, component := range []string{"pi", "catalog-migrations", "workspace-maintenance"} {
|
|
if !slices.Contains(m.Components, component) {
|
|
return invalidRelease()
|
|
}
|
|
}
|
|
if len(m.Images) != 5 || len(m.Files) == 0 || len(m.Files) > 256 || len(m.Compose) == 0 || len(m.Compose) > 8 {
|
|
return invalidRelease()
|
|
}
|
|
for _, service := range []string{"core", "frontend", "catalog", "qdrant", "embedding"} {
|
|
if len(m.Images[service]) == 0 {
|
|
return invalidRelease()
|
|
}
|
|
for platform, ref := range m.Images[service] {
|
|
if (platform != "linux/amd64" && platform != "linux/arm64") || !imageReference.MatchString(ref) {
|
|
return invalidRelease()
|
|
}
|
|
}
|
|
}
|
|
for name, digest := range m.Files {
|
|
if !safeRelative(name) || !hex256.MatchString(digest) {
|
|
return invalidRelease()
|
|
}
|
|
}
|
|
for _, name := range m.Compose {
|
|
if _, ok := m.Files[name]; !ok {
|
|
return invalidRelease()
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
func safeRelative(name string) bool {
|
|
return name != "" && !strings.Contains(name, "\\") && !strings.Contains(name, ":") && !strings.HasPrefix(name, "/") && filepath.ToSlash(filepath.Clean(name)) == name && name != ".." && !strings.HasPrefix(name, "../") && name != "."
|
|
}
|
|
func LoadManifest(path string) (Manifest, error) {
|
|
var m Manifest
|
|
contents, err := safeio.ReadCanonicalRegular(path, 1<<20)
|
|
if err != nil {
|
|
return m, invalidRelease()
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewReader(contents))
|
|
decoder.DisallowUnknownFields()
|
|
if decoder.Decode(&m) != nil || decoder.Decode(new(any)) != io.EOF {
|
|
return Manifest{}, invalidRelease()
|
|
}
|
|
if err = m.Validate(); err != nil {
|
|
return Manifest{}, err
|
|
}
|
|
for name, digest := range m.Files {
|
|
contents, err := safeio.ReadCanonicalRegular(filepath.Join(filepath.Dir(path), filepath.FromSlash(name)), 32<<20)
|
|
if err != nil {
|
|
return Manifest{}, invalidRelease()
|
|
}
|
|
sum := sha256.Sum256(contents)
|
|
if hex.EncodeToString(sum[:]) != digest {
|
|
return Manifest{}, invalidRelease()
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
func CheckImages(ctx context.Context, runner Runner, m Manifest, platform string) Report {
|
|
r := NewReport()
|
|
for _, service := range []string{"core", "frontend", "catalog", "qdrant", "embedding"} {
|
|
ref := m.Images[service][platform]
|
|
if ref == "" {
|
|
r.Add("image-"+service, "error", "release.images."+service, "Publish the selected Linux architecture before producing an executable plan.")
|
|
continue
|
|
}
|
|
result, err := docker(ctx, runner, "manifest", "inspect", "--verbose", ref)
|
|
var manifest struct {
|
|
Descriptor struct {
|
|
Digest string `json:"digest"`
|
|
Platform struct {
|
|
OS string `json:"os"`
|
|
Architecture string `json:"architecture"`
|
|
} `json:"platform"`
|
|
} `json:"Descriptor"`
|
|
}
|
|
good := err == nil && json.Unmarshal([]byte(result.Stdout), &manifest) == nil && manifest.Descriptor.Platform.OS+"/"+manifest.Descriptor.Platform.Architecture == platform && strings.HasSuffix(ref, "@"+manifest.Descriptor.Digest) && manifest.Descriptor.Digest != ""
|
|
outcome := "passed"
|
|
if !good {
|
|
outcome = "error"
|
|
}
|
|
r.Add("image-"+service, outcome, "release.images."+service, "Require the pinned digest to be publicly readable in Docker Hub for the selected Linux architecture.")
|
|
}
|
|
return r
|
|
}
|