Files
ThothII/tools/tht/internal/preflight/release.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
}