feat: manage embedded pi with thothctl
This commit is contained in:
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user