refactor(cli): rename operator command to tht
This commit is contained in:
@@ -0,0 +1,750 @@
|
||||
// tht is the host-side operator command for a local ThothII installation.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/config"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/output"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/pi"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/serverops"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/workspaceops"
|
||||
)
|
||||
|
||||
const usage = `Usage: tht --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 Show the latest 200 sanitized service log lines (bounded; no follow mode).
|
||||
start Start the installation in the background.
|
||||
stop Stop the installation.
|
||||
update --check-only Validate the current installation without changing containers.
|
||||
sessions migrate --yes
|
||||
Run only the server session migrator and verify pending=[] and drifted=[].
|
||||
remove Display exact stopped app container IDs without mutation.
|
||||
remove --yes ID... Remove only the stopped IDs copied from the preceding display.
|
||||
pi status Show the Pi version embedded in core.
|
||||
pi doctor Check Pi preconditions without changing the installation.
|
||||
pi test Run the temporary Pi/core smoke checks.
|
||||
pi check Alias for pi test.
|
||||
pi configure [--provider P --model M --thinking low|medium|high]
|
||||
Select closed backend defaults interactively on a TTY; all flags are required otherwise.
|
||||
pi restart --yes [--drain]
|
||||
Recreate only core with the currently selected Pi image and verify readiness.
|
||||
pi update --version V --source build --yes [--drain]
|
||||
Rebuild a pinned Pi version and recreate only core.
|
||||
pi update --version V --source pull --image IMAGE@sha256:DIGEST --yes [--drain]
|
||||
Pull an immutable candidate and recreate only core.
|
||||
pi rollback --yes Restore the image recorded by the latest Pi update.
|
||||
pi maintenance status
|
||||
Show the durable core admission-gate state.
|
||||
pi maintenance recover --yes
|
||||
Verify a terminal installation, remove stale lifecycle files, and clear maintenance.
|
||||
pi logs Show the latest 200 sanitized core log lines (bounded; no follow mode).
|
||||
workspace inspect --workspace ID [--json]
|
||||
Inspect the active registry snapshot for one workspace.
|
||||
workspace preprocess dwh --workspace ID [--resume RUN] [--json]
|
||||
workspace schema suggest-fks --workspace ID [--from-sql FILE]... [--assume COLUMN=TABLE]... [--output FILE] [--json]
|
||||
workspace schema check --workspace ID [--annotations FILE --reviewed-candidates sha256:HEX] [--json]
|
||||
workspace schema accept --workspace ID --run RUN --yes [--json]
|
||||
workspace index-schema --workspace ID [--json]
|
||||
workspace preprocess evidence --workspace ID [--dry-run] [--resume RUN] [--json]
|
||||
workspace preprocess run --workspace ID [--resume RUN] [--json]
|
||||
`
|
||||
|
||||
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, "tht: %s\n\n%s", err, usage)
|
||||
return 2
|
||||
}
|
||||
installation, err := config.Load(installationPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
|
||||
return 2
|
||||
}
|
||||
secretFiles, err := installation.SecretFiles()
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "tht: installation secret declarations could not be read")
|
||||
return 2
|
||||
}
|
||||
secretValues, err := output.SecretValuesFromFiles(secretFiles)
|
||||
if err != nil {
|
||||
fmt.Fprintln(stderr, "tht: declared secret file could not be read")
|
||||
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, secretValues, stdout, stderr)
|
||||
case "pi":
|
||||
return piCommand(ctx, installation, runner, commandArgs, secretValues, stdout, stderr)
|
||||
case "sessions":
|
||||
if len(commandArgs) != 2 || commandArgs[0] != "migrate" || commandArgs[1] != "--yes" {
|
||||
return commandUsageError(stderr, "sessions migrate requires --yes")
|
||||
}
|
||||
status, operationErr := serverops.MigrateSessions(ctx, installation, runner, true)
|
||||
if operationErr != nil {
|
||||
return serverOperationFailure(stderr, operationErr, secretValues)
|
||||
}
|
||||
if encodeErr := json.NewEncoder(stdout).Encode(status); encodeErr != nil {
|
||||
fmt.Fprintln(stderr, "tht: migration status could not be written")
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
case "remove":
|
||||
var confirmedIDs []string
|
||||
if len(commandArgs) > 0 {
|
||||
if commandArgs[0] != "--yes" || len(commandArgs) < 2 {
|
||||
return commandUsageError(stderr, "remove requires either no arguments or --yes followed by every displayed container ID")
|
||||
}
|
||||
confirmedIDs = commandArgs[1:]
|
||||
}
|
||||
removal, operationErr := serverops.Remove(ctx, installation, runner, confirmedIDs)
|
||||
writeRemovalTargets(stdout, installation.ProjectName(), removal.Targets)
|
||||
if errors.Is(operationErr, serverops.ErrConfirmationRequired) {
|
||||
fmt.Fprint(stderr, "tht: inspect the exact targets above, then re-run with remove --yes")
|
||||
for _, target := range removal.Targets {
|
||||
fmt.Fprintf(stderr, " %s", target.ID)
|
||||
}
|
||||
fmt.Fprintln(stderr)
|
||||
return 2
|
||||
}
|
||||
if operationErr != nil {
|
||||
return serverOperationFailure(stderr, operationErr, secretValues)
|
||||
}
|
||||
fmt.Fprintf(stdout, "Removed %d stopped app containers; verified %d preserved paths.\n", len(removal.Targets), removal.Preserved)
|
||||
return 0
|
||||
case "workspace":
|
||||
return workspaceCommand(ctx, installation, runner, commandArgs, secretValues, stdout, stderr)
|
||||
default:
|
||||
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
|
||||
}
|
||||
return writeResult(result, err, secretValues, stdout, stderr)
|
||||
}
|
||||
|
||||
func writeRemovalTargets(outputWriter io.Writer, project string, targets []serverops.Container) {
|
||||
fmt.Fprintf(outputWriter, "Removal targets for installation project %s:\n", project)
|
||||
if len(targets) == 0 {
|
||||
fmt.Fprintln(outputWriter, " (none)")
|
||||
return
|
||||
}
|
||||
for _, target := range targets {
|
||||
fmt.Fprintf(outputWriter, " service=%s name=%s id=%s state=%s\n", target.Service, target.Name, target.ID, target.State)
|
||||
}
|
||||
}
|
||||
|
||||
func workspaceCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, secretValues []string, stdout, stderr io.Writer) int {
|
||||
request, err := workspaceops.Parse(args)
|
||||
if err != nil {
|
||||
return commandUsageError(stderr, err.Error())
|
||||
}
|
||||
result, err := workspaceops.Execute(ctx, installation, runner, request)
|
||||
if err != nil {
|
||||
return workspaceFailure(stderr, err, secretValues)
|
||||
}
|
||||
if request.JSONMode() {
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if encodeErr := encoder.Encode(result); encodeErr != nil {
|
||||
fmt.Fprintln(stderr, "tht: workspace result could not be written")
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
fmt.Fprint(stdout, workspaceops.Human(result))
|
||||
}
|
||||
switch result.Status {
|
||||
case "blocked":
|
||||
return 3
|
||||
case "failed":
|
||||
return 1
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func workspaceFailure(stderr io.Writer, err error, secretValues []string) int {
|
||||
message := output.Sanitize(err.Error(), secretValues)
|
||||
var operationErr *workspaceops.OperationError
|
||||
if errors.As(err, &operationErr) && operationErr.Detail() != "" {
|
||||
detail := output.SanitizeDetail(operationErr.Detail(), secretValues)
|
||||
fmt.Fprintf(stderr, "tht: %s: %s\n", message, detail)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "tht: %s\n", message)
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func serverOperationFailure(stderr io.Writer, err error, secretValues []string) int {
|
||||
message := output.Sanitize(err.Error(), secretValues)
|
||||
var operationErr *serverops.OperationError
|
||||
if errors.As(err, &operationErr) && operationErr.Detail() != "" {
|
||||
detail := output.SanitizeDetail(operationErr.Detail(), secretValues)
|
||||
fmt.Fprintf(stderr, "tht: %s: %s\n", message, detail)
|
||||
} else {
|
||||
fmt.Fprintf(stderr, "tht: %s\n", message)
|
||||
}
|
||||
if errors.Is(err, serverops.ErrConfirmationRequired) || errors.Is(err, serverops.ErrUnsafeState) {
|
||||
return 2
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// installationRunner transforms only Compose invocations into the installation's validated,
|
||||
// profile-specific argument list. Direct Docker image commands remain host-side and use arguments.
|
||||
type installationRunner struct {
|
||||
installation config.Installation
|
||||
runner compose.Runner
|
||||
}
|
||||
|
||||
func (r installationRunner) SessionInventoryScope() string {
|
||||
if r.installation.Profile == "local" {
|
||||
return "mine"
|
||||
}
|
||||
return "all"
|
||||
}
|
||||
|
||||
func (r installationRunner) Run(ctx context.Context, args []string, stdin io.Reader) (compose.Result, error) {
|
||||
if len(args) > 0 && args[0] == "compose" {
|
||||
return r.runner.Run(ctx, r.installation.ComposeArgs(args[1:]...), stdin)
|
||||
}
|
||||
return r.runner.Run(ctx, args, stdin)
|
||||
}
|
||||
|
||||
func piCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, secretValues []string, stdout, stderr io.Writer) int {
|
||||
if len(args) == 0 {
|
||||
return commandUsageError(stderr, "pi requires a subcommand")
|
||||
}
|
||||
controlled := installationRunner{installation: installation, runner: runner}
|
||||
switch args[0] {
|
||||
case "status":
|
||||
if len(args) != 1 {
|
||||
return commandUsageError(stderr, "pi status does not accept arguments")
|
||||
}
|
||||
version, err := pi.Status(ctx, controlled)
|
||||
if err != nil {
|
||||
return piFailure(stderr, err, secretValues)
|
||||
}
|
||||
fmt.Fprintf(stdout, "Pi version: %s\n", output.Sanitize(version, secretValues))
|
||||
return 0
|
||||
case "doctor":
|
||||
if len(args) != 1 {
|
||||
return commandUsageError(stderr, "pi doctor does not accept arguments")
|
||||
}
|
||||
if err := pi.Doctor(ctx, controlled); err != nil {
|
||||
return piFailure(stderr, err, secretValues)
|
||||
}
|
||||
fmt.Fprintln(stdout, "Pi preflight checks passed.")
|
||||
return 0
|
||||
case "test", "check":
|
||||
if len(args) != 1 {
|
||||
return commandUsageError(stderr, "pi test does not accept arguments")
|
||||
}
|
||||
if err := pi.Test(ctx, controlled); err != nil {
|
||||
return piFailure(stderr, err, secretValues)
|
||||
}
|
||||
fmt.Fprintln(stdout, "Pi/core smoke checks passed.")
|
||||
return 0
|
||||
case "logs":
|
||||
if len(args) != 1 {
|
||||
return commandUsageError(stderr, "pi logs does not support --follow; use bounded snapshots")
|
||||
}
|
||||
logArgs := []string{"logs", "--tail", "200", "core"}
|
||||
result, err := controlled.Run(ctx, append([]string{"compose"}, logArgs...), nil)
|
||||
return writeResult(result, err, secretValues, stdout, stderr)
|
||||
case "configure":
|
||||
authFile, authErr := installation.EnvironmentValue("PI_AUTH_FILE")
|
||||
if authErr != nil || strings.TrimSpace(authFile) == "" {
|
||||
return commandUsageError(stderr, "PI_AUTH_FILE must name the actual protected host credential file")
|
||||
}
|
||||
defaults, err := resolvePiConfigure(ctx, controlled, args[1:], os.Stdin, stdout, stdinIsTTY(os.Stdin))
|
||||
if err != nil {
|
||||
return commandUsageError(stderr, err.Error())
|
||||
}
|
||||
if err := pi.Configure(ctx, controlled, defaults); err != nil {
|
||||
return piFailure(stderr, err, secretValues)
|
||||
}
|
||||
fmt.Fprintf(stdout, "Pi defaults applied and read back. Provider credentials remain only in the host file %s (mode 0600). Never pass credentials to tht.\n", authFile)
|
||||
return 0
|
||||
case "restart":
|
||||
request, err := parsePiRestartArgs(
|
||||
args[1:],
|
||||
installation.RestartStatePath(),
|
||||
installation.UpdateStatePath(),
|
||||
)
|
||||
if err != nil {
|
||||
return commandUsageError(stderr, err.Error())
|
||||
}
|
||||
result, err := pi.Restart(ctx, controlled, request)
|
||||
if err != nil {
|
||||
return piFailure(stderr, err, secretValues)
|
||||
}
|
||||
fmt.Fprintf(stdout, "Pi core restarted with the existing image; version %s readiness and smoke checks passed.\n", output.Sanitize(result.Version, secretValues))
|
||||
return 0
|
||||
case "update":
|
||||
request, err := parsePiUpdateArgs(
|
||||
args[1:],
|
||||
installation.UpdateStatePath(),
|
||||
installation.RestartStatePath(),
|
||||
)
|
||||
if err != nil {
|
||||
return commandUsageError(stderr, err.Error())
|
||||
}
|
||||
result, err := pi.Update(ctx, controlled, request)
|
||||
if err != nil {
|
||||
return piFailure(stderr, err, secretValues)
|
||||
}
|
||||
if result.Phase == pi.PhaseNoop {
|
||||
fmt.Fprintf(stdout, "Pi already runs requested version %s; no container was recreated.\n", request.Version)
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stdout, "Pi update verified. Recovery metadata: %s\n", result.StatePath)
|
||||
return 0
|
||||
case "rollback":
|
||||
if len(args) != 2 || args[1] != "--yes" {
|
||||
return commandUsageError(stderr, "pi rollback requires --yes")
|
||||
}
|
||||
result, err := pi.Rollback(
|
||||
ctx,
|
||||
controlled,
|
||||
installation.UpdateStatePath(),
|
||||
installation.RestartStatePath(),
|
||||
true,
|
||||
)
|
||||
if err != nil {
|
||||
return piFailure(stderr, err, secretValues)
|
||||
}
|
||||
fmt.Fprintf(stdout, "Pi rollback restored the recorded core image. Recovery metadata: %s\n", result.StatePath)
|
||||
return 0
|
||||
case "maintenance":
|
||||
if len(args) == 2 && args[1] == "status" {
|
||||
status, err := pi.MaintenanceStatus(ctx, controlled)
|
||||
if err != nil {
|
||||
return piFailure(stderr, err, secretValues)
|
||||
}
|
||||
fmt.Fprintf(stdout, "Pi maintenance active: %t (admissions: %d)\n", status.Active, status.Admissions)
|
||||
return 0
|
||||
}
|
||||
if len(args) == 3 && args[1] == "recover" && args[2] == "--yes" {
|
||||
if err := pi.RecoverLifecycleMaintenance(
|
||||
ctx,
|
||||
controlled,
|
||||
installation.UpdateStatePath(),
|
||||
installation.RestartStatePath(),
|
||||
true,
|
||||
); err != nil {
|
||||
return piFailure(stderr, err, secretValues)
|
||||
}
|
||||
fmt.Fprintln(stdout, "Pi maintenance recovery verified; stale lifecycle files were removed and admissions are open.")
|
||||
return 0
|
||||
}
|
||||
return commandUsageError(stderr, "pi maintenance requires status or recover --yes")
|
||||
default:
|
||||
return commandUsageError(stderr, fmt.Sprintf("unknown pi command %q", args[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func resolvePiConfigure(
|
||||
ctx context.Context,
|
||||
runner pi.Runner,
|
||||
args []string,
|
||||
input io.Reader,
|
||||
prompt io.Writer,
|
||||
isTTY bool,
|
||||
) (pi.Defaults, error) {
|
||||
if len(args) > 0 {
|
||||
return parsePiConfigureArgs(args)
|
||||
}
|
||||
if !isTTY {
|
||||
return pi.Defaults{}, errors.New("non-interactive pi configure requires --provider --model --thinking")
|
||||
}
|
||||
options, err := pi.ConfigurationOptions(ctx, runner)
|
||||
if err != nil {
|
||||
return pi.Defaults{}, err
|
||||
}
|
||||
providers := uniqueProviders(options)
|
||||
scanner := bufio.NewScanner(input)
|
||||
provider, err := numberedChoice(scanner, prompt, "provider", providers)
|
||||
if err != nil {
|
||||
return pi.Defaults{}, err
|
||||
}
|
||||
models := make([]string, 0)
|
||||
for _, option := range options {
|
||||
if option.Provider == provider {
|
||||
models = append(models, option.ID)
|
||||
}
|
||||
}
|
||||
model, err := numberedChoice(scanner, prompt, "model", models)
|
||||
if err != nil {
|
||||
return pi.Defaults{}, err
|
||||
}
|
||||
thinking, err := numberedChoice(scanner, prompt, "thinking level", []string{"low", "medium", "high"})
|
||||
if err != nil {
|
||||
return pi.Defaults{}, err
|
||||
}
|
||||
return pi.Defaults{Provider: provider, Model: model, Thinking: thinking}, nil
|
||||
}
|
||||
|
||||
func uniqueProviders(options []pi.ModelOption) []string {
|
||||
seen := make(map[string]bool)
|
||||
providers := make([]string, 0)
|
||||
for _, option := range options {
|
||||
if !seen[option.Provider] {
|
||||
seen[option.Provider] = true
|
||||
providers = append(providers, option.Provider)
|
||||
}
|
||||
}
|
||||
return providers
|
||||
}
|
||||
|
||||
func numberedChoice(scanner *bufio.Scanner, output io.Writer, label string, choices []string) (string, error) {
|
||||
if len(choices) == 0 {
|
||||
return "", fmt.Errorf("Pi returned no %s choices", label)
|
||||
}
|
||||
fmt.Fprintf(output, "Select %s:\n", label)
|
||||
for index, choice := range choices {
|
||||
fmt.Fprintf(output, " %d) %s\n", index+1, choice)
|
||||
}
|
||||
for {
|
||||
fmt.Fprintf(output, "Choice [1-%d]: ", len(choices))
|
||||
if !scanner.Scan() {
|
||||
return "", fmt.Errorf("interactive %s selection ended before a choice was entered", label)
|
||||
}
|
||||
selected, err := strconv.Atoi(strings.TrimSpace(scanner.Text()))
|
||||
if err == nil && selected >= 1 && selected <= len(choices) {
|
||||
return choices[selected-1], nil
|
||||
}
|
||||
fmt.Fprintln(output, "Enter one of the listed numbers.")
|
||||
}
|
||||
}
|
||||
|
||||
func stdinIsTTY(input *os.File) bool {
|
||||
info, err := input.Stat()
|
||||
return err == nil && info.Mode()&os.ModeCharDevice != 0
|
||||
}
|
||||
|
||||
func parsePiConfigureArgs(args []string) (pi.Defaults, error) {
|
||||
var value pi.Defaults
|
||||
for len(args) > 0 {
|
||||
if len(args) < 2 {
|
||||
return pi.Defaults{}, errors.New("configure options require values")
|
||||
}
|
||||
key, v := args[0], args[1]
|
||||
args = args[2:]
|
||||
switch key {
|
||||
case "--provider":
|
||||
value.Provider = v
|
||||
case "--model":
|
||||
value.Model = v
|
||||
case "--thinking":
|
||||
value.Thinking = v
|
||||
default:
|
||||
return pi.Defaults{}, fmt.Errorf("unknown pi configure option %q", key)
|
||||
}
|
||||
}
|
||||
if value.Provider == "" || value.Model == "" || value.Thinking == "" {
|
||||
return pi.Defaults{}, errors.New("pi configure requires --provider --model --thinking; THT_LLM_URL stays Compose-managed")
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func parsePiUpdateArgs(args []string, statePath, restartStatePath string) (pi.Request, error) {
|
||||
request := pi.Request{StatePath: statePath, RestartStatePath: restartStatePath}
|
||||
for len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "--version":
|
||||
if len(args) < 2 || request.Version != "" {
|
||||
return pi.Request{}, errors.New("pi update requires one --version <pinned-version>")
|
||||
}
|
||||
request.Version, args = args[1], args[2:]
|
||||
case "--source":
|
||||
if len(args) < 2 {
|
||||
return pi.Request{}, errors.New("--source requires build or pull")
|
||||
}
|
||||
request.Source, args = pi.Source(args[1]), args[2:]
|
||||
case "--image":
|
||||
if len(args) < 2 || request.Image != "" {
|
||||
return pi.Request{}, errors.New("--image requires one digest-pinned image reference")
|
||||
}
|
||||
request.Image, args = args[1], args[2:]
|
||||
case "--yes":
|
||||
if request.Confirm {
|
||||
return pi.Request{}, errors.New("--yes may be supplied once")
|
||||
}
|
||||
request.Confirm, args = true, args[1:]
|
||||
case "--drain":
|
||||
if request.Drain {
|
||||
return pi.Request{}, errors.New("--drain may be supplied once")
|
||||
}
|
||||
request.Drain, args = true, args[1:]
|
||||
default:
|
||||
return pi.Request{}, fmt.Errorf("unknown pi update option %q", args[0])
|
||||
}
|
||||
}
|
||||
if request.Version == "" {
|
||||
return pi.Request{}, errors.New("pi update requires --version <pinned-version>")
|
||||
}
|
||||
if request.Source == "" {
|
||||
return pi.Request{}, errors.New("pi update requires explicit --source build or pull")
|
||||
}
|
||||
if request.Source != pi.BuildSource && request.Source != pi.PullSource {
|
||||
return pi.Request{}, errors.New("--source requires build or pull")
|
||||
}
|
||||
if request.Source == pi.PullSource && request.Image == "" {
|
||||
return pi.Request{}, errors.New("--source pull requires --image <digest-reference>")
|
||||
}
|
||||
if request.Source == pi.BuildSource && request.Image != "" {
|
||||
return pi.Request{}, errors.New("--image is valid only with --source pull")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func parsePiRestartArgs(args []string, restartStatePath, updateStatePath string) (pi.RestartRequest, error) {
|
||||
request := pi.RestartRequest{StatePath: restartStatePath, UpdateStatePath: updateStatePath}
|
||||
for len(args) > 0 {
|
||||
switch args[0] {
|
||||
case "--yes":
|
||||
if request.Confirm {
|
||||
return pi.RestartRequest{}, errors.New("--yes may be supplied once")
|
||||
}
|
||||
request.Confirm, args = true, args[1:]
|
||||
case "--drain":
|
||||
if request.Drain {
|
||||
return pi.RestartRequest{}, errors.New("--drain may be supplied once")
|
||||
}
|
||||
request.Drain, args = true, args[1:]
|
||||
default:
|
||||
return pi.RestartRequest{}, errors.New("unknown pi restart option")
|
||||
}
|
||||
}
|
||||
if !request.Confirm {
|
||||
return pi.RestartRequest{}, errors.New("pi restart requires --yes")
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
func piFailure(stderr io.Writer, err error, secretValues []string) int {
|
||||
code := 1
|
||||
if errors.Is(err, pi.ErrConfirmationRequired) || errors.Is(err, pi.ErrInvalidRequest) || errors.Is(err, pi.ErrActiveSessions) || errors.Is(err, pi.ErrInterruptedUpdate) || errors.Is(err, pi.ErrInterruptedRestart) {
|
||||
code = 2
|
||||
}
|
||||
var childExit interface{ ExitCode() int }
|
||||
if errors.As(err, &childExit) && childExit.ExitCode() != 0 {
|
||||
code = childExit.ExitCode()
|
||||
}
|
||||
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), secretValues))
|
||||
return code
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
return nil, errors.New("logs does not accept arguments; use bounded snapshots")
|
||||
}
|
||||
|
||||
func commandUsageError(stderr io.Writer, message string) int {
|
||||
fmt.Fprintf(stderr, "tht: %s\n", message)
|
||||
return 2
|
||||
}
|
||||
|
||||
func writeResult(result compose.Result, err error, secretValues []string, stdout, stderr io.Writer) int {
|
||||
if result.Stdout != "" {
|
||||
fmt.Fprint(stdout, output.Sanitize(result.Stdout, secretValues))
|
||||
}
|
||||
if result.Stderr != "" {
|
||||
fmt.Fprint(stderr, output.Sanitize(result.Stderr, secretValues))
|
||||
}
|
||||
if err == nil {
|
||||
return 0
|
||||
}
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
fmt.Fprintln(stderr, "tht: 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, secretValues []string, 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, secretValues, stdout, stderr)
|
||||
}
|
||||
if index == 3 {
|
||||
renderedConfig = result.Stdout
|
||||
}
|
||||
if index == 4 {
|
||||
status = result.Stdout
|
||||
}
|
||||
}
|
||||
if err := requireLF(installation.ProjectDirectory); err != nil {
|
||||
fmt.Fprintf(stderr, "tht: %s\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := requireVolumes(renderedConfig); err != nil {
|
||||
fmt.Fprintf(stderr, "tht: %s\n", err)
|
||||
return 1
|
||||
}
|
||||
if err := requireHealthyServices(status); err != nil {
|
||||
fmt.Fprintf(stderr, "tht: %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() || entry.Type()&os.ModeSymlink != 0 || !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 {
|
||||
type serviceStatus struct {
|
||||
Service string `json:"Service"`
|
||||
State string `json:"State"`
|
||||
Health string `json:"Health"`
|
||||
}
|
||||
var services []serviceStatus
|
||||
if err := json.Unmarshal([]byte(status), &services); err != nil {
|
||||
decoder := json.NewDecoder(strings.NewReader(status))
|
||||
for {
|
||||
var service serviceStatus
|
||||
if err := decoder.Decode(&service); errors.Is(err, io.EOF) {
|
||||
break
|
||||
} else if err != nil {
|
||||
return errors.New("Compose returned invalid service status")
|
||||
}
|
||||
services = append(services, service)
|
||||
}
|
||||
if len(services) == 0 {
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user