feat: expose safe pi management api
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user