wip: guided standalone installation and workspace checks

This commit is contained in:
Codex
2026-09-26 16:41:15 +02:00
parent 0d2e573e0d
commit 67ee52624c
15 changed files with 902 additions and 560 deletions
+157 -8
View File
@@ -3,6 +3,8 @@ package setup
import (
"bufio"
"bytes"
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -13,6 +15,7 @@ import (
"sort"
"strconv"
"strings"
"unicode"
"github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
@@ -26,6 +29,7 @@ const (
)
var installationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
var secretBundleKeyPattern = regexp.MustCompile(`^[A-Z][A-Z0-9_]{0,127}$`)
// atomicWriteNewFile is a seam for failure testing. Its implementation never replaces an existing
// file and leaves no final target until all content is synced.
@@ -44,6 +48,7 @@ type answers struct {
secretsFile, piAuthFile string
gitCredentialsFile, gitCAFile string
gitSSHKeyFile, gitKnownHostsFile string
complete bool
createSecretTemplates bool
}
@@ -116,6 +121,11 @@ func EnsureFiles(request Request, input io.Reader, output io.Writer) (FilesResul
if err := validateOrCreateSecretFiles(values, output); err != nil {
return FilesResult{}, err
}
if values.complete {
if err := validateCompleteProtectedFiles(values); err != nil {
return FilesResult{}, err
}
}
created := make([]string, 0, 2)
cleanup := func() {
@@ -163,6 +173,27 @@ func collectAnswers(request Request, input io.Reader, output io.Writer, root str
value := answersFromRequest(request)
value.installationID = firstNonEmpty(request.InstallationID, os.Getenv("THT_SETUP_INSTALLATION_ID"), "local")
value.profile = firstNonEmpty(request.Profile, os.Getenv("THT_SETUP_PROFILE"), "local")
value.complete = request.Complete
if request.Complete {
// The complete path has one predictable protected directory. The user only fills the
// bundle and any repository credential that is genuinely required; catalog passwords
// are generated below and never appear in the questionnaire.
directory := filepath.Join(root, "deploy", value.installationID, "secrets")
value.workspaceBranch = firstNonEmpty(value.workspaceBranch, "main")
value.secretsFile = firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))
value.piAuthFile = firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json"))
if request.NonInteractive {
value.workspaceAccess = firstNonEmpty(value.workspaceAccess, accessForRemote(value.workspaceRemote))
if value.workspaceAccess == "ssh" {
value.gitSSHKeyFile = firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key"))
value.gitKnownHostsFile = firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts"))
} else {
value.gitCredentialsFile = firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials"))
value.gitCAFile = firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem"))
}
}
value.createSecretTemplates = true
}
if request.NonInteractive {
return requireNonInteractiveAnswers(value)
}
@@ -190,6 +221,18 @@ func collectAnswers(request Request, input io.Reader, output io.Writer, root str
return answers{}, err
}
directory := filepath.Join(root, "deploy", value.installationID, "secrets")
if request.Complete {
value.secretsFile = firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))
value.piAuthFile = firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json"))
if value.workspaceAccess == "ssh" {
value.gitSSHKeyFile = firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key"))
value.gitKnownHostsFile = firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts"))
} else {
value.gitCredentialsFile = firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials"))
value.gitCAFile = firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem"))
}
return value, nil
}
if value.secretsFile, err = prompt(scanner, output, "Secret file location", firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))); err != nil {
return answers{}, err
}
@@ -216,11 +259,15 @@ func collectAnswers(request Request, input io.Reader, output io.Writer, root str
return answers{}, missingErr
}
if len(missing) > 0 {
answer, promptErr := prompt(scanner, output, "Create blank secret-file templates for the missing locations? Type yes to confirm", "no")
if promptErr != nil {
return answers{}, promptErr
if request.Complete {
value.createSecretTemplates = true
} else {
answer, promptErr := prompt(scanner, output, "Create blank secret-file templates for the missing locations? Type yes to confirm", "no")
if promptErr != nil {
return answers{}, promptErr
}
value.createSecretTemplates = strings.EqualFold(answer, "yes")
}
value.createSecretTemplates = strings.EqualFold(answer, "yes")
}
return value, nil
}
@@ -379,6 +426,13 @@ func render(root, descriptorPath string, value answers) ([]byte, []byte, error)
if value.llmURL != "" {
lines = append(lines, "THT_LLM_URL="+dotenvValue(value.llmURL))
}
if value.complete {
passwordDirectory := filepath.Dir(value.secretsFile)
lines = append(lines,
"THT_CATALOG_RUNTIME_PASSWORD_SOURCE="+dotenvValue(filepath.Join(passwordDirectory, "catalog-runtime-password")),
"THT_CATALOG_MIGRATOR_PASSWORD_SOURCE="+dotenvValue(filepath.Join(passwordDirectory, "catalog-migrator-password")),
)
}
if value.profile == "server" {
installationDirectory := filepath.Dir(descriptorPath)
lines = append(lines,
@@ -474,10 +528,7 @@ func validateOrCreateSecretFiles(value answers, output io.Writer) error {
if err != nil {
return err
}
if len(missing) == 0 {
return nil
}
if !value.createSecretTemplates {
if len(missing) > 0 && !value.createSecretTemplates {
return fmt.Errorf("secret files are missing: %s; create them yourself or explicitly confirm blank secret-file templates", strings.Join(missing, ", "))
}
for _, path := range missing {
@@ -489,6 +540,104 @@ func validateOrCreateSecretFiles(value answers, output io.Writer) error {
}
fmt.Fprintf(output, "Created blank secret-file template: %s\n", path)
}
if value.complete {
for _, path := range catalogPasswordPaths(value) {
exists, err := inspectExistingSecretFile(path)
if err != nil {
return err
}
if exists {
continue
}
contents, err := generatedCatalogPassword()
if err != nil {
return fmt.Errorf("generate catalog password: %w", err)
}
if err := atomicWriteNewFile(path, contents, 0o600); err != nil {
return fmt.Errorf("create catalog password %s: %w", path, err)
}
fmt.Fprintf(output, "Created generated catalog password file: %s\n", path)
}
}
return nil
}
func catalogPasswordPaths(value answers) []string {
directory := filepath.Dir(value.secretsFile)
return []string{
filepath.Join(directory, "catalog-runtime-password"),
filepath.Join(directory, "catalog-migrator-password"),
}
}
func generatedCatalogPassword() ([]byte, error) {
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
return nil, errors.New("secure random source is unavailable")
}
return []byte(hex.EncodeToString(value) + "\n"), nil
}
func validateCompleteProtectedFiles(value answers) error {
if err := validateSecretBundle(value.secretsFile); err != nil {
return err
}
// The generated catalog deliberately uses Pi's built-in provider. A syntactically empty
// auth store would let Docker start only to fail at the first provider check, so catch it
// before any image is built. Other model providers can be selected later in the descriptor.
contents, err := safeio.ReadCanonicalRegular(value.piAuthFile, maxSecretBytes)
if err != nil || strings.TrimSpace(string(contents)) == "" || strings.TrimSpace(string(contents)) == "{}" {
return fmt.Errorf("complete setup requires usable Pi credentials in %s", value.piAuthFile)
}
if value.workspaceAccess == "ssh" {
for name, path := range map[string]string{
"workspace Git SSH key": value.gitSSHKeyFile,
"workspace Git known-hosts": value.gitKnownHostsFile,
} {
contents, readErr := safeio.ReadCanonicalRegular(path, maxSecretBytes)
if readErr != nil || strings.TrimSpace(string(contents)) == "" {
return fmt.Errorf("complete setup requires usable %s in %s", name, path)
}
}
} else {
for name, path := range map[string]string{
"workspace Git credentials": value.gitCredentialsFile,
"workspace Git CA": value.gitCAFile,
} {
contents, readErr := safeio.ReadCanonicalRegular(path, maxSecretBytes)
if readErr != nil || strings.TrimSpace(string(contents)) == "" {
return fmt.Errorf("complete setup requires usable %s in %s", name, path)
}
}
}
return nil
}
func validateSecretBundle(path string) error {
contents, err := safeio.ReadCanonicalRegular(path, maxSecretBytes)
if err != nil {
return fmt.Errorf("complete setup cannot read the secret bundle %s", path)
}
seen := make(map[string]struct{})
for lineNumber, raw := range strings.Split(string(contents), "\n") {
line := strings.TrimSuffix(raw, "\r")
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
key, secret, found := strings.Cut(line, "=")
invalid := !found || !secretBundleKeyPattern.MatchString(key) || strings.TrimSpace(key) != key ||
secret == "" || strings.TrimSpace(secret) != secret ||
strings.Contains(strings.ToLower(secret), "replace-me") ||
strings.IndexFunc(secret, unicode.IsSpace) >= 0
if invalid {
return fmt.Errorf("complete setup found an invalid secret bundle entry at line %d", lineNumber+1)
}
if _, duplicate := seen[key]; duplicate {
return fmt.Errorf("complete setup found a duplicate secret bundle key %s", key)
}
seen[key] = struct{}{}
}
return nil
}