feat: add cross-platform thothctl

This commit is contained in:
2026-08-04 16:48:40 +02:00
parent e2264ee295
commit 853a151796
11 changed files with 730 additions and 0 deletions
+241
View File
@@ -0,0 +1,241 @@
// thothctl is the host-side operator command for a local ThothII installation.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
"github.com/aritmolab/thothii/tools/thothctl/internal/output"
)
const usage = `Usage: thothctl --installation <absolute-path>/thothii-installation.yaml <command>
Commands:
status Show the Compose service state.
doctor Validate Docker, Compose, rendered configuration, line endings, volumes, and health.
logs [--follow] Show sanitized service logs (the default is the latest 200 lines).
start Start the installation in the background.
stop Stop the installation.
update --check-only Validate the current installation without changing containers.
`
func main() {
os.Exit(run(context.Background(), os.Args[1:], os.Stdout, os.Stderr))
}
func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
if len(args) == 1 && (args[0] == "--help" || args[0] == "-h") {
fmt.Fprint(stdout, usage)
return 0
}
installationPath, command, commandArgs, err := parseArgs(args)
if err != nil {
fmt.Fprintf(stderr, "thothctl: %s\n\n%s", err, usage)
return 2
}
installation, err := config.Load(installationPath)
if err != nil {
fmt.Fprintf(stderr, "thothctl: %s\n", output.Sanitize(err.Error(), nil))
return 2
}
runner := compose.NewRunner("")
var result compose.Result
switch command {
case "status":
if len(commandArgs) != 0 {
return commandUsageError(stderr, "status does not accept arguments")
}
result, err = runner.Run(ctx, installation.ComposeArgs("ps", "--format", "json"), nil)
case "logs":
logArgs, argumentError := logsArgs(commandArgs)
if argumentError != nil {
return commandUsageError(stderr, argumentError.Error())
}
result, err = runner.Run(ctx, installation.ComposeArgs(logArgs...), nil)
case "start":
if len(commandArgs) != 0 {
return commandUsageError(stderr, "start does not accept arguments")
}
result, err = runner.Run(ctx, installation.ComposeArgs("up", "--detach", "--remove-orphans"), nil)
case "stop":
if len(commandArgs) != 0 {
return commandUsageError(stderr, "stop does not accept arguments")
}
result, err = runner.Run(ctx, installation.ComposeArgs("stop"), nil)
case "update":
if len(commandArgs) != 1 || commandArgs[0] != "--check-only" {
return commandUsageError(stderr, "update currently requires --check-only")
}
result, err = runner.Run(ctx, installation.ComposeArgs("config", "--quiet"), nil)
case "doctor":
if len(commandArgs) != 0 {
return commandUsageError(stderr, "doctor does not accept arguments")
}
return doctor(ctx, installation, runner, stdout, stderr)
default:
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
}
return writeResult(result, err, stdout, stderr)
}
func parseArgs(args []string) (string, string, []string, error) {
if len(args) < 3 || args[0] != "--installation" {
return "", "", nil, errors.New("--installation <absolute-path> is required before the command")
}
if !filepath.IsAbs(args[1]) {
return "", "", nil, errors.New("--installation must be an absolute path")
}
return args[1], args[2], args[3:], nil
}
func logsArgs(args []string) ([]string, error) {
if len(args) == 0 {
return []string{"logs", "--tail", "200"}, nil
}
if len(args) == 1 && args[0] == "--follow" {
return []string{"logs", "--tail", "200", "--follow"}, nil
}
return nil, errors.New("logs accepts only --follow")
}
func commandUsageError(stderr io.Writer, message string) int {
fmt.Fprintf(stderr, "thothctl: %s\n", message)
return 2
}
func writeResult(result compose.Result, err error, stdout, stderr io.Writer) int {
if result.Stdout != "" {
fmt.Fprint(stdout, output.Sanitize(result.Stdout, nil))
}
if result.Stderr != "" {
fmt.Fprint(stderr, output.Sanitize(result.Stderr, nil))
}
if err == nil {
return 0
}
if errors.Is(err, exec.ErrNotFound) {
fmt.Fprintln(stderr, "thothctl: Docker is not installed or is not on PATH")
}
if result.ExitCode != 0 {
return result.ExitCode
}
return 1
}
func doctor(ctx context.Context, installation config.Installation, runner compose.Runner, stdout, stderr io.Writer) int {
checks := [][]string{
{"version", "--format", "{{.Client.Version}}"},
{"compose", "version", "--short"},
installation.ComposeArgs("config", "--quiet"),
installation.ComposeArgs("config", "--format", "json"),
installation.ComposeArgs("ps", "--format", "json"),
}
var renderedConfig, status string
for index, args := range checks {
result, err := runner.Run(ctx, args, nil)
if err != nil {
return writeResult(result, err, stdout, stderr)
}
if index == 3 {
renderedConfig = result.Stdout
}
if index == 4 {
status = result.Stdout
}
}
if err := requireLF(installation.ProjectDirectory); err != nil {
fmt.Fprintf(stderr, "thothctl: %s\n", err)
return 1
}
if err := requireVolumes(renderedConfig); err != nil {
fmt.Fprintf(stderr, "thothctl: %s\n", err)
return 1
}
if err := requireHealthyServices(status); err != nil {
fmt.Fprintf(stderr, "thothctl: %s\n", err)
return 1
}
fmt.Fprintln(stdout, "Doctor checks passed.")
return 0
}
func requireLF(root string) error {
return filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() || !requiresLF(entry.Name()) {
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
}
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 fmt.Errorf("Compose returned invalid rendered configuration")
}
if len(document.Volumes) == 0 {
return errors.New("rendered Compose configuration declares no volumes")
}
return nil
}
func requireHealthyServices(status string) error {
var services []struct {
Service string `json:"Service"`
State string `json:"State"`
Health string `json:"Health"`
}
if err := json.Unmarshal([]byte(status), &services); err != nil {
return errors.New("Compose returned invalid service status")
}
seen := map[string]bool{}
for _, service := range services {
if service.Service != "core" && service.Service != "frontend" {
continue
}
if service.State != "running" || service.Health != "healthy" {
return fmt.Errorf("%s is not healthy", service.Service)
}
seen[service.Service] = true
}
for _, service := range []string{"core", "frontend"} {
if !seen[service] {
return fmt.Errorf("%s service is not running", service)
}
}
return nil
}