fix: finalize durable Pi lifecycle
This commit is contained in:
@@ -1,8 +1,10 @@
|
||||
package pi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -26,6 +28,11 @@ type ModelOption struct {
|
||||
ID string `json:"id"`
|
||||
}
|
||||
|
||||
type settingsFileSnapshot struct {
|
||||
Exists bool `json:"exists"`
|
||||
RawBase64 string `json:"rawBase64"`
|
||||
}
|
||||
|
||||
var internalIdentityHeaders = []string{
|
||||
"-H", "x-thoth-principal-issuer: thothctl",
|
||||
"-H", "x-thoth-principal-subject: thothctl-maintenance",
|
||||
@@ -59,40 +66,34 @@ func Configure(ctx context.Context, runner Runner, value Defaults) error {
|
||||
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...)
|
||||
old, err := captureSettingsFile(ctx, runner)
|
||||
if err != nil {
|
||||
return commandError("Pi installation settings capture", oldResult, err)
|
||||
return 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")
|
||||
oldEffective, err := readEffectiveSettings(ctx, runner)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
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 {
|
||||
if restoreErr := restoreSettingsFile(context.Background(), runner, old); restoreErr != nil {
|
||||
return fmt.Errorf("%w; previous Pi settings restoration could not be verified: recovery required", cause)
|
||||
}
|
||||
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 := runCompose(ctx, runner, settingsArgs...)
|
||||
settings, err := readEffectiveSettings(ctx, runner)
|
||||
if err != nil {
|
||||
return restore(commandError("Pi installation settings read-back", settings, err))
|
||||
return restore(err)
|
||||
}
|
||||
var saved Defaults
|
||||
if json.Unmarshal([]byte(settings.Stdout), &saved) != nil || saved.Provider != value.Provider || saved.Model != value.Model || saved.Thinking != value.Thinking {
|
||||
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)
|
||||
@@ -130,16 +131,55 @@ func writeDefaults(ctx context.Context, runner Runner, value Defaults) (compose.
|
||||
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) {
|
||||
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 verifyErr == nil && verified == snapshot {
|
||||
return nil
|
||||
}
|
||||
if restoreErr != nil {
|
||||
return commandError("Pi installation settings restore", result, restoreErr)
|
||||
}
|
||||
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 Defaults{}, commandError("Pi installation settings restoration read-back", result, err)
|
||||
return nil, commandError("Pi installation settings 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")
|
||||
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")
|
||||
}
|
||||
return value, nil
|
||||
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 thothctl.
|
||||
@@ -165,9 +205,17 @@ func Doctor(ctx context.Context, runner Runner) error {
|
||||
if _, err := renderedCore(ctx, runner); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := Status(ctx, runner); err != nil {
|
||||
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"},
|
||||
@@ -181,6 +229,26 @@ func Doctor(ctx context.Context, runner Runner) error {
|
||||
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 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 {
|
||||
|
||||
Reference in New Issue
Block a user