feat(setup): generate local installation configuration

This commit is contained in:
2026-08-15 22:45:22 +02:00
parent 2c800cc521
commit 6dc9e80564
9 changed files with 951 additions and 5 deletions
+121
View File
@@ -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/<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) {
fmt.Fprintf(outputWriter, "Removal targets for installation project %s:\n", project)
if len(targets) == 0 {
+18
View File
@@ -174,6 +174,24 @@ func TestParseArgsMakesInstallationOptionalAndAcceptsOverrideAfterCommand(t *tes
}
}
func TestParseSetupArgsAcceptsExplicitNonInteractiveAnswers(t *testing.T) {
request, err := parseSetupArgs([]string{
"--configure-only", "--non-interactive", "--installation-id", "ci", "--profile", "server",
"--workspace-remote", "https://git.example.invalid/workspaces.git", "--workspace-branch", "release",
"--workspace-access", "https", "--secrets-file", "/tmp/thothii.secrets", "--pi-auth-file", "/tmp/pi-auth.json",
"--git-credentials-file", "/tmp/git-credentials", "--git-ca-file", "/tmp/git-ca.pem",
})
if err != nil {
t.Fatal(err)
}
if !request.ConfigureOnly || !request.NonInteractive || request.InstallationID != "ci" || request.Profile != "server" {
t.Fatalf("setup request = %#v", request)
}
if request.Answers.WorkspaceBranch != "release" || request.Answers.GitCAFile != "/tmp/git-ca.pem" {
t.Fatalf("setup answers = %#v", request.Answers)
}
}
func TestRunPiStatusUsesInstallationEnvironmentWithoutFlag(t *testing.T) {
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
fixture.setEnvironment(t)