From ece764f19ef05a573556fe114f7c8a55f3051c21 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 13 Oct 2026 15:53:27 +0000 Subject: [PATCH] Load backend smoke-test metadata --- backends.go | 66 +++++++++- construction_contract_test.go | 61 +++++++++ docs/formats.md | 38 +++++- docs/roadmap/implementation.md | 4 + internal/backend/registry.go | 17 ++- internal/backend/registry_test.go | 36 ++++++ internal/configuration/load.go | 73 ++++++++++- internal/configuration/load_test.go | 187 ++++++++++++++++++++++++++++ internal/configuration/targets.go | 17 ++- 9 files changed, 473 insertions(+), 26 deletions(-) diff --git a/backends.go b/backends.go index 3286ac4..d64ed0a 100644 --- a/backends.go +++ b/backends.go @@ -17,8 +17,9 @@ const BackendLocal = "local" // BackendConfig is a presence-aware convenience value for a keyed backend // definition. Nil pointer fields are omitted, while non-nil pointers preserve // explicit zero or blank values. A nil ExtraParams map is omitted; a non-nil -// empty map supplies an empty mapping. Use raw ConfigKit Mapping inputs for -// explicit null. BackendConfig has no stable JSON representation. +// empty map supplies an empty mapping. A nil SmokeTests pointer is omitted; a +// non-nil pointer supplies a smoke_tests mapping. Use raw ConfigKit Mapping +// inputs for explicit null. BackendConfig has no stable JSON representation. type BackendConfig struct { // Endpoint is the required absolute HTTP(S) base URL after all layers merge. Endpoint *string @@ -31,6 +32,43 @@ type BackendConfig struct { QueueCapacity *int // ExtraParams supplies JSON-compatible provider request defaults. ExtraParams map[string]any + // SmokeTests supplies optional diagnostic defaults and HTTP probe settings. + SmokeTests *BackendSmokeTestConfig +} + +// BackendSmokeTestConfig contains optional diagnostic settings associated with +// a backend. Nil nested fields are omitted by BackendSource. +type BackendSmokeTestConfig struct { + // DefaultProfile is the exact profile ID selected as the backend's + // diagnostic inference default. A supplied value must not be blank; the ID + // is resolved only when that default is selected. + DefaultProfile *string + // Reachability optionally selects a full HTTP destination for reachability + // diagnostics. A nil endpoint uses the effective backend endpoint. + Reachability *ReachabilityProbeConfig + // Authentication optionally declares a named authentication diagnostic. + // A supplied object requires Kind. + Authentication *AuthenticationProbeConfig +} + +// ReachabilityProbeConfig configures an optional reachability destination. +type ReachabilityProbeConfig struct { + // Endpoint is an absolute HTTP(S) URL, including any desired path or query. + // Nil selects the effective backend endpoint; an explicit blank value is + // invalid. + Endpoint *string +} + +// AuthenticationProbeConfig configures an optional named authentication +// diagnostic. +type AuthenticationProbeConfig struct { + // Kind selects the authentication adapter. Supported values are "none" and + // "openrouter_key"; surrounding whitespace and case are normalized. + Kind *string + // Endpoint is an absolute HTTP(S) URL, including any desired path or query. + // Nil selects the adapter's backend-relative destination; an explicit blank + // value is invalid. + Endpoint *string } // BackendSource creates a labeled ConfigKit mapping from keyed backend values. @@ -57,6 +95,30 @@ func BackendSource(label string, backends map[string]BackendConfig) configkit.So if config.ExtraParams != nil { fields["extra_params"] = providerMapForMapping(config.ExtraParams) } + if config.SmokeTests != nil { + smokeTests := make(map[string]any) + if config.SmokeTests.DefaultProfile != nil { + smokeTests["default_profile"] = *config.SmokeTests.DefaultProfile + } + if config.SmokeTests.Reachability != nil { + reachability := make(map[string]any) + if config.SmokeTests.Reachability.Endpoint != nil { + reachability["endpoint"] = *config.SmokeTests.Reachability.Endpoint + } + smokeTests["reachability"] = reachability + } + if config.SmokeTests.Authentication != nil { + authentication := make(map[string]any) + if config.SmokeTests.Authentication.Kind != nil { + authentication["kind"] = *config.SmokeTests.Authentication.Kind + } + if config.SmokeTests.Authentication.Endpoint != nil { + authentication["endpoint"] = *config.SmokeTests.Authentication.Endpoint + } + smokeTests["authentication"] = authentication + } + fields["smoke_tests"] = smokeTests + } definitions[id] = fields } return configkit.Mapping(label, map[string]any{"backends": definitions}) diff --git a/construction_contract_test.go b/construction_contract_test.go index 48e38f2..8438010 100644 --- a/construction_contract_test.go +++ b/construction_contract_test.go @@ -1,6 +1,7 @@ package promptkit_test import ( + "bytes" "context" "errors" "io" @@ -208,6 +209,66 @@ func TestPublicConstructionCanOverlayMaintainedBackend(t *testing.T) { } } +func TestBackendSourceOwnsSmokeTestValuesAndDefersDefaultLookup(t *testing.T) { + backendEndpoint := "https://backend.example/v1" + defaultProfile := "not-yet-listed" + reachabilityEndpoint := " https://probe.example/ready?check=1 " + authenticationKind := " OPENROUTER_KEY " + authenticationEndpoint := "https://probe.example/key?scope=current" + source := promptkit.BackendSource("diagnostic backend", map[string]promptkit.BackendConfig{ + "local": { + Endpoint: &backendEndpoint, + SmokeTests: &promptkit.BackendSmokeTestConfig{ + DefaultProfile: &defaultProfile, + Reachability: &promptkit.ReachabilityProbeConfig{Endpoint: &reachabilityEndpoint}, + Authentication: &promptkit.AuthenticationProbeConfig{ + Kind: &authenticationKind, Endpoint: &authenticationEndpoint, + }, + }, + }, + }) + backendEndpoint = "https://mutated.example/v1" + defaultProfile = "mutated-profile" + reachabilityEndpoint = "https://mutated.example/ready" + authenticationKind = "none" + authenticationEndpoint = "https://mutated.example/key" + + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: source}}}}, + }}) + if err != nil { + t.Fatalf("construct engine with dangling diagnostic default: %v", err) + } + inspection, err := engine.InspectConfiguration() + if err != nil { + t.Fatal(err) + } + var effective bytes.Buffer + if err := inspection.WriteEffective(&effective); err != nil { + t.Fatal(err) + } + contents := effective.String() + for _, want := range []string{ + "https://backend.example/v1", + "not-yet-listed", + "https://probe.example/ready?check=1", + "openrouter_key", + "https://probe.example/key?scope=current", + } { + if !strings.Contains(contents, want) { + t.Errorf("effective configuration does not contain %q:\n%s", want, contents) + } + } + for _, unwanted := range []string{ + "mutated.example", + "mutated-profile", + } { + if strings.Contains(contents, unwanted) { + t.Errorf("effective configuration unexpectedly contains %q:\n%s", unwanted, contents) + } + } +} + func TestPreparationRejectsAmbiguousPromptVersion(t *testing.T) { definition := func(content string) map[string]any { return map[string]any{ diff --git a/docs/formats.md b/docs/formats.md index 6930865..c70f594 100644 --- a/docs/formats.md +++ b/docs/formats.md @@ -168,16 +168,46 @@ are not reserved against configuration changes. | `rakestrawhome` | `https://inference.ai.rakestrawhome.com/v1` | `RAKESTRAWHOME_INFERENCE_API_KEY` | 4 | A backend definition may have `endpoint`, `api_key_env`, -`concurrency_limit`, `queue_capacity`, and `extra_params`. The final endpoint -is required and must be an absolute HTTP(S) URL with a host, no user -information, query, or fragment. An omitted or null API-key environment name -means no lookup. Omitted/null concurrency is zero (unlimited); a supplied +`concurrency_limit`, `queue_capacity`, `extra_params`, and `smoke_tests`. The +final endpoint is required and must be an absolute HTTP(S) URL with a host, no +user information, query, or fragment. An omitted or null API-key environment +name means no lookup. Omitted/null concurrency is zero (unlimited); a supplied negative value is invalid. For positive concurrency, omitted/null queue capacity defaults to 1024. An explicit queue capacity, including zero, requires positive concurrency and must be nonnegative. Unlimited concurrency has no queue. Provider maps contain JSON-compatible values; null entries are ordinary JSON null. Unknown fields, including a raw `api_key`, fail. +`smoke_tests` is optional backend metadata for explicit diagnostic operations. +It may contain `default_profile`, `reachability`, and `authentication`: + +```yaml +backends: + openrouter: + smoke_tests: + default_profile: promptkit-openrouter-standard + reachability: + endpoint: https://status.example/ready?check=1 + authentication: + kind: openrouter_key + endpoint: https://openrouter.ai/api/v1/key +``` + +An omitted or null `smoke_tests` block disables these metadata defaults. +`default_profile` is preserved as an exact, nonblank profile ID; its existence +is checked only if a diagnostic selects that default. Omitted/null +`reachability` or its endpoint uses the effective backend endpoint. An explicit +reachability endpoint is trimmed and must be an absolute HTTP(S) URL with a +host; unlike the backend base endpoint, its path and query are allowed. Null +means fallback, while an explicit blank value is invalid. + +Omitted/null `authentication` means no authentication adapter is declared. A +non-null object requires `kind`, which is trimmed and case-normalized; the +recognized values are `none` and `openrouter_key`. Its omitted/null endpoint +uses the adapter's backend-relative destination; an explicit endpoint follows +the same full-URL rules as reachability. These values are configuration only: +engine construction does not contact a backend. + ## Profile Definitions Profiles are keyed by literal ID and may inherit a named parent with diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 613d21f..f25f396 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -264,6 +264,10 @@ Validate: `go test ./internal/configuration ./internal/backend` and focused root backend-source tests. Update backend-field GoDoc and `docs/formats.md` as these configuration fields land; runtime smoke-test guidance waits for Stage 18. +**Completed: 2026-10-13.** Validation passed: +`go test ./internal/configuration ./internal/backend`, `go test .`, and +`git diff --check`. + ## Stage 3: Resolve Diagnostic Targets Without Preparing Applications Scope: selection helpers in `internal/usecase`; no transport. diff --git a/internal/backend/registry.go b/internal/backend/registry.go index 5f452a3..0c82903 100644 --- a/internal/backend/registry.go +++ b/internal/backend/registry.go @@ -7,7 +7,6 @@ import ( "fmt" "gitea.maximumdirect.net/eric/promptkit/internal/domain" - "gitea.maximumdirect.net/eric/promptkit/internal/jsonvalue" ) const ( @@ -31,13 +30,12 @@ type Registry struct { func NewEffectiveRegistry(definitions map[string]domain.Backend) (*Registry, error) { registry := &Registry{backends: make(map[string]domain.Backend, len(definitions))} for id, definition := range definitions { - params, err := jsonvalue.CopyMap(definition.ExtraParams) - if err != nil { - return nil, fmt.Errorf("backend %q extra parameters: %w", id, err) - } definition.ID = id - definition.ExtraParams = params - registry.backends[id] = definition + cloned, err := domain.CloneBackend(definition) + if err != nil { + return nil, fmt.Errorf("backend %q: %w", id, err) + } + registry.backends[id] = cloned } return registry, nil } @@ -51,12 +49,11 @@ func (r *Registry) GetBackend(id string) (domain.Backend, error) { if !ok { return domain.Backend{}, fmt.Errorf("%w: %q", ErrBackendNotFound, id) } - extraParams, err := jsonvalue.CopyMap(definition.ExtraParams) + cloned, err := domain.CloneBackend(definition) if err != nil { return domain.Backend{}, fmt.Errorf("copy backend %q: %w", id, err) } - definition.ExtraParams = extraParams - return definition, nil + return cloned, nil } // CapacityPolicies returns a copy of the normalized policies for limited diff --git a/internal/backend/registry_test.go b/internal/backend/registry_test.go index 049db47..40a7be2 100644 --- a/internal/backend/registry_test.go +++ b/internal/backend/registry_test.go @@ -11,25 +11,61 @@ import ( func TestEffectiveRegistryOwnsDefinitionsAndCapacityPolicies(t *testing.T) { params := map[string]any{"nested": map[string]any{"enabled": true}} + defaultProfile := "catalog-profile" + reachabilityEndpoint := "https://probe.example/ready" + authenticationKind := "openrouter_key" + authenticationEndpoint := "https://probe.example/key" + smokeTests := &domain.BackendSmokeTestConfig{ + DefaultProfile: &defaultProfile, + Reachability: &domain.ReachabilityProbeConfig{Endpoint: &reachabilityEndpoint}, + Authentication: &domain.AuthenticationProbeConfig{ + Kind: &authenticationKind, Endpoint: &authenticationEndpoint, + }, + } registry, err := backend.NewEffectiveRegistry(map[string]domain.Backend{ " local.v1 ": { Endpoint: "https://backend.example/v1", ExtraParams: params, ConcurrencyLimit: 2, QueueCapacity: 3, QueueCapacitySet: true, + SmokeTests: smokeTests, }, }) if err != nil { t.Fatal(err) } params["nested"].(map[string]any)["enabled"] = false + defaultProfile = "mutated-profile" + reachabilityEndpoint = "https://mutated.example/ready" + authenticationKind = "none" + authenticationEndpoint = "https://mutated.example/key" selected, err := registry.GetBackend(" local.v1 ") if err != nil || selected.ID != " local.v1 " || selected.ExtraParams["nested"].(map[string]any)["enabled"] != true { t.Fatalf("effective backend was not captured under its literal ID: %+v, %v", selected, err) } + if selected.SmokeTests == nil || selected.SmokeTests.DefaultProfile == nil || + *selected.SmokeTests.DefaultProfile != "catalog-profile" || selected.SmokeTests.Reachability == nil || + selected.SmokeTests.Reachability.Endpoint == nil || *selected.SmokeTests.Reachability.Endpoint != "https://probe.example/ready" || + selected.SmokeTests.Authentication == nil || selected.SmokeTests.Authentication.Kind == nil || + *selected.SmokeTests.Authentication.Kind != "openrouter_key" || selected.SmokeTests.Authentication.Endpoint == nil || + *selected.SmokeTests.Authentication.Endpoint != "https://probe.example/key" { + t.Fatalf("registry did not capture nested diagnostic metadata: %+v", selected.SmokeTests) + } selected.ExtraParams["nested"].(map[string]any)["enabled"] = false + *selected.SmokeTests.DefaultProfile = "mutated-return" + *selected.SmokeTests.Reachability.Endpoint = "https://mutated-return.example/ready" + *selected.SmokeTests.Authentication.Kind = "none" + *selected.SmokeTests.Authentication.Endpoint = "https://mutated-return.example/key" again, err := registry.GetBackend(" local.v1 ") if err != nil || again.ExtraParams["nested"].(map[string]any)["enabled"] != true { t.Fatalf("lookup exposed registry values: %+v, %v", again, err) } + if again.SmokeTests == nil || again.SmokeTests.DefaultProfile == nil || + *again.SmokeTests.DefaultProfile != "catalog-profile" || again.SmokeTests.Reachability == nil || + again.SmokeTests.Reachability.Endpoint == nil || *again.SmokeTests.Reachability.Endpoint != "https://probe.example/ready" || + again.SmokeTests.Authentication == nil || again.SmokeTests.Authentication.Kind == nil || + *again.SmokeTests.Authentication.Kind != "openrouter_key" || again.SmokeTests.Authentication.Endpoint == nil || + *again.SmokeTests.Authentication.Endpoint != "https://probe.example/key" { + t.Fatalf("lookup exposed nested diagnostic metadata: %+v", again.SmokeTests) + } policies := registry.CapacityPolicies() if !reflect.DeepEqual(policies, map[string]domain.BackendCapacityPolicy{ " local.v1 ": {ConcurrencyLimit: 2, QueueCapacity: 3}, diff --git a/internal/configuration/load.go b/internal/configuration/load.go index e9c9bf0..c4970c3 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -4,6 +4,7 @@ package configuration import ( "fmt" + "net/http" "regexp" "sort" "strings" @@ -22,11 +23,27 @@ type document struct { } type backendConfig struct { - Endpoint *string `yaml:"endpoint"` - APIKeyEnv *string `yaml:"api_key_env"` - ConcurrencyLimit *int `yaml:"concurrency_limit"` - QueueCapacity *int `yaml:"queue_capacity"` - ExtraParams map[string]any `yaml:"extra_params"` + Endpoint *string `yaml:"endpoint"` + APIKeyEnv *string `yaml:"api_key_env"` + ConcurrencyLimit *int `yaml:"concurrency_limit"` + QueueCapacity *int `yaml:"queue_capacity"` + ExtraParams map[string]any `yaml:"extra_params"` + SmokeTests *backendSmokeTestConfig `yaml:"smoke_tests"` +} + +type backendSmokeTestConfig struct { + DefaultProfile *string `yaml:"default_profile"` + Reachability *reachabilityProbeConfig `yaml:"reachability"` + Authentication *authenticationProbeConfig `yaml:"authentication"` +} + +type reachabilityProbeConfig struct { + Endpoint *string `yaml:"endpoint"` +} + +type authenticationProbeConfig struct { + Kind *string `yaml:"kind"` + Endpoint *string `yaml:"endpoint"` } type profileConfig struct { @@ -104,7 +121,7 @@ type loaded struct { var envName = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) -var backendFields = []string{"endpoint", "api_key_env", "concurrency_limit", "queue_capacity", "extra_params"} +var backendFields = []string{"endpoint", "api_key_env", "concurrency_limit", "queue_capacity", "extra_params", "smoke_tests"} var profileFields = []string{"backend", "endpoint", "model", "temperature", "top_p", "max_tokens", "timeout_seconds", "service_tier", "reasoning_effort", "api_key_env", "api_key_required", "extra_params"} // Load captures one immutable effective configuration for internal consumers. @@ -242,6 +259,21 @@ func resolveBackends(backends map[string]backendConfig, resolution *configkit.Re if backend.APIKeyEnv != nil { normalizeString(&backend.APIKeyEnv, base.Key("api_key_env"), resolution, strings.TrimSpace(*backend.APIKeyEnv)) } + if smokeTests := backend.SmokeTests; smokeTests != nil { + smokePath := base.Key("smoke_tests") + if smokeTests.Reachability != nil { + normalizeDiagnosticEndpoint(&smokeTests.Reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), resolution) + } + if smokeTests.Authentication != nil { + if smokeTests.Authentication.Kind != nil { + normalizeString(&smokeTests.Authentication.Kind, + smokePath.Key("authentication").Key("kind"), resolution, + strings.ToLower(strings.TrimSpace(*smokeTests.Authentication.Kind))) + } + normalizeDiagnosticEndpoint(&smokeTests.Authentication.Endpoint, + smokePath.Key("authentication").Key("endpoint"), resolution) + } + } if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit > 0 && backend.QueueCapacity == nil { capacity := 1024 if resolution.IsExplicit(base.Key("queue_capacity")) { @@ -254,6 +286,21 @@ func resolveBackends(backends map[string]backendConfig, resolution *configkit.Re } } +func normalizeDiagnosticEndpoint(value **string, path configkit.Path, resolution *configkit.Resolution) { + if value == nil || *value == nil { + return + } + normalized := strings.TrimSpace(**value) + request, err := http.NewRequest(http.MethodGet, normalized, nil) + if normalized == "" || err != nil || request.URL == nil || + (strings.ToLower(request.URL.Scheme) != "http" && strings.ToLower(request.URL.Scheme) != "https") || + request.URL.Hostname() == "" { + resolution.Error(path, "diagnostic endpoint must be an absolute HTTP(S) URL with a host") + return + } + normalizeString(value, path, resolution, normalized) +} + func resolveProfiles(profiles map[string]profileConfig, resolution *configkit.Resolution) { for _, id := range sortedKeys(profiles) { profile := profiles[id] @@ -309,6 +356,20 @@ func validateEffective(config *document, validation *configkit.Validation) { } } validateExtraParams(backend.ExtraParams, base.Key("extra_params"), true, validation) + if smokeTests := backend.SmokeTests; smokeTests != nil { + smokePath := base.Key("smoke_tests") + if smokeTests.DefaultProfile != nil && strings.TrimSpace(*smokeTests.DefaultProfile) == "" { + validation.Error(smokePath.Key("default_profile"), "default profile must not be blank") + } + if authentication := smokeTests.Authentication; authentication != nil { + kindPath := smokePath.Key("authentication").Key("kind") + if authentication.Kind == nil { + validation.Error(kindPath, "authentication kind is required") + } else if *authentication.Kind != "none" && *authentication.Kind != "openrouter_key" { + validation.Error(kindPath, "authentication kind must be none or openrouter_key") + } + } + } } for _, id := range sortedKeys(config.Profiles) { profile := config.Profiles[id] diff --git a/internal/configuration/load_test.go b/internal/configuration/load_test.go index 30bb52b..823b952 100644 --- a/internal/configuration/load_test.go +++ b/internal/configuration/load_test.go @@ -78,6 +78,159 @@ func TestLoadRecordsBackendNormalizationAndNullDefault(t *testing.T) { } } +func TestLoadBackendSmokeTestsOmissionAndNull(t *testing.T) { + for _, test := range []struct { + name string + smokeTests any + include bool + inherited bool + }{ + {name: "omitted"}, + {name: "null", smokeTests: nil, include: true, inherited: true}, + } { + t.Run(test.name, func(t *testing.T) { + backend := map[string]any{"endpoint": "https://backend.example/v1"} + if test.include { + backend["smoke_tests"] = test.smokeTests + } + main := mapping("backend", map[string]any{ + "backends": map[string]any{"local": backend}, + }) + var underlays []configkit.Underlay + if test.inherited { + underlays = []configkit.Underlay{{Inputs: []configkit.Input{mapping("defaults", map[string]any{ + "backends": map[string]any{"local": map[string]any{ + "endpoint": "https://backend.example/v1", + "smoke_tests": map[string]any{"default_profile": "inherited-profile"}, + }}, + })}}} + } + loaded, err := load(main, underlays) + if err != nil { + t.Fatal(err) + } + if got := loaded.result.Config.Backends["local"].SmokeTests; got != nil { + t.Fatalf("smoke_tests = %+v, want nil", got) + } + selected, err := loaded.registry.GetBackend("local") + if err != nil { + t.Fatal(err) + } + if selected.SmokeTests != nil { + t.Fatalf("indexed smoke_tests = %+v, want nil", selected.SmokeTests) + } + }) + } +} + +func TestLoadMergesBackendSmokeTestsAndRetainsFallbacks(t *testing.T) { + base := mapping("defaults", map[string]any{ + "backends": map[string]any{"local": map[string]any{ + "endpoint": "https://backend.example/v1", + "smoke_tests": map[string]any{ + "default_profile": "catalog-profile", + "reachability": map[string]any{"endpoint": " https://probe.example/ready?check=1 "}, + "authentication": map[string]any{ + "kind": " OPENROUTER_KEY ", + "endpoint": "https://probe.example/key?scope=current", + }, + }, + }}, + }) + overlay := mapping("operator", map[string]any{ + "backends": map[string]any{"local": map[string]any{ + "smoke_tests": map[string]any{ + "default_profile": "not-yet-listed", + "authentication": map[string]any{"kind": " NONE ", "endpoint": nil}, + }, + }}, + }) + loaded, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{ + {Inputs: []configkit.Input{base}}, + {Inputs: []configkit.Input{overlay}}, + }) + if err != nil { + t.Fatalf("load effective diagnostic metadata: %v", err) + } + selected, err := loaded.registry.GetBackend("local") + if err != nil { + t.Fatal(err) + } + metadata := selected.SmokeTests + if selected.Endpoint != "https://backend.example/v1" { + t.Fatalf("effective backend endpoint = %q", selected.Endpoint) + } + if metadata == nil || metadata.DefaultProfile == nil || *metadata.DefaultProfile != "not-yet-listed" { + t.Fatalf("default profile overlay was not retained: %+v", metadata) + } + if metadata.Reachability == nil || metadata.Reachability.Endpoint == nil || + *metadata.Reachability.Endpoint != "https://probe.example/ready?check=1" { + t.Fatalf("full reachability destination was not normalized: %+v", metadata.Reachability) + } + if metadata.Authentication == nil || metadata.Authentication.Kind == nil || *metadata.Authentication.Kind != "none" || + metadata.Authentication.Endpoint != nil { + t.Fatalf("authentication overlay or null fallback was not retained: %+v", metadata.Authentication) + } +} + +func TestLoadRejectsInvalidFinalBackendSmokeTests(t *testing.T) { + for _, test := range []struct { + name string + data map[string]any + path string + }{ + { + name: "blank default profile", + data: map[string]any{"default_profile": " \t "}, + path: "/backends/local/smoke_tests/default_profile", + }, + { + name: "authentication kind required", + data: map[string]any{"authentication": map[string]any{}}, + path: "/backends/local/smoke_tests/authentication/kind", + }, + { + name: "unknown authentication kind", + data: map[string]any{"authentication": map[string]any{"kind": "bearer"}}, + path: "/backends/local/smoke_tests/authentication/kind", + }, + { + name: "non-http reachability URL", + data: map[string]any{"reachability": map[string]any{"endpoint": "ftp://probe.example/ready"}}, + path: "/backends/local/smoke_tests/reachability/endpoint", + }, + { + name: "blank authentication URL", + data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": " "}}, + path: "/backends/local/smoke_tests/authentication/endpoint", + }, + { + name: "relative authentication URL", + data: map[string]any{"authentication": map[string]any{"kind": "none", "endpoint": "/key"}}, + path: "/backends/local/smoke_tests/authentication/endpoint", + }, + } { + t.Run(test.name, func(t *testing.T) { + _, err := load(mapping("backend", map[string]any{ + "backends": map[string]any{"local": map[string]any{ + "endpoint": "https://backend.example/v1", + "smoke_tests": test.data, + }}, + }), nil) + var loadError *configkit.LoadError + if !errors.As(err, &loadError) { + t.Fatalf("expected final-value validation error, got %v", err) + } + for _, diagnostic := range loadError.Diagnostics() { + if diagnostic.Path.String() == test.path { + return + } + } + t.Fatalf("expected diagnostic at %s, got %+v", test.path, loadError.Diagnostics()) + }) + } +} + func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) { loaded, err := load(mapping("backend", map[string]any{"backends": map[string]any{ "local": map[string]any{"endpoint": "http://localhost:8080/v1", "concurrency_limit": 1}, @@ -128,6 +281,40 @@ func TestLoadValidatesOnlyFinalDomainValues(t *testing.T) { } } +func TestLoadValidatesOnlyFinalBackendSmokeTestValues(t *testing.T) { + underlay := mapping("defaults", map[string]any{ + "backends": map[string]any{"local": map[string]any{ + "endpoint": "https://backend.example/v1", + "smoke_tests": map[string]any{ + "default_profile": " ", + "reachability": map[string]any{"endpoint": "ftp://invalid.example/"}, + "authentication": map[string]any{"kind": "unsupported"}, + }, + }}, + }) + main := mapping("operator", map[string]any{ + "backends": map[string]any{"local": map[string]any{ + "smoke_tests": map[string]any{ + "default_profile": "dangling-profile", + "reachability": map[string]any{"endpoint": nil}, + "authentication": map[string]any{"kind": "none"}, + }, + }}, + }) + loaded, err := load(main, []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}) + if err != nil { + t.Fatalf("replaced lower metadata should not fail: %v", err) + } + selected, err := loaded.registry.GetBackend("local") + if err != nil { + t.Fatal(err) + } + if selected.SmokeTests == nil || selected.SmokeTests.DefaultProfile == nil || + *selected.SmokeTests.DefaultProfile != "dangling-profile" { + t.Fatalf("final dangling default profile was not retained: %+v", selected.SmokeTests) + } +} + func TestLoadKeepsSyntaxErrorsFromLowerTiers(t *testing.T) { _, err := load(mapping("operator", map[string]any{"backends": map[string]any{"local": map[string]any{"endpoint": "http://localhost:8080"}}}), []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("broken", []byte("backends: ["))}}}}) diff --git a/internal/configuration/targets.go b/internal/configuration/targets.go index bc8acd6..d7fd6ad 100644 --- a/internal/configuration/targets.go +++ b/internal/configuration/targets.go @@ -15,11 +15,20 @@ func (state *loaded) buildExecutionProfiles() error { definitions := make(map[string]domain.Backend, len(state.result.Config.Backends)) for _, id := range sortedKeys(state.result.Config.Backends) { config := state.result.Config.Backends[id] - params, err := jsonvalue.CopyMap(config.ExtraParams) - if err != nil { - return fmt.Errorf("backend %q extra parameters: %w", id, err) + definition := domain.Backend{ID: id, ExtraParams: config.ExtraParams} + if config.SmokeTests != nil { + smokeTests := &domain.BackendSmokeTestConfig{DefaultProfile: config.SmokeTests.DefaultProfile} + if config.SmokeTests.Reachability != nil { + smokeTests.Reachability = &domain.ReachabilityProbeConfig{Endpoint: config.SmokeTests.Reachability.Endpoint} + } + if config.SmokeTests.Authentication != nil { + smokeTests.Authentication = &domain.AuthenticationProbeConfig{ + Kind: config.SmokeTests.Authentication.Kind, + Endpoint: config.SmokeTests.Authentication.Endpoint, + } + } + definition.SmokeTests = smokeTests } - definition := domain.Backend{ID: id, ExtraParams: params} if config.Endpoint != nil { definition.Endpoint = *config.Endpoint }