iop/apps/edge/internal/configrefresh/classify_test.go
toki 77ab36cbd1 feat: edge config refresh and OpenAI handlers update
- Add config refresh classification and test coverage
- Update OpenAI chat/responses/stream handlers with config refresh support
- Add comprehensive server tests for config refresh
- Update local dev guide with config refresh information
- Extend config package with ConfigRefresh field
2026-07-04 09:33:00 +09:00

1156 lines
32 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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"
default_max_tokens: 8192
min_max_tokens: 8192
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"
default_max_tokens: 32768
min_max_tokens: 32768
default_thinking_token_budget: 8192
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 := map[string]bool{}
for _, change := range result.Changes {
if change.Class == configrefresh.StatusApplied {
found[change.Path] = true
}
}
for _, path := range []string{
`models["qwen3.6:35b"].default_max_tokens`,
`models["qwen3.6:35b"].min_max_tokens`,
`models["qwen3.6:35b"].default_thinking_token_budget`,
`models["qwen3.6:35b"].providers`,
} {
if !found[path] {
t.Fatalf("model catalog change %s not found in %+v", path, 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)
}
}