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 }