feat(cli): add version diagnostics and build-aware start

This commit is contained in:
2026-08-15 23:35:55 +02:00
parent 9a8755e4c3
commit 7ac99b4f7e
11 changed files with 989 additions and 306 deletions
+16 -160
View File
@@ -3,7 +3,6 @@ package setup
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
@@ -11,18 +10,14 @@ import (
"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/doctor"
"github.com/aritmolab/thothii/tools/tht/internal/project"
"github.com/aritmolab/thothii/tools/tht/internal/service"
)
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 {
@@ -65,24 +60,19 @@ func Run(ctx context.Context, runner compose.Runner, request Request, input io.R
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)
if err := service.Start(ctx, installation, runner, true); err != nil {
if strings.Contains(err.Error(), "image build") {
return Result{}, fmt.Errorf("setup %w", err)
}
return Result{}, withStartupRecovery(fmt.Errorf("setup %w", err), recoveryService(err))
}
result.Built = true
if err := runCompose(ctx, runner, installation, "up", "--detach", "--remove-orphans"); err != nil {
return Result{}, withStartupRecovery(fmt.Errorf("setup stack start: %w", err), "core")
result.Built, result.Started, result.Healthy = true, true, true
report, err := doctor.Run(ctx, installation, runner)
if err != nil {
return Result{}, withStartupRecovery(fmt.Errorf("setup doctor: %w", err), "core")
}
result.Started = true
if err := waitForHealthyServices(ctx, runner, installation); err != nil {
return Result{}, withStartupRecovery(err, recoveryService(err))
}
result.Healthy = true
if err := aggregateDoctor(ctx, runner, installation); err != nil {
return Result{}, withStartupRecovery(err, "core")
}
controlled := compose.InstallationRunner{Installation: installation, Runner: runner}
if err := pi.Doctor(ctx, controlled); err != nil {
return Result{}, withStartupRecovery(fmt.Errorf("setup Pi doctor: %w", err), "core")
if !report.OK {
return Result{}, withStartupRecovery(errors.New("setup doctor reported failed checks"), "core")
}
fmt.Fprintf(output, "ThothII is ready at %s\nInstallation descriptor: %s\nNext: tht status\n", frontendURL(installation), result.DescriptorPath)
return result, nil
@@ -136,17 +126,6 @@ func composeFailure(result compose.Result, cause error) error {
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
}
func withStartupRecovery(cause error, service string) error {
if service == "" {
service = "core"
@@ -155,121 +134,13 @@ func withStartupRecovery(cause error, service string) error {
}
func recoveryService(cause error) string {
var healthFailure healthFailure
if errors.As(cause, &healthFailure) && healthFailure.service != "" {
return healthFailure.service
var healthFailure service.HealthFailure
if errors.As(cause, &healthFailure) && healthFailure.Service != "" {
return healthFailure.Service
}
return "core"
}
type serviceStatus struct {
Service string `json:"Service"`
State string `json:"State"`
Health string `json:"Health"`
ExitCode json.RawMessage `json:"ExitCode"`
}
type healthFailure struct {
service string
state string
}
func (e healthFailure) Error() string {
return fmt.Sprintf("setup health timed out waiting for %s (last state: %s)", e.service, e.state)
}
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 healthFailure{service: lastService, state: lastState}
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) == "" {
@@ -281,21 +152,6 @@ func frontendURL(installation config.Installation) string {
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")
}
for _, volume := range []string{"settings", "pi-state", "workspace-registry", "workspace-secrets", "sessions", "qdrant-data", "embedding-models"} {
if _, exists := document.Volumes[volume]; !exists {
return fmt.Errorf("rendered Compose configuration is missing required volume %s", volume)
}
}
return nil
}
func requireLF(root string) error {
for _, path := range []string{filepath.Join(root, "compose.yaml"), filepath.Join(root, "deploy"), filepath.Join(root, "docker")} {
if err := requireLFPath(path); err != nil {
+22 -14
View File
@@ -12,6 +12,7 @@ import (
"time"
"github.com/aritmolab/thothii/tools/tht/internal/compose"
"github.com/aritmolab/thothii/tools/tht/internal/doctor"
)
func TestRunBuildsStartsAndVerifiesInOrder(t *testing.T) {
@@ -35,7 +36,7 @@ func TestRunBuildsStartsAndVerifiesInOrder(t *testing.T) {
want := []string{
"docker engine", "docker compose", "architecture", "compose config", "compose build",
"compose up", "health", "health", "doctor", "pi doctor",
"compose up", "health", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "workflow doctor", "pi doctor",
}
if got := collapseStages(runner.stages); strings.Join(got, " | ") != strings.Join(want, " | ") {
t.Fatalf("runner stages = %v, want %v", got, want)
@@ -119,11 +120,11 @@ func TestRunUpFailurePreservesCauseAndOffersRecovery(t *testing.T) {
func TestRunAggregateDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) {
_, request := setupRunFixture(t, false)
runner := &setupRunner{failureAt: "doctor"}
runner := &setupRunner{failureAt: "doctor config"}
_, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard)
assertRecoveryFailure(t, err, "setup doctor", "core")
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor")
assertRecoveryFailure(t, err, "setup doctor reported failed checks", "core")
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "workflow doctor", "pi doctor")
}
func TestRunPiDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) {
@@ -131,8 +132,8 @@ func TestRunPiDoctorFailurePreservesCauseAndOffersRecovery(t *testing.T) {
runner := &setupRunner{failureAt: "pi doctor"}
_, err := Run(context.Background(), runner, request, strings.NewReader(""), io.Discard)
assertRecoveryFailure(t, err, "setup Pi doctor", "core")
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor", "pi doctor")
assertRecoveryFailure(t, err, "setup doctor reported failed checks", "core")
assertSetupStages(t, runner, "docker engine", "docker compose", "architecture", "compose config", "compose build", "compose up", "health", "doctor docker", "doctor compose", "compose config", "doctor config", "health", "workflow doctor", "pi doctor")
}
func TestRequireVolumesRequiresEveryInstallationVolume(t *testing.T) {
@@ -145,16 +146,16 @@ func TestRequireVolumesRequiresEveryInstallationVolume(t *testing.T) {
volumes = append(volumes, name)
}
}
if err := requireVolumes(renderedConfigForVolumes(volumes...)); err == nil || !strings.Contains(err.Error(), missing) {
t.Fatalf("requireVolumes() error = %v, want missing %q", err, missing)
if err := doctor.ValidateVolumes(renderedConfigForVolumes(volumes...)); err == nil || !strings.Contains(err.Error(), missing) {
t.Fatalf("ValidateVolumes() error = %v, want missing %q", err, missing)
}
})
}
if err := requireVolumes(renderedConfigForVolumes("unrelated")); err == nil {
t.Fatal("requireVolumes() error = nil, want required-volume failure for unrelated-only configuration")
if err := doctor.ValidateVolumes(renderedConfigForVolumes("unrelated")); err == nil {
t.Fatal("ValidateVolumes() error = nil, want required-volume failure for unrelated-only configuration")
}
if err := requireVolumes(renderedConfigForVolumes(all...)); err != nil {
t.Fatalf("requireVolumes() error = %v, want complete installation volume set", err)
if err := doctor.ValidateVolumes(renderedConfigForVolumes(all...)); err != nil {
t.Fatalf("ValidateVolumes() error = %v, want complete installation volume set", err)
}
}
@@ -216,7 +217,10 @@ type setupRunner struct {
func (r *setupRunner) Run(_ context.Context, args []string, _ io.Reader) (compose.Result, error) {
stage, result := setupStage(args)
if stage == "doctor" && containsStage(r.stages, "doctor") {
if stage == "docker compose" && containsStage(r.stages, "docker compose") {
stage = "doctor compose"
}
if stage == "doctor config" && containsStage(r.stages, "workflow doctor") {
stage = "pi doctor"
}
r.stages = append(r.stages, stage)
@@ -258,6 +262,8 @@ func setupStage(args []string) (string, compose.Result) {
switch {
case joined == "version --format {{.Server.Version}}":
return "docker engine", compose.Result{Stdout: "26.0.0\n"}
case joined == "version --format {{.Client.Version}}":
return "doctor docker", 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}}":
@@ -271,7 +277,9 @@ func setupStage(args []string) (string, 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}
return "doctor config", compose.Result{Stdout: renderedSetupConfig}
case strings.Contains(joined, "exec -T core tht doctor --json"):
return "workflow doctor", compose.Result{Stdout: `{"ok":true,"components":{}}`}
case strings.Contains(joined, " ps -q core"):
return "pi doctor", compose.Result{Stdout: "core-id\n"}
case strings.HasPrefix(joined, "inspect --format"):