feat(cli): add version diagnostics and build-aware start
This commit is contained in:
+88
-130
@@ -16,11 +16,14 @@ import (
|
||||
|
||||
"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/output"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/pi"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/project"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/serverops"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/service"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/setup"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/version"
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/workspaceops"
|
||||
)
|
||||
|
||||
@@ -32,10 +35,11 @@ descriptor in the current project tree.
|
||||
Commands:
|
||||
setup [--configure-only] [--installation-id ID] [--profile local|server]
|
||||
Create or validate the local non-secret installation configuration.
|
||||
version [--json] Show the host CLI build identity.
|
||||
status Show the Compose service state.
|
||||
doctor Validate Docker, Compose, rendered configuration, line endings, volumes, and health.
|
||||
doctor [--json] Run non-mutating host, Compose, workflow, and Pi diagnostics.
|
||||
logs Show the latest 200 sanitized service log lines (bounded; no follow mode).
|
||||
start Start the installation in the background.
|
||||
start [--build] Start the installation; --build builds current-checkout images first.
|
||||
stop Stop the installation.
|
||||
update --check-only Validate the current installation without changing containers.
|
||||
sessions migrate --yes
|
||||
@@ -93,6 +97,9 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
if command == "setup" {
|
||||
return setupCommand(ctx, installationPath, commandArgs, stdout, stderr)
|
||||
}
|
||||
if command == "version" {
|
||||
return versionCommand(commandArgs, stdout, stderr)
|
||||
}
|
||||
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
|
||||
}
|
||||
workingDirectory, err := os.Getwd()
|
||||
@@ -136,10 +143,15 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
result, err = runner.Run(ctx, installation.ComposeArgs(logArgs...), nil)
|
||||
case "start":
|
||||
if len(commandArgs) != 0 {
|
||||
return commandUsageError(stderr, "start does not accept arguments")
|
||||
build, argumentError := startArgs(commandArgs)
|
||||
if argumentError != nil {
|
||||
return commandUsageError(stderr, argumentError.Error())
|
||||
}
|
||||
result, err = runner.Run(ctx, installation.ComposeArgs("up", "--detach", "--remove-orphans"), nil)
|
||||
if err := service.Start(ctx, installation, runner, build); err != nil {
|
||||
return lifecycleFailure(stderr, err, secretValues)
|
||||
}
|
||||
fmt.Fprintln(stdout, "ThothII services are healthy.")
|
||||
return 0
|
||||
case "stop":
|
||||
if len(commandArgs) != 0 {
|
||||
return commandUsageError(stderr, "stop does not accept arguments")
|
||||
@@ -151,10 +163,7 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
|
||||
}
|
||||
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)
|
||||
return doctorCommand(ctx, installation, runner, commandArgs, stdout, stderr)
|
||||
case "pi":
|
||||
return piCommand(ctx, installation, runner, commandArgs, secretValues, stdout, stderr)
|
||||
case "sessions":
|
||||
@@ -762,6 +771,76 @@ func commandUsageError(stderr io.Writer, message string) int {
|
||||
return 2
|
||||
}
|
||||
|
||||
func versionCommand(args []string, stdout, stderr io.Writer) int {
|
||||
jsonMode := false
|
||||
if len(args) == 1 && args[0] == "--json" {
|
||||
jsonMode = true
|
||||
} else if len(args) != 0 {
|
||||
return commandUsageError(stderr, "version accepts only --json")
|
||||
}
|
||||
info := version.Current()
|
||||
if jsonMode {
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(info); err != nil {
|
||||
fmt.Fprintln(stderr, "tht: version output could not be written")
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
fmt.Fprintf(stdout, "tht %s\ncommit: %s\nbuilt: %s\nplatform: %s/%s\n", info.Version, info.Commit, info.BuildTime, info.OS, info.Arch)
|
||||
return 0
|
||||
}
|
||||
|
||||
func startArgs(args []string) (bool, error) {
|
||||
if len(args) == 0 {
|
||||
return false, nil
|
||||
}
|
||||
if len(args) == 1 && args[0] == "--build" {
|
||||
return true, nil
|
||||
}
|
||||
return false, errors.New("start accepts only --build")
|
||||
}
|
||||
|
||||
func doctorCommand(ctx context.Context, installation config.Installation, runner compose.Runner, args []string, stdout, stderr io.Writer) int {
|
||||
jsonMode := false
|
||||
if len(args) == 1 && args[0] == "--json" {
|
||||
jsonMode = true
|
||||
} else if len(args) != 0 {
|
||||
return commandUsageError(stderr, "doctor accepts only --json")
|
||||
}
|
||||
report, err := doctor.Run(ctx, installation, runner)
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), nil))
|
||||
return 1
|
||||
}
|
||||
if jsonMode {
|
||||
encoder := json.NewEncoder(stdout)
|
||||
encoder.SetEscapeHTML(false)
|
||||
if err := encoder.Encode(report); err != nil {
|
||||
fmt.Fprintln(stderr, "tht: doctor report could not be written")
|
||||
return 1
|
||||
}
|
||||
} else {
|
||||
for _, check := range report.Checks {
|
||||
fmt.Fprintf(stdout, "%s: %s", check.Name, check.Status)
|
||||
if check.Detail != "" {
|
||||
fmt.Fprintf(stdout, " - %s", check.Detail)
|
||||
}
|
||||
fmt.Fprintln(stdout)
|
||||
}
|
||||
}
|
||||
if report.OK {
|
||||
return 0
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
func lifecycleFailure(stderr io.Writer, err error, secretValues []string) int {
|
||||
fmt.Fprintf(stderr, "tht: %s\n", output.Sanitize(err.Error(), secretValues))
|
||||
return 1
|
||||
}
|
||||
|
||||
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))
|
||||
@@ -780,124 +859,3 @@ func writeResult(result compose.Result, err error, secretValues []string, stdout
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Catches version regressing into a descriptor-dependent operator command.
|
||||
func TestVersionCommandIsDescriptorFreeAndMachineReadable(t *testing.T) {
|
||||
projectRoot := newProjectWithoutInstallation(t)
|
||||
previousDirectory, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chdir(filepath.Join(projectRoot, "frontend")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chdir(previousDirectory) })
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(context.Background(), []string{"version", "--json"}, &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("run(version --json) = %d, stderr = %q", code, stderr.String())
|
||||
}
|
||||
var value struct {
|
||||
Version string `json:"version"`
|
||||
Commit string `json:"commit"`
|
||||
OS string `json:"os"`
|
||||
Arch string `json:"arch"`
|
||||
}
|
||||
decoder := json.NewDecoder(&stdout)
|
||||
if err := decoder.Decode(&value); err != nil || value.Version == "" || value.Commit == "" || value.OS == "" || value.Arch == "" {
|
||||
t.Fatalf("version JSON = %q, decoded %#v, err = %v", stdout.String(), value, err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
t.Fatalf("version stdout contains more than one JSON document: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Catches doctor --json writing a banner, progress, or Docker diagnostics to stdout.
|
||||
func TestDoctorJSONWritesOnlyOneReportDocument(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
||||
fixture.setEnvironment(t)
|
||||
fullHealth := `[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"},{"Service":"qdrant","State":"running","Health":"healthy"},{"Service":"embedding","State":"running","Health":"healthy"},{"Service":"embedding-model-init","State":"exited","ExitCode":0}]`
|
||||
t.Setenv("THT_FAKE_PS", fullHealth)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"--installation", fixture.installationPath, "doctor", "--json"}, &stdout, &stderr)
|
||||
if code != 0 && code != 1 {
|
||||
t.Fatalf("run(doctor --json) = %d, stderr = %q", code, stderr.String())
|
||||
}
|
||||
var report struct {
|
||||
OK *bool `json:"ok"`
|
||||
Checks []json.RawMessage `json:"checks"`
|
||||
}
|
||||
decoder := json.NewDecoder(&stdout)
|
||||
if err := decoder.Decode(&report); err != nil || report.OK == nil || report.Checks == nil {
|
||||
t.Fatalf("doctor JSON = %q, decoded %#v, err = %v", stdout.String(), report, err)
|
||||
}
|
||||
if err := decoder.Decode(&struct{}{}); err != io.EOF {
|
||||
t.Fatalf("doctor stdout contains more than one JSON document: %q", stdout.String())
|
||||
}
|
||||
if strings.Contains(stdout.String(), "Doctor checks passed") || strings.Contains(stdout.String(), "tht:") {
|
||||
t.Fatalf("doctor JSON stdout contains presentation text: %q", stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Catches start --build being parsed as an argument but never reaching the shared lifecycle runner.
|
||||
func TestStartBuildOptionBuildsBeforeUpWhilePlainStartDoesNotBuild(t *testing.T) {
|
||||
fullHealth := `[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"},{"Service":"qdrant","State":"running","Health":"healthy"},{"Service":"embedding","State":"running","Health":"healthy"},{"Service":"embedding-model-init","State":"exited","ExitCode":0}]`
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
args []string
|
||||
wantBuild bool
|
||||
}{
|
||||
{name: "plain", args: []string{"start"}, wantBuild: false},
|
||||
{name: "build", args: []string{"start", "--build"}, wantBuild: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
fixture := newCLIFixture(t, "SAFE_VALUE=1\n")
|
||||
fixture.setEnvironment(t)
|
||||
t.Setenv("THT_FAKE_PS", fullHealth)
|
||||
var stdout, stderr bytes.Buffer
|
||||
if code := run(context.Background(), append([]string{"--installation", fixture.installationPath}, test.args...), &stdout, &stderr); code != 0 {
|
||||
t.Fatalf("run(%v) = %d, stderr = %q", test.args, code, stderr.String())
|
||||
}
|
||||
calls := fixture.invocations(t)
|
||||
joined := make([]string, 0, len(calls))
|
||||
for _, call := range calls {
|
||||
joined = append(joined, strings.Join(call, " "))
|
||||
}
|
||||
sequence := strings.Join(joined, "\n")
|
||||
hasBuild := strings.Contains(sequence, " build")
|
||||
if hasBuild != test.wantBuild {
|
||||
t.Fatalf("start calls = %s, build present = %t, want %t", sequence, hasBuild, test.wantBuild)
|
||||
}
|
||||
if test.wantBuild && strings.Index(sequence, " build") > strings.Index(sequence, " up --detach --remove-orphans") {
|
||||
t.Fatalf("start calls = %s, want build before up", sequence)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -53,8 +53,8 @@ func TestRootCommandIdentity(t *testing.T) {
|
||||
{
|
||||
name: "version path",
|
||||
args: []string{"--installation", fixture.installationPath, "version"},
|
||||
wantCode: 2,
|
||||
wantText: `tht: unknown command "version"`,
|
||||
wantCode: 0,
|
||||
wantText: "tht 0.0.0-dev",
|
||||
},
|
||||
{
|
||||
name: "retired command is not an alias",
|
||||
@@ -1265,6 +1265,7 @@ case " $* " in
|
||||
exit "$THT_FAKE_MIGRATION_EXIT"
|
||||
fi
|
||||
printf '%s\n' '{"applied":[],"drifted":[],"pending":[]}' ;;
|
||||
*" ps --all --format json "*) printf '%s\n' "$THT_FAKE_PS" ;;
|
||||
*" ps --format json "*) printf '%s\n' "$THT_FAKE_PS" ;;
|
||||
*" ps -q core "*) printf '%s\n' 'core-id' ;;
|
||||
*" image inspect --format {{.Id}} "*) printf '%s\n' "${THT_FAKE_IMAGE_ID:-sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb}" ;;
|
||||
|
||||
Reference in New Issue
Block a user