From 47e0b2a385630f0ff9cbdb0858456fba028874d7 Mon Sep 17 00:00:00 2001 From: mptyl Date: Sat, 15 Aug 2026 23:08:34 +0200 Subject: [PATCH] feat(setup): build start and verify ThothII --- .../task-5-report.md | 55 ++++ tools/tht/cmd/tht/main.go | 44 ++- tools/tht/cmd/tht/main_test.go | 47 ++- tools/tht/internal/compose/runner.go | 38 ++- tools/tht/internal/compose/runner_test.go | 36 +++ tools/tht/internal/setup/run.go | 302 ++++++++++++++++++ tools/tht/internal/setup/run_test.go | 228 +++++++++++++ 7 files changed, 719 insertions(+), 31 deletions(-) create mode 100644 .superpowers/sdd/2026-08-15-unified-tht-cli-product-step/task-5-report.md create mode 100644 tools/tht/internal/setup/run.go create mode 100644 tools/tht/internal/setup/run_test.go diff --git a/.superpowers/sdd/2026-08-15-unified-tht-cli-product-step/task-5-report.md b/.superpowers/sdd/2026-08-15-unified-tht-cli-product-step/task-5-report.md new file mode 100644 index 00000000..b8ce8c9b --- /dev/null +++ b/.superpowers/sdd/2026-08-15-unified-tht-cli-product-step/task-5-report.md @@ -0,0 +1,55 @@ +# Task 5 Report — `tht setup` lifecycle orchestration + +## Status + +Completed. `tht setup` now validates the checkout and host prerequisites, creates or validates +the non-secret installation files, validates Compose, and by default builds, starts, health-checks, +and verifies the installation. `tht setup --configure-only` stops immediately after successful +Compose rendering. + +## Implementation + +- Added `setup.Run`, with an ordered host preflight: project/worktree discovery, Docker Engine, + Docker Compose, supported architecture, and LF line-ending checks. +- Reused `config.Installation.ComposeArgs` for all Compose calls and added a narrow + `compose.InstallationRunner` adapter for Pi diagnostics; no shell command construction was added + to the top-level CLI parser. +- Default setup performs `compose build`, `compose up --detach --remove-orphans`, bounded polling + for `core`, `frontend`, `qdrant`, `embedding`, and `embedding-model-init`, then aggregate volume + diagnostics and `pi.Doctor`. +- Health timeout errors identify the last failing service and preserve containers for diagnosis, + with `tht logs ` and `tht status` guidance. +- Completion output includes the frontend URL, selected descriptor, and next action. + +## TDD evidence + +The initial focused test run failed because `setup.Run` did not exist. Tests were then written +against a fake Compose runner before the orchestration was implemented. They cover the complete +ordered flow, configure-only stop, preflight failure before writing configuration, health retry, +timeout guidance, and CLI default versus `--configure-only` dispatch. + +## Verification + +Executed from `tools/tht`: + +```bash +go test ./internal/setup ./internal/compose ./cmd/tht -run 'TestRun|TestSetupCommand|TestInstallationRunner' -count=1 +go test ./internal/setup ./internal/compose ./cmd/tht -count=1 +go test ./... +git diff --check +``` + +All commands passed. No actual Docker build, container start, live-stack restart, system +installation, Pi configuration edit, or documentation rewrite was performed. + +## Commit + +`feat(setup): build start and verify ThothII` (this report is included in that commit). + +## Concerns + +- The bounded health wait is verified with fakes only, as required for this task. Real Docker + lifecycle verification belongs to the later live acceptance task. +- The existing aggregate `tht doctor` command remains a separate implementation; Task 5 performs + its equivalent setup-time prerequisite checks plus `pi.Doctor` without invoking a nested CLI + process. diff --git a/tools/tht/cmd/tht/main.go b/tools/tht/cmd/tht/main.go index 1d5f9ad0..0b4ec5b7 100644 --- a/tools/tht/cmd/tht/main.go +++ b/tools/tht/cmd/tht/main.go @@ -91,7 +91,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int { return 0 } if command == "setup" { - return setupCommand(installationPath, commandArgs, stdout, stderr) + return setupCommand(ctx, installationPath, commandArgs, stdout, stderr) } return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command)) } @@ -205,7 +205,21 @@ func isBootstrapCommand(command string) bool { return command == "help" || command == "setup" || command == "version" } -func setupCommand(installationPath string, args []string, stdout, stderr io.Writer) int { +type setupExecutor func(context.Context, compose.Runner, setup.Request, io.Reader, io.Writer) (setup.Result, error) + +func setupCommand(ctx context.Context, installationPath string, args []string, stdout, stderr io.Writer) int { + return setupCommandWith(ctx, installationPath, args, os.Stdin, compose.NewRunner(""), setup.Run, stdout, stderr) +} + +func setupCommandWith( + ctx context.Context, + installationPath string, + args []string, + input io.Reader, + runner compose.Runner, + execute setupExecutor, + stdout, stderr io.Writer, +) int { request, err := parseSetupArgs(args) if err != nil { return commandUsageError(stderr, err.Error()) @@ -229,11 +243,10 @@ func setupCommand(installationPath string, args []string, stdout, stderr io.Writ } request.InstallationID = id } - result, err := setup.EnsureFiles(request, os.Stdin, stdout) + _, err = execute(ctx, runner, request, input, stdout) if err != nil { return commandUsageError(stderr, err.Error()) } - fmt.Fprintf(stdout, "Configuration is ready: %s\n", result.DescriptorPath) return 0 } @@ -386,32 +399,11 @@ func serverOperationFailure(stderr io.Writer, err error, secretValues []string) return 1 } -// installationRunner transforms only Compose invocations into the installation's validated, -// profile-specific argument list. Direct Docker image commands remain host-side and use arguments. -type installationRunner struct { - installation config.Installation - runner compose.Runner -} - -func (r installationRunner) SessionInventoryScope() string { - if r.installation.Profile == "local" { - return "mine" - } - return "all" -} - -func (r installationRunner) Run(ctx context.Context, args []string, stdin io.Reader) (compose.Result, error) { - if len(args) > 0 && args[0] == "compose" { - return r.runner.Run(ctx, r.installation.ComposeArgs(args[1:]...), stdin) - } - return r.runner.Run(ctx, args, stdin) -} - func piCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, secretValues []string, stdout, stderr io.Writer) int { if len(args) == 0 { return commandUsageError(stderr, "pi requires a subcommand") } - controlled := installationRunner{installation: installation, runner: runner} + controlled := compose.InstallationRunner{Installation: installation, Runner: runner} switch args[0] { case "status": if len(args) != 1 { diff --git a/tools/tht/cmd/tht/main_test.go b/tools/tht/cmd/tht/main_test.go index ab703b6c..e2eeb9fd 100644 --- a/tools/tht/cmd/tht/main_test.go +++ b/tools/tht/cmd/tht/main_test.go @@ -14,6 +14,7 @@ import ( "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/pi" + "github.com/aritmolab/thothii/tools/tht/internal/setup" "github.com/aritmolab/thothii/tools/tht/internal/testsupport" ) @@ -25,7 +26,7 @@ func TestInstallationRunnerMapsProfileToSessionInventoryScope(t *testing.T) { {profile: "local", want: "mine"}, {profile: "server", want: "all"}, } { - runner := installationRunner{installation: config.Installation{Profile: test.profile}} + runner := compose.InstallationRunner{Installation: config.Installation{Profile: test.profile}} if got := runner.SessionInventoryScope(); got != test.want { t.Fatalf("profile %q maps to session scope %q, want %q", test.profile, got, test.want) } @@ -107,6 +108,50 @@ func TestBootstrapCommandsDoNotRequireAnInstallationDescriptor(t *testing.T) { } } +func TestSetupCommandBuildsAndStartsUnlessConfigureOnlyIsRequested(t *testing.T) { + projectRoot := newProjectWithoutInstallation(t) + previousDirectory, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + if err := os.Chdir(projectRoot); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.Chdir(previousDirectory) }) + + for _, test := range []struct { + name string + args []string + want bool + }{ + {name: "default", args: []string{"--non-interactive", "--installation-id", "cli", "--profile", "local"}, want: false}, + {name: "configuration only", args: []string{"--configure-only", "--non-interactive", "--installation-id", "cli", "--profile", "local"}, want: true}, + } { + t.Run(test.name, func(t *testing.T) { + var received setup.Request + executor := func(_ context.Context, _ compose.Runner, request setup.Request, _ io.Reader, _ io.Writer) (setup.Result, error) { + received = request + return setup.Result{DescriptorPath: filepath.Join(projectRoot, "deploy", "cli", "thothii-installation.yaml"), Configured: true, Built: !request.ConfigureOnly, Started: !request.ConfigureOnly, Healthy: !request.ConfigureOnly}, nil + } + var stdout, stderr bytes.Buffer + code := setupCommandWith(context.Background(), "", test.args, strings.NewReader(""), compose.NewRunner("docker"), executor, &stdout, &stderr) + if code != 0 { + t.Fatalf("setupCommandWith() = %d, stderr = %q", code, stderr.String()) + } + if received.ConfigureOnly != test.want { + t.Fatalf("ConfigureOnly = %t, want %t", received.ConfigureOnly, test.want) + } + canonicalRoot, pathErr := filepath.EvalSymlinks(projectRoot) + if pathErr != nil { + t.Fatal(pathErr) + } + if received.ProjectRoot != canonicalRoot { + t.Fatalf("ProjectRoot = %q, want %q", received.ProjectRoot, canonicalRoot) + } + }) + } +} + // Catches interactive configuration prompts that use retired model-only data instead of the // provider, model, and reasoning choices supplied by the dedicated Pi Management API. func TestResolvePiConfigureUsesNumberedClosedChoicesOnlyForTTY(t *testing.T) { diff --git a/tools/tht/internal/compose/runner.go b/tools/tht/internal/compose/runner.go index 5d34e273..aeb85860 100644 --- a/tools/tht/internal/compose/runner.go +++ b/tools/tht/internal/compose/runner.go @@ -9,6 +9,8 @@ import ( "io" "os" "os/exec" + + "github.com/aritmolab/thothii/tools/tht/internal/config" ) // Result is the captured output and process exit code for one Docker invocation. @@ -18,8 +20,13 @@ type Result struct { ExitCode int } -// Runner executes the Docker CLI. It never invokes a shell. -type Runner struct { +// Runner is the shell-free Docker command boundary used by the host CLI. +type Runner interface { + Run(context.Context, []string, io.Reader) (Result, error) +} + +// execRunner executes the Docker CLI. It never invokes a shell. +type execRunner struct { binary string } @@ -28,11 +35,11 @@ func NewRunner(binary string) Runner { if binary == "" { binary = "docker" } - return Runner{binary: binary} + return execRunner{binary: binary} } // Run invokes Docker with the supplied argument array and optional standard input. -func (r Runner) Run(ctx context.Context, args []string, stdin io.Reader) (Result, error) { +func (r execRunner) Run(ctx context.Context, args []string, stdin io.Reader) (Result, error) { command := exec.CommandContext(ctx, r.binary, args...) command.Stdin = stdin var stdout, stderr bytes.Buffer @@ -54,3 +61,26 @@ func (r Runner) Run(ctx context.Context, args []string, stdin io.Reader) (Result } return result, err } + +// InstallationRunner applies an installation's validated Compose arguments to commands that +// explicitly start with compose. Direct Docker image commands remain host-side. +type InstallationRunner struct { + Installation config.Installation + Runner Runner +} + +// SessionInventoryScope supplies the installation's intended visibility to lifecycle callers. +func (r InstallationRunner) SessionInventoryScope() string { + if r.Installation.Profile == "local" { + return "mine" + } + return "all" +} + +// Run transforms only Compose invocations. It never performs shell interpolation. +func (r InstallationRunner) Run(ctx context.Context, args []string, stdin io.Reader) (Result, error) { + if len(args) > 0 && args[0] == "compose" { + return r.Runner.Run(ctx, r.Installation.ComposeArgs(args[1:]...), stdin) + } + return r.Runner.Run(ctx, args, stdin) +} diff --git a/tools/tht/internal/compose/runner_test.go b/tools/tht/internal/compose/runner_test.go index f1467e9c..45697240 100644 --- a/tools/tht/internal/compose/runner_test.go +++ b/tools/tht/internal/compose/runner_test.go @@ -3,11 +3,14 @@ package compose import ( "context" "errors" + "io" "os" "os/exec" "path/filepath" "strings" "testing" + + "github.com/aritmolab/thothii/tools/tht/internal/config" ) func TestRunnerPassesEachArgumentWithoutShellSplitting(t *testing.T) { @@ -56,6 +59,39 @@ func TestRunnerReportsMissingDocker(t *testing.T) { } } +func TestInstallationRunnerAppliesComposeArgumentsWithoutRewritingHostDockerCommands(t *testing.T) { + t.Parallel() + + recorded := &recordingRunner{} + installation := config.Installation{ + Path: "/tmp/thothii/deploy/local/thothii-installation.yaml", + Profile: "local", + ProjectDirectory: "/tmp/thothii", + EnvFile: "/tmp/thothii/deploy/local/operator.env", + } + runner := InstallationRunner{Installation: installation, Runner: recorded} + if _, err := runner.Run(context.Background(), []string{"compose", "ps"}, nil); err != nil { + t.Fatal(err) + } + if _, err := runner.Run(context.Background(), []string{"inspect", "core-id"}, nil); err != nil { + t.Fatal(err) + } + + if got, want := strings.Join(recorded.calls[0], " "), strings.Join(installation.ComposeArgs("ps"), " "); got != want { + t.Fatalf("Compose call = %q, want %q", got, want) + } + if got, want := strings.Join(recorded.calls[1], " "), "inspect core-id"; got != want { + t.Fatalf("host Docker call = %q, want %q", got, want) + } +} + +type recordingRunner struct{ calls [][]string } + +func (r *recordingRunner) Run(_ context.Context, args []string, _ io.Reader) (Result, error) { + r.calls = append(r.calls, append([]string(nil), args...)) + return Result{}, nil +} + func writeExecutable(t *testing.T, contents string) string { t.Helper() path := filepath.Join(t.TempDir(), "fake-docker") diff --git a/tools/tht/internal/setup/run.go b/tools/tht/internal/setup/run.go new file mode 100644 index 00000000..f210e1b3 --- /dev/null +++ b/tools/tht/internal/setup/run.go @@ -0,0 +1,302 @@ +// Package setup orchestrates the safe local bootstrap of a ThothII installation. +package setup + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/aritmolab/thothii/tools/tht/internal/compose" + "github.com/aritmolab/thothii/tools/tht/internal/config" + "github.com/aritmolab/thothii/tools/tht/internal/pi" + "github.com/aritmolab/thothii/tools/tht/internal/project" +) + +const setupHealthTimeout = 5 * time.Minute + +var healthPollInterval = time.Second + +// Result records the completed setup phases. DescriptorPath always identifies the descriptor +// selected by this invocation, including an idempotent rerun. +type Result struct { + DescriptorPath string + ProjectName string + Configured bool + Built bool + Started bool + Healthy bool +} + +// Run validates the host, creates or validates non-secret configuration, and by default builds, +// starts, and verifies the current checkout. ConfigureOnly stops after Compose rendering. +func Run(ctx context.Context, runner compose.Runner, request Request, input io.Reader, output io.Writer) (Result, error) { + if runner == nil { + return Result{}, errors.New("setup requires a Docker command runner") + } + root, err := project.Discover(request.ProjectRoot) + if err != nil { + return Result{}, fmt.Errorf("setup project discovery: %w", err) + } + request.ProjectRoot = root.Path + if err := checkHost(ctx, runner, root.Path); err != nil { + return Result{}, err + } + + files, err := EnsureFiles(request, input, output) + if err != nil { + return Result{}, err + } + installation, err := config.Load(files.DescriptorPath) + if err != nil { + return Result{}, fmt.Errorf("setup generated configuration is invalid: %w", err) + } + result := Result{DescriptorPath: files.DescriptorPath, ProjectName: installation.ProjectName(), Configured: true} + if err := runCompose(ctx, runner, installation, "config", "--quiet"); err != nil { + return Result{}, fmt.Errorf("setup Compose configuration: %w", err) + } + if request.ConfigureOnly { + fmt.Fprintf(output, "Configuration is ready: %s\n", result.DescriptorPath) + return result, nil + } + if err := runCompose(ctx, runner, installation, "build"); err != nil { + return Result{}, fmt.Errorf("setup image build: %w", err) + } + result.Built = true + if err := runCompose(ctx, runner, installation, "up", "--detach", "--remove-orphans"); err != nil { + return Result{}, fmt.Errorf("setup stack start: %w", err) + } + result.Started = true + if err := waitForHealthyServices(ctx, runner, installation); err != nil { + return Result{}, err + } + result.Healthy = true + if err := aggregateDoctor(ctx, runner, installation); err != nil { + return Result{}, err + } + controlled := compose.InstallationRunner{Installation: installation, Runner: runner} + if err := pi.Doctor(ctx, controlled); err != nil { + return Result{}, fmt.Errorf("setup Pi doctor: %w", err) + } + fmt.Fprintf(output, "ThothII is ready at %s\nInstallation descriptor: %s\nNext: tht status\n", frontendURL(installation), result.DescriptorPath) + return result, nil +} + +func checkHost(ctx context.Context, runner compose.Runner, root string) error { + checks := []struct { + name string + args []string + }{ + {name: "Docker Engine", args: []string{"version", "--format", "{{.Server.Version}}"}}, + {name: "Docker Compose", args: []string{"compose", "version", "--short"}}, + {name: "supported Docker architecture", args: []string{"version", "--format", "{{.Server.Arch}}"}}, + } + for _, check := range checks { + result, err := runner.Run(ctx, check.args, nil) + if err != nil || strings.TrimSpace(result.Stdout) == "" { + return fmt.Errorf("setup %s check failed", check.name) + } + if check.name == "supported Docker architecture" && !supportedArchitecture(result.Stdout) { + return fmt.Errorf("setup Docker architecture %q is not supported", strings.TrimSpace(result.Stdout)) + } + } + if err := requireLF(root); err != nil { + return fmt.Errorf("setup line-ending check: %w", err) + } + return nil +} + +func supportedArchitecture(value string) bool { + switch strings.ToLower(strings.TrimSpace(value)) { + case "amd64", "x86_64", "arm64", "aarch64": + return true + default: + return false + } +} + +func runCompose(ctx context.Context, runner compose.Runner, installation config.Installation, command ...string) error { + result, err := runner.Run(ctx, installation.ComposeArgs(command...), nil) + if err != nil { + return composeFailure(result, err) + } + return nil +} + +func composeFailure(result compose.Result, cause error) error { + if result.ExitCode != 0 { + return fmt.Errorf("Docker exited with status %d", result.ExitCode) + } + return cause +} + +func aggregateDoctor(ctx context.Context, runner compose.Runner, installation config.Installation) error { + result, err := runner.Run(ctx, installation.ComposeArgs("config", "--format", "json"), nil) + if err != nil { + return fmt.Errorf("setup doctor: %w", composeFailure(result, err)) + } + if err := requireVolumes(result.Stdout); err != nil { + return fmt.Errorf("setup doctor: %w", err) + } + return nil +} + +type serviceStatus struct { + Service string `json:"Service"` + State string `json:"State"` + Health string `json:"Health"` + ExitCode json.RawMessage `json:"ExitCode"` +} + +func waitForHealthyServices(ctx context.Context, runner compose.Runner, installation config.Installation) error { + healthContext, cancel := context.WithTimeout(ctx, setupHealthTimeout) + defer cancel() + lastService, lastState := "core", "unknown" + for { + result, err := runner.Run(healthContext, installation.ComposeArgs("ps", "--all", "--format", "json"), nil) + if err == nil { + statuses, parseErr := parseServiceStatuses(result.Stdout) + if parseErr == nil { + if service, state, ready := healthyServices(statuses); ready { + return nil + } else { + lastService, lastState = service, state + } + } else { + lastState = "Compose returned invalid status" + } + } else if result.ExitCode != 0 { + lastState = fmt.Sprintf("Compose exited with status %d", result.ExitCode) + } else { + lastState = "Compose status command failed" + } + select { + case <-healthContext.Done(): + return fmt.Errorf("setup health timed out waiting for %s (last state: %s); containers were left running for diagnosis: tht logs %s; then run tht status", lastService, lastState, lastService) + case <-time.After(healthPollInterval): + } + } +} + +func parseServiceStatuses(value string) ([]serviceStatus, error) { + var statuses []serviceStatus + if err := json.Unmarshal([]byte(value), &statuses); err == nil && len(statuses) > 0 { + return statuses, nil + } + decoder := json.NewDecoder(strings.NewReader(value)) + for { + var status serviceStatus + err := decoder.Decode(&status) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return nil, errors.New("Compose returned invalid service status") + } + statuses = append(statuses, status) + } + if len(statuses) == 0 { + return nil, errors.New("Compose returned invalid service status") + } + return statuses, nil +} + +func healthyServices(statuses []serviceStatus) (string, string, bool) { + wanted := map[string]bool{"core": false, "frontend": false, "qdrant": false, "embedding": false, "embedding-model-init": false} + for _, status := range statuses { + if _, required := wanted[status.Service]; !required { + continue + } + if status.Service == "embedding-model-init" { + if strings.EqualFold(status.State, "exited") && exitCodeZero(status.ExitCode) { + wanted[status.Service] = true + continue + } + return status.Service, status.State, false + } + if strings.EqualFold(status.State, "running") && strings.EqualFold(status.Health, "healthy") { + wanted[status.Service] = true + continue + } + return status.Service, strings.TrimSpace(status.State + "/" + status.Health), false + } + for _, service := range []string{"core", "frontend", "qdrant", "embedding", "embedding-model-init"} { + if !wanted[service] { + return service, "not reported by Docker Compose", false + } + } + return "", "", true +} + +func exitCodeZero(value json.RawMessage) bool { + if len(value) == 0 || string(value) == "null" { + return false + } + var number int + if json.Unmarshal(value, &number) == nil { + return number == 0 + } + var text string + return json.Unmarshal(value, &text) == nil && strings.TrimSpace(text) == "0" +} + +func frontendURL(installation config.Installation) string { + port, err := installation.EnvironmentValue("THOTH_HTTP_PORT") + if err != nil || strings.TrimSpace(port) == "" { + port = "8080" + } + if number, err := strconv.Atoi(port); err != nil || number < 1 || number > 65535 { + port = "8080" + } + return "http://127.0.0.1:" + port +} + +func requireVolumes(renderedConfig string) error { + var document struct { + Volumes map[string]json.RawMessage `json:"volumes"` + } + if err := json.Unmarshal([]byte(renderedConfig), &document); err != nil { + return errors.New("Compose returned invalid rendered configuration") + } + if len(document.Volumes) == 0 { + return errors.New("rendered Compose configuration declares no volumes") + } + return nil +} + +func requireLF(root string) error { + return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 || !requiresLF(entry.Name()) { + return nil + } + contents, err := os.ReadFile(path) + if err != nil { + return err + } + if strings.Contains(string(contents), "\r\n") { + return fmt.Errorf("CRLF line endings found in %s", filepath.Base(path)) + } + return nil + }) +} + +func requiresLF(name string) bool { + if name == "Dockerfile" || strings.HasPrefix(name, "Dockerfile.") || strings.HasSuffix(name, ".Dockerfile") { + return true + } + for _, suffix := range []string{".sh", ".yml", ".yaml"} { + if strings.HasSuffix(name, suffix) { + return true + } + } + return false +} diff --git a/tools/tht/internal/setup/run_test.go b/tools/tht/internal/setup/run_test.go new file mode 100644 index 00000000..a0e96223 --- /dev/null +++ b/tools/tht/internal/setup/run_test.go @@ -0,0 +1,228 @@ +package setup + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/aritmolab/thothii/tools/tht/internal/compose" +) + +func TestRunBuildsStartsAndVerifiesInOrder(t *testing.T) { + projectRoot, request := setupRunFixture(t, false) + runner := &setupRunner{health: []string{ + unhealthyServicesJSON, + healthyServicesJSON, + }} + var output bytes.Buffer + + result, err := Run(context.Background(), runner, request, strings.NewReader(""), &output) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if !result.Configured || !result.Built || !result.Started || !result.Healthy { + t.Fatalf("Run() result = %#v, want all lifecycle phases complete", result) + } + if result.ProjectName == "" || result.DescriptorPath != filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml") { + t.Fatalf("Run() result = %#v, want descriptor below the project deployment directory", result) + } + + want := []string{ + "docker engine", "docker compose", "architecture", "compose config", "compose build", + "compose up", "health", "health", "doctor", "pi doctor", + } + if got := collapseStages(runner.stages); strings.Join(got, " | ") != strings.Join(want, " | ") { + t.Fatalf("runner stages = %v, want %v", got, want) + } + for _, text := range []string{"ThothII is ready", "http://127.0.0.1:8080", result.DescriptorPath} { + if !strings.Contains(output.String(), text) { + t.Errorf("output = %q, want %q", output.String(), text) + } + } +} + +func TestRunConfigureOnlyStopsAfterRenderedConfiguration(t *testing.T) { + _, request := setupRunFixture(t, true) + runner := &setupRunner{} + + result, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard) + if err != nil { + t.Fatalf("Run() error = %v", err) + } + if !result.Configured || result.Built || result.Started || result.Healthy { + t.Fatalf("Run() result = %#v, want only configuration", result) + } + want := []string{"docker engine", "docker compose", "architecture", "compose config"} + if got := runner.stages; strings.Join(got, " | ") != strings.Join(want, " | ") { + t.Fatalf("runner stages = %v, want %v", got, want) + } +} + +func TestRunPropagatesPreflightFailureBeforeWritingConfiguration(t *testing.T) { + projectRoot, request := setupRunFixture(t, false) + runner := &setupRunner{failureAt: "docker engine"} + + _, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard) + if err == nil || !strings.Contains(err.Error(), "Docker Engine") { + t.Fatalf("Run() error = %v, want Docker Engine failure", err) + } + if _, statErr := os.Stat(filepath.Join(projectRoot, "deploy", "ci", "thothii-installation.yaml")); !errors.Is(statErr, os.ErrNotExist) { + t.Fatalf("descriptor was written after preflight failure: %v", statErr) + } +} + +func TestRunTimesOutWithPartialStartupGuidance(t *testing.T) { + _, request := setupRunFixture(t, false) + runner := &setupRunner{health: []string{unhealthyServicesJSON}} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Millisecond) + defer cancel() + + _, err := Run(ctx, runner, request, strings.NewReader(""), io.Discard) + if err == nil { + t.Fatal("Run() error = nil, want health timeout") + } + for _, text := range []string{"frontend", "tht logs frontend", "tht status", "left running"} { + if !strings.Contains(err.Error(), text) { + t.Errorf("Run() error = %q, want %q", err, text) + } + } +} + +const unhealthyServicesJSON = `[ + {"Service":"core","State":"running","Health":"healthy"}, + {"Service":"frontend","State":"running","Health":"starting"}, + {"Service":"qdrant","State":"running","Health":"healthy"}, + {"Service":"embedding","State":"running","Health":"healthy"}, + {"Service":"embedding-model-init","State":"exited","ExitCode":0} +]` + +const healthyServicesJSON = `[ + {"Service":"core","State":"running","Health":"healthy"}, + {"Service":"frontend","State":"running","Health":"healthy"}, + {"Service":"qdrant","State":"running","Health":"healthy"}, + {"Service":"embedding","State":"running","Health":"healthy"}, + {"Service":"embedding-model-init","State":"exited","ExitCode":0} +]` + +type setupRunner struct { + stages []string + health []string + failureAt string +} + +func (r *setupRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) { + stage, result := setupStage(args) + if stage == "doctor" && containsStage(r.stages, "doctor") { + stage = "pi doctor" + } + r.stages = append(r.stages, stage) + if stage == r.failureAt { + return compose.Result{ExitCode: 41}, errors.New("fixture failure") + } + if stage == "health" { + if len(r.health) == 0 { + result.Stdout = healthyServicesJSON + } else { + result.Stdout, r.health = r.health[0], r.health[1:] + } + } + return result, nil +} + +func containsStage(stages []string, wanted string) bool { + for _, stage := range stages { + if stage == wanted { + return true + } + } + return false +} + +func collapseStages(stages []string) []string { + collapsed := make([]string, 0, len(stages)) + for _, stage := range stages { + if stage == "pi doctor" && len(collapsed) > 0 && collapsed[len(collapsed)-1] == stage { + continue + } + collapsed = append(collapsed, stage) + } + return collapsed +} + +func setupStage(args []string) (string, compose.Result) { + joined := strings.Join(args, " ") + switch { + case joined == "version --format {{.Server.Version}}": + return "docker engine", compose.Result{Stdout: "26.0.0\n"} + case joined == "compose version --short": + return "docker compose", compose.Result{Stdout: "v2.30.0\n"} + case joined == "version --format {{.Server.Arch}}": + return "architecture", compose.Result{Stdout: "arm64\n"} + case strings.HasSuffix(joined, " config --quiet"): + return "compose config", compose.Result{} + case strings.HasSuffix(joined, " build"): + return "compose build", compose.Result{} + case strings.HasSuffix(joined, " up --detach --remove-orphans"): + return "compose up", compose.Result{} + case strings.HasSuffix(joined, " ps --all --format json"): + return "health", compose.Result{} + case strings.HasSuffix(joined, " config --format json"): + return "doctor", compose.Result{Stdout: renderedSetupConfig} + case strings.Contains(joined, " ps -q core"): + return "pi doctor", compose.Result{Stdout: "core-id\n"} + case strings.HasPrefix(joined, "inspect --format"): + return "pi doctor", compose.Result{Stdout: "0.80.3\n"} + case strings.Contains(joined, "pi --version") || strings.Contains(joined, "PI_VERSION") || strings.Contains(joined, "test -w") || strings.Contains(joined, "test -r") || strings.Contains(joined, "127.0.0.1:8787/health"): + return "pi doctor", compose.Result{Stdout: "0.80.3\n"} + case strings.Contains(joined, "/pi-management/test"): + return "pi doctor", compose.Result{Stdout: `{"ready":true}`} + default: + return "unexpected: " + joined, compose.Result{} + } +} + +const renderedSetupConfig = `{"volumes":{"settings":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}` + +func setupRunFixture(t *testing.T, configureOnly bool) (string, Request) { + t.Helper() + temporaryRoot, err := filepath.EvalSymlinks(os.TempDir()) + if err != nil { + t.Fatal(err) + } + root, err := os.MkdirTemp(temporaryRoot, "tht-setup-run-") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = os.RemoveAll(root) }) + for _, directory := range []string{".git", "backend", "frontend", "harness", "tools", "deploy", "docker"} { + if err := os.MkdirAll(filepath.Join(root, directory), 0o755); err != nil { + t.Fatal(err) + } + } + for _, path := range []string{ + "compose.yaml", "deploy/compose.local.yaml", "deploy/compose.server.yaml", "deploy/compose.git-https.yaml", + } { + if err := os.WriteFile(filepath.Join(root, path), []byte("services: {}\n"), 0o600); err != nil { + t.Fatal(err) + } + } + secrets := filepath.Join(root, "deploy", "ci", "secrets") + if err := os.MkdirAll(secrets, 0o700); err != nil { + t.Fatal(err) + } + return root, Request{ + ProjectRoot: root, InstallationID: "ci", Profile: "local", ConfigureOnly: configureOnly, NonInteractive: true, + Answers: Answers{ + WorkspaceRemote: "https://git.example.invalid/thothii-workspaces.git", WorkspaceBranch: "main", WorkspaceAccess: "https", + SecretsFile: filepath.Join(secrets, "thothii.secrets"), PiAuthFile: filepath.Join(secrets, "pi-auth.json"), + GitCredentialsFile: filepath.Join(secrets, "git-credentials"), GitCAFile: filepath.Join(secrets, "git-ca.pem"), + CreateSecretTemplates: true, + }, + } +}