feat(setup): generate local installation configuration
This commit is contained in:
@@ -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/
|
||||||
|
|||||||
Vendored
+3
-2
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user