- Add classify.go/classify_test.go for config-based priority routing - Add model_queue.go/test with priority queue support - Update run_dispatch.go to use priority routing - Add edge.yaml config for model priority settings - Add config.go/config_test.go for priority config parsing - Remove G06 priority routing plan/review (merged into main flow) - Add archive for m-node-provider-first-config-surface task
1144 lines
32 KiB
Go
1144 lines
32 KiB
Go
package configrefresh_test
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"path/filepath"
|
||
"testing"
|
||
|
||
"iop/apps/edge/internal/configrefresh"
|
||
"iop/packages/go/config"
|
||
)
|
||
|
||
func writeYAML(t *testing.T, dir, name, content string) string {
|
||
t.Helper()
|
||
path := filepath.Join(dir, name)
|
||
if err := os.WriteFile(path, []byte(content), 0o600); err != nil {
|
||
t.Fatalf("write %s: %v", name, err)
|
||
}
|
||
return path
|
||
}
|
||
|
||
// buildNormalizedCurrent creates a current config that mimics what a running
|
||
// serve process holds after loadRuntimeConfig normalization. Empty logging
|
||
// paths are resolved, explicit logging paths are preserved, and bootstrap
|
||
// artifact_dir is resolved to the effective runtime path.
|
||
func buildNormalizedCurrent(t *testing.T, path string) *config.EdgeConfig {
|
||
t.Helper()
|
||
cfg, err := config.LoadEdge(path)
|
||
if err != nil {
|
||
t.Fatalf("load current: %v", err)
|
||
}
|
||
// Apply the same normalization that loadRuntimeConfig performs.
|
||
// NOTE: explicit relative logging.path is preserved exactly to match
|
||
// edgecmd.ResolveEdgeLogPath – only empty paths are resolved.
|
||
if cfg.Logging.Path == "" {
|
||
cfg.Logging.Path = _normalizedLogPath()
|
||
}
|
||
// Explicit absolute / relative paths are intentionally left untouched.
|
||
cfg.Bootstrap.ArtifactDir = _normalizeArtifactDir(cfg.Bootstrap.ArtifactDir)
|
||
return cfg
|
||
}
|
||
|
||
func _normalizedLogPath() string {
|
||
if exe, err := os.Executable(); err == nil {
|
||
return filepath.Join(filepath.Dir(exe), "logs", "edge.log")
|
||
}
|
||
if wd, err := os.Getwd(); err == nil {
|
||
return filepath.Join(wd, "logs", "edge.log")
|
||
}
|
||
return "logs/edge.log"
|
||
}
|
||
|
||
func _normalizeArtifactDir(dir string) string {
|
||
if dir == "" {
|
||
dir = "artifacts"
|
||
}
|
||
if filepath.IsAbs(dir) {
|
||
return dir
|
||
}
|
||
return filepath.Join(binaryDirForTesting(), dir)
|
||
}
|
||
|
||
func binaryDirForTesting() string {
|
||
if exe, err := os.Executable(); err == nil {
|
||
return filepath.Dir(exe)
|
||
}
|
||
if wd, err := os.Getwd(); err == nil {
|
||
return wd
|
||
}
|
||
return "."
|
||
}
|
||
|
||
const baseEdgeYAML = `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
|
||
// TestClassifyProviderCapacityApplied verifies S07: provider capacity change is classified as applied.
|
||
func TestClassifyProviderCapacityApplied(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 8
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
ctx := context.Background()
|
||
result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-s07",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusApplied {
|
||
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
|
||
}
|
||
if len(result.Changes) == 0 {
|
||
t.Fatalf("expected at least one change")
|
||
}
|
||
found := false
|
||
for _, c := range result.Changes {
|
||
if c.Class == configrefresh.StatusApplied {
|
||
found = true
|
||
}
|
||
if c.Class == configrefresh.StatusRestartRequired {
|
||
t.Errorf("unexpected restart_required change: %s", c.Path)
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("no applied change found in: %+v", result.Changes)
|
||
}
|
||
}
|
||
|
||
// TestClassifyListenPortRestartRequired verifies S08: listen port change is classified as restart_required.
|
||
func TestClassifyListenPortRestartRequired(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:19090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
ctx := context.Background()
|
||
result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-s08",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusRestartRequired {
|
||
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusRestartRequired, result.Status, result.Summary)
|
||
}
|
||
found := false
|
||
for _, c := range result.Changes {
|
||
if c.Path == "server.listen" && c.Class == configrefresh.StatusRestartRequired {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("server.listen restart_required change not found in: %+v", result.Changes)
|
||
}
|
||
}
|
||
|
||
// TestRefreshInvalidYAMLRejected verifies S09: invalid YAML is classified as rejected.
|
||
func TestRefreshInvalidYAMLRejected(t *testing.T) {
|
||
dir := t.TempDir()
|
||
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
|
||
invalidPath := writeYAML(t, dir, "invalid.yaml", "nodes: [\n - bad: [unterminated")
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
ctx := context.Background()
|
||
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: invalidPath,
|
||
RequestID: "test-s09-yaml",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate should not return error for rejected config: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusRejected {
|
||
t.Errorf("expected status=%q, got %q", configrefresh.StatusRejected, result.Status)
|
||
}
|
||
if candidate != nil {
|
||
t.Errorf("expected nil candidate for rejected result")
|
||
}
|
||
}
|
||
|
||
func TestClassifyModelCatalogProviderMappingApplied(t *testing.T) {
|
||
dir := t.TempDir()
|
||
currentYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
- id: "prov-b"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
models:
|
||
- id: "qwen3.6:35b"
|
||
display_name: "Qwen"
|
||
providers:
|
||
prov-a: "llama3.1"
|
||
`
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
- id: "prov-b"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
models:
|
||
- id: "qwen3.6:35b"
|
||
display_name: "Qwen"
|
||
providers:
|
||
prov-b: "llama3.1"
|
||
`
|
||
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-model-catalog",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusApplied {
|
||
t.Fatalf("expected status=%q, got %q changes=%+v", configrefresh.StatusApplied, result.Status, result.Changes)
|
||
}
|
||
found := false
|
||
for _, change := range result.Changes {
|
||
if change.Path == `models["qwen3.6:35b"].providers` && change.Class == configrefresh.StatusApplied {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("model catalog providers change not found in %+v", result.Changes)
|
||
}
|
||
}
|
||
|
||
func TestClassifyProviderStructuralChangeRestartRequired(t *testing.T) {
|
||
dir := t.TempDir()
|
||
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "vllm"
|
||
category: "api"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-provider-structural",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusRestartRequired {
|
||
t.Fatalf("expected status=%q, got %q changes=%+v", configrefresh.StatusRestartRequired, result.Status, result.Changes)
|
||
}
|
||
found := false
|
||
for _, change := range result.Changes {
|
||
if change.Path == `nodes[].providers["prov-a"].type` && change.Class == configrefresh.StatusRestartRequired {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("provider structural type change not found in %+v", result.Changes)
|
||
}
|
||
}
|
||
|
||
// TestRefreshInvalidProviderRejected verifies S09: invalid provider config is classified as rejected.
|
||
func TestRefreshInvalidProviderRejected(t *testing.T) {
|
||
dir := t.TempDir()
|
||
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML)
|
||
// Invalid: shared validator rejects enabled openai_compat without endpoint.
|
||
invalidProviderYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
openai_compat:
|
||
enabled: true
|
||
endpoint: ""
|
||
`
|
||
invalidPath := writeYAML(t, dir, "invalid_provider.yaml", invalidProviderYAML)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
ctx := context.Background()
|
||
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: invalidPath,
|
||
RequestID: "test-s09-provider",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate should not return error for rejected config: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusRejected {
|
||
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusRejected, result.Status, result.Summary)
|
||
}
|
||
if candidate != nil {
|
||
t.Errorf("expected nil candidate for rejected result")
|
||
}
|
||
}
|
||
|
||
// TestClassifyServeNormalizedRefreshApplied verifies the archived bug:
|
||
// when current config is serve-normalized (logging.path absolute,
|
||
// bootstrap.artifact_dir absolute) and candidate uses source-level defaults
|
||
// (empty logging.path, relative bootstrap.artifact_dir) with only provider
|
||
// capacity changes, the result must be applied, NOT restart_required.
|
||
func TestClassifyServeNormalizedRefreshApplied(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
// Source-level YAML with empty logging.path and relative artifact_dir.
|
||
// Only capacity differs (2 → 8).
|
||
sourceYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
bootstrap:
|
||
artifact_dir: artifacts
|
||
logging:
|
||
level: info
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
// Candidate: only capacity changes to 8; logging/artifact_dir are same source-level defaults.
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
bootstrap:
|
||
artifact_dir: artifacts
|
||
logging:
|
||
level: info
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 8
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
|
||
currentPath := writeYAML(t, dir, "current.yaml", sourceYAML)
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
// Build current as serve-normalized: explicit absolute paths.
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
// Verify that the current config actually has normalized paths.
|
||
if current.Logging.Path == "" {
|
||
t.Fatalf("expected normalized logging.path, got empty")
|
||
}
|
||
if current.Bootstrap.ArtifactDir == "" {
|
||
t.Fatalf("expected normalized bootstrap.artifact_dir, got empty")
|
||
}
|
||
|
||
// Evaluate the candidate via LoadCandidate (which now applies normalization).
|
||
ctx := context.Background()
|
||
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-serve-normalized",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
|
||
// Key assertion: capacity-only refresh must NOT be restart_required.
|
||
if result.Status != configrefresh.StatusApplied {
|
||
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
|
||
}
|
||
|
||
// Ensure no restart_required changes for logging or bootstrap.artifact_dir.
|
||
for _, c := range result.Changes {
|
||
if c.Class == configrefresh.StatusRestartRequired {
|
||
t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next)
|
||
}
|
||
}
|
||
|
||
// Verify the capacity change is captured as applied.
|
||
foundCapacity := false
|
||
for _, c := range result.Changes {
|
||
if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied {
|
||
foundCapacity = true
|
||
if c.Previous != "2" || c.Next != "8" {
|
||
t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next)
|
||
}
|
||
}
|
||
}
|
||
if !foundCapacity {
|
||
t.Fatalf("capacity change not found in applied changes: %+v", result.Changes)
|
||
}
|
||
|
||
// Verify the candidate is also normalized.
|
||
if candidate == nil {
|
||
t.Fatalf("expected non-nil candidate")
|
||
}
|
||
if candidate.Logging.Path == "" {
|
||
t.Fatalf("expected normalized candidate logging.path, got empty")
|
||
}
|
||
}
|
||
|
||
// TestClassifyExplicitRelativeLoggingPathNoRestartRequired verifies that when
|
||
// both current and candidate use the same explicit relative logging.path
|
||
// (e.g. "./logs/edge.log") and only provider capacity differs, the refresh
|
||
// is classified as applied – NOT restart_required.
|
||
//
|
||
// This is the regression test for the archived bug where applyRuntimeNormalization
|
||
// called filepath.Clean on explicit relative paths, producing a false diff
|
||
// against the serve-side value that preserves "./logs/edge.log" unchanged.
|
||
func TestClassifyExplicitRelativeLoggingPathNoRestartRequired(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
// Current config with explicit relative logging.path – mimics what serve
|
||
// holds in memory after loading YAML with logging.path: "./logs/edge.log".
|
||
currentYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
logging:
|
||
path: "./logs/edge.log"
|
||
level: info
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
|
||
// Candidate: same explicit relative logging.path, only capacity changes.
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
logging:
|
||
path: "./logs/edge.log"
|
||
level: info
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 8
|
||
max_queue: 4
|
||
queue_timeout_ms: 5000
|
||
`
|
||
|
||
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
// buildNormalizedCurrent preserves the explicit relative logging.path.
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
// Verify the current config still has the explicit relative path.
|
||
if current.Logging.Path != "./logs/edge.log" {
|
||
t.Fatalf("expected current logging.path to remain './logs/edge.log', got %q", current.Logging.Path)
|
||
}
|
||
|
||
ctx := context.Background()
|
||
result, candidate, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-explicit-relative-logpath",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
|
||
// Key assertion: capacity-only refresh with same explicit relative path must be applied.
|
||
if result.Status != configrefresh.StatusApplied {
|
||
t.Errorf("expected status=%q, got %q (summary: %s)", configrefresh.StatusApplied, result.Status, result.Summary)
|
||
}
|
||
|
||
// Ensure no restart_required changes at all.
|
||
for _, c := range result.Changes {
|
||
if c.Class == configrefresh.StatusRestartRequired {
|
||
t.Errorf("unexpected restart_required change for capacity-only refresh: %s (prev=%s, next=%s)", c.Path, c.Previous, c.Next)
|
||
}
|
||
}
|
||
|
||
// Verify the capacity change is captured as applied.
|
||
foundCapacity := false
|
||
for _, c := range result.Changes {
|
||
if c.Path == `nodes[].providers["prov-a"].capacity` && c.Class == configrefresh.StatusApplied {
|
||
foundCapacity = true
|
||
if c.Previous != "2" || c.Next != "8" {
|
||
t.Errorf("expected capacity change 2→8, got %s→%s", c.Previous, c.Next)
|
||
}
|
||
}
|
||
}
|
||
if !foundCapacity {
|
||
t.Fatalf("capacity change not found in applied changes: %+v", result.Changes)
|
||
}
|
||
|
||
// Candidate should also preserve the explicit relative path.
|
||
if candidate == nil {
|
||
t.Fatalf("expected non-nil candidate")
|
||
}
|
||
if candidate.Logging.Path != "./logs/edge.log" {
|
||
t.Errorf("expected candidate logging.path to remain './logs/edge.log', got %q", candidate.Logging.Path)
|
||
}
|
||
}
|
||
|
||
// TestClassifyDryRunTwiceNoDiff verifies that after apply, re-running dry-run
|
||
// on the same candidate produces no diff.
|
||
func TestClassifyDryRunTwiceNoDiff(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
baseYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
`
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 4
|
||
`
|
||
|
||
currentPath := writeYAML(t, dir, "current.yaml", baseYAML)
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
ctx := context.Background()
|
||
|
||
// First dry-run: should detect capacity change.
|
||
result1, cand1, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-first-dryrun",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("first Evaluate: %v", err)
|
||
}
|
||
if result1.Status != configrefresh.StatusApplied {
|
||
t.Fatalf("first dry-run: expected status=%q, got %q", configrefresh.StatusApplied, result1.Status)
|
||
}
|
||
|
||
// Second dry-run with the same candidate loaded again.
|
||
// The candidate path still exists, so LoadCandidate re-reads the same YAML.
|
||
result2, cand2, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-second-dryrun",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("second Evaluate: %v", err)
|
||
}
|
||
|
||
// Both results should be identical (same changes).
|
||
if len(result1.Changes) != len(result2.Changes) {
|
||
t.Fatalf("expected same number of changes, got %d vs %d: result1=%+v result2=%+v",
|
||
len(result1.Changes), len(result2.Changes), result1.Changes, result2.Changes)
|
||
}
|
||
|
||
// Both candidates should be normalized identically.
|
||
if cand1 == nil || cand2 == nil {
|
||
t.Fatalf("expected non-nil candidates")
|
||
}
|
||
if cand1.Logging.Path != cand2.Logging.Path {
|
||
t.Fatalf("candidate logging.path mismatch: %s vs %s", cand1.Logging.Path, cand2.Logging.Path)
|
||
}
|
||
}
|
||
|
||
// TestClassifyNodeRuntimeConcurrencyApplied verifies S15: node runtime
|
||
// concurrency is applied live.
|
||
func TestClassifyNodeRuntimeConcurrencyApplied(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
currentYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
runtime:
|
||
concurrency: 2
|
||
`
|
||
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
|
||
|
||
concurrencyOnlyYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
runtime:
|
||
concurrency: 4
|
||
`
|
||
concurrencyOnlyPath := writeYAML(t, dir, "concurrency.yaml", concurrencyOnlyYAML)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
ctx := context.Background()
|
||
|
||
// concurrency-only change → applied.
|
||
result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: concurrencyOnlyPath,
|
||
RequestID: "test-s15-conc",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate concurrency: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusApplied {
|
||
t.Fatalf("expected concurrency change status=applied, got %q (summary: %s)", result.Status, result.Summary)
|
||
}
|
||
foundConc := false
|
||
for _, c := range result.Changes {
|
||
if c.Path == `nodes["node-1"].runtime.concurrency` {
|
||
foundConc = true
|
||
if c.Class != configrefresh.StatusApplied {
|
||
t.Errorf("expected concurrency class=applied, got %q", c.Class)
|
||
}
|
||
}
|
||
if c.Class == configrefresh.StatusRestartRequired {
|
||
t.Errorf("unexpected restart_required change for concurrency-only diff: %s", c.Path)
|
||
}
|
||
}
|
||
if !foundConc {
|
||
t.Errorf("concurrency change path not found in: %+v", result.Changes)
|
||
}
|
||
|
||
}
|
||
|
||
// TestClassifyProviderEnabledLiveApply verifies that toggling providers[].enabled
|
||
// is classified as StatusApplied (live apply, no restart required).
|
||
func TestClassifyProviderEnabledLiveApply(t *testing.T) {
|
||
dir := t.TempDir()
|
||
currentYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
`
|
||
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
|
||
// Candidate disables prov-a.
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
enabled: false
|
||
`
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-enabled-liveapply",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusApplied {
|
||
t.Fatalf("expected status=applied for enabled toggle, got %q", result.Status)
|
||
}
|
||
foundEnabled := false
|
||
for _, c := range result.Changes {
|
||
if c.Path == `nodes[].providers["prov-a"].enabled` {
|
||
foundEnabled = true
|
||
if c.Class != configrefresh.StatusApplied {
|
||
t.Errorf("providers[].enabled class: got %q, want applied", c.Class)
|
||
}
|
||
if c.Previous != "true" || c.Next != "false" {
|
||
t.Errorf("providers[].enabled change: prev=%q next=%q", c.Previous, c.Next)
|
||
}
|
||
}
|
||
if c.Class == configrefresh.StatusRestartRequired {
|
||
t.Errorf("unexpected restart_required change for enabled-only diff: %s", c.Path)
|
||
}
|
||
}
|
||
if !foundEnabled {
|
||
t.Errorf("providers[].enabled change path not found in: %+v", result.Changes)
|
||
}
|
||
}
|
||
|
||
// TestClassifyProviderFirstEndpointOnlyRestartRequired verifies that changing
|
||
// only the provider endpoint (type/category unchanged) is classified as
|
||
// restart_required and surfaces the correct path.
|
||
func TestClassifyProviderFirstEndpointOnlyRestartRequired(t *testing.T) {
|
||
dir := t.TempDir()
|
||
currentYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
providers:
|
||
- id: "prov-oc"
|
||
type: "openai_compat"
|
||
category: "api"
|
||
endpoint: "http://127.0.0.1:8000/v1"
|
||
models: ["model-a"]
|
||
capacity: 2
|
||
`
|
||
// Only endpoint changes — type, category, models, capacity are identical.
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
providers:
|
||
- id: "prov-oc"
|
||
type: "openai_compat"
|
||
category: "api"
|
||
endpoint: "http://127.0.0.1:9000/v1"
|
||
models: ["model-a"]
|
||
capacity: 2
|
||
`
|
||
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-endpoint-only",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusRestartRequired {
|
||
t.Fatalf("expected status=restart_required for endpoint-only change, got %q; changes=%+v", result.Status, result.Changes)
|
||
}
|
||
found := false
|
||
for _, c := range result.Changes {
|
||
if c.Path == `nodes[].providers["prov-oc"].endpoint` && c.Class == configrefresh.StatusRestartRequired {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("expected restart_required change for providers[].endpoint; changes=%+v", result.Changes)
|
||
}
|
||
}
|
||
|
||
// TestClassifyProviderFirstHeadersChangeRestartRequired verifies that changing
|
||
// a provider headers map (type/category unchanged) is classified as
|
||
// restart_required — covers map deep-diff path.
|
||
func TestClassifyProviderFirstHeadersChangeRestartRequired(t *testing.T) {
|
||
dir := t.TempDir()
|
||
currentYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
providers:
|
||
- id: "prov-oc"
|
||
type: "openai_compat"
|
||
category: "api"
|
||
endpoint: "http://127.0.0.1:8000/v1"
|
||
headers:
|
||
Authorization: "Bearer old-token"
|
||
models: ["model-a"]
|
||
capacity: 2
|
||
`
|
||
// Only headers change.
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
providers:
|
||
- id: "prov-oc"
|
||
type: "openai_compat"
|
||
category: "api"
|
||
endpoint: "http://127.0.0.1:8000/v1"
|
||
headers:
|
||
Authorization: "Bearer new-token"
|
||
models: ["model-a"]
|
||
capacity: 2
|
||
`
|
||
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-headers-only",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusRestartRequired {
|
||
t.Fatalf("expected status=restart_required for headers change, got %q; changes=%+v", result.Status, result.Changes)
|
||
}
|
||
found := false
|
||
for _, c := range result.Changes {
|
||
if c.Path == `nodes[].providers["prov-oc"].headers` && c.Class == configrefresh.StatusRestartRequired {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("expected restart_required change for providers[].headers; changes=%+v", result.Changes)
|
||
}
|
||
}
|
||
|
||
func TestClassifyProviderFirstExecutionFieldRestartRequired(t *testing.T) {
|
||
dir := t.TempDir()
|
||
currentYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
vllm:
|
||
enabled: true
|
||
endpoint: "http://127.0.0.1:8000/v1"
|
||
providers:
|
||
- id: "prov-vllm"
|
||
type: "vllm"
|
||
category: "api"
|
||
endpoint: "http://127.0.0.1:8000/v1"
|
||
models: ["model-a"]
|
||
capacity: 2
|
||
`
|
||
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
|
||
// Candidate changes endpoint (provider-first execution field) → restart required.
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
vllm:
|
||
enabled: true
|
||
endpoint: "http://127.0.0.1:8080/v1"
|
||
providers:
|
||
- id: "prov-vllm"
|
||
type: "openai_compat"
|
||
category: "api"
|
||
endpoint: "http://127.0.0.1:8080/v1"
|
||
models: ["model-a"]
|
||
capacity: 2
|
||
`
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-provfirst-restart",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusRestartRequired {
|
||
t.Fatalf("expected status=restart_required for provider type change, got %q", result.Status)
|
||
}
|
||
hasTypeRestart := false
|
||
for _, c := range result.Changes {
|
||
if c.Path == `nodes[].providers["prov-vllm"].type` && c.Class == configrefresh.StatusRestartRequired {
|
||
hasTypeRestart = true
|
||
}
|
||
}
|
||
if !hasTypeRestart {
|
||
t.Errorf("expected restart_required for providers[].type change; changes: %+v", result.Changes)
|
||
}
|
||
}
|
||
|
||
// TestClassifyProviderPriorityApplied verifies that provider priority change
|
||
// is classified as StatusApplied (live apply, no restart required).
|
||
func TestClassifyProviderPriorityApplied(t *testing.T) {
|
||
dir := t.TempDir()
|
||
currentYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
priority: 5
|
||
`
|
||
currentPath := writeYAML(t, dir, "current.yaml", currentYAML)
|
||
// Candidate only changes priority.
|
||
candidateYAML := `
|
||
server:
|
||
listen: "0.0.0.0:9090"
|
||
nodes:
|
||
- id: "node-1"
|
||
alias: "n1"
|
||
token: "tok-1"
|
||
adapters:
|
||
cli:
|
||
enabled: true
|
||
providers:
|
||
- id: "prov-a"
|
||
type: "ollama"
|
||
category: "local_inference"
|
||
adapter: "cli"
|
||
models: ["llama3.1"]
|
||
capacity: 2
|
||
priority: 1
|
||
`
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", candidateYAML)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
result, _, err := configrefresh.Evaluate(context.Background(), current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-priority-applied",
|
||
})
|
||
if err != nil {
|
||
t.Fatalf("Evaluate: %v", err)
|
||
}
|
||
if result.Status != configrefresh.StatusApplied {
|
||
t.Fatalf("expected status=applied for priority-only change, got %q (summary: %s)", result.Status, result.Summary)
|
||
}
|
||
foundPriority := false
|
||
for _, c := range result.Changes {
|
||
if c.Path == `nodes[].providers["prov-a"].priority` {
|
||
foundPriority = true
|
||
if c.Class != configrefresh.StatusApplied {
|
||
t.Errorf("providers[].priority class: got %q, want applied", c.Class)
|
||
}
|
||
if c.Previous != "5" || c.Next != "1" {
|
||
t.Errorf("providers[].priority change: prev=%q next=%q", c.Previous, c.Next)
|
||
}
|
||
}
|
||
if c.Class == configrefresh.StatusRestartRequired {
|
||
t.Errorf("unexpected restart_required change for priority-only diff: %s", c.Path)
|
||
}
|
||
}
|
||
if !foundPriority {
|
||
t.Errorf("providers[].priority change path not found in: %+v", result.Changes)
|
||
}
|
||
}
|