fix: harden pi maintenance lifecycle
This commit is contained in:
@@ -7,9 +7,6 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
@@ -22,22 +19,29 @@ type Defaults struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
Thinking string `json:"thinking"`
|
||||
LLMURL string `json:"llm_url"`
|
||||
}
|
||||
|
||||
// Configure validates and atomically stores only local non-secret Pi defaults.
|
||||
func Configure(ctx context.Context, runner Runner, path string, value Defaults) error {
|
||||
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")
|
||||
}
|
||||
u, err := url.Parse(value.LLMURL)
|
||||
if err != nil || (u.Scheme != "https" && u.Scheme != "http") || u.Host == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
|
||||
return errors.New("LLM endpoint must be an http(s) URL without credentials, query, or fragment")
|
||||
}
|
||||
models, err := runCompose(ctx, runner, "exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/models")
|
||||
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)
|
||||
}
|
||||
@@ -59,39 +63,19 @@ func Configure(ctx context.Context, runner Runner, path string, value Defaults)
|
||||
if !found {
|
||||
return errors.New("provider/model is not in Pi options")
|
||||
}
|
||||
return writeJSON(path, value)
|
||||
}
|
||||
|
||||
func writeJSON(path string, value any) error {
|
||||
contents, err := json.MarshalIndent(value, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
contents = append(contents, '\n')
|
||||
if err = os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
|
||||
return errors.New("could not create Pi configuration directory")
|
||||
}
|
||||
temporary, err := os.CreateTemp(filepath.Dir(path), ".pi-defaults-*.tmp")
|
||||
if err != nil {
|
||||
return errors.New("could not write Pi configuration")
|
||||
}
|
||||
name := temporary.Name()
|
||||
defer os.Remove(name)
|
||||
if err = temporary.Chmod(0o600); err == nil {
|
||||
_, err = temporary.Write(contents)
|
||||
}
|
||||
if err == nil {
|
||||
err = temporary.Sync()
|
||||
}
|
||||
if closeErr := temporary.Close(); err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err == nil {
|
||||
err = os.Rename(name, path)
|
||||
}
|
||||
if err != nil {
|
||||
return errors.New("could not atomically write Pi configuration")
|
||||
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) }
|
||||
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 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
|
||||
}
|
||||
|
||||
@@ -131,7 +115,7 @@ func Doctor(ctx context.Context, runner Runner) error {
|
||||
return commandError("Pi preflight check", result, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return Test(ctx, runner)
|
||||
}
|
||||
|
||||
// Test performs the pre-Task-8 composite smoke through core's private loopback endpoint.
|
||||
@@ -140,7 +124,10 @@ func Test(ctx context.Context, runner Runner) error {
|
||||
return err
|
||||
}
|
||||
for _, path := range []string{"health", "models", "settings"} {
|
||||
result, err := runCompose(ctx, runner, "exec", "-T", "core", "curl", "-fsS", "http://127.0.0.1:8787/"+path)
|
||||
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)
|
||||
}
|
||||
@@ -148,13 +135,28 @@ func Test(ctx context.Context, runner Runner) error {
|
||||
if err := json.Unmarshal([]byte(result.Stdout), &payload); err != nil {
|
||||
return fmt.Errorf("Pi smoke check returned invalid %s response", path)
|
||||
}
|
||||
if _, ok := payload.(map[string]any); !ok {
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user