feat(cli): prepare and validate application documents offline
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/preparation"
|
||||
)
|
||||
|
||||
func installationDocumentsCommand(ctx context.Context, installationPath string, args []string, stdout io.Writer) int {
|
||||
report := preparation.NewReport()
|
||||
status := 0
|
||||
options := map[string]string{}
|
||||
valid := len(args) > 0
|
||||
for index := 1; index < len(args); index++ {
|
||||
key := args[index]
|
||||
if _, duplicate := options[key]; duplicate {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
if key == "--json" {
|
||||
options[key] = "true"
|
||||
continue
|
||||
}
|
||||
if (key != "--directory" && key != "--workspaces" && key != "--bootstrap") || index+1 == len(args) {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
options[key] = args[index+1]
|
||||
index++
|
||||
}
|
||||
if !valid || (args[0] == "validate" && (installationPath == "" || options["--workspaces"] == "" || options["--directory"] != "")) || (args[0] != "validate" && (options["--directory"] == "" || options["--workspaces"] != "" || options["--bootstrap"] != "")) {
|
||||
report.Add("CLI", "$", "usage", "Use installation prepare|credentials --directory PATH [--json], or tht --installation ABSOLUTE_PATH installation validate --workspaces PATH [--bootstrap PATH] [--json].")
|
||||
status = 2
|
||||
} else if args[0] == "validate" {
|
||||
installation, checkedReport := preparation.Validate(installationPath)
|
||||
report = checkedReport
|
||||
if report.OK {
|
||||
bootstrap := options["--bootstrap"]
|
||||
if bootstrap == "" {
|
||||
bootstrap = filepath.Join(filepath.Dir(installationPath), "database-bootstrap.yaml")
|
||||
}
|
||||
bootstrap, _ = filepath.Abs(bootstrap)
|
||||
workspace, _ := filepath.Abs(options["--workspaces"])
|
||||
if resolved, err := filepath.EvalSymlinks(workspace); err == nil {
|
||||
workspace = resolved
|
||||
}
|
||||
outsideWorkspace := func(path, document, field string) bool {
|
||||
relative, err := filepath.Rel(workspace, path)
|
||||
if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
report.Add(document, field, "installation_file_in_workspace", "Keep installation documents and credential files outside the shared workspace repository.")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
for _, path := range []string{installationPath, installation.EnvFile, installation.AuthenticationDirectory(), bootstrap} {
|
||||
outsideWorkspace(path, "thothii-installation.yaml", "local-files")
|
||||
}
|
||||
files, _ := installation.SecretFiles()
|
||||
for _, path := range files {
|
||||
outsideWorkspace(path, "operator.env", "protected-file-reference")
|
||||
}
|
||||
if preparation.CheckYAML(bootstrap, "database-bootstrap.yaml", &report) {
|
||||
var output, discarded bytes.Buffer
|
||||
code := workspaceDocumentsCommand(ctx, []string{"bootstrap", "--directory", workspace, "--bootstrap", bootstrap, "--json"}, &output, &discarded)
|
||||
var checked struct {
|
||||
OK bool `json:"ok"`
|
||||
Issues []preparation.Issue `json:"issues"`
|
||||
Warnings []string `json:"warnings"`
|
||||
SecretFiles []struct {
|
||||
Field string `json:"field"`
|
||||
Path string `json:"path"`
|
||||
} `json:"secret_files"`
|
||||
}
|
||||
if json.Unmarshal(output.Bytes(), &checked) != nil {
|
||||
report.Add("CLI", "$", "validator_unavailable", "Reinstall the matching tht and workspace helper pair.")
|
||||
} else if code != 0 || !checked.OK {
|
||||
report.Issues = append(report.Issues, checked.Issues...)
|
||||
if len(checked.Issues) == 0 {
|
||||
report.Add("database-bootstrap.yaml", "$", "bootstrap_invalid", "Correct workspace and binding documents, then validate again.")
|
||||
}
|
||||
} else {
|
||||
report.Warnings = append(report.Warnings, checked.Warnings...)
|
||||
for _, file := range checked.SecretFiles {
|
||||
if outsideWorkspace(file.Path, "database-bootstrap.yaml", file.Field) {
|
||||
preparation.CheckSecret(file.Path, "database-bootstrap.yaml", file.Field, false, &report)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
directory, err := filepath.Abs(options["--directory"])
|
||||
if err == nil {
|
||||
if args[0] == "prepare" {
|
||||
err = preparation.Prepare(directory)
|
||||
} else {
|
||||
err = preparation.Credentials(ctx, directory)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
report.Add("preparation", "$", "preparation_refused", err.Error())
|
||||
}
|
||||
}
|
||||
report.OK = len(report.Issues) == 0
|
||||
if !report.OK && status == 0 {
|
||||
status = 1
|
||||
}
|
||||
if slices.Contains(args, "--json") {
|
||||
_ = json.NewEncoder(stdout).Encode(report)
|
||||
} else {
|
||||
if report.OK {
|
||||
fmt.Fprintln(stdout, "Document operation completed. Local validation does not establish runtime readiness.")
|
||||
}
|
||||
for _, issue := range report.Issues {
|
||||
fmt.Fprintf(stdout, "%s [%s] %s: %s\n", issue.Document, issue.Field, issue.Code, issue.Correction)
|
||||
}
|
||||
for _, warning := range report.Warnings {
|
||||
fmt.Fprintln(stdout, warning)
|
||||
}
|
||||
}
|
||||
return status
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallationPrepareDocumentsBeforeRuntime(t *testing.T) {
|
||||
t.Setenv("PATH", "")
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
destination := filepath.Join(root, "installation")
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"installation", "prepare", "--directory", destination, "--json"}, &stdout, &stderr)
|
||||
if status != 0 {
|
||||
t.Fatalf("status=%d stdout=%s stderr=%s", status, &stdout, &stderr)
|
||||
}
|
||||
var report map[string]any
|
||||
if json.Unmarshal(stdout.Bytes(), &report) != nil || report["ok"] != true {
|
||||
t.Fatalf("report=%s", &stdout)
|
||||
}
|
||||
for _, name := range []string{"thothii-installation.yaml", "operator.env", "database-bootstrap.yaml", "README.md"} {
|
||||
if _, err := os.Stat(filepath.Join(destination, name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
before, _ := os.ReadFile(filepath.Join(destination, "thothii-installation.yaml"))
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if run(context.Background(), []string{"installation", "prepare", "--directory", destination, "--json"}, &stdout, &stderr) == 0 {
|
||||
t.Fatal("overwrote existing documents")
|
||||
}
|
||||
after, _ := os.ReadFile(filepath.Join(destination, "thothii-installation.yaml"))
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("existing document changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallationCredentialsAreExplicitPrivateAndNeverReplaced(t *testing.T) {
|
||||
t.Setenv("PATH", "")
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
destination := filepath.Join(root, "installation")
|
||||
var stdout, stderr bytes.Buffer
|
||||
if run(context.Background(), []string{"installation", "prepare", "--directory", destination}, &stdout, &stderr) != 0 {
|
||||
t.Fatal(&stdout, &stderr)
|
||||
}
|
||||
secret := filepath.Join(destination, "secrets", "catalog-runtime-password")
|
||||
if _, err := os.Stat(secret); !os.IsNotExist(err) {
|
||||
t.Fatal("prepare generated a secret implicitly")
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if run(context.Background(), []string{"installation", "credentials", "--directory", destination, "--json"}, &stdout, &stderr) != 0 {
|
||||
t.Fatal(&stdout, &stderr)
|
||||
}
|
||||
before, err := os.ReadFile(secret)
|
||||
if err != nil || len(bytes.TrimSpace(before)) < 32 {
|
||||
t.Fatal("missing strong technical credential", err)
|
||||
}
|
||||
if bytes.Contains(stdout.Bytes(), bytes.TrimSpace(before)) || bytes.Contains(stderr.Bytes(), bytes.TrimSpace(before)) {
|
||||
t.Fatal("secret leaked")
|
||||
}
|
||||
info, _ := os.Stat(secret)
|
||||
if runtime.GOOS != "windows" && info.Mode().Perm()&0o077 != 0 {
|
||||
t.Fatal("credential is not private")
|
||||
}
|
||||
stdout.Reset()
|
||||
stderr.Reset()
|
||||
if run(context.Background(), []string{"installation", "credentials", "--directory", destination, "--json"}, &stdout, &stderr) != 0 {
|
||||
t.Fatal(&stdout, &stderr)
|
||||
}
|
||||
after, _ := os.ReadFile(secret)
|
||||
if !bytes.Equal(before, after) {
|
||||
t.Fatal("credential was replaced")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallationValidateRejectsWrongEnvironmentFieldType(t *testing.T) {
|
||||
root, _ := filepath.EvalSymlinks(t.TempDir())
|
||||
path := filepath.Join(root, "thothii-installation.yaml")
|
||||
if err := os.WriteFile(path, []byte("schemaVersion: 2\nenvFile: []\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var stdout, stderr bytes.Buffer
|
||||
status := run(context.Background(), []string{"--installation", path, "installation", "validate", "--workspaces", root, "--json"}, &stdout, &stderr)
|
||||
if status != 1 {
|
||||
t.Fatalf("invalid field accepted: %d %s", status, &stdout)
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,12 @@ Commands:
|
||||
setup [--complete|--configure-only] [--installation-id ID] [--profile local|server]
|
||||
[--shell-mode full|embedded] [--shell-default-locale BCP47-TAG] [--shell-adapter omics-portal]
|
||||
Create, validate, and optionally complete the local installation.
|
||||
installation prepare --directory NEW_PATH [--json]
|
||||
Create commented installation, environment and database bootstrap templates.
|
||||
installation credentials --directory PATH [--json]
|
||||
Explicitly generate protected technical credentials before setup.
|
||||
installation validate --workspaces PATH [--bootstrap PATH] [--json]
|
||||
Check prepared application documents; 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.
|
||||
@@ -138,6 +144,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] == "prepare" || commandArgs[0] == "credentials" || commandArgs[0] == "validate") {
|
||||
return installationDocumentsCommand(ctx, installationPath, commandArgs, stdout)
|
||||
}
|
||||
if len(commandArgs) > 0 && commandArgs[0] == "generate" {
|
||||
return installationGenerationCommand(installationPath, commandArgs[1:], stdout, stderr)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user