diff --git a/cmd/cognitive-worker/main.go b/cmd/cognitive-worker/main.go index a3eac54..c4ecfbe 100644 --- a/cmd/cognitive-worker/main.go +++ b/cmd/cognitive-worker/main.go @@ -45,7 +45,7 @@ func main() { discoveries := map[string]providerRegistration{} var builtinLocal *providerRegistration externalRecovery := []providerRegistration{} - cpuAPIKey, err := loadLocalCPUProviderAPIKey() + cpuAPIKey, err := loadProviderAPIKey("LOCAL_CPU_PROVIDER_API_KEY", "LOCAL_CPU_PROVIDER_API_KEY_FILE", "LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1") if err != nil { log.Fatal(err) } @@ -62,6 +62,27 @@ func main() { cpu := providerRegistration{ProviderInstanceID: "local-cpu-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-cpu-primary-v1", CredentialRef: cpuCredentialRef, AuthMode: cpuAuthMode, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyLocalOnly, Provider: cpuAdapter, Discovery: cpuAdapter} providers[cpu.Kind] = append(providers[cpu.Kind], cpu) discoveries["cpu"] = cpu + mediumURL := strings.TrimSpace(os.Getenv("LOCAL_MEDIUM_PROVIDER_URL")) + mediumAPIKey := "" + if mediumURL != "" { + mediumAPIKey, err = loadProviderAPIKey("LOCAL_MEDIUM_PROVIDER_API_KEY", "LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1") + if err != nil { + log.Fatal(err) + } + mediumAdapter, adapterErr := models.NewOpenAICompatibleLocalAdapterWithAPIKey(strings.TrimRight(mediumURL, "/"), mediumAPIKey, nil) + if adapterErr != nil { + log.Fatal(adapterErr) + } + mediumAuthMode := capability.AuthNone + mediumCredentialRef := capability.CredentialRef("") + if mediumAPIKey != "" { + mediumAuthMode = capability.AuthKey + mediumCredentialRef = capability.CredentialRef(envOr("LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1")) + } + medium := providerRegistration{ProviderInstanceID: "local-medium-primary", Kind: "openai-compatible-chat-v1", EndpointRef: "local-medium-primary-v1", CredentialRef: mediumCredentialRef, AuthMode: mediumAuthMode, BillingMode: capability.BillingFree, DataPolicy: capability.DataPolicyLocalOnly, Provider: mediumAdapter, Discovery: mediumAdapter} + providers[medium.Kind] = append(providers[medium.Kind], medium) + discoveries["medium"] = medium + } if lifeboat, enabled, lifeboatErr := newLifeboatProvider(os.Getenv("LOCAL_LIFEBOAT_PROVIDER_URL")); lifeboatErr != nil { log.Fatal(lifeboatErr) } else if enabled { @@ -86,6 +107,7 @@ func main() { mux.Handle("/healthz", health.Handler{Runtime: "cognitive-worker", SemanticGeneration: 1, ArchitectureGeneration: 1}) readinessClient := &http.Client{Timeout: 2 * time.Second} cpuReadinessClient := &http.Client{Timeout: 10 * time.Second} + mediumReadinessClient := &http.Client{Timeout: 10 * time.Second} lifeboatURL := strings.TrimSuffix(strings.TrimRight(envOr("LOCAL_LIFEBOAT_PROVIDER_URL", "http://cognitive-lifeboat:8086/v1"), "/"), "/v1") + "/health" fleetClient := &http.Client{Timeout: 2 * time.Second} preferredFleet := capability.FleetTier(envOr("COGNITIVE_PREFERRED_FLEET_TIER", string(capability.FleetTierHeavy))) @@ -96,16 +118,28 @@ func main() { cpuEndpoint := strings.TrimRight(envOr("LOCAL_CPU_PROVIDER_URL", "http://docker.cin.su:18082/v1"), "/") + "/models" cpuHealthProbe := health.OptionalHTTPProbeWithHeaders(cpuReadinessClient, cpuEndpoint, cpuFleetHeaders) cpuHealthProbe.Timeout = 10 * time.Second + mediumEndpoint := "" + mediumHeaders := map[string]string{} + mediumHealthProbe := health.Probe{} + if mediumURL != "" { + mediumEndpoint = strings.TrimRight(mediumURL, "/") + "/models" + if mediumAPIKey != "" { + mediumHeaders["Authorization"] = "Bearer " + mediumAPIKey + } + mediumHealthProbe = health.OptionalHTTPProbeWithHeaders(mediumReadinessClient, mediumEndpoint, mediumHeaders) + mediumHealthProbe.Timeout = 10 * time.Second + } readinessProbes := []health.Probe{ health.HTTPProbe(readinessClient, strings.TrimRight(envOr("CORE_RUNTIME_URL", "http://core-runtime:8080"), "/")+"/healthz"), cpuHealthProbe, + mediumHealthProbe, health.HTTPProbe(readinessClient, lifeboatURL), health.OptionalHTTPProbe(readinessClient, strings.TrimRight(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), "/")+"/api/tags"), } mux.Handle("/readyz", health.ReadinessHandler{Runtime: "cognitive-worker", Probes: readinessProbes}) mux.Handle("/v1/fleet/readiness", fleetReadinessHandler(fleetClient, preferredFleet, []fleetEndpoint{ {ProviderID: "local-cpu-primary", ModelID: "local-cpu-model", Tier: capability.FleetTierFast, URL: cpuEndpoint, Headers: cpuFleetHeaders, Client: &http.Client{Timeout: 10 * time.Second}}, - {ProviderID: "local-medium-unconfigured", ModelID: "medium-unconfigured", Tier: capability.FleetTierMedium}, + {ProviderID: "local-medium-primary", ModelID: "local-medium-model", Tier: capability.FleetTierMedium, URL: mediumEndpoint, Headers: mediumHeaders, Client: mediumReadinessClient}, {ProviderID: "local-gpu-opportunistic", ModelID: "gpu-opportunistic", Tier: capability.FleetTierHeavy, URL: strings.TrimRight(envOr("LOCAL_GPU_PROVIDER_URL", "http://192.168.220.91:11434"), "/") + "/api/tags"}, {ProviderID: "cognitive-lifeboat", ModelID: "qwen3-8b-lifeboat", Tier: capability.FleetTierLifeboat, URL: lifeboatURL}, })) @@ -166,17 +200,20 @@ func refreshProviderDiscoveries(resolver bindingResolver, service discoveryServi } } -func loadLocalCPUProviderAPIKey() (string, error) { - if _, set := os.LookupEnv("LOCAL_CPU_PROVIDER_API_KEY"); set { - return "", fmt.Errorf("LOCAL_CPU_PROVIDER_API_KEY direct environment secret is forbidden; use LOCAL_CPU_PROVIDER_API_KEY_FILE") +func loadProviderAPIKey(directEnvName, fileEnvName, refEnvName, defaultRef string) (string, error) { + if _, set := os.LookupEnv(directEnvName); set { + return "", fmt.Errorf("%s direct environment secret is forbidden; use %s", directEnvName, fileEnvName) } - fileName := strings.TrimSpace(os.Getenv("LOCAL_CPU_PROVIDER_API_KEY_FILE")) + fileName := strings.TrimSpace(os.Getenv(fileEnvName)) if fileName == "" { return "", nil } - ref := strings.TrimSpace(os.Getenv("LOCAL_CPU_PROVIDER_CREDENTIAL_REF")) + ref := strings.TrimSpace(os.Getenv(refEnvName)) if ref == "" { - return "", fmt.Errorf("LOCAL_CPU_PROVIDER_CREDENTIAL_REF is required when LOCAL_CPU_PROVIDER_API_KEY_FILE is configured") + ref = defaultRef + } + if ref == "" { + return "", fmt.Errorf("%s is required when %s is configured", refEnvName, fileEnvName) } resolver, err := platformsecrets.NewFileResolver(envOr("SECRET_BACKEND_ROOT", "/run/secrets")) if err != nil { @@ -185,6 +222,10 @@ func loadLocalCPUProviderAPIKey() (string, error) { return resolver.ResolveFile(secretcontracts.SecretFileRef{CredentialRef: secretcontracts.CredentialRef(ref), Purpose: secretcontracts.SecretPurposeProviderAPIKey, Consumer: secretcontracts.RuntimeConsumerCognitive, Path: fileName}) } +func loadLocalCPUProviderAPIKey() (string, error) { + return loadProviderAPIKey("LOCAL_CPU_PROVIDER_API_KEY", "LOCAL_CPU_PROVIDER_API_KEY_FILE", "LOCAL_CPU_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-cpu/provider-api-key/v1") +} + func deterministicAdvisory(w http.ResponseWriter, r *http.Request) { request, ok := decodeAdvisoryRequest(w, r) if !ok { diff --git a/cmd/cognitive-worker/main_test.go b/cmd/cognitive-worker/main_test.go index 4784697..8e3f8ab 100644 --- a/cmd/cognitive-worker/main_test.go +++ b/cmd/cognitive-worker/main_test.go @@ -43,3 +43,28 @@ func TestLoadLocalCPUProviderAPIKeyRejectsDirectEnvironmentSecret(t *testing.T) t.Fatal("expected direct environment secret to be rejected") } } + +func TestLoadProviderAPIKeySupportsMediumCredentialRef(t *testing.T) { + t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY", "") + _ = os.Unsetenv("LOCAL_MEDIUM_PROVIDER_API_KEY") + root := t.TempDir() + path := filepath.Join(root, "medium_provider_api_key") + if err := os.WriteFile(path, []byte("medium-key\n"), 0o600); err != nil { + t.Fatal(err) + } + t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", path) + t.Setenv("LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1") + t.Setenv("SECRET_BACKEND_ROOT", root) + key, err := loadProviderAPIKey("LOCAL_MEDIUM_PROVIDER_API_KEY", "LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1") + if err != nil || key != "medium-key" { + t.Fatalf("unexpected medium key resolution result: err=%v", err) + } +} + +func TestLoadProviderAPIKeyRejectsMediumDirectEnvironmentSecret(t *testing.T) { + t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY", "must-not-be-used") + t.Setenv("LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "") + if _, err := loadProviderAPIKey("LOCAL_MEDIUM_PROVIDER_API_KEY", "LOCAL_MEDIUM_PROVIDER_API_KEY_FILE", "LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF", "credential://domovoy/local-medium/provider-api-key/v1"); err == nil { + t.Fatal("expected direct medium environment secret to be rejected") + } +} diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index b1f8314..20d0b9d 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -214,6 +214,9 @@ services: LOCAL_CPU_PROVIDER_URL: http://docker.cin.su:18082/v1 LOCAL_CPU_PROVIDER_API_KEY_FILE: ${LOCAL_CPU_PROVIDER_API_KEY_FILE:-} LOCAL_CPU_PROVIDER_CREDENTIAL_REF: ${LOCAL_CPU_PROVIDER_CREDENTIAL_REF:-credential://domovoy/local-cpu/provider-api-key/v1} + LOCAL_MEDIUM_PROVIDER_URL: ${LOCAL_MEDIUM_PROVIDER_URL:-} + LOCAL_MEDIUM_PROVIDER_API_KEY_FILE: ${LOCAL_MEDIUM_PROVIDER_API_KEY_FILE:-} + LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF: ${LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF:-credential://domovoy/local-medium/provider-api-key/v1} LOCAL_LIFEBOAT_PROVIDER_URL: http://cognitive-lifeboat:8086/v1 LOCAL_GPU_PROVIDER_URL: http://192.168.220.91:11434 COGNITIVE_OWNER_PRINCIPAL_ID: domovoy-creator-principal diff --git a/dev/ACTIVE_TASK.json b/dev/ACTIVE_TASK.json index 5c79bd2..ae1104c 100644 --- a/dev/ACTIVE_TASK.json +++ b/dev/ACTIVE_TASK.json @@ -1,5 +1,5 @@ { - "task_file": "dev/tasks/DEV-000167-cpu-readiness-client-timeout.json", - "task_id": "DEV-000167", + "task_file": "dev/tasks/DEV-000168-configurable-medium-fleet-slot.json", + "task_id": "DEV-000168", "task_revision": 1 } diff --git a/dev/tasks/DEV-000168-configurable-medium-fleet-slot.json b/dev/tasks/DEV-000168-configurable-medium-fleet-slot.json new file mode 100644 index 0000000..332abcf --- /dev/null +++ b/dev/tasks/DEV-000168-configurable-medium-fleet-slot.json @@ -0,0 +1,23 @@ +{ + "task_id": "DEV-000168", + "task_revision": 1, + "architecture_revision": "ADR-0018", + "base_repo_revision": "205d8f620603ccf3e0138f6beb93156450664314", + "risk_tier": "E", + "intent": "Make the MEDIUM model-fleet slot a real optional OpenAI-compatible provider path with protected file credentials, discovery, readiness and gateway registration.", + "requirement_ids": ["WP-200", "WP-301", "Model fleet/failover"], + "allowed_change_scope": {"modules": ["cognitive-worker", "deployment-policy", "tests"], "paths": ["cmd/cognitive-worker/main.go", "cmd/cognitive-worker/main_test.go", "deploy/docker-compose.prod.yml", "tests/cognitive_advisory/cognitive_worker_env_policy.ps1", "tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1", "dev/ACTIVE_TASK.json", "dev/tasks/DEV-000168-configurable-medium-fleet-slot.json"]}, + "forbidden_change_scope": {"paths": ["architecture/**", "security/**", "database/**", "contracts/**", "adapters/**", ".gitea/**"]}, + "allowed_architecture_delta": {"new_cross_module_dependencies": 0, "new_canonical_owners": 0, "new_privileges": 1, "new_third_party_dependencies": 0, "new_database_owner_roles": 0}, + "contracts": {"consume": ["existing provider adapter contract", "existing discovery registration", "existing fleet readiness contract", "existing CredentialRef file contract"], "may_change": []}, + "semantic_owner": "cognitive-worker/capability registration boundary", + "canonical_state": [], + "state_transitions": ["MEDIUM endpoint absent -> NOT_CONFIGURED", "MEDIUM endpoint configured -> discovery/readiness registration", "available provider -> eligible gateway binding subject to core registry policy"], + "side_effects_and_privileges": ["declares one new deployment configuration surface for an existing read-only secret volume; provider key is resolved only through the existing protected file CredentialRef"], + "persistence_and_migrations": ["none"], + "failure_retry_restart": ["medium probe has a bounded 10-second client timeout; absent endpoint remains NOT_CONFIGURED; fallback selection remains Lifeboat when preferred tiers are unavailable"], + "assumptions": ["A real medium OpenAI-compatible runtime may be supplied later through deployment environment injection", "No production medium runtime is claimed by this change"], + "acceptance_scenarios": ["empty medium URL produces NOT_CONFIGURED without startup failure", "configured medium URL is registered for discovery and fleet readiness", "direct medium secret environment is rejected", "medium CredentialRef file is accepted", "CPU/GPU/Lifeboat behavior remains unchanged"], + "required_tests": ["go test ./...", "go vet ./...", "preflight", "check-diff", "check-architecture", "independent", "git diff --check", "environment policy tests", "CI", "production fleet readiness"], + "terminal_gap_states": ["BLOCKED_BY_MISSING_CAPABILITY", "BLOCKED_BY_CONFLICT"] +} diff --git a/tests/cognitive_advisory/cognitive_worker_env_policy.ps1 b/tests/cognitive_advisory/cognitive_worker_env_policy.ps1 index 63cb3e7..452ddc4 100644 --- a/tests/cognitive_advisory/cognitive_worker_env_policy.ps1 +++ b/tests/cognitive_advisory/cognitive_worker_env_policy.ps1 @@ -3,6 +3,11 @@ Set-StrictMode -Version Latest $script:AllowedCognitiveWorkerEnvironment = @{ 'CORE_RUNTIME_URL' = 'http_url' 'LOCAL_CPU_PROVIDER_URL' = 'http_url' + 'LOCAL_CPU_PROVIDER_API_KEY_FILE' = 'secret_file' + 'LOCAL_CPU_PROVIDER_CREDENTIAL_REF' = 'nonempty' + 'LOCAL_MEDIUM_PROVIDER_URL' = 'http_url' + 'LOCAL_MEDIUM_PROVIDER_API_KEY_FILE' = 'secret_file' + 'LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF' = 'nonempty' 'LOCAL_GPU_PROVIDER_URL' = 'http_url' 'LOCAL_OLLAMA_PROVIDER_URL' = 'http_url' 'COGNITIVE_OWNER_PRINCIPAL_ID' = 'nonempty' diff --git a/tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1 b/tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1 index ea7bdc7..d22e0b4 100644 --- a/tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1 +++ b/tests/cognitive_advisory/cognitive_worker_env_policy_test.ps1 @@ -15,6 +15,8 @@ function Assert-PolicyFail([string]$Name, [string[]]$Environment) { } Assert-PolicyPass 'CPU provider endpoint' @('LOCAL_CPU_PROVIDER_URL=http://docker.cin.su:18082/v1') +Assert-PolicyPass 'optional medium provider endpoint' @('LOCAL_MEDIUM_PROVIDER_URL=http://medium.example/v1') +Assert-PolicyPass 'approved medium CredentialRef file' @('LOCAL_MEDIUM_PROVIDER_API_KEY_FILE=/run/secrets/medium_api_key', 'LOCAL_MEDIUM_PROVIDER_CREDENTIAL_REF=credential://domovoy/local-medium/provider-api-key/v1') Assert-PolicyPass 'Ollama provider endpoint' @('LOCAL_OLLAMA_PROVIDER_URL=http://192.168.220.91:11434') Assert-PolicyFail 'plaintext OpenAI API key' @('OPENAI_API_KEY=secret') Assert-PolicyFail 'plaintext provider token' @('PROVIDER_TOKEN=secret')