fix: harden model catalog projections

This commit is contained in:
Codex
2026-09-02 19:25:01 +02:00
parent ce4c31a6fb
commit a6a5bf2036
38 changed files with 573 additions and 83 deletions
@@ -78,6 +78,13 @@ func TestLoadRejectsInvalidModelCatalogDefaultsAndAuthentication(t *testing.T) {
catalog: strings.Replace(validModelCatalogYAML(), "id: ollama/qwen3-embedding:0.6b", "id: external/qwen3-embedding:0.6b", 1),
wantError: "ollama canonical id",
},
{
name: "upstream model aliases must be unique within a provider",
catalog: strings.Replace(validModelCatalogYAML(),
" models:\n glm-5.3:\n",
" models:\n glm-alias:\n upstreamModel: glm-5.3\n session:\n contextWindow: 200000\n maxTokens: 131072\n glm-5.3:\n", 1),
wantError: "same upstreamModel",
},
{
name: "metadata default required",
catalog: strings.Replace(validModelCatalogYAML(), " metadataGeneration: local/qwen\n", "", 1),
@@ -14,6 +14,7 @@ const maxCatalogModels = 64
var catalogKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}$`)
var catalogModelIDPattern = regexp.MustCompile(`^[a-z][a-z0-9._-]{0,63}/[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$`)
var catalogUpstreamModelPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$`)
var catalogAPIVersionPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
// ModelCatalog is the only operator-authored source for model identity and runtime eligibility.
@@ -163,11 +164,23 @@ func (c ModelCatalog) Validate(environment map[string]string) error {
return err
}
hasSession, providerHasMetadata := false, false
upstreamModels := make(map[string]string)
for modelID, model := range provider.Models {
if !catalogKeyPattern.MatchString(modelID) {
return fmt.Errorf("modelCatalog model %q/%q is invalid", providerID, modelID)
}
canonical := providerID + "/" + modelID
upstream := model.UpstreamModel
if upstream == "" {
upstream = modelID
}
if !catalogUpstreamModelPattern.MatchString(upstream) {
return fmt.Errorf("modelCatalog model %q upstreamModel is invalid", canonical)
}
if existing, duplicate := upstreamModels[upstream]; duplicate {
return fmt.Errorf("modelCatalog models %q and %q use the same upstreamModel", existing, canonical)
}
upstreamModels[upstream] = canonical
if model.Session == nil && model.MetadataGeneration == nil {
return fmt.Errorf("modelCatalog model %q has no runtime use", canonical)
}
@@ -119,9 +119,10 @@ func Run(request Request) error {
if !filepath.IsAbs(request.InstallationPath) || filepath.Base(request.InstallationPath) != "thothii-installation.yaml" {
return errors.New("installation migration requires an absolute thothii-installation.yaml path")
}
if !filepath.IsAbs(request.OutputPath) || filepath.Base(request.OutputPath) != "thothii-installation.yaml" ||
outputExtension := strings.ToLower(filepath.Ext(request.OutputPath))
if !filepath.IsAbs(request.OutputPath) || outputExtension != ".yaml" && outputExtension != ".yml" ||
filepath.Clean(request.OutputPath) == filepath.Clean(request.InstallationPath) {
return errors.New("installation migration output must be a different absolute thothii-installation.yaml path")
return errors.New("installation migration output must be a different absolute YAML path")
}
if request.SessionDefault == "" || request.EmbeddingID == "" || request.EmbeddingDimensions <= 0 {
return errors.New("installation migration requires session default, embedding id, and positive embedding dimensions")
@@ -69,6 +69,19 @@ func TestRunReportsProviderAuthenticationConflictAndPublishesNothing(t *testing.
}
}
func TestRunAcceptsVersionedYAMLCandidateFilename(t *testing.T) {
fixture := migrationFixture(t, false)
request := fixture.request()
request.OutputPath = filepath.Join(filepath.Dir(request.OutputPath), "thothii-installation.v2.yaml")
if err := Run(request); err != nil {
t.Fatalf("Run() error = %v", err)
}
if info, err := os.Stat(request.OutputPath); err != nil || !info.Mode().IsRegular() {
t.Fatalf("versioned candidate: info=%v err=%v", info, err)
}
}
type migrationTestFixture struct {
legacy string
output string
@@ -21,6 +21,8 @@ const (
ComposeFile = "compose.models.yaml"
)
var renameProjectionDirectory = os.Rename
type runtimeCatalog struct {
SchemaVersion int `json:"schemaVersion"`
DefaultSession string `json:"defaultSession"`
@@ -87,10 +89,11 @@ func Render(installation config.Installation) (map[string][]byte, error) {
if model.Session == nil {
continue
}
enabled = append(enabled, model.ID)
if model.SessionAdapter == nil || model.SessionAdapter.Mode != "openai_compatible" {
enabled = append(enabled, model.ID)
continue
}
enabled = append(enabled, model.Provider+"/"+model.UpstreamModel)
provider := customProviders[model.Provider]
provider.BaseURL = model.Endpoint.BaseURL
provider.API = "openai-completions"
@@ -137,52 +140,84 @@ func Render(installation config.Installation) (map[string][]byte, error) {
}, nil
}
// Generate atomically replaces each generated adapter after the complete candidate has rendered.
// Generate publishes all adapters as one directory generation. A failed replacement restores the
// previous directory, so callers never observe a successfully returned mixed generation.
func Generate(installation config.Installation) error {
artifacts, err := Render(installation)
if err != nil {
return err
}
paths := sortedArtifactPaths(artifacts)
for _, relative := range paths {
destination := filepath.Join(installation.GeneratedDirectory(), filepath.FromSlash(relative))
// The generated catalog contains references to secret environment variable names, never
// secret values. Core runs as an unprivileged container user and must be able to traverse
// the bind-mounted host directories and read the projections.
target := installation.GeneratedDirectory()
parent := filepath.Dir(target)
if err := os.MkdirAll(parent, 0o755); err != nil {
return fmt.Errorf("create model projection parent: %w", err)
}
candidate, err := os.MkdirTemp(parent, ".model-projections-candidate-*")
if err != nil {
return fmt.Errorf("create model projection candidate: %w", err)
}
defer func() { _ = os.RemoveAll(candidate) }()
if err := writeProjectionCandidate(candidate, artifacts); err != nil {
return err
}
info, statErr := os.Lstat(target)
if os.IsNotExist(statErr) {
if err := renameProjectionDirectory(candidate, target); err != nil {
return fmt.Errorf("publish model projection generation: %w", err)
}
return nil
}
if statErr != nil {
return fmt.Errorf("inspect current model projection generation: %w", statErr)
}
if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 {
return fmt.Errorf("current model projection path is not a regular directory")
}
previous, err := absentTemporaryPath(parent)
if err != nil {
return fmt.Errorf("reserve previous model projection generation: %w", err)
}
if err := renameProjectionDirectory(target, previous); err != nil {
return fmt.Errorf("prepare model projection generation replacement: %w", err)
}
if err := renameProjectionDirectory(candidate, target); err != nil {
if restoreErr := renameProjectionDirectory(previous, target); restoreErr != nil {
return fmt.Errorf("publish model projection generation: %v; restore previous generation: %w", err, restoreErr)
}
return fmt.Errorf("publish model projection generation: %w", err)
}
_ = os.RemoveAll(previous)
return nil
}
func writeProjectionCandidate(directory string, artifacts map[string][]byte) error {
if err := os.Chmod(directory, 0o755); err != nil {
return fmt.Errorf("protect model projection candidate: %w", err)
}
for _, relative := range sortedArtifactPaths(artifacts) {
destination := filepath.Join(directory, filepath.FromSlash(relative))
if err := os.MkdirAll(filepath.Dir(destination), 0o755); err != nil {
return fmt.Errorf("create model projection directory: %w", err)
}
if err := os.Chmod(filepath.Dir(destination), 0o755); err != nil {
return fmt.Errorf("protect model projection directory: %w", err)
}
temporary, err := os.CreateTemp(filepath.Dir(destination), ".projection-*")
if err != nil {
return fmt.Errorf("create model projection candidate: %w", err)
}
temporaryName := temporary.Name()
published := false
defer func() {
if !published {
_ = os.Remove(temporaryName)
}
}()
if err := temporary.Chmod(0o644); err == nil {
_, err = temporary.Write(artifacts[relative])
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
if err := os.WriteFile(destination, artifacts[relative], 0o644); err != nil {
return fmt.Errorf("write model projection candidate: %w", err)
}
if err := os.Rename(temporaryName, destination); err != nil {
return fmt.Errorf("publish model projection: %w", err)
}
published = true
}
return nil
}
func absentTemporaryPath(parent string) (string, error) {
path, err := os.MkdirTemp(parent, ".model-projections-previous-*")
if err != nil {
return "", err
}
if err := os.Remove(path); err != nil {
return "", err
}
return path, nil
}
// Check returns relative artifact names whose current bytes differ from the catalog projection.
func Check(installation config.Installation) ([]string, error) {
artifacts, err := Render(installation)
@@ -2,6 +2,7 @@ package modelprojection
import (
"bytes"
"errors"
"os"
"path/filepath"
"strings"
@@ -41,9 +42,12 @@ func TestRenderProducesDeterministicCatalogPiAndComposeProjections(t *testing.T)
t.Fatalf("Pi models projection must contain only custom providers:\n%s", piModels)
}
settings := string(first[PiSettingsFile])
if !strings.Contains(settings, `"deepseek/deepseek-v4-pro"`) || !strings.Contains(settings, `"local/qwen"`) {
if !strings.Contains(settings, `"deepseek/deepseek-v4-pro"`) || !strings.Contains(settings, `"local/qwen2.5:7b"`) {
t.Fatalf("Pi settings projection missing enabled session models:\n%s", settings)
}
if !strings.Contains(piModels, `"id": "qwen2.5:7b"`) || !strings.Contains(catalog, `"id": "local/qwen"`) {
t.Fatalf("Pi projection must map canonical catalog keys to upstream model IDs")
}
compose := string(first[ComposeFile])
for _, expected := range []string{"THT_MODEL_CATALOG_FILE", "THT_DEFAULT_SESSION_MODEL", "zai/glm-5.3", "THT_INTERNAL_EMBEDDING_ID", "THT_INTERNAL_EMBEDDING_MODEL", "OLLAMA_MODEL", installation.GeneratedModelCatalogPath()} {
if !strings.Contains(compose, expected) {
@@ -52,6 +56,46 @@ func TestRenderProducesDeterministicCatalogPiAndComposeProjections(t *testing.T)
}
}
func TestGenerateRestoresWholePreviousGenerationWhenPublishFails(t *testing.T) {
installation := projectionFixture(t)
if err := Generate(installation); err != nil {
t.Fatalf("Generate() initial error = %v", err)
}
before := make(map[string][]byte)
for _, path := range []string{CatalogFile, PiModelsFile, PiSettingsFile, ComposeFile} {
contents, err := os.ReadFile(filepath.Join(installation.GeneratedDirectory(), filepath.FromSlash(path)))
if err != nil {
t.Fatal(err)
}
before[path] = contents
}
provider := installation.ModelCatalog.Providers["zai"]
model := provider.Models["glm-5.3"]
model.Label = "Changed label"
provider.Models["glm-5.3"] = model
installation.ModelCatalog.Providers["zai"] = provider
originalRename := renameProjectionDirectory
t.Cleanup(func() { renameProjectionDirectory = originalRename })
renames := 0
renameProjectionDirectory = func(oldPath, newPath string) error {
renames++
if renames == 2 {
return errors.New("injected publish failure")
}
return os.Rename(oldPath, newPath)
}
if err := Generate(installation); err == nil {
t.Fatal("Generate() error = nil, want injected publish failure")
}
for path, expected := range before {
actual, err := os.ReadFile(filepath.Join(installation.GeneratedDirectory(), filepath.FromSlash(path)))
if err != nil || !bytes.Equal(actual, expected) {
t.Fatalf("artifact %q changed after failed generation: err=%v", path, err)
}
}
}
func TestGeneratePublishesAllArtifactsAndCheckDetectsDrift(t *testing.T) {
installation := projectionFixture(t)
if err := Generate(installation); err != nil {
@@ -84,7 +128,7 @@ func projectionFixture(t *testing.T) config.Installation {
Providers: map[string]config.ModelProvider{
"deepseek": {Authentication: config.ModelAuthentication{Mode: "pi_auth"}, Session: &config.ModelSessionAdapter{Mode: "pi_builtin"}, Models: map[string]config.CatalogModel{"deepseek-v4-pro": {Session: &config.SessionModel{}}}},
"zai": {Endpoint: &config.ModelEndpoint{BaseURL: "https://api.z.ai/v1"}, Authentication: config.ModelAuthentication{Mode: "none"}, Session: &config.ModelSessionAdapter{Mode: "openai_compatible"}, Models: map[string]config.CatalogModel{"glm-5.3": {Label: "GLM-5.3", Session: &config.SessionModel{Reasoning: true, ContextWindow: 200000, MaxTokens: 131072}}}},
"local": {Endpoint: &config.ModelEndpoint{BaseURL: "http://ollama:11434/v1"}, Authentication: config.ModelAuthentication{Mode: "none"}, Session: &config.ModelSessionAdapter{Mode: "openai_compatible"}, MetadataGeneration: &config.ModelMetadataAdapter{LiteLLMProvider: "openai"}, Models: map[string]config.CatalogModel{"qwen": {Session: &config.SessionModel{ContextWindow: 32768, MaxTokens: 8192}, MetadataGeneration: &config.MetadataGenerationModel{DisableThinking: true}}}},
"local": {Endpoint: &config.ModelEndpoint{BaseURL: "http://ollama:11434/v1"}, Authentication: config.ModelAuthentication{Mode: "none"}, Session: &config.ModelSessionAdapter{Mode: "openai_compatible"}, MetadataGeneration: &config.ModelMetadataAdapter{LiteLLMProvider: "openai"}, Models: map[string]config.CatalogModel{"qwen": {UpstreamModel: "qwen2.5:7b", Session: &config.SessionModel{ContextWindow: 32768, MaxTokens: 8192}, MetadataGeneration: &config.MetadataGenerationModel{DisableThinking: true}}}},
},
},
}