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