Stream Gate 준비 실패에서도 요청 terminal 관측 계약을 지키고, direct provider identity 마이그레이션 누락으로 기존 검증과 운영 설정이 깨지지 않게 한다.
461 lines
14 KiB
Go
461 lines
14 KiB
Go
package configrefresh_test
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"iop/apps/edge/internal/configrefresh"
|
||
)
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
|
||
// TestClassifyStreamEvidenceGateEnabledRestartRequired verifies that toggling
|
||
// openai.stream_evidence_gate.enabled is classified as restart_required: the
|
||
// request runtime owns the response lifecycle for the whole serve process and
|
||
// cannot be swapped live.
|
||
func TestClassifyStreamEvidenceGateEnabledRestartRequired(t *testing.T) {
|
||
dir := t.TempDir()
|
||
|
||
openAIDisabled := `
|
||
openai:
|
||
enabled: true
|
||
listen: "127.0.0.1:8081"
|
||
provider_id: "test-provider"
|
||
stream_evidence_gate:
|
||
enabled: false
|
||
`
|
||
openAIEnabled := `
|
||
openai:
|
||
enabled: true
|
||
listen: "127.0.0.1:8081"
|
||
provider_id: "test-provider"
|
||
stream_evidence_gate:
|
||
enabled: true
|
||
`
|
||
currentPath := writeYAML(t, dir, "current.yaml", baseEdgeYAML+openAIDisabled)
|
||
candidatePath := writeYAML(t, dir, "candidate.yaml", baseEdgeYAML+openAIEnabled)
|
||
|
||
current := buildNormalizedCurrent(t, currentPath)
|
||
|
||
ctx := context.Background()
|
||
result, _, err := configrefresh.Evaluate(ctx, current, configrefresh.Request{
|
||
Mode: configrefresh.ModeDryRun,
|
||
ConfigPath: candidatePath,
|
||
RequestID: "test-stream-gate-enabled",
|
||
})
|
||
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 == "openai" && c.Class == configrefresh.StatusRestartRequired {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Errorf("openai 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")
|
||
}
|
||
}
|
||
|
||
// 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)
|
||
}
|
||
}
|