feat(cli): validate prerequisites and seal installation plans
This commit is contained in:
@@ -0,0 +1,122 @@
|
||||
// Package preflight checks a prepared installation without creating application state.
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
)
|
||||
|
||||
const Protocol = 1
|
||||
|
||||
type Check struct {
|
||||
ID string `json:"id"`
|
||||
Outcome string `json:"outcome"`
|
||||
Field string `json:"field"`
|
||||
Action string `json:"action"`
|
||||
}
|
||||
type Report struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
OK bool `json:"ok"`
|
||||
Checks []Check `json:"checks"`
|
||||
}
|
||||
|
||||
func NewReport() Report { return Report{SchemaVersion: 1, OK: true, Checks: []Check{}} }
|
||||
func (r *Report) Add(id, outcome, field, action string) {
|
||||
r.Checks = append(r.Checks, Check{id, outcome, field, action})
|
||||
if outcome == "error" {
|
||||
r.OK = false
|
||||
}
|
||||
}
|
||||
func (r *Report) Merge(other Report) {
|
||||
r.Checks = append(r.Checks, other.Checks...)
|
||||
r.OK = r.OK && other.OK
|
||||
}
|
||||
func (r Report) JSON() string { data, _ := json.Marshal(r); return string(data) }
|
||||
|
||||
type Runner interface {
|
||||
Run(context.Context, []string, io.Reader) (compose.Result, error)
|
||||
}
|
||||
|
||||
func docker(ctx context.Context, runner Runner, args ...string) (compose.Result, error) {
|
||||
bound, cancel := context.WithTimeout(ctx, 15*time.Second)
|
||||
defer cancel()
|
||||
return runner.Run(bound, args, nil)
|
||||
}
|
||||
|
||||
type Requirements struct {
|
||||
CPUs int `json:"cpus"`
|
||||
MemoryBytes uint64 `json:"memory_bytes"`
|
||||
DiskBytes uint64 `json:"disk_bytes"`
|
||||
}
|
||||
|
||||
func DefaultRequirements() Requirements { return Requirements{2, 4 << 30, 10 << 30} }
|
||||
|
||||
type Host struct {
|
||||
OS string
|
||||
Arch string
|
||||
Kernel string
|
||||
Distribution string
|
||||
FreeBytes uint64
|
||||
}
|
||||
|
||||
func InspectHost(directory string) (Host, error) {
|
||||
h := Host{OS: runtime.GOOS, Arch: runtime.GOARCH}
|
||||
kernel, _ := os.ReadFile("/proc/sys/kernel/osrelease")
|
||||
h.Kernel = strings.ToLower(string(kernel))
|
||||
distribution, _ := os.ReadFile("/etc/os-release")
|
||||
h.Distribution = strings.ToLower(string(distribution))
|
||||
var err error
|
||||
h.FreeBytes, err = freeBytes(directory)
|
||||
return h, err
|
||||
}
|
||||
func CheckHost(ctx context.Context, runner Runner, host Host, minimum Requirements) Report {
|
||||
r := NewReport()
|
||||
check := func(id string, ok bool, action string) {
|
||||
outcome := "passed"
|
||||
if !ok {
|
||||
outcome = "error"
|
||||
}
|
||||
r.Add(id, outcome, "host", action)
|
||||
}
|
||||
check("host-platform", (host.OS == "linux" || host.OS == "darwin") && (host.Arch == "amd64" || host.Arch == "arm64"), "Use the Linux executable in Ubuntu WSL2/Omarchy, or the matching macOS executable; native Windows is not the installation path.")
|
||||
if strings.Contains(strings.ToLower(host.Kernel), "microsoft") {
|
||||
check("wsl2", strings.Contains(strings.ToLower(host.Kernel), "wsl2") && strings.Contains(host.Distribution, "ubuntu"), "Use Ubuntu WSL2 and enable Docker Desktop integration for that distribution.")
|
||||
}
|
||||
info, err := docker(ctx, runner, "info", "--format", "{{json .}}")
|
||||
var parsed struct {
|
||||
OSType string
|
||||
Architecture string
|
||||
NCPU int
|
||||
MemTotal uint64
|
||||
}
|
||||
good := err == nil && json.Unmarshal([]byte(info.Stdout), &parsed) == nil
|
||||
check("docker-daemon", good && parsed.OSType == "linux", "Start a reachable Linux Docker daemon for this user.")
|
||||
arch := parsed.Architecture
|
||||
if arch == "x86_64" {
|
||||
arch = "amd64"
|
||||
}
|
||||
if arch == "aarch64" {
|
||||
arch = "arm64"
|
||||
}
|
||||
check("docker-architecture", good && arch == host.Arch, "Use a Linux Docker daemon matching the host architecture; emulation is not certified.")
|
||||
check("docker-resources", good && parsed.NCPU >= minimum.CPUs && parsed.MemTotal >= minimum.MemoryBytes, "Allocate at least the release CPU and memory minimum to Docker.")
|
||||
check("installation-disk", host.FreeBytes >= minimum.DiskBytes && host.FreeBytes > 0, "Provide the release minimum free space on the installation filesystem.")
|
||||
result, err := docker(ctx, runner, "compose", "version", "--short")
|
||||
parts := strings.Split(strings.TrimPrefix(strings.TrimSpace(result.Stdout), "v"), ".")
|
||||
major, _ := strconv.Atoi(parts[0])
|
||||
minor := 0
|
||||
if len(parts) > 1 {
|
||||
minor, _ = strconv.Atoi(parts[1])
|
||||
}
|
||||
check("docker-compose", err == nil && (major > 2 || (major == 2 && minor >= 24)), "Install Docker Compose v2.24 or newer.")
|
||||
r.Add("daemon-storage", "warning", "host", "Host disk capacity does not measure a Docker Desktop VM disk; reserve equivalent Docker storage and verify volume allocation during setup.")
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
)
|
||||
|
||||
// CheckCompose resolves the distributed release plus authored overrides without starting services.
|
||||
func CheckCompose(ctx context.Context, runner Runner, installation config.Installation, m Manifest, manifestPath, platform string) Report {
|
||||
r := NewReport()
|
||||
args := []string{"compose", "--project-directory", installation.ProjectDirectory, "--env-file", installation.EnvFile}
|
||||
for _, name := range m.Compose {
|
||||
args = append(args, "-f", filepath.Join(filepath.Dir(manifestPath), filepath.FromSlash(name)))
|
||||
}
|
||||
for _, path := range installation.Overrides {
|
||||
if _, err := os.Stat(path); os.IsNotExist(err) {
|
||||
// Only the two well-known distribution-owned transport overlays can be relocated.
|
||||
name := "deploy/" + filepath.Base(path)
|
||||
if path != filepath.Join(installation.ProjectDirectory, filepath.FromSlash(name)) || (name != "deploy/compose.git-https.yaml" && name != "deploy/compose.git-ssh.yaml") || m.Files[name] == "" {
|
||||
r.Add("compose-assets", "error", "overrides", "Include the selected Git transport overlay in the verified release.")
|
||||
return r
|
||||
}
|
||||
path = filepath.Join(filepath.Dir(manifestPath), filepath.FromSlash(name))
|
||||
}
|
||||
args = append(args, "-f", path)
|
||||
}
|
||||
args = append(args, "config", "--format", "json")
|
||||
result, err := docker(ctx, runner, args...)
|
||||
var effective struct {
|
||||
Services map[string]struct {
|
||||
Image string `json:"image"`
|
||||
Build any `json:"build"`
|
||||
Platform string `json:"platform"`
|
||||
} `json:"services"`
|
||||
}
|
||||
if err != nil || json.Unmarshal([]byte(result.Stdout), &effective) != nil {
|
||||
r.Add("compose-configuration", "error", "operator.env", "Correct the effective Compose configuration using the release assets and prepared environment; no raw output is logged.")
|
||||
return r
|
||||
}
|
||||
roles := map[string]string{"core": "core", "catalog-migrate": "core", "workspace-maintenance": "core", "frontend": "frontend", "catalog-db": "catalog", "qdrant": "qdrant", "embedding": "embedding", "embedding-model-init": "embedding"}
|
||||
for service, role := range roles {
|
||||
entry, exists := effective.Services[service]
|
||||
if !exists || entry.Build != nil || entry.Image != m.Images[role][platform] || (entry.Platform != "" && entry.Platform != platform) {
|
||||
r.Add("compose-image-"+service, "error", "release.compose", "Each runtime and maintenance service must use its released immutable image without a source build.")
|
||||
}
|
||||
}
|
||||
for service := range effective.Services {
|
||||
if _, ok := roles[service]; !ok {
|
||||
r.Add("compose-service", "error", "release.compose", "Additional services need an explicit release contract before execution.")
|
||||
}
|
||||
}
|
||||
if r.OK {
|
||||
r.Add("compose-configuration", "passed", "release.compose", "Effective Compose configuration uses the complete released image set.")
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//go:build !windows
|
||||
|
||||
package preflight
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
func freeBytes(path string) (uint64, error) {
|
||||
var stat unix.Statfs_t
|
||||
if err := unix.Statfs(path, &stat); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint64(stat.Bavail) * uint64(stat.Bsize), nil
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
//go:build windows
|
||||
|
||||
package preflight
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
func freeBytes(path string) (uint64, error) {
|
||||
p, err := windows.UTF16PtrFromString(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var available uint64
|
||||
err = windows.GetDiskFreeSpaceEx(p, &available, nil, nil)
|
||||
return available, err
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"errors"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/crypto/ssh/knownhosts"
|
||||
)
|
||||
|
||||
func CheckExternal(ctx context.Context, installation config.Installation) Report {
|
||||
r := NewReport()
|
||||
value := func(name string) string { result, _ := installation.EnvironmentValue(name); return result }
|
||||
err := checkGit(ctx, installation, value)
|
||||
outcome := "passed"
|
||||
if err != nil {
|
||||
outcome = "error"
|
||||
}
|
||||
r.Add("workspace-remote", outcome, "workspaceRepository", "Require authenticated read access to the configured Git remote and branch using the prepared trust/credential files.")
|
||||
for name, provider := range installation.ModelCatalog.Providers {
|
||||
if provider.Endpoint == nil {
|
||||
r.Add("provider-"+name, "warning", "modelCatalog.providers", "Built-in provider endpoint resolution belongs to the bundled Pi SDK. The required Pi runtime smoke check verifies model availability and credentials; preflight makes no billable generation requests.")
|
||||
continue
|
||||
}
|
||||
parsed, err := url.Parse(provider.Endpoint.BaseURL)
|
||||
if err == nil {
|
||||
err = probeOrigin(ctx, parsed)
|
||||
}
|
||||
outcome := "passed"
|
||||
if err != nil {
|
||||
outcome = "error"
|
||||
}
|
||||
r.Add("provider-"+name, outcome, "modelCatalog.providers."+name+".endpoint", "Require DNS/TCP/TLS reachability of the configured provider origin. Credential/model eligibility still requires the bundled Pi runtime smoke check; no generation request is sent here.")
|
||||
}
|
||||
return r
|
||||
}
|
||||
func probeOrigin(ctx context.Context, target *url.URL) error {
|
||||
bound, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
port := target.Port()
|
||||
if port == "" {
|
||||
port = "443"
|
||||
if target.Scheme == "http" {
|
||||
port = "80"
|
||||
}
|
||||
}
|
||||
address := net.JoinHostPort(target.Hostname(), port)
|
||||
var connection net.Conn
|
||||
var err error
|
||||
if target.Scheme == "https" {
|
||||
dialer := tls.Dialer{NetDialer: &net.Dialer{Timeout: 5 * time.Second}, Config: &tls.Config{MinVersion: tls.VersionTLS12, ServerName: target.Hostname()}}
|
||||
connection, err = dialer.DialContext(bound, "tcp", address)
|
||||
} else {
|
||||
connection, err = (&net.Dialer{Timeout: 5 * time.Second}).DialContext(bound, "tcp", address)
|
||||
}
|
||||
if err == nil {
|
||||
connection.Close()
|
||||
}
|
||||
return err
|
||||
}
|
||||
func checkGit(ctx context.Context, installation config.Installation, value func(string) string) error {
|
||||
remote := installation.WorkspaceRepository.Remote
|
||||
if installation.WorkspaceRepository.Access == "ssh" && strings.HasPrefix(remote, "git@") {
|
||||
host, path, found := strings.Cut(strings.TrimPrefix(remote, "git@"), ":")
|
||||
if !found {
|
||||
return errors.New("invalid Git remote")
|
||||
}
|
||||
return checkSSHGit(ctx, &url.URL{Scheme: "ssh", User: url.User("git"), Host: host, Path: path}, installation.WorkspaceRepository.Branch, value)
|
||||
}
|
||||
u, err := url.Parse(remote)
|
||||
if err != nil {
|
||||
return errors.New("remote unavailable")
|
||||
}
|
||||
if installation.WorkspaceRepository.Access == "ssh" {
|
||||
return checkSSHGit(ctx, u, installation.WorkspaceRepository.Branch, value)
|
||||
}
|
||||
ca, err := safeio.ReadCanonicalPrivateRegular(value("THT_WORKSPACE_GIT_CA_FILE"), 64<<10)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(ca) {
|
||||
return errors.New("invalid Git trust")
|
||||
}
|
||||
credentials, err := safeio.ReadCanonicalPrivateRegular(value("THT_WORKSPACE_GIT_CREDENTIALS_FILE"), 64<<10)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var user, password string
|
||||
for _, line := range strings.Split(string(credentials), "\n") {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
credential, err := url.Parse(strings.TrimSpace(line))
|
||||
if err != nil || credential.User == nil {
|
||||
return errors.New("invalid Git credentials")
|
||||
}
|
||||
if credential.Scheme == u.Scheme && credential.Host == u.Host && (credential.Path == "" || credential.Path == u.Path) {
|
||||
user = credential.User.Username()
|
||||
password, _ = credential.User.Password()
|
||||
}
|
||||
}
|
||||
u.Path = strings.TrimSuffix(u.Path, "/") + "/info/refs"
|
||||
u.RawQuery = "service=git-upload-pack"
|
||||
bound, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
request, err := http.NewRequestWithContext(bound, http.MethodGet, u.String(), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if user != "" {
|
||||
request.SetBasicAuth(user, password)
|
||||
}
|
||||
transport := &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}, Proxy: http.ProxyFromEnvironment}
|
||||
defer transport.CloseIdleConnections()
|
||||
client := http.Client{Transport: transport, Timeout: 5 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return errors.New("Git remote refused")
|
||||
}
|
||||
data, err := io.ReadAll(io.LimitReader(response.Body, (1<<20)+1))
|
||||
if err != nil || len(data) > 1<<20 || !advertisesBranch(data, installation.WorkspaceRepository.Branch) {
|
||||
return errors.New("Git branch unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func advertisesBranch(data []byte, branch string) bool {
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.SplitN(line, "\x00", 2)[0]
|
||||
if strings.HasSuffix(line, " refs/heads/"+branch) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func checkSSHGit(ctx context.Context, target *url.URL, branch string, value func(string) string) error {
|
||||
// Git paths and branch names are already validated by the canonical installation loader.
|
||||
if target.Scheme != "ssh" || target.User == nil || strings.ContainsAny(target.Path, "'\r\n\x00") {
|
||||
return errors.New("use canonical ssh:// remote")
|
||||
}
|
||||
key, err := safeio.ReadCanonicalPrivateRegular(value("THT_WORKSPACE_GIT_SSH_KEY_FILE"), 64<<10)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
signer, err := ssh.ParsePrivateKey(key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hostKey, err := knownhosts.New(value("THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
port := target.Port()
|
||||
if port == "" {
|
||||
port = "22"
|
||||
}
|
||||
if _, err := strconv.Atoi(port); err != nil {
|
||||
return err
|
||||
}
|
||||
address := net.JoinHostPort(target.Hostname(), port)
|
||||
bound, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
connection, err := (&net.Dialer{Timeout: 5 * time.Second}).DialContext(bound, "tcp", address)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer connection.Close()
|
||||
deadline, _ := bound.Deadline()
|
||||
_ = connection.SetDeadline(deadline)
|
||||
clientConnection, channels, requests, err := ssh.NewClientConn(connection, address, &ssh.ClientConfig{User: target.User.Username(), Auth: []ssh.AuthMethod{ssh.PublicKeys(signer)}, HostKeyCallback: hostKey, Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := ssh.NewClient(clientConnection, channels, requests)
|
||||
defer client.Close()
|
||||
session, err := client.NewSession()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer session.Close()
|
||||
stdout, err := session.StdoutPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
stdin, err := session.StdinPipe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err = session.Start("git-upload-pack --advertise-refs '" + target.Path + "'"); err != nil {
|
||||
return err
|
||||
}
|
||||
_ = stdin.Close()
|
||||
data, err := io.ReadAll(io.LimitReader(stdout, (1<<20)+1))
|
||||
if err != nil || len(data) > 1<<20 || !advertisesBranch(data, branch) {
|
||||
return errors.New("Git branch unavailable")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/pem"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
)
|
||||
|
||||
func TestGitProbeAuthenticatesAndRejectsWrongBranchAndCredentials(t *testing.T) {
|
||||
status := 200
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user, password, ok := r.BasicAuth()
|
||||
if !ok || user != "reader" || password != "PRIVATE_SENTINEL" {
|
||||
w.WriteHeader(401)
|
||||
return
|
||||
}
|
||||
if r.Method != "GET" || r.URL.Path != "/workspaces.git/info/refs" || r.URL.RawQuery != "service=git-upload-pack" {
|
||||
t.Error("unexpected Git mutation/request")
|
||||
w.WriteHeader(400)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte("0044" + strings.Repeat("a", 40) + " refs/heads/main\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
ca := filepath.Join(root, "ca.pem")
|
||||
credentials := filepath.Join(root, "credentials")
|
||||
_ = os.WriteFile(ca, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: server.Certificate().Raw}), 0o600)
|
||||
_ = os.WriteFile(credentials, []byte(strings.Replace(server.URL, "https://", "https://reader:PRIVATE_SENTINEL@", 1)), 0o600)
|
||||
installation := config.Installation{WorkspaceRepository: config.WorkspaceRepository{Remote: server.URL + "/workspaces.git", Branch: "main", Access: "https"}}
|
||||
value := func(name string) string {
|
||||
if name == "THT_WORKSPACE_GIT_CA_FILE" {
|
||||
return ca
|
||||
}
|
||||
return credentials
|
||||
}
|
||||
if err := checkGit(context.Background(), installation, value); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
installation.WorkspaceRepository.Branch = "missing"
|
||||
if checkGit(context.Background(), installation, value) == nil {
|
||||
t.Fatal("missing branch accepted")
|
||||
}
|
||||
installation.WorkspaceRepository.Branch = "main"
|
||||
status = 503
|
||||
if checkGit(context.Background(), installation, value) == nil {
|
||||
t.Fatal("unavailable existing Git accepted")
|
||||
}
|
||||
status = 200
|
||||
_ = os.WriteFile(credentials, []byte(strings.Replace(server.URL, "https://", "https://reader:rotated@", 1)), 0o600)
|
||||
if checkGit(context.Background(), installation, value) == nil {
|
||||
t.Fatal("bad credential accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"crypto/hmac"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/version"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type Plan struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
ValidatorProtocol int `json:"validator_protocol"`
|
||||
Validator version.Info `json:"validator"`
|
||||
Installation config.Installation `json:"installation"`
|
||||
Release Manifest `json:"release"`
|
||||
Platform string `json:"platform"`
|
||||
WorkspaceDirectory string `json:"workspace_directory"`
|
||||
WorkspaceRevision string `json:"workspace_revision"`
|
||||
Inputs []string `json:"inputs"`
|
||||
AbsentInputs []string `json:"absent_inputs"`
|
||||
InputSeal string `json:"input_seal"`
|
||||
Report Report `json:"report"`
|
||||
}
|
||||
|
||||
func invalidPlan() error {
|
||||
return errors.New("plan inputs changed or protected plan files are unavailable; repeat validation and produce a new plan")
|
||||
}
|
||||
|
||||
// CaptureInputs gives a value-free freshness guard around live probes and document validation.
|
||||
func CaptureInputs(paths []string, workspace string, absent ...string) (func() bool, error) {
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return nil, invalidPlan()
|
||||
}
|
||||
probe := Plan{Inputs: paths, AbsentInputs: absent}
|
||||
before, err := seal(probe, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
files, err := treeFiles(workspace, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func() bool {
|
||||
after, err := seal(probe, key)
|
||||
current, treeErr := treeFiles(workspace, true)
|
||||
return err == nil && treeErr == nil && hmac.Equal([]byte(before), []byte(after)) && slices.Equal(files, current)
|
||||
}, nil
|
||||
}
|
||||
|
||||
// A separate owner-only random key prevents public/offline guessing of low-entropy secrets.
|
||||
// Live prerequisites must still be rechecked immediately before any execution or resumption.
|
||||
func seal(plan Plan, key []byte) (string, error) {
|
||||
for _, path := range plan.AbsentInputs {
|
||||
if _, err := os.Lstat(path); !errors.Is(err, os.ErrNotExist) {
|
||||
return "", invalidPlan()
|
||||
}
|
||||
}
|
||||
mac := hmac.New(sha256.New, key)
|
||||
plan.InputSeal = ""
|
||||
data, err := json.Marshal(plan)
|
||||
if err != nil {
|
||||
return "", invalidPlan()
|
||||
}
|
||||
mac.Write(data)
|
||||
var total int
|
||||
for _, path := range plan.Inputs {
|
||||
contents, err := safeio.ReadCanonicalRegular(path, 32<<20)
|
||||
if err != nil {
|
||||
return "", invalidPlan()
|
||||
}
|
||||
total += len(contents)
|
||||
if total > 256<<20 {
|
||||
return "", invalidPlan()
|
||||
}
|
||||
length, _ := json.Marshal([]any{path, len(contents)})
|
||||
mac.Write(length)
|
||||
mac.Write(contents)
|
||||
}
|
||||
return hex.EncodeToString(mac.Sum(nil)), nil
|
||||
}
|
||||
|
||||
func AbsentOverrides(installation config.Installation) []string {
|
||||
paths := []string{}
|
||||
for _, path := range installation.Overrides {
|
||||
if _, err := os.Lstat(path); errors.Is(err, os.ErrNotExist) {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
func WritePlan(path string, plan *Plan, guards ...func() bool) error {
|
||||
if !plan.Report.OK || plan.ValidatorProtocol != Protocol || !filepath.IsAbs(path) {
|
||||
return invalidPlan()
|
||||
}
|
||||
if exists, err := safeio.PreflightPrivateDirectory(filepath.Dir(path)); err != nil || !exists {
|
||||
return invalidPlan()
|
||||
}
|
||||
for _, target := range []string{path, path + ".key"} {
|
||||
if _, err := os.Lstat(target); !errors.Is(err, os.ErrNotExist) {
|
||||
return invalidPlan()
|
||||
}
|
||||
}
|
||||
key := make([]byte, 32)
|
||||
if _, err := rand.Read(key); err != nil {
|
||||
return invalidPlan()
|
||||
}
|
||||
var err error
|
||||
plan.InputSeal, err = seal(*plan, key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, guard := range guards {
|
||||
if guard == nil || !guard() {
|
||||
return invalidPlan()
|
||||
}
|
||||
}
|
||||
data, _ := json.MarshalIndent(plan, "", " ")
|
||||
if err := safeio.WriteCanonicalNewPrivateFile(path+".key", key, 0o600); err != nil {
|
||||
return invalidPlan()
|
||||
}
|
||||
if err := safeio.WriteCanonicalNewPrivateFile(path, append(data, '\n'), 0o600); err != nil {
|
||||
_ = safeio.RemoveCanonicalPrivateRegular(path + ".key")
|
||||
return invalidPlan()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func VerifyPlanInputs(path string) error {
|
||||
data, err := safeio.ReadCanonicalPrivateRegular(path, 4<<20)
|
||||
if err != nil {
|
||||
return invalidPlan()
|
||||
}
|
||||
key, err := safeio.ReadCanonicalPrivateRegular(path+".key", 32)
|
||||
if err != nil || len(key) != 32 {
|
||||
return invalidPlan()
|
||||
}
|
||||
var plan Plan
|
||||
if json.Unmarshal(data, &plan) != nil || plan.SchemaVersion != 1 || plan.ValidatorProtocol != Protocol || !plan.Report.OK || len(plan.Inputs) == 0 || len(plan.Inputs) > 10000 {
|
||||
return invalidPlan()
|
||||
}
|
||||
actual, err := seal(plan, key)
|
||||
if err != nil || !hmac.Equal([]byte(actual), []byte(plan.InputSeal)) {
|
||||
return invalidPlan()
|
||||
}
|
||||
// Detect added or removed workspace files as well as changes to known file bytes.
|
||||
if plan.WorkspaceDirectory != "" {
|
||||
files, err := treeFiles(plan.WorkspaceDirectory, true)
|
||||
if err != nil {
|
||||
return invalidPlan()
|
||||
}
|
||||
for _, file := range files {
|
||||
if !slices.Contains(plan.Inputs, file) {
|
||||
return invalidPlan()
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func treeFiles(root string, skipGit bool) ([]string, error) {
|
||||
paths := []string{}
|
||||
count := 0
|
||||
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return invalidPlan()
|
||||
}
|
||||
count++
|
||||
if count > 10000 {
|
||||
return invalidPlan()
|
||||
}
|
||||
if skipGit && entry.Name() == ".git" {
|
||||
if entry.IsDir() {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
if !entry.Type().IsRegular() {
|
||||
return invalidPlan()
|
||||
}
|
||||
paths = append(paths, path)
|
||||
return nil
|
||||
})
|
||||
return paths, err
|
||||
}
|
||||
|
||||
// CollectInputs fingerprints exact prepared contents, with normalized configuration in Plan.
|
||||
// Referenced credentials are sealed, never copied. Git object stores are excluded.
|
||||
func CollectInputs(installation config.Installation, workspace, bootstrap, manifest string) ([]string, string, error) {
|
||||
paths := []string{installation.Path, installation.EnvFile, bootstrap, manifest}
|
||||
for _, root := range []string{workspace, installation.AuthenticationDirectory()} {
|
||||
files, err := treeFiles(root, root == workspace)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
paths = append(paths, files...)
|
||||
}
|
||||
secrets, err := installation.SecretFiles()
|
||||
if err != nil {
|
||||
return nil, "", invalidPlan()
|
||||
}
|
||||
for _, path := range secrets {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
data, err := safeio.ReadCanonicalPrivateRegular(bootstrap, 1<<20)
|
||||
if err != nil {
|
||||
return nil, "", invalidPlan()
|
||||
}
|
||||
var bindings struct {
|
||||
Databases []struct {
|
||||
SecretFiles map[string]string `yaml:"secretFiles"`
|
||||
EvidenceSecretFiles map[string]string `yaml:"evidenceSecretFiles"`
|
||||
} `yaml:"databases"`
|
||||
}
|
||||
if yaml.Unmarshal(data, &bindings) != nil {
|
||||
return nil, "", invalidPlan()
|
||||
}
|
||||
for _, entry := range bindings.Databases {
|
||||
for _, values := range []map[string]string{entry.SecretFiles, entry.EvidenceSecretFiles} {
|
||||
for _, path := range values {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
m, err := LoadManifest(manifest)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
for name := range m.Files {
|
||||
paths = append(paths, filepath.Join(filepath.Dir(manifest), filepath.FromSlash(name)))
|
||||
}
|
||||
for _, path := range installation.Overrides {
|
||||
if _, err := os.Lstat(path); err == nil {
|
||||
paths = append(paths, path)
|
||||
}
|
||||
}
|
||||
revision := "content-snapshot"
|
||||
head := filepath.Join(workspace, ".git", "HEAD")
|
||||
if data, err := safeio.ReadCanonicalRegular(head, 1024); err == nil {
|
||||
paths = append(paths, head)
|
||||
value := strings.TrimSpace(string(data))
|
||||
if strings.HasPrefix(value, "ref: refs/") {
|
||||
ref := strings.TrimPrefix(value, "ref: ")
|
||||
if safeRelative(ref) {
|
||||
path := filepath.Join(workspace, ".git", filepath.FromSlash(ref))
|
||||
if data, err := safeio.ReadCanonicalRegular(path, 1024); err == nil {
|
||||
paths = append(paths, path)
|
||||
value = strings.TrimSpace(string(data))
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(value) == 40 {
|
||||
if _, err := hex.DecodeString(value); err == nil {
|
||||
revision = value
|
||||
}
|
||||
}
|
||||
}
|
||||
slices.Sort(paths)
|
||||
paths = slices.Compact(paths)
|
||||
return paths, revision, nil
|
||||
}
|
||||
|
||||
func AddRuntimeObligations(report *Report) {
|
||||
for _, check := range []Check{
|
||||
{"container-network", "deferred-to-runtime", "bindings", "Repeat authenticated DWH and external connectivity checks from the core network."},
|
||||
{"catalog-initialization", "deferred-to-runtime", "catalog", "Apply migrations and verify Catalog health plus prepared binding import."},
|
||||
{"pi-operation", "deferred-to-runtime", "release.components.pi", "Verify the bundled Pi version and authenticated provider/model smoke operation inside core; do not install Pi on the host."},
|
||||
{"local-embedding", "deferred-to-runtime", "modelCatalog.embedding", "Initialize the local embedding model and verify returned vector dimensions."},
|
||||
{"workspace-preprocessing", "deferred-to-runtime", "workspaces", "Sync the exact verified workspace contents, materialize Evidence, preprocess and verify collections."},
|
||||
{"workspace-readiness", "deferred-to-runtime", "workspaces", "Complete required administrative and human review gates before claiming final readiness."},
|
||||
} {
|
||||
report.Checks = append(report.Checks, check)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestPlanBindsInputsAndDetectsCredentialRotationWithoutPublicSecretHashes(t *testing.T) {
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
if err := safeio.ProtectPrivateDirectory(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := filepath.Join(root, "descriptor.yaml")
|
||||
secret := filepath.Join(root, "credential")
|
||||
for path, data := range map[string]string{input: "schemaVersion: 2\n", secret: "PRIVATE_SENTINEL"} {
|
||||
if err := safeio.WriteCanonicalNewPrivateFile(path, []byte(data), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
plan := Plan{SchemaVersion: 1, ValidatorProtocol: Protocol, Inputs: []string{input, secret}, WorkspaceRevision: "content-snapshot", Report: NewReport()}
|
||||
output := filepath.Join(root, "plan.json")
|
||||
if err := WritePlan(output, &plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, _ := os.ReadFile(output)
|
||||
if strings.Contains(string(data), "PRIVATE_SENTINEL") {
|
||||
t.Fatal("secret in plan")
|
||||
}
|
||||
if err := VerifyPlanInputs(output); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := WritePlan(output, &plan); err == nil {
|
||||
t.Fatal("existing plan replaced")
|
||||
}
|
||||
if err := os.WriteFile(secret, []byte("rotated"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := VerifyPlanInputs(output); err == nil {
|
||||
t.Fatal("credential rotation did not invalidate plan")
|
||||
}
|
||||
if err := os.WriteFile(secret, []byte("PRIVATE_SENTINEL"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(input, []byte("schemaVersion: 3\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := VerifyPlanInputs(output); err == nil {
|
||||
t.Fatal("document change did not invalidate plan")
|
||||
}
|
||||
if err := os.WriteFile(input, []byte("schemaVersion: 2\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
absent := filepath.Join(root, "transport-override.yaml")
|
||||
plan.AbsentInputs = []string{absent}
|
||||
second := filepath.Join(root, "second-plan.json")
|
||||
if err := WritePlan(second, &plan); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := safeio.WriteCanonicalNewPrivateFile(absent, []byte("services: {}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if VerifyPlanInputs(second) == nil {
|
||||
t.Fatal("newly appearing override did not invalidate plan")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package preflight
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
)
|
||||
|
||||
type fakeDocker struct {
|
||||
calls [][]string
|
||||
fail string
|
||||
effective string
|
||||
}
|
||||
|
||||
func (f *fakeDocker) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
|
||||
f.calls = append(f.calls, args)
|
||||
if strings.Contains(strings.Join(args, " "), f.fail) && f.fail != "" {
|
||||
return compose.Result{Stderr: "PRIVATE_SENTINEL"}, errors.New("PRIVATE_SENTINEL")
|
||||
}
|
||||
switch args[0] {
|
||||
case "info":
|
||||
return compose.Result{Stdout: `{"OSType":"linux","Architecture":"x86_64","NCPU":4,"MemTotal":17179869184}`}, nil
|
||||
case "compose":
|
||||
if args[len(args)-1] == "json" {
|
||||
if f.effective != "" {
|
||||
return compose.Result{Stdout: f.effective}, nil
|
||||
}
|
||||
return compose.Result{Stdout: `{"services":{"core":{"image":"example/core:latest"}}}`}, nil
|
||||
}
|
||||
return compose.Result{Stdout: "2.39.0"}, nil
|
||||
case "manifest":
|
||||
return compose.Result{Stdout: `{"Descriptor":{"digest":"sha256:` + strings.Repeat("a", 64) + `","platform":{"os":"linux","architecture":"amd64"}}}`}, nil
|
||||
}
|
||||
return compose.Result{}, errors.New("unexpected command")
|
||||
}
|
||||
|
||||
func TestComposeRejectsIncompleteMutableServiceSet(t *testing.T) {
|
||||
r := CheckCompose(context.Background(), &fakeDocker{}, config.Installation{ProjectDirectory: "/private", EnvFile: "/private/operator.env"}, Manifest{Compose: []string{"compose.yaml"}}, "/release/manifest.json", "linux/amd64")
|
||||
if r.OK {
|
||||
t.Fatal("unreleased service set accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeRejectsPlatformOverrideAgainstSelectedImage(t *testing.T) {
|
||||
m := Manifest{Images: map[string]map[string]string{}, Compose: []string{"compose.yaml"}}
|
||||
services := map[string]map[string]string{}
|
||||
for service, role := range map[string]string{"core": "core", "frontend": "frontend", "catalog-db": "catalog", "catalog-migrate": "core", "workspace-maintenance": "core", "qdrant": "qdrant", "embedding": "embedding", "embedding-model-init": "embedding"} {
|
||||
m.Images[role] = map[string]string{"linux/amd64": "docker.io/example/" + role + "@sha256:" + strings.Repeat("a", 64)}
|
||||
services[service] = map[string]string{"image": m.Images[role]["linux/amd64"]}
|
||||
}
|
||||
services["core"]["platform"] = "linux/arm64"
|
||||
data, _ := json.Marshal(map[string]any{"services": services})
|
||||
r := CheckCompose(context.Background(), &fakeDocker{effective: string(data)}, config.Installation{}, m, "/release/manifest.json", "linux/amd64")
|
||||
if r.OK {
|
||||
t.Fatal("incompatible Compose platform accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHostChecksAreReadOnlyAndRejectUnavailableDocker(t *testing.T) {
|
||||
f := &fakeDocker{}
|
||||
host := Host{OS: "linux", Arch: "amd64", Kernel: "6.6-microsoft-standard-WSL2", Distribution: "ubuntu", FreeBytes: 30 << 30}
|
||||
r := CheckHost(context.Background(), f, host, Requirements{CPUs: 2, MemoryBytes: 4 << 30, DiskBytes: 10 << 30})
|
||||
if !r.OK {
|
||||
t.Fatalf("host rejected: %+v", r)
|
||||
}
|
||||
for _, args := range f.calls {
|
||||
if args[0] != "info" && !(args[0] == "compose" && args[1] == "version") {
|
||||
t.Fatalf("mutating call: %v", args)
|
||||
}
|
||||
}
|
||||
f.fail = "info"
|
||||
r = CheckHost(context.Background(), f, host, Requirements{CPUs: 2, MemoryBytes: 4 << 30, DiskBytes: 10 << 30})
|
||||
if r.OK || strings.Contains(r.JSON(), "PRIVATE_SENTINEL") {
|
||||
t.Fatalf("unsafe success/report: %s", r.JSON())
|
||||
}
|
||||
host.Kernel = "4.4-microsoft"
|
||||
if CheckHost(context.Background(), &fakeDocker{}, host, Requirements{CPUs: 2, MemoryBytes: 4 << 30, DiskBytes: 10 << 30}).OK {
|
||||
t.Fatal("WSL1 accepted")
|
||||
}
|
||||
host.OS = "windows"
|
||||
if CheckHost(context.Background(), &fakeDocker{}, host, Requirements{}).OK {
|
||||
t.Fatal("native Windows accepted instead of WSL2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReleaseChecksEveryImmutableImageAndPlatform(t *testing.T) {
|
||||
m := Manifest{SchemaVersion: 1, Version: "1.0.0", Revision: strings.Repeat("b", 40), ValidatorProtocol: 1, Requirements: Requirements{CPUs: 2, MemoryBytes: 4 << 30, DiskBytes: 10 << 30}, Components: []string{"pi", "catalog-migrations", "workspace-maintenance"}, Images: map[string]map[string]string{}}
|
||||
for _, service := range []string{"core", "frontend", "catalog", "qdrant", "embedding"} {
|
||||
m.Images[service] = map[string]string{"linux/amd64": "docker.io/example/" + service + "@sha256:" + strings.Repeat("a", 64)}
|
||||
}
|
||||
m.Files = map[string]string{"deploy/compose.yaml": strings.Repeat("c", 64)}
|
||||
m.Compose = []string{"deploy/compose.yaml"}
|
||||
if err := m.Validate(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f := &fakeDocker{}
|
||||
r := CheckImages(context.Background(), f, m, "linux/amd64")
|
||||
if !r.OK || len(f.calls) != 5 {
|
||||
t.Fatalf("images not checked: %s calls=%d", r.JSON(), len(f.calls))
|
||||
}
|
||||
if CheckImages(context.Background(), f, m, "linux/arm64").OK {
|
||||
t.Fatal("unsupported release architecture accepted")
|
||||
}
|
||||
f.fail = "frontend"
|
||||
if CheckImages(context.Background(), f, m, "linux/amd64").OK {
|
||||
t.Fatal("missing image accepted")
|
||||
}
|
||||
m.Images["core"]["linux/amd64"] = "example/core:latest"
|
||||
if m.Validate() == nil {
|
||||
t.Fatal("mutable tag accepted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user