355 lines
11 KiB
Go
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
|
|
}
|