155 lines
5.6 KiB
Go
155 lines
5.6 KiB
Go
package pi
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strings"
|
|
|
|
"github.com/aritmolab/thothii/tools/tht/internal/compose"
|
|
)
|
|
|
|
// 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 invokes the same Pi management service via a
|
|
// scoped core-side command. No HTTP principal or privileged header exists on this path.
|
|
func Test(ctx context.Context, runner Runner) error {
|
|
if _, err := Status(ctx, runner); err != nil {
|
|
return err
|
|
}
|
|
smoke, err := runCompose(ctx, runner, "exec", "-T", "core", "node", "/app/backend/dist/operator-command.js", "pi-test")
|
|
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 }
|