// 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" "github.com/aritmolab/thothii/tools/tht/internal/authconfig" "github.com/aritmolab/thothii/tools/tht/internal/compose" "github.com/aritmolab/thothii/tools/tht/internal/config" "github.com/aritmolab/thothii/tools/tht/internal/doctor" "github.com/aritmolab/thothii/tools/tht/internal/modelprojection" "github.com/aritmolab/thothii/tools/tht/internal/project" "github.com/aritmolab/thothii/tools/tht/internal/service" ) var publishProjectedCanonical = authconfig.PublishProjectedCanonical var requireRuntimeAuthProjectionReady = authconfig.RequireRuntimeAuthProjectionReady // 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. Complete additionally migrates the Catalog and // imports the configured workspace repository. 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 := configureAuthentication(ctx, installation, request, input, output); err != nil { return Result{}, err } if err := modelprojection.Generate(installation); err != nil { return Result{}, fmt.Errorf("setup model runtime projection: %w", err) } 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 request.Complete { if err := runCompose(ctx, runner, installation, "build"); err != nil { return Result{}, fmt.Errorf("setup image build: %w", err) } if err := runCompose(ctx, runner, installation, "up", "--detach", "catalog-db"); err != nil { return Result{}, fmt.Errorf("setup Catalog database start: %w", err) } if err := runCompose(ctx, runner, installation, "--profile", "catalog-maintenance", "run", "--rm", "catalog-migrate"); err != nil { return Result{}, fmt.Errorf("setup Catalog migration: %w", err) } } if err := service.Start(ctx, installation, runner, !request.Complete); 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, result.Started, result.Healthy = true, true, true if request.Complete { if err := runOperator(ctx, runner, installation, "workspace-pull"); err != nil { return Result{}, withStartupRecovery(fmt.Errorf("setup workspace import: %w", err), "core") } if err := runOperator(ctx, runner, installation, "pi-test"); err != nil { return Result{}, withStartupRecovery(fmt.Errorf("setup LLM credential test: %w", err), "core") } } report, err := doctor.Run(ctx, installation, runner) if err != nil { return Result{}, withStartupRecovery(fmt.Errorf("setup doctor: %w", err), "core") } if !report.OK { return Result{}, withStartupRecovery(errors.New("setup doctor reported failed checks"), "core") } if request.Complete { fmt.Fprintln(output, "Workspace repository pulled and activated; run 'tht workspace test' after configuring each workspace database.") } fmt.Fprintf(output, "ThothII is ready at %s\nInstallation descriptor: %s\nNext: tht status\n", frontendURL(installation), result.DescriptorPath) return result, nil } func configureAuthentication(ctx context.Context, installation config.Installation, request Request, input io.Reader, output io.Writer) error { directory := installation.AuthenticationDirectory() if _, _, err := authconfig.Load(directory); err == nil { return publishConfiguredAuthentication(ctx, installation) } if _, err := os.Lstat(filepath.Join(directory, "auth.yaml")); !errors.Is(err, os.ErrNotExist) { return errors.New("setup authentication configuration is invalid") } args, err := authenticationConfigureArgs(request) if err != nil { return err } if exitCode := authconfig.Run(ctx, installation, args, input, io.Discard, output); exitCode != 0 { return errors.New("setup authentication configuration failed") } if _, _, err := authconfig.Load(directory); err != nil { return errors.New("setup authentication configuration is invalid") } return publishConfiguredAuthentication(ctx, installation) } func publishConfiguredAuthentication(ctx context.Context, installation config.Installation) error { projection := installation.RuntimeAuthProjection() if projection == nil { return nil } status, err := publishProjectedCanonical(ctx, installation.AuthenticationDirectory(), authconfig.ProjectionSpec{ RuntimeRoot: projection.Directory, UID: projection.UID, GID: projection.GID, }) if err != nil || status.State != "ready" || !status.Equal { return errors.New("setup authentication runtime projection could not be published") } if err := requireRuntimeAuthProjectionReady(installation); err != nil { return errors.New("setup authentication runtime projection could not be verified") } return nil } func authenticationConfigureArgs(request Request) ([]string, error) { answers := request.Answers mode := answers.AuthMode if mode == "" && !request.NonInteractive { mode = "local" } if mode != "local" && mode != "oidc" { return nil, errors.New("setup requires --auth-mode local or oidc") } if mode == "local" && request.NonInteractive && (answers.AuthAdminUser == "" || answers.AuthAdminDisplayName == "" || answers.AuthPasswordFile == "") { return nil, errors.New("non-interactive local authentication requires --auth-admin-user, --auth-admin-display-name, and --auth-password-file") } publicURL := answers.AuthPublicURL if publicURL == "" && !request.NonInteractive && mode == "local" { publicURL = "http://127.0.0.1:8080" } if publicURL == "" { return nil, errors.New("setup requires --auth-public-url") } args := []string{"configure", "--mode", mode, "--public-url", publicURL} if mode == "local" { if answers.AuthAdminUser != "" { args = append(args, "--admin-user", answers.AuthAdminUser) } if answers.AuthAdminDisplayName != "" { args = append(args, "--admin-display-name", answers.AuthAdminDisplayName) } if answers.AuthPasswordFile != "" { args = append(args, "--password-file", answers.AuthPasswordFile) } return args, nil } for _, option := range []struct { name, value string }{ {"--issuer", answers.AuthIssuer}, {"--client-id", answers.AuthClientID}, {"--authentik-base-url", answers.AuthAuthentikBaseURL}, {"--user-group", answers.AuthUserGroup}, {"--admin-group", answers.AuthAdminGroup}, } { if option.value == "" { return nil, errors.New("OIDC authentication requires complete provider and group options") } args = append(args, option.name, option.value) } return args, 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 runOperator(ctx context.Context, runner compose.Runner, installation config.Installation, action string) error { result, err := runner.Run(ctx, installation.ComposeArgs( "exec", "-T", "core", "node", "dist/operator-command.js", action, ), nil) if err != nil { if result.ExitCode != 0 { return fmt.Errorf("Docker exited with status %d", result.ExitCode) } return err } var payload struct { Ready *bool `json:"ready"` } if err := json.Unmarshal([]byte(result.Stdout), &payload); err != nil || payload.Ready == nil || !*payload.Ready { return fmt.Errorf("operator action %s reported failure", action) } 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 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 service.HealthFailure if errors.As(cause, &healthFailure) && healthFailure.Service != "" { return healthFailure.Service } return "core" } 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 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 }