feat(setup): build start and verify ThothII
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user