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

307 lines
10 KiB
Go

// Package setup orchestrates the safe local bootstrap of a ThothII installation.
package setup
import (
"context"
"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/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. 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 := 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 := 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, 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")
}
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
}
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 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
}