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"` } 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) (retErr 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 } 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 commandError("Pi options check", models, err) } var payload struct { Models []struct { Provider string `json:"provider"` ID string `json:"id"` } `json:"models"` } if json.Unmarshal([]byte(models.Stdout), &payload) != nil || len(payload.Models) == 0 { return errors.New("Pi options response is invalid or empty") } found := false for _, model := range payload.Models { 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") } wrote := false defer func() { if retErr != nil && wrote { result, restoreErr := runCompose(context.Background(), runner, "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--provider", old.Provider, "--model", old.Model, "--thinking", old.Thinking) if restoreErr != nil || result.ExitCode != 0 { retErr = fmt.Errorf("%w; previous Pi settings could not be restored: recovery required", retErr) } } }() result, err := runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/settings/settings-cli.js", "--provider", value.Provider, "--model", value.Model, "--thinking", value.Thinking) if err != nil { return commandError("Pi installation settings write", result, err) } wrote = true settings, err := runCompose(ctx, runner, settingsArgs...) if err != nil { return 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 errors.New("Pi installation settings read-back did not match requested provider, model, and thinking") } after, err := renderedCore(ctx, runner) if err != nil { return err } if before.ConfigurationSHA != after.ConfigurationSHA { return errors.New("external endpoint configuration changed while configuring Pi") } return 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 } for _, path := range []string{"health", "models", "settings"} { args := []string{"exec", "-T", "core", "curl", "-fsS"} if path != "health" { args = append(args, internalIdentityHeaders...) } args = append(args, "http://127.0.0.1:8787/"+path) result, err := runCompose(ctx, runner, args...) if err != nil { return commandError("Pi smoke check", result, err) } var payload any if err := json.Unmarshal([]byte(result.Stdout), &payload); err != nil { return fmt.Errorf("Pi smoke check returned invalid %s response", path) } object, ok := payload.(map[string]any) if !ok { return fmt.Errorf("Pi smoke check returned invalid %s response", path) } switch path { case "health": if object["status"] != "ok" { return errors.New("Pi smoke health response is not ready") } case "models": models, ok := object["models"].([]any) if !ok || len(models) == 0 { return errors.New("Pi smoke models response is empty") } valid := false for _, item := range models { if model, ok := item.(map[string]any); ok && stringField(model, "provider") != "" && stringField(model, "id") != "" { valid = true; break } } if !valid { return errors.New("Pi smoke models response has no provider/model choices") } case "settings": if stringField(object, "provider") == "" || stringField(object, "model") == "" || stringField(object, "thinking") == "" { return errors.New("Pi smoke settings response is incomplete") } } } return nil } func stringField(value map[string]any, key string) string { text, _ := value[key].(string); return strings.TrimSpace(text) } 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 struct { Services map[string]struct { Image string `json:"image"` Environment map[string]any `json:"environment"` } `json:"services"` } if err := json.Unmarshal([]byte(result.Stdout), &document); err != nil { return Image{}, errors.New("Compose returned invalid rendered configuration") } core, exists := document.Services["core"] if !exists || core.Image == "" { return Image{}, errors.New("rendered Compose configuration has no core image") } endpoint, exists := core.Environment["THT_LLM_URL"].(string) if !exists || strings.TrimSpace(endpoint) == "" { return Image{}, errors.New("THT_LLM_URL must be configured before Pi lifecycle operations") } digest := sha256.Sum256([]byte(result.Stdout)) return Image{Reference: core.Image, 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 }