feat(cli): validate prerequisites and seal installation plans

This commit is contained in:
Codex
2026-09-28 17:03:25 +02:00
parent b9c3369e7b
commit 55f3569e55
30 changed files with 2271 additions and 4 deletions
+192
View File
@@ -0,0 +1,192 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"slices"
"strings"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/preflight"
"github.com/aritmolab/thothii/tools/tht/internal/preparation"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"github.com/aritmolab/thothii/tools/tht/internal/version"
)
func installationPreflightCommand(ctx context.Context, installationPath string, args []string, stdout io.Writer) int {
report := preflight.NewReport()
options := map[string]string{}
usage := false
for index := 1; index < len(args); index++ {
key := args[index]
if _, exists := options[key]; exists {
usage = true
break
}
if key == "--json" {
options[key] = "true"
continue
}
if !slices.Contains([]string{"--directory", "--workspaces", "--bootstrap", "--release", "--output"}, key) || index+1 == len(args) {
usage = true
break
}
options[key] = args[index+1]
index++
}
planning := len(args) > 0 && args[0] == "plan"
if usage || len(args) == 0 || (!planning && args[0] != "preflight") || (planning && (installationPath == "" || options["--workspaces"] == "" || options["--release"] == "" || options["--output"] == "" || options["--directory"] != "")) || (!planning && (options["--directory"] == "" || options["--workspaces"] != "" || options["--bootstrap"] != "" || options["--output"] != "")) {
report.Add("usage", "error", "CLI", "Use installation preflight --directory PATH [--release MANIFEST] [--json], or --installation ABS_PATH installation plan --workspaces PATH --release MANIFEST --output NEW_PLAN [--bootstrap PATH] [--json].")
writePreflight(stdout, report, slices.Contains(args, "--json"))
return 2
}
for key, value := range options {
if key != "--json" {
absolute, err := filepath.Abs(value)
if err != nil {
report.Add("path", "error", "CLI", "Supply canonical absolute paths.")
} else {
options[key] = absolute
}
}
}
var installation config.Installation
if planning {
root, err := filepath.EvalSymlinks(options["--workspaces"])
if err == nil {
relative, err := filepath.Rel(root, options["--output"])
if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
report.Add("plan-in-workspace", "error", "output", "Keep the private plan and its key outside the shared workspace repository.")
}
}
}
if planning {
bootstrap := options["--bootstrap"]
if bootstrap == "" {
bootstrap = filepath.Join(filepath.Dir(installationPath), "database-bootstrap.yaml")
options["--bootstrap"] = bootstrap
}
var output bytes.Buffer
code := installationDocumentsCommand(ctx, installationPath, []string{"validate", "--workspaces", options["--workspaces"], "--bootstrap", bootstrap, "--json"}, &output)
var documents preparation.Report
if code != 0 || json.Unmarshal(output.Bytes(), &documents) != nil || !documents.OK {
report.Add("application-documents", "error", "documents", "Run installation validate and correct every reported issue before planning.")
} else {
var err error
installation, err = config.LoadPrepared(installationPath)
if err != nil {
report.Add("application-documents", "error", "documents", "Prepared inputs changed; repeat validation.")
}
options["--directory"] = installation.ProjectDirectory
}
}
if report.OK {
if exists, err := safeio.PreflightPrivateDirectory(options["--directory"]); err != nil || !exists {
report.Add("installation-directory", "error", "projectDirectory", "Use an existing private canonical installation directory.")
}
}
minimum := preflight.DefaultRequirements()
var inputs []string
var absent []string
var revision string
var unchanged func() bool
var manifest preflight.Manifest
if options["--release"] != "" {
var err error
manifest, err = preflight.LoadManifest(options["--release"])
if err != nil {
report.Add("release-manifest", "error", "release", "Use a complete supported manifest with all packaged file digests verified; publication must precede planning.")
} else {
minimum = manifest.Requirements
}
}
if report.OK && planning {
var err error
inputs, revision, err = preflight.CollectInputs(installation, options["--workspaces"], options["--bootstrap"], options["--release"])
if err == nil {
absent = preflight.AbsentOverrides(installation)
unchanged, err = preflight.CaptureInputs(inputs, options["--workspaces"], absent...)
}
if err != nil {
report.Add("input-snapshot", "error", "documents", "Keep input trees bounded, readable and free from links; repeat document validation.")
} else {
var discarded bytes.Buffer
if installationDocumentsCommand(ctx, installationPath, []string{"validate", "--workspaces", options["--workspaces"], "--bootstrap", options["--bootstrap"], "--json"}, &discarded) != 0 {
report.Add("changed-documents", "error", "documents", "Documents changed during validation; correct and repeat.")
}
}
}
host, hostErr := preflight.InspectHost(options["--directory"])
if hostErr != nil {
report.Add("host-filesystem", "error", "projectDirectory", "Allow filesystem capacity inspection at the canonical installation path.")
}
// Docker connection/trust and executable discovery remain host-owned. Compose parameters
// are exclusively read from the authored env file, not inherited shell overrides.
dockerEnvironment := []string{}
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if slices.Contains([]string{"PATH", "HOME", "USERPROFILE", "SystemRoot", "SYSTEMROOT", "TEMP", "TMP", "TMPDIR", "DOCKER_HOST", "DOCKER_CONTEXT", "DOCKER_CONFIG", "DOCKER_TLS_VERIFY", "DOCKER_CERT_PATH", "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"}, key) {
dockerEnvironment = append(dockerEnvironment, entry)
}
}
runner := compose.NewRunnerWithEnvironment("", dockerEnvironment)
if report.OK {
report.Merge(preflight.CheckHost(ctx, runner, host, minimum))
}
platform := "linux/" + host.Arch
if report.OK && options["--release"] != "" {
report.Merge(preflight.CheckImages(ctx, runner, manifest, platform))
}
if report.OK && planning {
report.Merge(preflight.CheckCompose(ctx, runner, installation, manifest, options["--release"], platform))
report.Merge(preflight.CheckExternal(ctx, installation))
var output, discarded bytes.Buffer
bound, cancel := context.WithTimeout(ctx, 60*time.Second)
code := workspaceDocumentsCommand(bound, []string{"probe", "--directory", options["--workspaces"], "--bootstrap", options["--bootstrap"], "--json"}, &output, &discarded)
cancel()
var probes preflight.Report
if json.Unmarshal(output.Bytes(), &probes) != nil || len(probes.Checks) == 0 {
report.Add("database-probes", "error", "database-bootstrap", "Restore the matching validation helper and rerun bounded external dependency checks.")
} else {
report.Merge(probes)
}
if code != 0 && report.OK {
report.Add("database-probes", "error", "database-bootstrap", "A required external dependency is unavailable; correct it before planning.")
}
if report.OK {
if unchanged == nil || !unchanged() {
report.Add("changed-inputs", "error", "documents", "An input or credential changed during checks; repeat planning with stable prepared files.")
} else {
preflight.AddRuntimeObligations(&report)
plan := preflight.Plan{SchemaVersion: 1, ValidatorProtocol: preflight.Protocol, Validator: version.Current(), Installation: installation, Release: manifest, Platform: platform, WorkspaceDirectory: options["--workspaces"], WorkspaceRevision: revision, Inputs: inputs, AbsentInputs: absent, Report: report}
if err := preflight.WritePlan(options["--output"], &plan, unchanged); err != nil {
report.Add("plan-output", "error", "output", "Choose a new filename in a private canonical directory; existing plans and key files are never overwritten.")
}
}
}
}
if !planning {
report.Add("prepared-inputs", "warning", "documents", "Host preflight alone is not an executable plan; complete application validation and release selection at step 5.")
}
writePreflight(stdout, report, options["--json"] != "")
if report.OK {
return 0
}
return 1
}
func writePreflight(stdout io.Writer, report preflight.Report, structured bool) {
if structured {
_ = json.NewEncoder(stdout).Encode(report)
return
}
for _, check := range report.Checks {
fmt.Fprintf(stdout, "%s [%s] %s: %s\n", check.Outcome, check.ID, check.Field, check.Action)
}
}
@@ -0,0 +1,138 @@
package main
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"encoding/pem"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"testing"
"time"
"github.com/aritmolab/thothii/tools/tht/internal/preflight"
)
func TestNativeInstallationPlanBeforeContainersExist(t *testing.T) {
binary := os.Getenv("THT_INSTALLATION_TEST_CLI")
if binary == "" || runtime.GOOS == "windows" {
t.Skip("set THT_INSTALLATION_TEST_CLI to the compiled sibling bundle")
}
root, _ := filepath.EvalSymlinks(t.TempDir())
_ = os.Chmod(root, 0o700)
workspace := filepath.Join(root, "workspaces")
installation := filepath.Join(root, "installation")
release := filepath.Join(root, "release")
_ = os.Mkdir(release, 0o700)
command := func(args ...string) (int, string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binary, append(args, "--json")...)
cmd.Env = append(os.Environ(), "PATH="+root)
data, err := cmd.CombinedOutput()
if err != nil {
return 1, string(data)
}
return 0, string(data)
}
for _, args := range [][]string{{"workspace", "prepare", "--directory", workspace, "--id", "practice", "--name", "Practice"}, {"installation", "prepare", "--directory", installation}, {"installation", "credentials", "--directory", installation}} {
if code, text := command(args...); code != 0 {
t.Fatal(text)
}
}
git := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Error("Git mutation")
}
fmt.Fprintln(w, "0044"+strings.Repeat("b", 40)+" refs/heads/main")
}))
defer git.Close()
database := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
t.Error("DWH mutation")
}
if r.Header.Get("Authorization") != "Bearer PRIVATE_DATABASE_VALUE" {
w.WriteHeader(401)
}
}))
defer database.Close()
write := func(path string, data []byte) {
t.Helper()
if err := os.WriteFile(path, data, 0o600); err != nil {
t.Fatal(err)
}
}
descriptor := filepath.Join(installation, "thothii-installation.yaml")
for _, name := range []string{"thothii-installation.yaml", "operator.env"} {
path := filepath.Join(installation, name)
data, _ := os.ReadFile(path)
write(path, []byte(strings.ReplaceAll(string(data), "https://CHANGE_ME/workspaces.git", git.URL+"/workspaces.git")))
}
for name, data := range map[string][]byte{"secrets.env": []byte("OPENAI_API_KEY=PRIVATE_PROVIDER_VALUE\n"), "database-password": []byte("PRIVATE_DATABASE_VALUE"), "git-credentials": {}, "git-ca.pem": pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: git.Certificate().Raw})} {
write(filepath.Join(installation, "secrets", name), data)
}
write(filepath.Join(installation, "database-bootstrap.yaml"), []byte(fmt.Sprintf("schemaVersion: 1\ndatabases:\n - workspaceId: practice\n engine: postgres\n databaseName: practice\n schema: public\n binding: {transport: rest_api, baseUrl: %q, restPath: /health, restAuth: bearer}\n secretFiles: {apiKey: %q}\n", database.URL, filepath.Join(installation, "secrets/database-password"))))
manifest := preflight.Manifest{SchemaVersion: 1, Version: "1.0.0", Revision: strings.Repeat("b", 40), ValidatorProtocol: 1, Requirements: preflight.DefaultRequirements(), Components: []string{"pi", "catalog-migrations", "workspace-maintenance"}, Images: map[string]map[string]string{}, Files: map[string]string{}, Compose: []string{"compose.yaml"}}
platform := "linux/" + runtime.GOARCH
services := map[string]map[string]string{}
for _, role := range []string{"core", "frontend", "catalog", "qdrant", "embedding"} {
manifest.Images[role] = map[string]string{platform: "docker.io/example/" + role + "@sha256:" + strings.Repeat("a", 64)}
}
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"} {
services[service] = map[string]string{"image": manifest.Images[role][platform]}
}
_ = os.Mkdir(filepath.Join(release, "deploy"), 0o700)
for name, contents := range map[string]string{"compose.yaml": "services: {}\n", "deploy/compose.git-https.yaml": "services: {}\n"} {
write(filepath.Join(release, filepath.FromSlash(name)), []byte(contents))
sum := sha256.Sum256([]byte(contents))
manifest.Files[name] = hex.EncodeToString(sum[:])
}
manifestPath := filepath.Join(release, "release-manifest.json")
manifestJSON, _ := json.Marshal(manifest)
write(manifestPath, manifestJSON)
effective, _ := json.Marshal(map[string]any{"services": services})
script := fmt.Sprintf("#!/bin/sh\ncase \"$1\" in\ninfo) printf '%%s\\n' '{\"OSType\":\"linux\",\"Architecture\":\"%s\",\"NCPU\":4,\"MemTotal\":17179869184}';;\ncompose) if [ \"$2\" = version ]; then printf '2.39.0\\n'; else printf '%%s\\n' '%s'; fi;;\nmanifest) printf '%%s\\n' '{\"Descriptor\":{\"digest\":\"sha256:%s\",\"platform\":{\"os\":\"linux\",\"architecture\":\"%s\"}}}';;\n*) exit 1;;\nesac\n", runtime.GOARCH, effective, strings.Repeat("a", 64), runtime.GOARCH)
write(filepath.Join(root, "docker"), []byte(script))
_ = os.Chmod(filepath.Join(root, "docker"), 0o700)
planPath := filepath.Join(installation, "plan.json")
if code, text := command("--installation", descriptor, "installation", "validate", "--workspaces", workspace); code != 0 {
t.Fatalf("documents: %s", text)
}
args := []string{"--installation", descriptor, "installation", "plan", "--workspaces", workspace, "--release", manifestPath, "--output", planPath}
args[len(args)-1] = filepath.Join(workspace, "forbidden-plan.json")
if code, _ := command(args...); code == 0 {
t.Fatal("plan written inside workspace")
}
if _, err := os.Stat(args[len(args)-1]); !os.IsNotExist(err) {
t.Fatal("private plan published inside workspace")
}
args[len(args)-1] = planPath
code, text := command(args...)
if code != 0 {
t.Fatalf("plan failed: %s", text)
}
if strings.Contains(text, "PRIVATE_") {
t.Fatal("secret in report")
}
if err := preflight.VerifyPlanInputs(planPath); err != nil {
t.Fatal(err)
}
write(filepath.Join(installation, "secrets/database-password"), []byte("rotated-invalid"))
if preflight.VerifyPlanInputs(planPath) == nil {
t.Fatal("rotation did not invalidate plan")
}
args[len(args)-1] = filepath.Join(installation, "second-plan.json")
if code, text := command(args...); code == 0 || strings.Contains(text, "PRIVATE_") {
t.Fatalf("invalid credential plan: %s", text)
}
if _, err := os.Stat(args[len(args)-1]); !os.IsNotExist(err) {
t.Fatal("failed checks published plan")
}
}
+7
View File
@@ -49,6 +49,10 @@ Commands:
Explicitly generate protected technical credentials before setup.
installation validate --workspaces PATH [--bootstrap PATH] [--json]
Check prepared application documents; requires --installation.
installation preflight --directory PATH [--release MANIFEST] [--json]
Check host prerequisites and optionally the published release, without a stack.
installation plan --workspaces PATH --release MANIFEST --output NEW_PLAN [--bootstrap PATH] [--json]
Validate documents and live dependencies, then seal a private plan; requires --installation.
installation migrate --output PATH --session-default PROVIDER/MODEL
--embedding-id PROVIDER/MODEL --embedding-dimensions N
Create a review-only schema-v2 candidate from all three legacy model sources.
@@ -144,6 +148,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
return versionCommand(commandArgs, stdout, stderr)
}
if command == "installation" {
if len(commandArgs) > 0 && (commandArgs[0] == "preflight" || commandArgs[0] == "plan") {
return installationPreflightCommand(ctx, installationPath, commandArgs, stdout)
}
if len(commandArgs) > 0 && (commandArgs[0] == "prepare" || commandArgs[0] == "credentials" || commandArgs[0] == "validate") {
return installationDocumentsCommand(ctx, installationPath, commandArgs, stdout)
}