feat(cli): validate prerequisites and seal installation plans
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user