feat(setup): build start and verify ThothII

This commit is contained in:
2026-08-15 23:09:10 +02:00
parent 2856964654
commit 47e0b2a385
7 changed files with 719 additions and 31 deletions
@@ -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 <service>` 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.
+18 -26
View File
@@ -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 {
+46 -1
View File
@@ -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) {
+34 -4
View File
@@ -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)
}
+36
View File
@@ -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")
+302
View File
@@ -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
}
+228
View File
@@ -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,
},
}
}