feat(setup): generate local installation configuration

This commit is contained in:
2026-08-15 22:45:22 +02:00
parent 2c800cc521
commit 6dc9e80564
9 changed files with 951 additions and 5 deletions
+7
View File
@@ -44,6 +44,13 @@ deploy/secrets/*
!deploy/secrets/README.md !deploy/secrets/README.md
!deploy/secrets/*.example !deploy/secrets/*.example
# Per-installation configuration generated by `tht setup` (examples stay tracked).
deploy/*/thothii-installation.yaml
deploy/*/operator.env
deploy/*/secrets/*
!deploy/*/secrets/.gitkeep
!deploy/*/secrets/*.example
# === Runtime data (sessions contain PII; indexes are derived) === # === Runtime data (sessions contain PII; indexes are derived) ===
harness/sessions/ harness/sessions/
harness/indexes/ harness/indexes/
+3 -2
View File
@@ -1,5 +1,6 @@
# Local profile defaults. Copy this file to an untracked local.env and pass it with --env-file. # Local profile defaults. `tht setup` writes the active non-secret file to
# Values are non-secret documentation values only. # deploy/<installation-id>/operator.env; this tracked file is only an example.
# Values are locations and non-secret defaults, never credentials.
THOTH_HTTP_PORT=8080 THOTH_HTTP_PORT=8080
THOTH_CORE_HTTP_PORT=8787 THOTH_CORE_HTTP_PORT=8787
MAX_PI_PROCESSES=4 MAX_PI_PROCESSES=4
+3 -2
View File
@@ -1,4 +1,5 @@
# Copia in deploy/psd/operator.env (non tracciato). Solo path non-segreti. # Esempio soltanto: `tht setup` genera deploy/<installation-id>/operator.env (non tracciato).
# Solo path non-segreti: i valori delle credenziali restano nei file protetti indicati qui sotto.
THT_WORKSPACE_GIT_REMOTE=git@github.com:mptyl/tht-workspace-psd.git THT_WORKSPACE_GIT_REMOTE=git@github.com:mptyl/tht-workspace-psd.git
THT_WORKSPACE_GIT_BRANCH=main THT_WORKSPACE_GIT_BRANCH=main
THT_WORKSPACE_INSTALLATION_ID=psd-local THT_WORKSPACE_INSTALLATION_ID=psd-local
@@ -13,7 +14,7 @@ PI_AUTH_FILE=<abs>/deploy/psd/secrets/pi-auth.json
# Pi (LLM) # Pi (LLM)
PI_PROVIDER=zai PI_PROVIDER=zai
PI_MODEL=glm-5.2 PI_MODEL=glm-5.3
PI_THINKING=medium PI_THINKING=medium
# App defaults # App defaults
+2 -1
View File
@@ -1,4 +1,5 @@
# Copia in deploy/psd/thothii-installation.yaml. Sostituisci i path assoluti. # Esempio soltanto: `tht setup` genera deploy/<installation-id>/thothii-installation.yaml.
# Sostituisci i path assoluti se usi questo riferimento per una configurazione avanzata.
# Seleziona UN solo override Git (https o ssh). # Seleziona UN solo override Git (https o ssh).
profile: local profile: local
projectDirectory: "<abs>/projects/ThothII" projectDirectory: "<abs>/projects/ThothII"
+121
View File
@@ -18,7 +18,9 @@ import (
"github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/config"
"github.com/aritmolab/thothii/tools/tht/internal/output" "github.com/aritmolab/thothii/tools/tht/internal/output"
"github.com/aritmolab/thothii/tools/tht/internal/pi" "github.com/aritmolab/thothii/tools/tht/internal/pi"
"github.com/aritmolab/thothii/tools/tht/internal/project"
"github.com/aritmolab/thothii/tools/tht/internal/serverops" "github.com/aritmolab/thothii/tools/tht/internal/serverops"
"github.com/aritmolab/thothii/tools/tht/internal/setup"
"github.com/aritmolab/thothii/tools/tht/internal/workspaceops" "github.com/aritmolab/thothii/tools/tht/internal/workspaceops"
) )
@@ -28,6 +30,8 @@ When --installation is omitted, tht uses THOTHII_INSTALLATION or discovers one v
descriptor in the current project tree. descriptor in the current project tree.
Commands: Commands:
setup [--configure-only] [--installation-id ID] [--profile local|server]
Create or validate the local non-secret installation configuration.
status Show the Compose service state. status Show the Compose service state.
doctor Validate Docker, Compose, rendered configuration, line endings, volumes, and health. doctor Validate Docker, Compose, rendered configuration, line endings, volumes, and health.
logs Show the latest 200 sanitized service log lines (bounded; no follow mode). logs Show the latest 200 sanitized service log lines (bounded; no follow mode).
@@ -86,6 +90,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
fmt.Fprint(stdout, usage) fmt.Fprint(stdout, usage)
return 0 return 0
} }
if command == "setup" {
return setupCommand(installationPath, commandArgs, stdout, stderr)
}
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command)) return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
} }
workingDirectory, err := os.Getwd() workingDirectory, err := os.Getwd()
@@ -198,6 +205,120 @@ func isBootstrapCommand(command string) bool {
return command == "help" || command == "setup" || command == "version" return command == "help" || command == "setup" || command == "version"
} }
func setupCommand(installationPath string, args []string, stdout, stderr io.Writer) int {
request, err := parseSetupArgs(args)
if err != nil {
return commandUsageError(stderr, err.Error())
}
workingDirectory, err := os.Getwd()
if err != nil {
return commandUsageError(stderr, "current directory is unavailable")
}
root, err := project.Discover(workingDirectory)
if err != nil {
return commandUsageError(stderr, err.Error())
}
request.ProjectRoot = root.Path
if installationPath != "" {
id, pathErr := installationIDFromPath(root.Path, installationPath)
if pathErr != nil {
return commandUsageError(stderr, pathErr.Error())
}
if request.InstallationID != "" && request.InstallationID != id {
return commandUsageError(stderr, "--installation and --installation-id must identify the same installation")
}
request.InstallationID = id
}
result, err := setup.EnsureFiles(request, os.Stdin, stdout)
if err != nil {
return commandUsageError(stderr, err.Error())
}
fmt.Fprintf(stdout, "Configuration is ready: %s\n", result.DescriptorPath)
return 0
}
func installationIDFromPath(root, installationPath string) (string, error) {
if filepath.Base(installationPath) != "thothii-installation.yaml" {
return "", errors.New("--installation must name thothii-installation.yaml below deploy/<installation-id>")
}
relative, err := filepath.Rel(filepath.Join(root, "deploy"), installationPath)
if err != nil {
return "", errors.New("--installation must be below this project's deploy directory")
}
parts := strings.Split(filepath.Clean(relative), string(filepath.Separator))
if len(parts) != 2 || parts[0] == "." || parts[0] == ".." || parts[1] != "thothii-installation.yaml" {
return "", errors.New("--installation must be below this project's deploy/<installation-id> directory")
}
return parts[0], nil
}
func parseSetupArgs(args []string) (setup.Request, error) {
request := setup.Request{}
for len(args) > 0 {
flag := args[0]
args = args[1:]
switch flag {
case "--configure-only":
if request.ConfigureOnly {
return setup.Request{}, errors.New("--configure-only may be supplied once")
}
request.ConfigureOnly = true
case "--non-interactive":
if request.NonInteractive {
return setup.Request{}, errors.New("--non-interactive may be supplied once")
}
request.NonInteractive = true
case "--create-secret-templates":
if request.Answers.CreateSecretTemplates {
return setup.Request{}, errors.New("--create-secret-templates may be supplied once")
}
request.Answers.CreateSecretTemplates = true
default:
if len(args) == 0 {
return setup.Request{}, fmt.Errorf("%s requires a value", flag)
}
value := args[0]
args = args[1:]
var target *string
switch flag {
case "--installation-id":
target = &request.InstallationID
case "--profile":
target = &request.Profile
case "--workspace-remote":
target = &request.Answers.WorkspaceRemote
case "--workspace-branch":
target = &request.Answers.WorkspaceBranch
case "--workspace-access":
target = &request.Answers.WorkspaceAccess
case "--dwh-rest-url":
target = &request.Answers.DWHRESTURL
case "--llm-url":
target = &request.Answers.LLMURL
case "--secrets-file":
target = &request.Answers.SecretsFile
case "--pi-auth-file":
target = &request.Answers.PiAuthFile
case "--git-credentials-file":
target = &request.Answers.GitCredentialsFile
case "--git-ca-file":
target = &request.Answers.GitCAFile
case "--git-ssh-key-file":
target = &request.Answers.GitSSHKeyFile
case "--git-known-hosts-file":
target = &request.Answers.GitKnownHostsFile
default:
return setup.Request{}, fmt.Errorf("unknown setup option %q", flag)
}
if *target != "" {
return setup.Request{}, fmt.Errorf("%s may be supplied once", flag)
}
*target = value
}
}
return request, nil
}
func writeRemovalTargets(outputWriter io.Writer, project string, targets []serverops.Container) { func writeRemovalTargets(outputWriter io.Writer, project string, targets []serverops.Container) {
fmt.Fprintf(outputWriter, "Removal targets for installation project %s:\n", project) fmt.Fprintf(outputWriter, "Removal targets for installation project %s:\n", project)
if len(targets) == 0 { if len(targets) == 0 {
+18
View File
@@ -174,6 +174,24 @@ func TestParseArgsMakesInstallationOptionalAndAcceptsOverrideAfterCommand(t *tes
} }
} }
func TestParseSetupArgsAcceptsExplicitNonInteractiveAnswers(t *testing.T) {
request, err := parseSetupArgs([]string{
"--configure-only", "--non-interactive", "--installation-id", "ci", "--profile", "server",
"--workspace-remote", "https://git.example.invalid/workspaces.git", "--workspace-branch", "release",
"--workspace-access", "https", "--secrets-file", "/tmp/thothii.secrets", "--pi-auth-file", "/tmp/pi-auth.json",
"--git-credentials-file", "/tmp/git-credentials", "--git-ca-file", "/tmp/git-ca.pem",
})
if err != nil {
t.Fatal(err)
}
if !request.ConfigureOnly || !request.NonInteractive || request.InstallationID != "ci" || request.Profile != "server" {
t.Fatalf("setup request = %#v", request)
}
if request.Answers.WorkspaceBranch != "release" || request.Answers.GitCAFile != "/tmp/git-ca.pem" {
t.Fatalf("setup answers = %#v", request.Answers)
}
}
func TestRunPiStatusUsesInstallationEnvironmentWithoutFlag(t *testing.T) { func TestRunPiStatusUsesInstallationEnvironmentWithoutFlag(t *testing.T) {
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n") fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
fixture.setEnvironment(t) fixture.setEnvironment(t)
+468
View File
@@ -0,0 +1,468 @@
package setup
import (
"bufio"
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"github.com/aritmolab/thothii/tools/tht/internal/safeio"
"gopkg.in/yaml.v3"
)
const (
descriptorName = "thothii-installation.yaml"
environmentName = "operator.env"
)
var installationIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9_-]*$`)
// atomicWriteNewFile is a seam for failure testing. Its implementation never replaces an existing
// file and leaves no final target until all content is synced.
var atomicWriteNewFile = writeNewFileAtomically
type answers struct {
installationID, profile string
workspaceRemote, workspaceBranch string
workspaceAccess string
dwhRESTURL, llmURL string
secretsFile, piAuthFile string
gitCredentialsFile, gitCAFile string
gitSSHKeyFile, gitKnownHostsFile string
createSecretTemplates bool
}
type generatedDescriptor struct {
Profile string `yaml:"profile"`
ProjectDirectory string `yaml:"projectDirectory"`
EnvFile string `yaml:"envFile"`
Workspace struct {
Remote string `yaml:"remote"`
Branch string `yaml:"branch"`
Access string `yaml:"access"`
} `yaml:"workspaceRepository"`
Overrides []string `yaml:"overrides"`
}
// EnsureFiles writes a descriptor and non-secret environment file below deploy/<installation-id>.
// Existing files are accepted only when their bytes exactly match the requested configuration.
func EnsureFiles(request Request, input io.Reader, output io.Writer) (FilesResult, error) {
root, err := canonicalProjectRoot(request.ProjectRoot)
if err != nil {
return FilesResult{}, err
}
values, err := collectAnswers(request, input, output, root)
if err != nil {
return FilesResult{}, err
}
if err := validateAnswers(values); err != nil {
return FilesResult{}, err
}
directory, err := installationDirectory(root, values.installationID)
if err != nil {
return FilesResult{}, err
}
descriptorPath := filepath.Join(directory, descriptorName)
environmentPath := filepath.Join(directory, environmentName)
result := FilesResult{DescriptorPath: descriptorPath, EnvironmentPath: environmentPath}
descriptor, environment, err := render(root, descriptorPath, values)
if err != nil {
return FilesResult{}, err
}
if err := requireCompatibleOrAbsent(descriptorPath, descriptor); err != nil {
return FilesResult{}, err
}
if err := requireCompatibleOrAbsent(environmentPath, environment); err != nil {
return FilesResult{}, err
}
if err := validateOrCreateSecretFiles(values, output); err != nil {
return FilesResult{}, err
}
created := make([]string, 0, 2)
cleanup := func() {
for index := len(created) - 1; index >= 0; index-- {
_ = os.Remove(created[index])
}
}
if err := writeIfAbsent(descriptorPath, descriptor, &created); err != nil {
cleanup()
return FilesResult{}, err
}
if err := writeIfAbsent(environmentPath, environment, &created); err != nil {
cleanup()
return FilesResult{}, err
}
result.Created = created
return result, nil
}
func canonicalProjectRoot(path string) (string, error) {
if strings.TrimSpace(path) == "" {
return "", errors.New("setup requires the current ThothII project root")
}
if !filepath.IsAbs(path) || filepath.Clean(path) != path {
return "", errors.New("setup project root must be an absolute canonical path")
}
resolved, err := filepath.EvalSymlinks(path)
if err != nil || resolved != path {
return "", errors.New("setup project root is unavailable or contains a symlink")
}
for _, required := range []string{filepath.Join(path, "compose.yaml"), filepath.Join(path, "deploy")} {
info, statErr := os.Stat(required)
if statErr != nil || (filepath.Base(required) == "deploy" && !info.IsDir()) || (filepath.Base(required) != "deploy" && !info.Mode().IsRegular()) {
return "", errors.New("setup project root is not a ThothII checkout")
}
}
deployInfo, err := os.Lstat(filepath.Join(path, "deploy"))
if err != nil || deployInfo.Mode()&os.ModeSymlink != 0 {
return "", errors.New("setup project deployment directory is unavailable or contains a symlink")
}
return path, nil
}
func collectAnswers(request Request, input io.Reader, output io.Writer, root string) (answers, error) {
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")
if request.NonInteractive {
return requireNonInteractiveAnswers(value)
}
scanner := bufio.NewScanner(input)
var err error
if value.installationID, err = prompt(scanner, output, "Installation ID", value.installationID); err != nil {
return answers{}, err
}
if value.profile, err = prompt(scanner, output, "Deployment profile (local or server)", value.profile); err != nil {
return answers{}, err
}
if value.dwhRESTURL, err = prompt(scanner, output, "DWH API endpoint (optional)", value.dwhRESTURL); err != nil {
return answers{}, err
}
if value.llmURL, err = prompt(scanner, output, "LLM API endpoint (optional)", value.llmURL); err != nil {
return answers{}, err
}
if value.workspaceRemote, err = prompt(scanner, output, "Workspace repository URL", firstNonEmpty(value.workspaceRemote, "https://git.example.invalid/thothii-workspaces.git")); err != nil {
return answers{}, err
}
if value.workspaceBranch, err = prompt(scanner, output, "Workspace repository branch", firstNonEmpty(value.workspaceBranch, "main")); err != nil {
return answers{}, err
}
if value.workspaceAccess, err = prompt(scanner, output, "Workspace repository access (https or ssh)", firstNonEmpty(value.workspaceAccess, accessForRemote(value.workspaceRemote))); err != nil {
return answers{}, err
}
directory := filepath.Join(root, "deploy", value.installationID, "secrets")
if value.secretsFile, err = prompt(scanner, output, "Secret file location", firstNonEmpty(value.secretsFile, filepath.Join(directory, "thothii.secrets"))); err != nil {
return answers{}, err
}
if value.piAuthFile, err = prompt(scanner, output, "Pi credentials file location", firstNonEmpty(value.piAuthFile, filepath.Join(directory, "pi-auth.json"))); err != nil {
return answers{}, err
}
if value.workspaceAccess == "ssh" {
if value.gitSSHKeyFile, err = prompt(scanner, output, "Workspace Git SSH key location", firstNonEmpty(value.gitSSHKeyFile, filepath.Join(directory, "workspace-git-key"))); err != nil {
return answers{}, err
}
if value.gitKnownHostsFile, err = prompt(scanner, output, "Workspace Git known-hosts location", firstNonEmpty(value.gitKnownHostsFile, filepath.Join(directory, "workspace-git-known-hosts"))); err != nil {
return answers{}, err
}
} else {
if value.gitCredentialsFile, err = prompt(scanner, output, "Workspace Git credentials file location", firstNonEmpty(value.gitCredentialsFile, filepath.Join(directory, "workspace-git-credentials"))); err != nil {
return answers{}, err
}
if value.gitCAFile, err = prompt(scanner, output, "Workspace Git CA file location", firstNonEmpty(value.gitCAFile, filepath.Join(directory, "workspace-git-ca.pem"))); err != nil {
return answers{}, err
}
}
missing := missingSecretFiles(value)
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
}
value.createSecretTemplates = strings.EqualFold(answer, "yes")
}
return value, nil
}
func answersFromRequest(request Request) answers {
answer := request.Answers
return answers{
workspaceRemote: firstNonEmpty(answer.WorkspaceRemote, os.Getenv("THT_SETUP_WORKSPACE_REMOTE")),
workspaceBranch: firstNonEmpty(answer.WorkspaceBranch, os.Getenv("THT_SETUP_WORKSPACE_BRANCH")),
workspaceAccess: firstNonEmpty(answer.WorkspaceAccess, os.Getenv("THT_SETUP_WORKSPACE_ACCESS")),
dwhRESTURL: firstNonEmpty(answer.DWHRESTURL, os.Getenv("THT_SETUP_DWH_REST_URL")),
llmURL: firstNonEmpty(answer.LLMURL, os.Getenv("THT_SETUP_LLM_URL")),
secretsFile: firstNonEmpty(answer.SecretsFile, os.Getenv("THT_SETUP_SECRETS_FILE")),
piAuthFile: firstNonEmpty(answer.PiAuthFile, os.Getenv("THT_SETUP_PI_AUTH_FILE")),
gitCredentialsFile: firstNonEmpty(answer.GitCredentialsFile, os.Getenv("THT_SETUP_GIT_CREDENTIALS_FILE")),
gitCAFile: firstNonEmpty(answer.GitCAFile, os.Getenv("THT_SETUP_GIT_CA_FILE")),
gitSSHKeyFile: firstNonEmpty(answer.GitSSHKeyFile, os.Getenv("THT_SETUP_GIT_SSH_KEY_FILE")),
gitKnownHostsFile: firstNonEmpty(answer.GitKnownHostsFile, os.Getenv("THT_SETUP_GIT_KNOWN_HOSTS_FILE")),
createSecretTemplates: answer.CreateSecretTemplates,
}
}
func requireNonInteractiveAnswers(value answers) (answers, error) {
required := []struct{ name, value string }{
{"THT_SETUP_WORKSPACE_REMOTE", value.workspaceRemote}, {"THT_SETUP_WORKSPACE_BRANCH", value.workspaceBranch},
{"THT_SETUP_WORKSPACE_ACCESS", value.workspaceAccess}, {"THT_SETUP_SECRETS_FILE", value.secretsFile}, {"THT_SETUP_PI_AUTH_FILE", value.piAuthFile},
}
if value.workspaceAccess == "ssh" {
required = append(required, struct{ name, value string }{"THT_SETUP_GIT_SSH_KEY_FILE", value.gitSSHKeyFile}, struct{ name, value string }{"THT_SETUP_GIT_KNOWN_HOSTS_FILE", value.gitKnownHostsFile})
} else if value.workspaceAccess == "https" {
required = append(required, struct{ name, value string }{"THT_SETUP_GIT_CREDENTIALS_FILE", value.gitCredentialsFile}, struct{ name, value string }{"THT_SETUP_GIT_CA_FILE", value.gitCAFile})
}
for _, requiredValue := range required {
if strings.TrimSpace(requiredValue.value) == "" {
return answers{}, fmt.Errorf("non-interactive setup requires %s or its matching setup flag", requiredValue.name)
}
}
return value, nil
}
func prompt(scanner *bufio.Scanner, output io.Writer, question, defaultValue string) (string, error) {
fmt.Fprintf(output, "%s [%s]: ", question, defaultValue)
if !scanner.Scan() {
return "", fmt.Errorf("setup input ended while waiting for %s", strings.ToLower(question))
}
value := strings.TrimSpace(scanner.Text())
if value == "" {
return defaultValue, nil
}
return value, nil
}
func validateAnswers(value answers) error {
if !installationIDPattern.MatchString(value.installationID) {
return errors.New("installation ID must contain only letters, numbers, dashes, and underscores")
}
if value.profile != "local" && value.profile != "server" {
return errors.New("deployment profile must be local or server")
}
if value.workspaceAccess != "ssh" && value.workspaceAccess != "https" {
return errors.New("workspace repository access must be ssh or https")
}
for name, path := range map[string]string{
"secret file location": value.secretsFile, "Pi credentials file location": value.piAuthFile,
"workspace Git credentials file location": value.gitCredentialsFile, "workspace Git CA file location": value.gitCAFile,
"workspace Git SSH key location": value.gitSSHKeyFile, "workspace Git known-hosts location": value.gitKnownHostsFile,
} {
if path == "" && ((value.workspaceAccess == "ssh" && (name == "workspace Git credentials file location" || name == "workspace Git CA file location")) || (value.workspaceAccess == "https" && (name == "workspace Git SSH key location" || name == "workspace Git known-hosts location"))) {
continue
}
if err := safeio.ValidateCanonicalPath(path); err != nil {
return fmt.Errorf("%s must be an absolute canonical path", name)
}
}
return nil
}
func installationDirectory(root, id string) (string, error) {
directory := filepath.Join(root, "deploy", id)
if err := safeio.ValidateCanonicalPath(directory); err != nil {
return "", errors.New("installation directory is unsafe")
}
if info, err := os.Lstat(directory); err == nil {
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return "", fmt.Errorf("installation directory %s is unavailable or unsafe", directory)
}
return directory, nil
} else if !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("installation directory %s could not be inspected", directory)
}
if err := os.Mkdir(directory, 0o700); err != nil {
return "", fmt.Errorf("create installation directory %s: %w", directory, err)
}
return directory, nil
}
func render(root, descriptorPath string, value answers) ([]byte, []byte, error) {
descriptor := generatedDescriptor{Profile: value.profile, ProjectDirectory: root, EnvFile: filepath.Join(filepath.Dir(descriptorPath), environmentName)}
descriptor.Workspace.Remote, descriptor.Workspace.Branch, descriptor.Workspace.Access = value.workspaceRemote, value.workspaceBranch, value.workspaceAccess
descriptor.Overrides = []string{filepath.Join(root, "deploy", "compose.git-"+value.workspaceAccess+".yaml")}
descriptorBytes, err := yaml.Marshal(descriptor)
if err != nil {
return nil, nil, err
}
lines := []string{
"# Generated by tht setup. This file contains locations, never secret values.",
"THT_WORKSPACE_GIT_REMOTE=" + dotenvValue(value.workspaceRemote),
"THT_WORKSPACE_GIT_BRANCH=" + dotenvValue(value.workspaceBranch),
"THT_WORKSPACE_INSTALLATION_ID=" + dotenvValue(value.installationID),
"THT_SECRETS_FILE=" + dotenvValue(value.secretsFile),
"PI_AUTH_FILE=" + dotenvValue(value.piAuthFile),
"THOTH_HTTP_PORT=8080", "THOTH_CORE_HTTP_PORT=8787", "MAX_PI_PROCESSES=4",
}
if value.workspaceAccess == "ssh" {
lines = append(lines, "THT_WORKSPACE_GIT_SSH_KEY_FILE="+dotenvValue(value.gitSSHKeyFile), "THT_WORKSPACE_GIT_KNOWN_HOSTS_FILE="+dotenvValue(value.gitKnownHostsFile))
} else {
lines = append(lines, "THT_WORKSPACE_GIT_CREDENTIALS_FILE="+dotenvValue(value.gitCredentialsFile), "THT_WORKSPACE_GIT_CA_FILE="+dotenvValue(value.gitCAFile))
}
if value.dwhRESTURL != "" {
lines = append(lines, "THT_DWH_REST_URL="+dotenvValue(value.dwhRESTURL))
}
if value.llmURL != "" {
lines = append(lines, "THT_LLM_URL="+dotenvValue(value.llmURL))
}
if value.profile == "server" {
installationDirectory := filepath.Dir(descriptorPath)
lines = append(lines,
"THT_DATA_ROOT="+dotenvValue(filepath.Join(installationDirectory, "data")),
"THT_PI_STATE_ROOT="+dotenvValue(filepath.Join(installationDirectory, "pi-state")),
"THT_WORKSPACE_REGISTRY_ROOT="+dotenvValue(filepath.Join(installationDirectory, "workspace-registry")),
"THT_BACKUP_ROOT="+dotenvValue(filepath.Join(installationDirectory, "backups")),
)
}
return descriptorBytes, []byte(strings.Join(lines, "\n") + "\n"), nil
}
func dotenvValue(value string) string { return strconv.Quote(value) }
func requireCompatibleOrAbsent(path string, expected []byte) error {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil || !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("configuration file %s is unsafe; choose a different installation ID or remove the unsafe file", path)
}
actual, err := os.ReadFile(path)
if err != nil || !bytes.Equal(actual, expected) {
return fmt.Errorf("configuration file %s already exists with different content; choose a different installation ID or move that file before running setup", path)
}
return nil
}
func writeIfAbsent(path string, contents []byte, created *[]string) error {
if _, err := os.Lstat(path); err == nil {
if err := requireCompatibleOrAbsent(path, contents); err != nil {
return err
}
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("inspect configuration file %s: %w", path, err)
}
if err := atomicWriteNewFile(path, contents, 0o600); err != nil {
return fmt.Errorf("write configuration file %s: %w", path, err)
}
*created = append(*created, path)
return nil
}
func missingSecretFiles(value answers) []string {
paths := configuredSecretPaths(value)
missing := make([]string, 0, len(paths))
for _, path := range paths {
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
missing = append(missing, path)
}
}
sort.Strings(missing)
return missing
}
func validateOrCreateSecretFiles(value answers, output io.Writer) error {
missing := missingSecretFiles(value)
if len(missing) == 0 {
return nil
}
if !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 {
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return fmt.Errorf("create secret-template directory: %w", err)
}
if err := atomicWriteNewFile(path, secretTemplate(path), 0o600); err != nil {
return fmt.Errorf("create secret-file template %s: %w", path, err)
}
fmt.Fprintf(output, "Created blank secret-file template: %s\n", path)
}
return nil
}
func configuredSecretPaths(value answers) []string {
paths := []string{value.secretsFile, value.piAuthFile}
if value.workspaceAccess == "ssh" {
paths = append(paths, value.gitSSHKeyFile, value.gitKnownHostsFile)
} else {
paths = append(paths, value.gitCredentialsFile, value.gitCAFile)
}
return paths
}
func secretTemplate(path string) []byte {
if strings.HasSuffix(path, ".json") {
return []byte("{}\n")
}
return []byte("# Add the required credential value to this protected local file.\n")
}
func writeNewFileAtomically(path string, contents []byte, mode os.FileMode) error {
if err := safeio.ValidateCanonicalPath(path); err != nil {
return err
}
directory := filepath.Dir(path)
if info, err := os.Lstat(directory); err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return safeio.ErrUnsafeFile
}
resolvedDirectory, err := filepath.EvalSymlinks(directory)
if err != nil || resolvedDirectory != directory {
return safeio.ErrUnsafeFile
}
temporary, err := os.CreateTemp(directory, ".tht-setup-*")
if err != nil {
return err
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
if err := temporary.Chmod(mode); err != nil {
temporary.Close()
return err
}
if _, err := temporary.Write(contents); err != nil {
temporary.Close()
return err
}
if err := temporary.Sync(); err != nil {
temporary.Close()
return err
}
if err := temporary.Close(); err != nil {
return err
}
if err := os.Link(temporaryPath, path); err != nil {
return err
}
directoryFile, err := os.Open(directory)
if err == nil {
_ = directoryFile.Sync()
_ = directoryFile.Close()
}
return nil
}
func accessForRemote(remote string) string {
if strings.HasPrefix(remote, "git@") || strings.HasPrefix(remote, "ssh://") {
return "ssh"
}
return "https"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
+291
View File
@@ -0,0 +1,291 @@
package setup
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"github.com/aritmolab/thothii/tools/tht/internal/config"
)
func TestEnsureFilesCreatesDiscoverableConfigurationInProjectWithSpaces(t *testing.T) {
root := newProject(t, "checkout with spaces")
secrets := newExternalSecrets(t, root)
setNonInteractiveAnswers(t, secrets)
trackedExample := filepath.Join(root, "deploy", "env", "local.env.example")
before, err := os.ReadFile(trackedExample)
if err != nil {
t.Fatal(err)
}
result, err := EnsureFiles(Request{
ProjectRoot: root, InstallationID: "local-dev", Profile: "local", NonInteractive: true,
}, strings.NewReader(""), ioDiscard{})
if err != nil {
t.Fatal(err)
}
wantDirectory := filepath.Join(root, "deploy", "local-dev")
if result.DescriptorPath != filepath.Join(wantDirectory, "thothii-installation.yaml") {
t.Fatalf("descriptor = %q", result.DescriptorPath)
}
if result.EnvironmentPath != filepath.Join(wantDirectory, "operator.env") {
t.Fatalf("environment = %q", result.EnvironmentPath)
}
for _, path := range []string{result.DescriptorPath, result.EnvironmentPath} {
info, statErr := os.Stat(path)
if statErr != nil {
t.Fatalf("generated file %s: %v", path, statErr)
}
if info.Mode().Perm()&0o077 != 0 {
t.Errorf("generated file %s has permissions %o, want owner-only", path, info.Mode().Perm())
}
}
descriptor, err := os.ReadFile(result.DescriptorPath)
if err != nil {
t.Fatal(err)
}
environment, err := os.ReadFile(result.EnvironmentPath)
if err != nil {
t.Fatal(err)
}
for _, secretValue := range []string{"super-secret-value", "pi-secret-value", "private-key-value"} {
if bytes.Contains(descriptor, []byte(secretValue)) || bytes.Contains(environment, []byte(secretValue)) {
t.Fatalf("generated configuration contains a secret value %q", secretValue)
}
}
for _, path := range []string{secrets.secrets, secrets.piAuth, secrets.sshKey, secrets.knownHosts} {
contents, readErr := os.ReadFile(path)
if readErr != nil || len(contents) == 0 {
t.Fatalf("existing secret file %s was not preserved: %v", path, readErr)
}
}
if _, err := config.Resolve("", nil, root); err != nil {
t.Fatalf("generated descriptor was not discoverable: %v", err)
}
after, err := os.ReadFile(trackedExample)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("tracked example was modified")
}
}
func TestEnsureFilesIsIdempotentForCompatibleFiles(t *testing.T) {
root := newProject(t, "linked worktree")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
request := Request{ProjectRoot: root, InstallationID: "worktree", Profile: "local", NonInteractive: true}
first, err := EnsureFiles(request, strings.NewReader(""), ioDiscard{})
if err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(first.EnvironmentPath)
if err != nil {
t.Fatal(err)
}
second, err := EnsureFiles(request, strings.NewReader(""), ioDiscard{})
if err != nil {
t.Fatal(err)
}
if len(second.Created) != 0 {
t.Fatalf("compatible rerun created %v, want no files", second.Created)
}
after, err := os.ReadFile(first.EnvironmentPath)
if err != nil {
t.Fatal(err)
}
if !bytes.Equal(before, after) {
t.Fatal("compatible environment was rewritten")
}
}
func TestEnsureFilesRefusesConflictingConfiguration(t *testing.T) {
root := newProject(t, "conflict")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
directory := filepath.Join(root, "deploy", "existing")
if err := os.Mkdir(directory, 0o700); err != nil {
t.Fatal(err)
}
descriptor := filepath.Join(directory, "thothii-installation.yaml")
if err := os.WriteFile(descriptor, []byte("profile: server\n"), 0o600); err != nil {
t.Fatal(err)
}
_, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "existing", Profile: "local", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
if err == nil || !strings.Contains(err.Error(), descriptor) || !strings.Contains(err.Error(), "different installation ID") {
t.Fatalf("EnsureFiles() error = %v, want exact file and corrective action", err)
}
if _, statErr := os.Stat(filepath.Join(directory, "operator.env")); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("operator.env was created after conflict: %v", statErr)
}
}
func TestEnsureFilesRemovesOwnFilesWhenAtomicWriteIsInterrupted(t *testing.T) {
root := newProject(t, "interrupted")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
previous := atomicWriteNewFile
t.Cleanup(func() { atomicWriteNewFile = previous })
calls := 0
atomicWriteNewFile = func(path string, contents []byte, mode os.FileMode) error {
calls++
if calls == 2 {
return errors.New("interrupted write")
}
return previous(path, contents, mode)
}
_, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "interrupted", Profile: "local", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
if err == nil || !strings.Contains(err.Error(), "interrupted write") {
t.Fatalf("EnsureFiles() error = %v, want interrupted write", err)
}
directory := filepath.Join(root, "deploy", "interrupted")
for _, name := range []string{"thothii-installation.yaml", "operator.env"} {
if _, statErr := os.Stat(filepath.Join(directory, name)); !errors.Is(statErr, os.ErrNotExist) {
t.Fatalf("%s remains after interrupted write: %v", name, statErr)
}
}
}
func TestEnsureFilesCreatesSecretTemplatesOnlyAfterExplicitConfirmation(t *testing.T) {
root := newProject(t, "secret prompt")
var output bytes.Buffer
input := strings.Join([]string{
"demo", "local", "", "", "https://git.example.invalid/workspaces.git", "main", "https", "", "", "", "", "yes",
}, "\n") + "\n"
result, err := EnsureFiles(Request{ProjectRoot: root}, strings.NewReader(input), &output)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(output.String(), "Create blank secret-file templates") {
t.Fatalf("prompt = %q, want explicit secret-template confirmation", output.String())
}
for _, path := range []string{
filepath.Join(root, "deploy", "demo", "secrets", "thothii.secrets"),
filepath.Join(root, "deploy", "demo", "secrets", "pi-auth.json"),
filepath.Join(root, "deploy", "demo", "secrets", "workspace-git-credentials"),
filepath.Join(root, "deploy", "demo", "secrets", "workspace-git-ca.pem"),
} {
info, statErr := os.Stat(path)
if statErr != nil {
t.Fatalf("secret template %s: %v", path, statErr)
}
if info.Mode().Perm()&0o077 != 0 {
t.Errorf("secret template %s has permissions %o, want owner-only", path, info.Mode().Perm())
}
}
if _, err := os.Stat(result.DescriptorPath); err != nil {
t.Fatal(err)
}
}
func TestEnsureFilesRequiresExplicitNonInteractiveAnswers(t *testing.T) {
root := newProject(t, "noninteractive")
_, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "ci", Profile: "local", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
if err == nil || !strings.Contains(err.Error(), "THT_SETUP_WORKSPACE_REMOTE") {
t.Fatalf("EnsureFiles() error = %v, want non-interactive environment guidance", err)
}
}
func TestEnsureFilesIncludesServerStorageLocations(t *testing.T) {
root := newProject(t, "server profile")
setNonInteractiveAnswers(t, newExternalSecrets(t, root))
result, err := EnsureFiles(Request{ProjectRoot: root, InstallationID: "server", Profile: "server", NonInteractive: true}, strings.NewReader(""), ioDiscard{})
if err != nil {
t.Fatal(err)
}
contents, err := os.ReadFile(result.EnvironmentPath)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"THT_DATA_ROOT", "THT_PI_STATE_ROOT", "THT_WORKSPACE_REGISTRY_ROOT", "THT_BACKUP_ROOT"} {
if !strings.Contains(string(contents), name+"=") {
t.Errorf("server configuration is missing %s: %s", name, contents)
}
}
}
func newProject(t *testing.T, name string) string {
t.Helper()
root := filepath.Join(t.TempDir(), name)
for _, path := range []string{
filepath.Join(root, "deploy", "env"),
filepath.Join(root, "deploy"),
filepath.Join(root, ".git"),
filepath.Join(root, "backend"),
filepath.Join(root, "frontend"),
filepath.Join(root, "harness"),
filepath.Join(root, "tools"),
} {
if err := os.MkdirAll(path, 0o700); err != nil {
t.Fatal(err)
}
}
for path, contents := range map[string]string{
filepath.Join(root, "compose.yaml"): "services: {}\n",
filepath.Join(root, "deploy", "compose.local.yaml"): "services: {}\n",
filepath.Join(root, "deploy", "compose.git-ssh.yaml"): "services: {}\n",
filepath.Join(root, "deploy", "compose.git-https.yaml"): "services: {}\n",
filepath.Join(root, "deploy", "env", "local.env.example"): "tracked example\n",
} {
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
canonical, err := filepath.EvalSymlinks(root)
if err != nil {
t.Fatal(err)
}
return canonical
}
type secretPaths struct {
secrets, piAuth, sshKey, knownHosts string
}
func newExternalSecrets(t *testing.T, root string) secretPaths {
t.Helper()
directory := filepath.Join(root, "external secrets")
if err := os.Mkdir(directory, 0o700); err != nil {
t.Fatal(err)
}
paths := secretPaths{
secrets: filepath.Join(directory, "thothii.secrets"), piAuth: filepath.Join(directory, "pi-auth.json"),
sshKey: filepath.Join(directory, "git-key"), knownHosts: filepath.Join(directory, "known-hosts"),
}
for path, contents := range map[string]string{
paths.secrets: "super-secret-value\n", paths.piAuth: "pi-secret-value\n", paths.sshKey: "private-key-value\n", paths.knownHosts: "git.example.invalid ssh-ed25519 AAAA\n",
} {
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
}
return paths
}
func setNonInteractiveAnswers(t *testing.T, paths secretPaths) {
t.Helper()
for name, value := range map[string]string{
"THT_SETUP_WORKSPACE_REMOTE": "git@git.example.invalid:team/workspaces.git",
"THT_SETUP_WORKSPACE_BRANCH": "main",
"THT_SETUP_WORKSPACE_ACCESS": "ssh",
"THT_SETUP_SECRETS_FILE": paths.secrets,
"THT_SETUP_PI_AUTH_FILE": paths.piAuth,
"THT_SETUP_GIT_SSH_KEY_FILE": paths.sshKey,
"THT_SETUP_GIT_KNOWN_HOSTS_FILE": paths.knownHosts,
"THT_SETUP_DWH_REST_URL": "https://dwh.example.invalid",
"THT_SETUP_LLM_URL": "https://llm.example.invalid",
} {
t.Setenv(name, value)
}
}
type ioDiscard struct{}
func (ioDiscard) Write(value []byte) (int, error) { return len(value), nil }
+38
View File
@@ -0,0 +1,38 @@
// Package setup creates the local, non-secret configuration selected by tht setup.
package setup
// Request contains the stable setup-file inputs. Task 5 will use ConfigureOnly when it adds
// Compose validation and lifecycle orchestration.
type Request struct {
ProjectRoot string
InstallationID string
Profile string
ConfigureOnly bool
NonInteractive bool
Answers Answers
}
// Answers are optional explicit answers supplied by command flags. Empty values may be supplied
// through THT_SETUP_* environment variables or collected interactively.
type Answers struct {
WorkspaceRemote string
WorkspaceBranch string
WorkspaceAccess string
DWHRESTURL string
LLMURL string
SecretsFile string
PiAuthFile string
GitCredentialsFile string
GitCAFile string
GitSSHKeyFile string
GitKnownHostsFile string
CreateSecretTemplates bool
}
// FilesResult identifies configuration written or validated by EnsureFiles. Created contains
// only files created during this invocation, in deterministic order.
type FilesResult struct {
DescriptorPath string
EnvironmentPath string
Created []string
}