Files
ThothII/tools/tht/internal/setup/run.go
T

355 lines
11 KiB
Go

// 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{}, withStartupRecovery(fmt.Errorf("setup stack start: %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")
}
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
}
func withStartupRecovery(cause error, service string) error {
if service == "" {
service = "core"
}
return fmt.Errorf("%w; containers were left running for diagnosis: tht logs %s; then run tht status", cause, service)
}
func recoveryService(cause error) string {
var healthFailure 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) == "" {
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")
}
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 {
return err
}
}
return nil
}
func requireLFPath(path string) error {
info, err := os.Lstat(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
if err != nil || info.Mode()&os.ModeSymlink != 0 {
return nil
}
if !info.IsDir() {
return requireLFFile(path, info.Mode())
}
return filepath.WalkDir(path, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || entry.Type()&os.ModeSymlink != 0 {
return nil
}
return requireLFFile(path, entry.Type())
})
}
func requireLFFile(path string, mode os.FileMode) error {
if !mode.IsRegular() || !requiresLF(filepath.Base(path)) {
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
}