fix: harden model catalog projections
This commit is contained in:
@@ -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}}}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user