feat(cli): add version diagnostics and build-aware start
This commit is contained in:
+16
-160
@@ -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 {
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user