From 6dc9e805645e764ff0de4be32264cb290da37934 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 15 Aug 2026 22:45:22 +0200 Subject: [PATCH] feat(setup): generate local installation configuration --- .gitignore | 7 + deploy/env/local.env.example | 5 +- deploy/psd/operator.env.example | 5 +- deploy/psd/thothii-installation.yaml.example | 3 +- tools/tht/cmd/tht/main.go | 121 +++++ tools/tht/cmd/tht/main_test.go | 18 + tools/tht/internal/setup/files.go | 468 +++++++++++++++++++ tools/tht/internal/setup/files_test.go | 291 ++++++++++++ tools/tht/internal/setup/request.go | 38 ++ 9 files changed, 951 insertions(+), 5 deletions(-) create mode 100644 tools/tht/internal/setup/files.go create mode 100644 tools/tht/internal/setup/files_test.go create mode 100644 tools/tht/internal/setup/request.go diff --git a/.gitignore b/.gitignore index 6011cd14..bec6ca03 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,13 @@ deploy/secrets/* !deploy/secrets/README.md !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) === harness/sessions/ harness/indexes/ diff --git a/deploy/env/local.env.example b/deploy/env/local.env.example index 1714bc4b..483869ca 100644 --- a/deploy/env/local.env.example +++ b/deploy/env/local.env.example @@ -1,5 +1,6 @@ -# Local profile defaults. Copy this file to an untracked local.env and pass it with --env-file. -# Values are non-secret documentation values only. +# Local profile defaults. `tht setup` writes the active non-secret file to +# deploy//operator.env; this tracked file is only an example. +# Values are locations and non-secret defaults, never credentials. THOTH_HTTP_PORT=8080 THOTH_CORE_HTTP_PORT=8787 MAX_PI_PROCESSES=4 diff --git a/deploy/psd/operator.env.example b/deploy/psd/operator.env.example index 0816595e..612e2dca 100644 --- a/deploy/psd/operator.env.example +++ b/deploy/psd/operator.env.example @@ -1,4 +1,5 @@ -# Copia in deploy/psd/operator.env (non tracciato). Solo path non-segreti. +# Esempio soltanto: `tht setup` genera deploy//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_BRANCH=main THT_WORKSPACE_INSTALLATION_ID=psd-local @@ -13,7 +14,7 @@ PI_AUTH_FILE=/deploy/psd/secrets/pi-auth.json # Pi (LLM) PI_PROVIDER=zai -PI_MODEL=glm-5.2 +PI_MODEL=glm-5.3 PI_THINKING=medium # App defaults diff --git a/deploy/psd/thothii-installation.yaml.example b/deploy/psd/thothii-installation.yaml.example index 57b8fa5c..2811a5aa 100644 --- a/deploy/psd/thothii-installation.yaml.example +++ b/deploy/psd/thothii-installation.yaml.example @@ -1,4 +1,5 @@ -# Copia in deploy/psd/thothii-installation.yaml. Sostituisci i path assoluti. +# Esempio soltanto: `tht setup` genera deploy//thothii-installation.yaml. +# Sostituisci i path assoluti se usi questo riferimento per una configurazione avanzata. # Seleziona UN solo override Git (https o ssh). profile: local projectDirectory: "/projects/ThothII" diff --git a/tools/tht/cmd/tht/main.go b/tools/tht/cmd/tht/main.go index c2edc150..1d5f9ad0 100644 --- a/tools/tht/cmd/tht/main.go +++ b/tools/tht/cmd/tht/main.go @@ -18,7 +18,9 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/output" "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/setup" "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. 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. doctor Validate Docker, Compose, rendered configuration, line endings, volumes, and health. 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) return 0 } + if command == "setup" { + return setupCommand(installationPath, commandArgs, stdout, stderr) + } return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command)) } workingDirectory, err := os.Getwd() @@ -198,6 +205,120 @@ func isBootstrapCommand(command string) bool { 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/") + } + 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/ 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) { fmt.Fprintf(outputWriter, "Removal targets for installation project %s:\n", project) if len(targets) == 0 { diff --git a/tools/tht/cmd/tht/main_test.go b/tools/tht/cmd/tht/main_test.go index 278e69d4..ab703b6c 100644 --- a/tools/tht/cmd/tht/main_test.go +++ b/tools/tht/cmd/tht/main_test.go @@ -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) { fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n") fixture.setEnvironment(t) diff --git a/tools/tht/internal/setup/files.go b/tools/tht/internal/setup/files.go new file mode 100644 index 00000000..d8b50a40 --- /dev/null +++ b/tools/tht/internal/setup/files.go @@ -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/. +// 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 "" +} diff --git a/tools/tht/internal/setup/files_test.go b/tools/tht/internal/setup/files_test.go new file mode 100644 index 00000000..4bc41077 --- /dev/null +++ b/tools/tht/internal/setup/files_test.go @@ -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 } diff --git a/tools/tht/internal/setup/request.go b/tools/tht/internal/setup/request.go new file mode 100644 index 00000000..d6156f5d --- /dev/null +++ b/tools/tht/internal/setup/request.go @@ -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 +}