refactor(cli): rename operator command to tht
This commit is contained in:
@@ -0,0 +1,365 @@
|
||||
package pi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
||||
)
|
||||
|
||||
var choicePattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$`)
|
||||
|
||||
type Defaults struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Thinking string `json:"thinking"`
|
||||
}
|
||||
|
||||
type ModelOption struct {
|
||||
Provider string `json:"provider"`
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type piOptions struct {
|
||||
Providers []string `json:"providers"`
|
||||
Models []ModelOption `json:"models"`
|
||||
Reasoning []string `json:"reasoning"`
|
||||
}
|
||||
|
||||
type settingsFileSnapshot struct {
|
||||
Exists bool `json:"exists"`
|
||||
RawBase64 string `json:"rawBase64"`
|
||||
}
|
||||
|
||||
var internalIdentityHeaders = []string{
|
||||
"-H", "x-thoth-principal-issuer: tht",
|
||||
"-H", "x-thoth-principal-subject: tht-maintenance",
|
||||
"-H", "x-thoth-principal-display-name: Tht maintenance",
|
||||
"-H", "x-thoth-is-admin: 1",
|
||||
}
|
||||
|
||||
// Configure changes the backend's real installation settings through a core-side helper. It
|
||||
// deliberately has no secret or endpoint input: external endpoints remain Compose-owned.
|
||||
func Configure(ctx context.Context, runner Runner, value Defaults) error {
|
||||
if !choicePattern.MatchString(value.Provider) || !choicePattern.MatchString(value.Model) {
|
||||
return errors.New("provider and model must be supported identifiers")
|
||||
}
|
||||
before, err := renderedCore(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
options, err := configurationOptions(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, model := range options.Models {
|
||||
if model.Provider == value.Provider && model.ID == value.Model {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return errors.New("provider/model is not in Pi options")
|
||||
}
|
||||
thinkingFound := false
|
||||
for _, reasoning := range options.Reasoning {
|
||||
if reasoning == value.Thinking {
|
||||
thinkingFound = true
|
||||
}
|
||||
}
|
||||
if !thinkingFound {
|
||||
return errors.New("thinking is not in Pi options")
|
||||
}
|
||||
old, err := captureSettingsFile(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
oldEffective, err := readEffectiveSettings(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
restore := func(cause error) error {
|
||||
if restoreErr := restoreSettingsFile(context.Background(), runner, old); restoreErr != nil {
|
||||
return fmt.Errorf("%w; previous Pi settings restoration could not be verified: %w", cause, restoreErr)
|
||||
}
|
||||
restoredEffective, restoreErr := readEffectiveSettings(context.Background(), runner)
|
||||
if restoreErr != nil || !bytes.Equal(restoredEffective, oldEffective) {
|
||||
return fmt.Errorf("%w; previous effective Pi settings could not be verified: recovery required", cause)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
result, err := writeDefaults(ctx, runner, value)
|
||||
if err != nil {
|
||||
return restore(commandError("Pi installation settings write", result, err))
|
||||
}
|
||||
settings, err := readEffectiveSettings(ctx, runner)
|
||||
if err != nil {
|
||||
return restore(err)
|
||||
}
|
||||
var saved Defaults
|
||||
if json.Unmarshal(settings, &saved) != nil || saved.Provider != value.Provider || saved.Model != value.Model || saved.Thinking != value.Thinking {
|
||||
return restore(errors.New("Pi installation settings read-back did not match requested provider, model, and thinking"))
|
||||
}
|
||||
after, err := renderedCore(ctx, runner)
|
||||
if err != nil {
|
||||
return restore(err)
|
||||
}
|
||||
if before.ConfigurationSHA != after.ConfigurationSHA {
|
||||
return restore(errors.New("external endpoint configuration changed while configuring Pi"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ConfigurationOptions(ctx context.Context, runner Runner) ([]ModelOption, error) {
|
||||
options, err := configurationOptions(ctx, runner)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return options.Models, nil
|
||||
}
|
||||
|
||||
func configurationOptions(ctx context.Context, runner Runner) (piOptions, error) {
|
||||
args := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...)
|
||||
args = append(args, "http://127.0.0.1:8787/pi-management/options")
|
||||
result, err := runCompose(ctx, runner, args...)
|
||||
if err != nil {
|
||||
return piOptions{}, commandError("Pi options check", result, err)
|
||||
}
|
||||
var payload piOptions
|
||||
if json.Unmarshal([]byte(result.Stdout), &payload) != nil || len(payload.Providers) == 0 || len(payload.Models) == 0 || len(payload.Reasoning) == 0 {
|
||||
return piOptions{}, errors.New("Pi options response is invalid or empty")
|
||||
}
|
||||
providers := make(map[string]bool, len(payload.Providers))
|
||||
for _, provider := range payload.Providers {
|
||||
if !choicePattern.MatchString(provider) || providers[provider] {
|
||||
return piOptions{}, errors.New("Pi options response contains an invalid provider")
|
||||
}
|
||||
providers[provider] = true
|
||||
}
|
||||
models := make(map[string]bool, len(payload.Models))
|
||||
for _, option := range payload.Models {
|
||||
key := option.Provider + "\x00" + option.ID
|
||||
if !providers[option.Provider] || !choicePattern.MatchString(option.ID) || models[key] {
|
||||
return piOptions{}, errors.New("Pi options response contains an invalid provider/model")
|
||||
}
|
||||
models[key] = true
|
||||
}
|
||||
reasoning := make(map[string]bool, len(payload.Reasoning))
|
||||
for _, value := range payload.Reasoning {
|
||||
if (value != "low" && value != "medium" && value != "high") || reasoning[value] {
|
||||
return piOptions{}, errors.New("Pi options response contains an invalid reasoning choice")
|
||||
}
|
||||
reasoning[value] = true
|
||||
}
|
||||
return payload, nil
|
||||
}
|
||||
|
||||
func writeDefaults(ctx context.Context, runner Runner, value Defaults) (compose.Result, error) {
|
||||
return runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--provider", value.Provider, "--model", value.Model, "--thinking", value.Thinking)
|
||||
}
|
||||
|
||||
func captureSettingsFile(ctx context.Context, runner Runner) (settingsFileSnapshot, error) {
|
||||
result, err := runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--snapshot")
|
||||
if err != nil {
|
||||
return settingsFileSnapshot{}, commandError("Pi installation settings snapshot", result, err)
|
||||
}
|
||||
var snapshot settingsFileSnapshot
|
||||
if json.Unmarshal([]byte(result.Stdout), &snapshot) != nil {
|
||||
return settingsFileSnapshot{}, errors.New("Pi installation settings snapshot is invalid")
|
||||
}
|
||||
raw, decodeErr := base64.StdEncoding.DecodeString(snapshot.RawBase64)
|
||||
if decodeErr != nil || base64.StdEncoding.EncodeToString(raw) != snapshot.RawBase64 || (!snapshot.Exists && len(raw) != 0) {
|
||||
return settingsFileSnapshot{}, errors.New("Pi installation settings snapshot is invalid")
|
||||
}
|
||||
return snapshot, nil
|
||||
}
|
||||
|
||||
func restoreSettingsFile(ctx context.Context, runner Runner, snapshot settingsFileSnapshot) error {
|
||||
payload, err := json.Marshal(snapshot)
|
||||
if err != nil {
|
||||
return errors.New("Pi installation settings snapshot could not be encoded")
|
||||
}
|
||||
args := []string{"compose", "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--restore"}
|
||||
result, restoreErr := runner.Run(ctx, args, bytes.NewReader(payload))
|
||||
verified, verifyErr := captureSettingsFile(ctx, runner)
|
||||
if restoreErr != nil {
|
||||
cause := commandError("Pi installation settings restore", result, restoreErr)
|
||||
if verifyErr == nil && verified == snapshot {
|
||||
return recoveryRequired("previous Pi settings bytes were restored but durability was not acknowledged", cause)
|
||||
}
|
||||
return cause
|
||||
}
|
||||
if verifyErr == nil && verified == snapshot {
|
||||
return nil
|
||||
}
|
||||
return errors.New("Pi installation settings restore did not reproduce the exact prior file state")
|
||||
}
|
||||
|
||||
func readEffectiveSettings(ctx context.Context, runner Runner) ([]byte, error) {
|
||||
args := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...)
|
||||
args = append(args, "http://127.0.0.1:8787/settings")
|
||||
result, err := runCompose(ctx, runner, args...)
|
||||
if err != nil {
|
||||
return nil, commandError("Pi installation settings read-back", result, err)
|
||||
}
|
||||
var settings map[string]json.RawMessage
|
||||
if json.Unmarshal([]byte(result.Stdout), &settings) != nil || settings == nil {
|
||||
return nil, errors.New("Pi installation settings read-back is invalid")
|
||||
}
|
||||
canonical, err := json.Marshal(settings)
|
||||
if err != nil {
|
||||
return nil, errors.New("Pi installation settings read-back could not be normalized")
|
||||
}
|
||||
return canonical, nil
|
||||
}
|
||||
|
||||
// Runner is the narrow, shell-free command boundary shared with tht.
|
||||
type Runner interface {
|
||||
Run(context.Context, []string, io.Reader) (compose.Result, error)
|
||||
}
|
||||
|
||||
// Status reports the image-bundled Pi version without using a host Pi executable.
|
||||
func Status(ctx context.Context, runner Runner) (string, error) {
|
||||
result, err := runCompose(ctx, runner, "exec", "-T", "core", "pi", "--version")
|
||||
if err != nil {
|
||||
return "", commandError("Pi version check", result, err)
|
||||
}
|
||||
version := strings.TrimSpace(result.Stdout)
|
||||
if version == "" {
|
||||
return "", errors.New("Pi version check returned no version")
|
||||
}
|
||||
return version, nil
|
||||
}
|
||||
|
||||
// Doctor verifies the installation-side invariants Pi needs before an update.
|
||||
func Doctor(ctx context.Context, runner Runner) error {
|
||||
if _, err := renderedCore(ctx, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
actual, err := Status(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
expected, label, err := expectedVersions(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if actual != expected || actual != label {
|
||||
return errors.New("Pi version does not match the image PI_VERSION and io.thothii.pi.version contract")
|
||||
}
|
||||
for _, check := range [][]string{
|
||||
{"exec", "-T", "core", "sh", "-ceu", "test -w /home/thoth/.pi"},
|
||||
{"exec", "-T", "core", "sh", "-ceu", "test -r /home/thoth/.pi/agent/auth.json"},
|
||||
{"exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/health"},
|
||||
} {
|
||||
result, err := runCompose(ctx, runner, check...)
|
||||
if err != nil {
|
||||
return commandError("Pi preflight check", result, err)
|
||||
}
|
||||
}
|
||||
return Test(ctx, runner)
|
||||
}
|
||||
|
||||
func expectedVersions(ctx context.Context, runner Runner) (string, string, error) {
|
||||
environment, err := runCompose(ctx, runner, "exec", "-T", "core", "sh", "-ceu", `printf '%s\n' "${PI_VERSION:-}"`)
|
||||
if err != nil {
|
||||
return "", "", commandError("Pi expected-version check", environment, err)
|
||||
}
|
||||
container, err := runCompose(ctx, runner, "ps", "-q", "core")
|
||||
if err != nil || strings.TrimSpace(container.Stdout) == "" {
|
||||
return "", "", commandError("Pi image-label check", container, err)
|
||||
}
|
||||
label, err := runner.Run(ctx, []string{"inspect", "--format", `{{ index .Config.Labels "io.thothii.pi.version" }}`, strings.TrimSpace(container.Stdout)}, nil)
|
||||
if err != nil {
|
||||
return "", "", commandError("Pi image-label check", label, err)
|
||||
}
|
||||
expectedValue, labelValue := strings.TrimSpace(environment.Stdout), strings.TrimSpace(label.Stdout)
|
||||
if expectedValue == "" || labelValue == "" {
|
||||
return "", "", errors.New("Pi image expected-version contract is empty")
|
||||
}
|
||||
return expectedValue, labelValue, nil
|
||||
}
|
||||
|
||||
// Test retains the direct image-version signal, then delegates all Pi configuration/provider smoke
|
||||
// validation to core's dedicated, admin-only Pi Management endpoint.
|
||||
func Test(ctx context.Context, runner Runner) error {
|
||||
if _, err := Status(ctx, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
args := append([]string{"exec", "-T", "core", "curl", "-fsS", "-X", "POST"}, internalIdentityHeaders...)
|
||||
args = append(args, "http://127.0.0.1:8787/pi-management/test")
|
||||
smoke, err := runCompose(ctx, runner, args...)
|
||||
if err != nil {
|
||||
return commandError("Pi smoke check", smoke, err)
|
||||
}
|
||||
var smokePayload struct {
|
||||
Ready bool `json:"ready"`
|
||||
}
|
||||
if json.Unmarshal([]byte(smoke.Stdout), &smokePayload) != nil || !smokePayload.Ready {
|
||||
return errors.New("Pi smoke response is not ready")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func renderedCore(ctx context.Context, runner Runner) (Image, error) {
|
||||
result, err := runCompose(ctx, runner, "config", "--format", "json")
|
||||
if err != nil {
|
||||
return Image{}, commandError("Compose configuration check", result, err)
|
||||
}
|
||||
var document map[string]any
|
||||
if err := json.Unmarshal([]byte(result.Stdout), &document); err != nil {
|
||||
return Image{}, errors.New("Compose returned invalid rendered configuration")
|
||||
}
|
||||
services, ok := document["services"].(map[string]any)
|
||||
if !ok {
|
||||
return Image{}, errors.New("rendered Compose configuration has no services")
|
||||
}
|
||||
core, ok := services["core"].(map[string]any)
|
||||
reference, _ := core["image"].(string)
|
||||
if !ok || reference == "" {
|
||||
return Image{}, errors.New("rendered Compose configuration has no core image")
|
||||
}
|
||||
environment, _ := core["environment"].(map[string]any)
|
||||
endpoint, exists := environment["THT_LLM_URL"].(string)
|
||||
if !exists || strings.TrimSpace(endpoint) == "" {
|
||||
return Image{}, errors.New("THT_LLM_URL must be configured before Pi lifecycle operations")
|
||||
}
|
||||
// Lifecycle overrides intentionally replace only core.image. Normalize that field so the
|
||||
// non-secret configuration digest continues to detect endpoint/mount/configuration drift.
|
||||
core["image"] = "<lifecycle-image>"
|
||||
normalized, err := json.Marshal(document)
|
||||
if err != nil {
|
||||
return Image{}, errors.New("Compose configuration could not be normalized")
|
||||
}
|
||||
digest := sha256.Sum256(normalized)
|
||||
return Image{Reference: reference, ConfigurationSHA: fmt.Sprintf("%x", digest[:])}, nil
|
||||
}
|
||||
|
||||
func runCompose(ctx context.Context, runner Runner, args ...string) (compose.Result, error) {
|
||||
return runner.Run(ctx, append([]string{"compose"}, args...), nil)
|
||||
}
|
||||
|
||||
func commandError(label string, result compose.Result, err error) error {
|
||||
if result.ExitCode != 0 {
|
||||
return commandFailure{message: fmt.Sprintf("%s failed (exit %d)", label, result.ExitCode), exitCode: result.ExitCode}
|
||||
}
|
||||
return commandFailure{message: fmt.Sprintf("%s failed", label)}
|
||||
}
|
||||
|
||||
type commandFailure struct {
|
||||
message string
|
||||
exitCode int
|
||||
}
|
||||
|
||||
func (e commandFailure) Error() string { return e.message }
|
||||
|
||||
// ExitCode exposes a Docker child exit code without exposing its output.
|
||||
func (e commandFailure) ExitCode() int { return e.exitCode }
|
||||
Reference in New Issue
Block a user