feat: expose safe pi management api

This commit is contained in:
2026-08-05 00:31:44 +02:00
parent 09eeea17e5
commit d6b4a08a02
10 changed files with 744 additions and 61 deletions
+63 -44
View File
@@ -28,6 +28,12 @@ type ModelOption struct {
ID string `json:"id"`
}
type piOptions struct {
Providers []string `json:"providers"`
Models []ModelOption `json:"models"`
Reasoning []string `json:"reasoning"`
}
type settingsFileSnapshot struct {
Exists bool `json:"exists"`
RawBase64 string `json:"rawBase64"`
@@ -46,19 +52,16 @@ 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)
options, err := configurationOptions(ctx, runner)
if err != nil {
return err
}
found := false
for _, model := range options {
for _, model := range options.Models {
if model.Provider == value.Provider && model.ID == value.Model {
found = true
}
@@ -66,6 +69,15 @@ func Configure(ctx context.Context, runner Runner, value Defaults) error {
if !found {
return errors.New("provider/model is not in Pi options")
}
thinkingFound := false
for _, reasoning := range options.Reasoning {
if reasoning == value.Thinking {
thinkingFound = true
}
}
if !thinkingFound {
return errors.New("thinking is not in Pi options")
}
old, err := captureSettingsFile(ctx, runner)
if err != nil {
return err
@@ -107,24 +119,47 @@ func Configure(ctx context.Context, runner Runner, value Defaults) error {
}
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...)
options, err := configurationOptions(ctx, runner)
if err != nil {
return nil, commandError("Pi options check", models, err)
return nil, err
}
var payload struct {
Models []ModelOption `json:"models"`
return options.Models, nil
}
func configurationOptions(ctx context.Context, runner Runner) (piOptions, error) {
args := append([]string{"exec", "-T", "core", "curl", "-fsS"}, internalIdentityHeaders...)
args = append(args, "http://127.0.0.1:8787/pi-management/options")
result, err := runCompose(ctx, runner, args...)
if err != nil {
return piOptions{}, commandError("Pi options check", result, err)
}
if json.Unmarshal([]byte(models.Stdout), &payload) != nil || len(payload.Models) == 0 {
return nil, errors.New("Pi options response is invalid or empty")
var payload piOptions
if json.Unmarshal([]byte(result.Stdout), &payload) != nil || len(payload.Providers) == 0 || len(payload.Models) == 0 || len(payload.Reasoning) == 0 {
return piOptions{}, 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")
providers := make(map[string]bool, len(payload.Providers))
for _, provider := range payload.Providers {
if !choicePattern.MatchString(provider) || providers[provider] {
return piOptions{}, errors.New("Pi options response contains an invalid provider")
}
providers[provider] = true
}
return payload.Models, nil
models := make(map[string]bool, len(payload.Models))
for _, option := range payload.Models {
key := option.Provider + "\x00" + option.ID
if !providers[option.Provider] || !choicePattern.MatchString(option.ID) || models[key] {
return piOptions{}, errors.New("Pi options response contains an invalid provider/model")
}
models[key] = true
}
reasoning := make(map[string]bool, len(payload.Reasoning))
for _, value := range payload.Reasoning {
if (value != "low" && value != "medium" && value != "high") || reasoning[value] {
return piOptions{}, errors.New("Pi options response contains an invalid reasoning choice")
}
reasoning[value] = true
}
return payload, nil
}
func writeDefaults(ctx context.Context, runner Runner, value Defaults) (compose.Result, error) {
@@ -253,41 +288,25 @@ func expectedVersions(ctx context.Context, runner Runner) (string, string, error
return expectedValue, labelValue, nil
}
// Test performs the pre-Task-8 composite smoke through core's private loopback endpoint.
// Test retains the direct image-version signal, then delegates all Pi configuration/provider smoke
// validation to core's dedicated, admin-only Pi Management 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")
args := append([]string{"exec", "-T", "core", "curl", "-fsS", "-X", "POST"}, internalIdentityHeaders...)
args = append(args, "http://127.0.0.1:8787/pi-management/test")
smoke, err := runCompose(ctx, runner, args...)
if err != nil {
return commandError("Pi smoke check", health, err)
return commandError("Pi smoke check", smoke, err)
}
var healthPayload struct {
Status string `json:"status"`
var smokePayload struct {
Ready bool `json:"ready"`
}
if json.Unmarshal([]byte(health.Stdout), &healthPayload) != nil || healthPayload.Status != "ok" {
return errors.New("Pi smoke health response is not ready")
if json.Unmarshal([]byte(smoke.Stdout), &smokePayload) != nil || !smokePayload.Ready {
return errors.New("Pi smoke 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")
return nil
}
func renderedCore(ctx context.Context, runner Runner) (Image, error) {
+32 -10
View File
@@ -123,8 +123,8 @@ func (f *configureRunner) Run(_ context.Context, args []string, stdin io.Reader)
endpoint = "https://drift.example.invalid"
}
return compose.Result{Stdout: `{"services":{"core":{"image":"thothii-core:local","environment":{"THT_LLM_URL":"` + endpoint + `"}}}}`}, nil
case strings.Contains(call, "/models"):
return compose.Result{Stdout: `{"models":[{"provider":"old","id":"old-model"},{"provider":"new","id":"new-model"}]}`}, nil
case strings.Contains(call, "/pi-management/options"):
return compose.Result{Stdout: `{"providers":["old","new"],"models":[{"provider":"old","id":"old-model"},{"provider":"new","id":"new-model"}],"reasoning":["low","medium","high"]}`}, nil
case strings.Contains(call, "settings-cli.js --snapshot"):
raw := f.settingsRaw
payload := map[string]any{"exists": f.settingsExist, "rawBase64": base64.StdEncoding.EncodeToString(raw)}
@@ -218,11 +218,14 @@ func TestConfigureCompensationRestoresAbsentAndExactEmptyPriorFiles(t *testing.T
}
}
func TestConfigureValidatesBackendModelOptionsWritesRealCoreSettingsAndUsesUpstreamIdentity(t *testing.T) {
// Catches thothctl reading the legacy public model route instead of the admin-only closed Pi
// Management choices before it writes shared installation defaults.
func TestConfigureLoadsDedicatedClosedOptionsWritesRealCoreSettingsAndUsesUpstreamIdentity(t *testing.T) {
fake := newFakeRunner()
if err := Configure(context.Background(), fake, Defaults{Provider: "provider", Model: "model", Thinking: "medium"}); err != nil {
t.Fatal(err)
}
assertCalled(t, fake.calls, "/pi-management/options")
assertCalled(t, fake.calls, "node /app/backend/dist/settings/settings-cli.js --provider provider --model model --thinking medium")
assertCalled(t, fake.calls, "x-thoth-principal-subject: thothctl-maintenance")
if got := strings.Join(fake.calls, "\n"); strings.Contains(got, "pi-defaults.json") || strings.Contains(got, "secret") {
@@ -233,28 +236,47 @@ func TestConfigureValidatesBackendModelOptionsWritesRealCoreSettingsAndUsesUpstr
}
}
func TestTestUsesOnlySanitizedPiAndCoreProbes(t *testing.T) {
// Catches a smoke check that composes health/models/settings itself and drifts from the dedicated
// backend contract, rather than retaining only the independent in-container version signal.
func TestTestUsesDedicatedSmokeEndpointAndIndependentImageVersionProbe(t *testing.T) {
fake := newFakeRunner()
if err := Test(context.Background(), fake); err != nil {
t.Fatalf("Test() error = %v", err)
}
for _, command := range []string{"pi --version", "/health", "/models", "/settings"} {
for _, command := range []string{"pi --version", "/pi-management/test", "x-thoth-principal-subject: thothctl-maintenance"} {
assertCalled(t, fake.calls, command)
}
for _, legacy := range []string{"/health", "/models", "/settings"} {
if strings.Contains(strings.Join(fake.calls, "\n"), legacy) {
t.Fatalf("Pi smoke invoked legacy endpoint %q: %s", legacy, strings.Join(fake.calls, "\n"))
}
}
if got := strings.Join(fake.calls, "\n"); strings.Contains(got, "secret") {
t.Fatalf("probe commands expose secret: %s", got)
}
}
func TestTestRequiresConfiguredProviderAndModelToMatchOneAvailableEntry(t *testing.T) {
// Catches an ignored negative ready result from the backend smoke endpoint, which would report a
// successfully verified candidate image while its configured Pi runtime is unusable.
func TestTestRequiresDedicatedSmokeEndpointToReportReady(t *testing.T) {
fake := newFakeRunner()
fake.modelsWire = `{"models":[{"id":"different-model","provider":"provider"}]}`
if err := Test(context.Background(), fake); err == nil || !strings.Contains(err.Error(), "configured provider/model") {
t.Fatalf("Test() error = %v, want exact settings/model mismatch", err)
fake.piManagementTestWire = `{"ready":false,"message":"provider unavailable"}`
if err := Test(context.Background(), fake); err == nil || !strings.Contains(err.Error(), "Pi smoke response is not ready") {
t.Fatalf("Test() error = %v, want negative dedicated smoke result", err)
}
fake.modelsWire = `{"models":[{"id":"model","provider":"provider"}]}`
fake.piManagementTestWire = `{"ready":true}`
if err := Test(context.Background(), fake); err != nil {
t.Fatalf("Test() exact match error = %v", err)
}
assertCalled(t, fake.calls, "pi --version")
}
// Catches thothctl accepting a reasoning level that the backend did not publish as a closed
// installation option, which would bypass the Pi Management validation surface.
func TestConfigureRejectsReasoningOutsideDedicatedClosedOptions(t *testing.T) {
fake := newFakeRunner()
fake.piManagementOptionsWire = `{"providers":["provider"],"models":[{"provider":"provider","id":"model"}],"reasoning":["low"]}`
if err := Configure(context.Background(), fake, Defaults{Provider: "provider", Model: "model", Thinking: "high"}); err == nil || !strings.Contains(err.Error(), "Pi options") {
t.Fatalf("Configure() error = %v, want closed reasoning rejection", err)
}
}
+19 -3
View File
@@ -517,6 +517,8 @@ func TestMaintenanceClearAndCompensationFailuresRemainGated(t *testing.T) {
}
}
// Catches maintenance recovery clearing admission after the obsolete composite smoke rather than
// the same dedicated Pi Management smoke contract used for ordinary image verification.
func TestRecoverMaintenanceClearsOnlyAfterTerminalStateAndVerifiedSmoke(t *testing.T) {
fake := newFakeRunner()
fake.maintenance = true
@@ -544,8 +546,7 @@ func TestRecoverMaintenanceClearsOnlyAfterTerminalStateAndVerifiedSmoke(t *testi
if selected := readSelectorReference(t, currentImageOverridePath(statePath)); selected != previous.Reference {
t.Fatalf("maintenance cleanup changed durable selector to %q", selected)
}
assertCalled(t, fake.calls, "/models")
assertCalled(t, fake.calls, "/settings")
assertCalled(t, fake.calls, "/pi-management/test")
}
func TestRecoverMaintenanceRefusesPendingTransaction(t *testing.T) {
@@ -811,6 +812,8 @@ type fakeRunner struct {
backendRestarts int
dropMaintenanceAfterCandidate bool
modelsWire string
piManagementOptionsWire string
piManagementTestWire string
rollbackPrepared bool
coreRunning bool
execFailuresWhileStopped int
@@ -867,7 +870,7 @@ func (f *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose
if f.fail == "version" && f.built && strings.Contains(call, "pi --version") && strings.Contains(call, "exec") {
return compose.Result{ExitCode: 1}, errors.New("version token=secret")
}
if f.fail == "smoke" && f.built && strings.Contains(call, "127.0.0.1:8787/models") {
if f.fail == "smoke" && f.built && strings.Contains(call, "127.0.0.1:8787/pi-management/test") {
return compose.Result{ExitCode: 1}, errors.New("smoke token=secret")
}
switch {
@@ -1016,6 +1019,19 @@ func (f *fakeRunner) Run(_ context.Context, args []string, _ io.Reader) (compose
return compose.Result{Stdout: f.version + "\n"}, nil
case strings.Contains(call, "PI_VERSION"):
return compose.Result{Stdout: f.expectedVersion + "\n"}, nil
case strings.Contains(call, "/pi-management/options"):
if f.piManagementOptionsWire != "" {
return compose.Result{Stdout: f.piManagementOptionsWire}, nil
}
return compose.Result{Stdout: `{"providers":["provider"],"models":[{"id":"model","provider":"provider"}],"reasoning":["low","medium","high"]}`}, nil
case strings.Contains(call, "/pi-management/test"):
if f.currentImage == "sha256:old" {
f.restoredProofComplete = true
}
if f.piManagementTestWire != "" {
return compose.Result{Stdout: f.piManagementTestWire}, nil
}
return compose.Result{Stdout: `{"ready":true}`}, nil
case strings.Contains(call, "/models"):
if f.modelsWire != "" {
return compose.Result{Stdout: f.modelsWire}, nil