275 lines
10 KiB
Go
275 lines
10 KiB
Go
package pi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/aritmolab/thothii/tools/thothctl/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"`
|
|
}
|
|
|
|
var internalIdentityHeaders = []string{
|
|
"-H", "x-thoth-principal-issuer: thothctl",
|
|
"-H", "x-thoth-principal-subject: thothctl-maintenance",
|
|
"-H", "x-thoth-principal-display-name: Thothctl 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")
|
|
}
|
|
if value.Thinking != "low" && value.Thinking != "medium" && value.Thinking != "high" {
|
|
return errors.New("thinking must be low, medium, or high")
|
|
}
|
|
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 {
|
|
if model.Provider == value.Provider && model.ID == value.Model {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
return errors.New("provider/model is not in Pi options")
|
|
}
|
|
settingsArgs := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...)
|
|
settingsArgs = append(settingsArgs, "http://127.0.0.1:8787/settings")
|
|
oldResult, err := runCompose(ctx, runner, settingsArgs...)
|
|
if err != nil {
|
|
return commandError("Pi installation settings capture", oldResult, err)
|
|
}
|
|
var old Defaults
|
|
if json.Unmarshal([]byte(oldResult.Stdout), &old) != nil || old.Provider == "" || old.Model == "" || old.Thinking == "" {
|
|
return errors.New("Pi installation settings capture is invalid")
|
|
}
|
|
restore := func(cause error) error {
|
|
result, restoreErr := writeDefaults(context.Background(), runner, old)
|
|
if restoreErr != nil {
|
|
return fmt.Errorf("%w; previous Pi settings could not be restored: recovery required", cause)
|
|
}
|
|
if result.ExitCode != 0 {
|
|
return fmt.Errorf("%w; previous Pi settings could not be restored: recovery required", cause)
|
|
}
|
|
verified, readErr := readDefaults(context.Background(), runner, settingsArgs)
|
|
if readErr != nil || verified != old {
|
|
return fmt.Errorf("%w; previous Pi settings restoration 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 := runCompose(ctx, runner, settingsArgs...)
|
|
if err != nil {
|
|
return restore(commandError("Pi installation settings read-back", settings, err))
|
|
}
|
|
var saved Defaults
|
|
if json.Unmarshal([]byte(settings.Stdout), &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) {
|
|
args := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...)
|
|
args = append(args, "http://127.0.0.1:8787/models")
|
|
models, err := runCompose(ctx, runner, args...)
|
|
if err != nil {
|
|
return nil, commandError("Pi options check", models, err)
|
|
}
|
|
var payload struct {
|
|
Models []ModelOption `json:"models"`
|
|
}
|
|
if json.Unmarshal([]byte(models.Stdout), &payload) != nil || len(payload.Models) == 0 {
|
|
return nil, errors.New("Pi options response is invalid or empty")
|
|
}
|
|
for _, option := range payload.Models {
|
|
if !choicePattern.MatchString(option.Provider) || !choicePattern.MatchString(option.ID) {
|
|
return nil, errors.New("Pi options response contains an invalid provider/model")
|
|
}
|
|
}
|
|
return payload.Models, 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 readDefaults(ctx context.Context, runner Runner, args []string) (Defaults, error) {
|
|
result, err := runCompose(ctx, runner, args...)
|
|
if err != nil {
|
|
return Defaults{}, commandError("Pi installation settings restoration read-back", result, err)
|
|
}
|
|
var value Defaults
|
|
if json.Unmarshal([]byte(result.Stdout), &value) != nil {
|
|
return Defaults{}, errors.New("Pi installation settings restoration read-back is invalid")
|
|
}
|
|
return value, nil
|
|
}
|
|
|
|
// Runner is the narrow, shell-free command boundary shared with thothctl.
|
|
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
|
|
}
|
|
if _, err := Status(ctx, runner); err != nil {
|
|
return err
|
|
}
|
|
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)
|
|
}
|
|
|
|
// Test performs the pre-Task-8 composite smoke through core's private loopback endpoint.
|
|
func Test(ctx context.Context, runner Runner) error {
|
|
if _, err := Status(ctx, runner); err != nil {
|
|
return err
|
|
}
|
|
health, err := runCompose(ctx, runner, "exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/health")
|
|
if err != nil {
|
|
return commandError("Pi smoke check", health, err)
|
|
}
|
|
var healthPayload struct {
|
|
Status string `json:"status"`
|
|
}
|
|
if json.Unmarshal([]byte(health.Stdout), &healthPayload) != nil || healthPayload.Status != "ok" {
|
|
return errors.New("Pi smoke health response is not ready")
|
|
}
|
|
models, err := ConfigurationOptions(ctx, runner)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
settingsArgs := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...)
|
|
settingsArgs = append(settingsArgs, "http://127.0.0.1:8787/settings")
|
|
settings, err := runCompose(ctx, runner, settingsArgs...)
|
|
if err != nil {
|
|
return commandError("Pi smoke settings check", settings, err)
|
|
}
|
|
var selected Defaults
|
|
if json.Unmarshal([]byte(settings.Stdout), &selected) != nil || !choicePattern.MatchString(selected.Provider) || !choicePattern.MatchString(selected.Model) || (selected.Thinking != "low" && selected.Thinking != "medium" && selected.Thinking != "high") {
|
|
return errors.New("Pi smoke settings response is incomplete")
|
|
}
|
|
for _, model := range models {
|
|
if model.Provider == selected.Provider && model.ID == selected.Model {
|
|
return nil
|
|
}
|
|
}
|
|
return errors.New("configured provider/model does not match an available Pi model entry")
|
|
}
|
|
|
|
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 }
|