feat: manage embedded pi with thothctl

This commit is contained in:
2026-08-04 18:20:39 +02:00
parent c90299f24d
commit 20e59b8d32
8 changed files with 1122 additions and 1 deletions
+147
View File
@@ -15,6 +15,7 @@ import (
"github.com/aritmolab/thothii/tools/thothctl/internal/compose"
"github.com/aritmolab/thothii/tools/thothctl/internal/config"
"github.com/aritmolab/thothii/tools/thothctl/internal/output"
"github.com/aritmolab/thothii/tools/thothctl/internal/pi"
)
const usage = `Usage: thothctl --installation <absolute-path>/thothii-installation.yaml <command>
@@ -26,6 +27,12 @@ Commands:
start Start the installation in the background.
stop Stop the installation.
update --check-only Validate the current installation without changing containers.
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 update Rebuild or pull a pinned Pi image (requires --yes).
pi rollback --yes Restore the image recorded by the latest Pi update.
pi logs [--follow] Show sanitized core logs.
`
func main() {
@@ -92,12 +99,152 @@ func run(ctx context.Context, args []string, stdout, stderr io.Writer) int {
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)
default:
return commandUsageError(stderr, fmt.Sprintf("unknown command %q", command))
}
return writeResult(result, err, secretValues, stdout, stderr)
}
// 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) 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":
logArgs, err := logsArgs(args[1:])
if err != nil {
return commandUsageError(stderr, "pi logs accepts only --follow")
}
logArgs = append(logArgs, "core")
result, err := controlled.Run(ctx, append([]string{"compose"}, logArgs...), nil)
return writeResult(result, err, secretValues, stdout, stderr)
case "update":
request, err := parsePiUpdateArgs(args[1:], filepath.Join(installation.ProjectDirectory, ".thothctl", "update-state.json"))
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, filepath.Join(installation.ProjectDirectory, ".thothctl", "update-state.json"), 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
default:
return commandUsageError(stderr, fmt.Sprintf("unknown pi command %q", args[0]))
}
}
func parsePiUpdateArgs(args []string, statePath string) (pi.Request, error) {
request := pi.Request{StatePath: statePath, Source: pi.BuildSource}
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>")
}
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.ErrActiveSessions) || errors.Is(err, pi.ErrInterruptedUpdate) {
code = 2
}
var childExit interface{ ExitCode() int }
if errors.As(err, &childExit) && childExit.ExitCode() != 0 {
code = childExit.ExitCode()
}
fmt.Fprintf(stderr, "thothctl: %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")
+70 -1
View File
@@ -347,6 +347,59 @@ func TestRunPreservesChildExitCodes(t *testing.T) {
}
}
func TestRunPiStatusUsesImageBundledPi(t *testing.T) {
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
fixture.setEnvironment(t)
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "pi", "status"}, &stdout, &stderr)
if exitCode != 0 {
t.Fatalf("run() exit code = %d, stderr = %s", exitCode, stderr.String())
}
if stdout.String() != "Pi version: 0.80.3\n" {
t.Errorf("stdout = %q, want image-bundled Pi version", stdout.String())
}
assertInvocationContains(t, fixture.invocations(t), "exec", "-T", "core", "pi", "--version")
}
func TestRunPiUpdateRequiresExplicitConfirmationWithoutInvokingDocker(t *testing.T) {
fixture := newCLIFixture(t, "THT_LLM_URL=https://llm.example.invalid\n")
fixture.setEnvironment(t)
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "pi", "update", "--version", "0.81.0"}, &stdout, &stderr)
if exitCode != 2 {
t.Errorf("run() exit code = %d, want 2", exitCode)
}
if !strings.Contains(stderr.String(), "requires --yes") {
t.Errorf("stderr = %q, want explicit confirmation guidance", stderr.String())
}
assertDockerNotInvoked(t, fixture)
}
func TestRunPiStatusPreservesDockerExitCodeAndRedactsDiagnostics(t *testing.T) {
fixture := newCLIFixture(t, "PI_TOKEN_FILE=%s\n")
secretPath := filepath.Join(fixture.root, "pi-secret")
if err := os.WriteFile(secretPath, []byte("pi-status-secret"), 0o600); err != nil {
t.Fatal(err)
}
fixture.setEnvironment(t, secretPath)
t.Setenv("THOTHCTL_FAKE_FAIL_ON", "version")
t.Setenv("THOTHCTL_FAKE_FAILURE", "pi-status-secret")
var stdout, stderr bytes.Buffer
exitCode := run(context.Background(), []string{"--installation", fixture.installationPath, "pi", "status"}, &stdout, &stderr)
if exitCode != 41 {
t.Errorf("run() exit code = %d, want 41", exitCode)
}
if strings.Contains(stdout.String()+stderr.String(), "pi-status-secret") {
t.Errorf("Pi status exposed a secret: stdout=%q stderr=%q", stdout.String(), stderr.String())
}
}
type cliFixture struct {
root string
installationPath string
@@ -392,8 +445,9 @@ func newCLIFixture(t *testing.T, envTemplate string) cliFixture {
printf '%s\n' "$@" >> "$THOTHCTL_FAKE_ARGS"
printf '%s\n' -- >> "$THOTHCTL_FAKE_ARGS"
case " $* " in
*" config --format json "*) printf '%s\n' '{"volumes":{"settings":{}}}' ;;
*" config --format json "*) printf '%s\n' '{"volumes":{"settings":{}},"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"https://llm.example.invalid"}}}}' ;;
*" ps --format json "*) printf '%s\n' '[{"Service":"core","State":"running","Health":"healthy"},{"Service":"frontend","State":"running","Health":"healthy"}]' ;;
*" pi --version "*) printf '%s\n' '0.80.3' ;;
*" logs "*) printf '%s\n' "$THOTHCTL_FAKE_LOG" ;;
esac
if [ "${THOTHCTL_FAKE_FAIL_ON:-}" = "version" ]; then
@@ -458,3 +512,18 @@ func assertDockerNotInvoked(t *testing.T, fixture cliFixture) {
t.Errorf("Docker was invoked: stat error = %v", err)
}
}
func assertInvocationContains(t *testing.T, invocations [][]string, want ...string) {
t.Helper()
for _, invocation := range invocations {
for start := range invocation {
if len(invocation)-start < len(want) {
continue
}
if strings.Join(invocation[start:start+len(want)], "\x00") == strings.Join(want, "\x00") {
return
}
}
}
t.Fatalf("invocations = %#v, want %#v", invocations, want)
}