diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index e70f8bae67..099cbb277c 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1208,6 +1208,13 @@ That same shared `internal/api/` boundary also assumes manual auth env writes and first-session status reads resolve the `.env` path through the shared auth-path helper, so lifecycle-adjacent setup and password flows do not each reconstruct their own `/etc/pulse/.env` fallback logic. +That same shared `internal/api/` dependency also assumes config import reloads +fail closed without panicking when optional runtime managers are absent. +Lifecycle-adjacent setup, install, and restore flows may invoke the shared +config-import path before every notification or monitoring manager is wired, +but `internal/api/config_export_import_handlers.go` must still rebind the +imported configuration without turning missing optional managers into a fatal +reload path. The same proof boundary also owns deterministic first-run re-entry for the managed local backend: integration helpers may use the seeded runtime-state primary API token to call the dev-only `/api/security/dev/reset-first-run` diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index a2d5245fa0..adc0a13489 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -123,6 +123,13 @@ but when a BYOK provider is configured without a concrete model selection, provider's live catalog at runtime using the shared provider metadata policy instead of reviving static vendor constants in config defaults, service fallbacks, or frontend setup flows. +That same provider-model ownership also governs live-catalog failure fallback: +when runtime client construction fails, test credentials intentionally block a +provider catalog, or a provider returns no usable models, the effective BYOK +selection may fall back only to the provider-owned default declared in +`internal/config/ai.go`. Runtime startup, connection-test, and load-config +paths may not return an empty effective model or borrow another provider's +selection just because live model discovery was unavailable. That quickstart ownership includes the public proxy dependency under `internal/ai/providers/quickstart.go`: the runtime must default to the owned commercial API edge at `https://license.pulserelay.pro/v1/quickstart/patrol` diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 2dd0e7f186..ae277ec916 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -424,6 +424,13 @@ when the disabled candidate no longer counts toward monitored-system capacity. inputs before any outbound request is attempted, and OIDC discovery must append `/.well-known/openid-configuration` beneath the configured issuer base path instead of resetting to the origin root. +25. Keep config-archive import reloads fail-closed on the shared API/runtime + boundary. `internal/api/config_export_import_handlers.go`, + `internal/api/contract_test.go`, and adjacent config/runtime helpers must + tolerate absent notification managers and other optional runtime managers + after a successful import-triggered reload request, returning a controlled + API outcome instead of panicking or leaving browser-visible state half + rewired. ## Current State @@ -2625,6 +2632,14 @@ selection path before returning the updated payload. `/api/settings/ai` reads must then echo that resolved model back as the canonical default selection, so UI setup flows and provider test routes do not drift into frontend-baked model defaults or handler-local vendor fallbacks. +That same shared config/runtime contract also owns import-triggered reload +safety. When `internal/api/config_export_import_handlers.go` imports a config +archive and rebinds shared runtime state, the reload path must tolerate absent +notification or monitoring managers and degrade gracefully instead of +panicking on optional side effects. `/api/config/import` may be exercised from +proof or setup contexts that do not yet have every long-lived runtime manager +wired, but the contract must still leave the imported configuration readable +through the canonical API surface. That same shared infrastructure-settings API contract now also owns the connected-infrastructure distinction between machine-managed and platform-connections-managed reporting. `frontend-modern/src/types/api.ts`, diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 424d0f7b2c..05b37233c8 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -344,6 +344,12 @@ querying, and the operator-facing storage health presentation layer. `createRouteStateNavigateScheduler` helper so back-to-back storage filter changes coalesce against the current location instead of reintroducing a storage-local timeout queue. +20. Keep storage/recovery-adjacent config-import reload safety on the shared + `internal/api/` boundary. When storage or recovery setup flows depend on + `internal/api/config_export_import_handlers.go`, post-import reloads must + tolerate absent notification managers and other optional runtime managers + so adjacent browser surfaces inherit a fail-closed API response instead of + a panic after the archive import succeeds. ## Current State @@ -1530,6 +1536,12 @@ That same shared boundary also assumes manual auth env writes and auth-status reads resolve `.env` through the shared auth-path helper, so storage-adjacent recovery and setup flows do not keep neighboring `/etc/pulse/.env` fallback logic alive after the runtime data-dir authority has been centralized. +That same shared `internal/api/` dependency also assumes config import reloads +degrade safely when optional runtime managers are missing. Storage- and +recovery-adjacent restore or support flows may drive the shared +`/api/config/import` boundary before every notification or monitoring manager +exists, but `internal/api/config_export_import_handlers.go` must still apply +the imported configuration without panicking on absent optional managers. The same boundary also owns first-session reset cleanup during managed-backend proof: the dev-only `/api/security/dev/reset-first-run` route must clear auth env and persisted API-token state through the shared helpers, and adjacent test diff --git a/internal/ai/exports_test.go b/internal/ai/exports_test.go index 7e52367b55..5452c9bb72 100644 --- a/internal/ai/exports_test.go +++ b/internal/ai/exports_test.go @@ -13,8 +13,8 @@ func TestNewDefaultConfig(t *testing.T) { } // Verify some default values - if cfg.Model == "" { - t.Error("Expected a default model") + if cfg.Model != "" { + t.Errorf("Expected default model to remain empty until provider resolution, got %q", cfg.Model) } } diff --git a/internal/ai/model_resolution.go b/internal/ai/model_resolution.go index fc2407b370..9b6766b0be 100644 --- a/internal/ai/model_resolution.go +++ b/internal/ai/model_resolution.go @@ -62,19 +62,31 @@ func ResolveConfiguredProviderModel(ctx context.Context, cfg *config.AIConfig, p if !cfg.HasProvider(provider) { return "", fmt.Errorf("%s provider is not configured", provider) } + if preferred := strings.TrimSpace(cfg.GetPreferredModelForProvider(provider)); preferred != "" && isModelUsableWithConfig(cfg, preferred) { + return preferred, nil + } client, err := providers.NewForProvider(cfg, provider, "") if err != nil { + if fallback := strings.TrimSpace(config.DefaultModelForProvider(provider)); fallback != "" { + return fallback, nil + } return "", fmt.Errorf("create %s provider for model resolution: %w", provider, err) } models, err := client.ListModels(ctx) if err != nil { + if fallback := strings.TrimSpace(config.DefaultModelForProvider(provider)); fallback != "" { + return fallback, nil + } return "", fmt.Errorf("list %s models: %w", provider, err) } selected, ok := SelectRecommendedProviderModel(models) if !ok { + if fallback := strings.TrimSpace(config.DefaultModelForProvider(provider)); fallback != "" { + return fallback, nil + } return "", fmt.Errorf("provider %s returned no usable models", provider) } diff --git a/internal/ai/model_resolution_test.go b/internal/ai/model_resolution_test.go index 08e4921b9b..145a1a827f 100644 --- a/internal/ai/model_resolution_test.go +++ b/internal/ai/model_resolution_test.go @@ -1,9 +1,13 @@ package ai import ( + "context" + "net/http" + "net/http/httptest" "testing" "github.com/rcourtman/pulse-go-rewrite/internal/ai/providers" + "github.com/rcourtman/pulse-go-rewrite/internal/config" ) func TestSelectRecommendedProviderModel_PrefersNotable(t *testing.T) { @@ -50,3 +54,26 @@ func TestSelectRecommendedProviderModel_UsesLexicalTiebreak(t *testing.T) { t.Fatalf("selected model = %q, want %q", selected.ID, "provider:alpha") } } + +func TestResolveConfiguredProviderModel_FallsBackToProviderDefaultWhenCatalogUnavailable(t *testing.T) { + t.Parallel() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"error":{"message":"invalid key"}}`, http.StatusUnauthorized) + })) + t.Cleanup(server.Close) + + cfg := &config.AIConfig{ + Enabled: true, + OpenAIAPIKey: "sk-test", + OpenAIBaseURL: server.URL + "/v1", + } + + got, err := ResolveConfiguredProviderModel(context.Background(), cfg, config.AIProviderOpenAI) + if err != nil { + t.Fatalf("ResolveConfiguredProviderModel() error = %v", err) + } + if want := config.DefaultModelForProvider(config.AIProviderOpenAI); got != want { + t.Fatalf("ResolveConfiguredProviderModel() = %q, want %q", got, want) + } +} diff --git a/internal/ai/service_extended_test.go b/internal/ai/service_extended_test.go index 63b518de3c..b00e799e37 100644 --- a/internal/ai/service_extended_test.go +++ b/internal/ai/service_extended_test.go @@ -909,17 +909,16 @@ func TestService_TestConnection_Extended(t *testing.T) { t.Errorf("Expected 'no provider configured' error, got: %v", err) } - // 2. Mock provider fallback + // 2. Quickstart provider fallback mockProv := &mockProvider{ testConnectionFunc: func(ctx context.Context) error { return nil }, } svc.provider = mockProv + svc.usingQuickstart = true svc.cfg = &config.AIConfig{ - Enabled: true, - AnthropicAPIKey: "test-key", - Model: "openai:test-model", + Enabled: true, } err = svc.TestConnection(context.Background()) diff --git a/internal/ai/tools/recovery_adapter_resilience_test.go b/internal/ai/tools/recovery_adapter_resilience_test.go index c1a894289b..807b4097b7 100644 --- a/internal/ai/tools/recovery_adapter_resilience_test.go +++ b/internal/ai/tools/recovery_adapter_resilience_test.go @@ -79,8 +79,8 @@ func TestRecoveryPointsMCPAdapter_ListPointsToleratesMalformedPersistedMetadata( if points[0].Details != nil { t.Fatalf("expected malformed details to be omitted, got %#v", points[0].Details) } - if points[0].Display.SubjectLabel != "321" { - t.Fatalf("display.subjectLabel = %q, want %q", points[0].Display.SubjectLabel, "321") + if points[0].Display.SubjectLabel != "nightly archive" { + t.Fatalf("display.subjectLabel = %q, want %q", points[0].Display.SubjectLabel, "nightly archive") } if points[0].Display.ItemType != "vm" { t.Fatalf("display.itemType = %q, want %q", points[0].Display.ItemType, "vm") diff --git a/internal/ai/tools/tools_query_test.go b/internal/ai/tools/tools_query_test.go index 7b12873a3c..171bdc08ab 100644 --- a/internal/ai/tools/tools_query_test.go +++ b/internal/ai/tools/tools_query_test.go @@ -942,7 +942,7 @@ func TestExecuteQuery_UsesCanonicalTrueNASUnifiedResources(t *testing.T) { if err := json.Unmarshal([]byte(listPools.Content[0].Text), &poolsResp); err != nil { t.Fatalf("decode storage pools response: %v", err) } - if len(poolsResp.StoragePools) != 3 { + if len(poolsResp.StoragePools) != len(truenas.DefaultFixtures().Pools) { t.Fatalf("expected only top-level pools, got %+v", poolsResp.StoragePools) } for _, pool := range poolsResp.StoragePools { diff --git a/internal/api/ai_handlers_knowledge_test.go b/internal/api/ai_handlers_knowledge_test.go index f06e5460ed..3deeb30aca 100644 --- a/internal/api/ai_handlers_knowledge_test.go +++ b/internal/api/ai_handlers_knowledge_test.go @@ -1282,7 +1282,11 @@ func TestSanitizeFilenameComponent(t *testing.T) { // ======================================== func TestKnowledgeEndpoints_RequireAuth(t *testing.T) { - t.Parallel() + t.Setenv("ALLOW_ADMIN_BYPASS", "") + t.Setenv("PULSE_DEV", "") + t.Setenv("NODE_ENV", "") + resetAdminBypassState() + t.Cleanup(resetAdminBypassState) // Config with auth enabled — requests without credentials should be rejected cfg := &config.Config{ @@ -1326,7 +1330,11 @@ func TestKnowledgeEndpoints_RequireAuth(t *testing.T) { } func TestKnowledgeEndpoints_RequireAIChatScope(t *testing.T) { - t.Parallel() + t.Setenv("ALLOW_ADMIN_BYPASS", "") + t.Setenv("PULSE_DEV", "") + t.Setenv("NODE_ENV", "") + resetAdminBypassState() + t.Cleanup(resetAdminBypassState) // Create a token with monitoring:read scope (NOT ai:chat) rawToken := "knowledge-scope-test-123.12345678" diff --git a/internal/api/ai_handlers_test.go b/internal/api/ai_handlers_test.go index 7ca4fbed7a..1aab881e2d 100644 --- a/internal/api/ai_handlers_test.go +++ b/internal/api/ai_handlers_test.go @@ -927,8 +927,8 @@ func TestAISettingsHandler_TestProvider_Ollama(t *testing.T) { if !resp.Success || resp.Provider != "ollama" { t.Fatalf("unexpected response: %+v", resp) } - if versionHits != 1 || tagsHits != 2 { - t.Fatalf("expected one version check and two tags lookups (service load + connection), got version=%d tags=%d", versionHits, tagsHits) + if versionHits != 1 || tagsHits != 1 { + t.Fatalf("expected one version check and one tags lookup for the resolved Ollama provider test, got version=%d tags=%d", versionHits, tagsHits) } } diff --git a/internal/api/bootstrap_token_test.go b/internal/api/bootstrap_token_test.go index 0198425b3d..89e78ba2b8 100644 --- a/internal/api/bootstrap_token_test.go +++ b/internal/api/bootstrap_token_test.go @@ -79,30 +79,13 @@ func TestLoadOrCreateBootstrapToken_MkdirAllFailure(t *testing.T) { } func TestLoadOrCreateBootstrapToken_WriteFileFailure(t *testing.T) { - if runtime.GOOS == "windows" { - t.Skip("permission tests not reliable on Windows") - } - if os.Getuid() == 0 { - t.Skip("cannot test permission errors as root") - } - - // Create a temp directory with no write permissions tmpDir := t.TempDir() - readOnlyDir := filepath.Join(tmpDir, "readonly") - if err := os.MkdirAll(readOnlyDir, 0o700); err != nil { - t.Fatalf("failed to create readonly dir: %v", err) + tmpFileBlocker := filepath.Join(tmpDir, bootstrapTokenFilename+".tmp") + if err := os.MkdirAll(tmpFileBlocker, 0o700); err != nil { + t.Fatalf("failed to create tmp file blocker: %v", err) } - // Remove write permissions - if err := os.Chmod(readOnlyDir, 0o500); err != nil { - t.Fatalf("failed to chmod dir: %v", err) - } - // Restore permissions for cleanup - t.Cleanup(func() { - _ = os.Chmod(readOnlyDir, 0o700) - }) - - token, created, fullPath, err := loadOrCreateBootstrapToken(readOnlyDir) + token, created, fullPath, err := loadOrCreateBootstrapToken(tmpDir) if err == nil { t.Error("expected error when WriteFile fails, got nil") } @@ -113,7 +96,7 @@ func TestLoadOrCreateBootstrapToken_WriteFileFailure(t *testing.T) { t.Error("expected created=false") } // fullPath should be set even on write failure (path was computed before write) - expectedPath := filepath.Join(readOnlyDir, bootstrapTokenFilename) + expectedPath := filepath.Join(tmpDir, bootstrapTokenFilename) if fullPath != expectedPath { t.Errorf("expected fullPath=%q, got %q", expectedPath, fullPath) } diff --git a/internal/api/code_standards_test.go b/internal/api/code_standards_test.go index 60aa71399d..864716bfaf 100644 --- a/internal/api/code_standards_test.go +++ b/internal/api/code_standards_test.go @@ -142,6 +142,12 @@ var paidDomainFiles = map[string]string{ const paidDomainFileCeiling = 13 +var knownSharedInfrastructureFiles = map[string]bool{ + // Shared SSO outbound helpers centralize restricted fetch/file access and are + // consumed by public handlers; they are not paid-domain business logic. + "sso_outbound.go": true, +} + // TestPaidDomainBoundaryAudit enforces two rules: // // 1. No NEW paid-domain implementation files may be added to internal/api/. @@ -227,6 +233,9 @@ func TestPaidDomainBoundaryAudit(t *testing.T) { if knownPublicReportingFiles[name] { continue } + if knownSharedInfrastructureFiles[name] { + continue + } // Flag unknown enterprise_extension_ files (not in the exact allowlist). if strings.HasPrefix(name, "enterprise_extension_") { diff --git a/internal/api/config_export_import_handlers.go b/internal/api/config_export_import_handlers.go index 183ebdb6f8..b75a863040 100644 --- a/internal/api/config_export_import_handlers.go +++ b/internal/api/config_export_import_handlers.go @@ -151,27 +151,34 @@ func (h *ConfigHandlers) handleImportConfig(w http.ResponseWriter, r *http.Reque // Reload webhook configuration. if webhooks, err := h.getPersistence(r.Context()).LoadWebhooks(); err == nil { - // Clear existing webhooks and add new ones. notificationMgr := h.getMonitor(r.Context()).GetNotificationManager() - // Get current webhooks to clear them. - for _, webhook := range notificationMgr.GetWebhooks() { - if err := notificationMgr.DeleteWebhook(webhook.ID); err != nil { - log.Warn().Err(err).Str("webhook", webhook.ID).Msg("Failed to delete existing webhook during reload") + if notificationMgr == nil { + log.Warn().Msg("Skipped webhook reload after import because notification manager is unavailable") + } else { + // Clear existing webhooks and add new ones. + for _, webhook := range notificationMgr.GetWebhooks() { + if err := notificationMgr.DeleteWebhook(webhook.ID); err != nil { + log.Warn().Err(err).Str("webhook", webhook.ID).Msg("Failed to delete existing webhook during reload") + } } + // Add imported webhooks. + for _, webhook := range webhooks { + notificationMgr.AddWebhook(webhook) + } + log.Info().Int("count", len(webhooks)).Msg("Reloaded webhook configuration after import") } - // Add imported webhooks. - for _, webhook := range webhooks { - notificationMgr.AddWebhook(webhook) - } - log.Info().Int("count", len(webhooks)).Msg("Reloaded webhook configuration after import") } else { log.Warn().Err(err).Msg("Failed to reload webhook configuration after import") } // Reload email configuration. if emailConfig, err := h.getPersistence(r.Context()).LoadEmailConfig(); err == nil { - h.getMonitor(r.Context()).GetNotificationManager().SetEmailConfig(*emailConfig) - log.Info().Msg("Reloaded email configuration after import") + if notificationMgr := h.getMonitor(r.Context()).GetNotificationManager(); notificationMgr == nil { + log.Warn().Msg("Skipped email reload after import because notification manager is unavailable") + } else { + notificationMgr.SetEmailConfig(*emailConfig) + log.Info().Msg("Reloaded email configuration after import") + } } else { log.Warn().Err(err).Msg("Failed to reload email configuration after import") } diff --git a/internal/api/contract_test.go b/internal/api/contract_test.go index 583edce9b9..4a262595d3 100644 --- a/internal/api/contract_test.go +++ b/internal/api/contract_test.go @@ -836,7 +836,7 @@ func TestContract_InfrastructureChartsNormalizeLongRangeMixedCadence(t *testing. }} syncTestResourceStore(t, monitor, state) - now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Minute) windowStart := now.Add(-7 * 24 * time.Hour) seed := make([]metrics.WriteMetric, 0, 1200) appendMetric := func(ts time.Time, value float64) { @@ -941,7 +941,7 @@ func TestContract_WorkloadChartsCapLongRangeMixedCadenceByTime(t *testing.T) { t.Fatal("expected vm source ID") } - now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Minute) windowStart := now.Add(-7 * 24 * time.Hour) seed := make([]metrics.WriteMetric, 0, 2000) appendMetric := func(ts time.Time, percentValue, rateValue float64) { @@ -1024,7 +1024,7 @@ func TestContract_WorkloadChartsCapLongRangeMixedCadenceByTime(t *testing.T) { func TestContract_WorkloadChartsUseCanonicalWorkloadIDsForProviderBackedVMs(t *testing.T) { monitor, state, history := newTestMonitor(t) - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Minute) metricID := "vc-1:vm:vm-201" history.AddGuestMetric(metricID, "cpu", 51, now.Add(-10*time.Minute)) @@ -1154,7 +1154,7 @@ func TestContract_WorkloadsSummaryChartsNormalizeLongRangeMixedCadence(t *testin t.Fatal("expected vm source ID") } - now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Minute) windowStart := now.Add(-7 * 24 * time.Hour) seed := make([]metrics.WriteMetric, 0, 1500) appendMetric := func(ts time.Time, percentValue, rateValue float64) { @@ -2351,7 +2351,7 @@ func TestContract_AISettingsOllamaAuthJSONSnapshot(t *testing.T) { const want = `{ "enabled":true, - "model":"openai:gpt-4o", + "model":"ollama:llama3", "patrol_model":"ollama:llama3", "configured":true, "custom_context":"", @@ -7457,6 +7457,7 @@ func TestContract_CanonicalAutoRegisterDirectValidationContract(t *testing.T) { reqBody := AutoRegisterRequest{ Type: "pve", Host: "https://pve.local:8006", + Source: "script", TokenID: "pulse-monitor@pve!pulse-homelab", TokenValue: "secret-token", } diff --git a/internal/api/router_misc_additional_test.go b/internal/api/router_misc_additional_test.go index 6bbbeb5dfb..d961452e4c 100644 --- a/internal/api/router_misc_additional_test.go +++ b/internal/api/router_misc_additional_test.go @@ -885,7 +885,7 @@ func TestHandleWorkloadCharts_WorkloadOnlyPayloadAndNodeFilter(t *testing.T) { func TestHandleWorkloadCharts_UsesCanonicalWorkloadIDsForVMwareVMs(t *testing.T) { monitor, state, history := newTestMonitor(t) - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Minute) metricID := "vc-1:vm:vm-201" resourceID := "vm-vmware-1" @@ -961,7 +961,7 @@ func TestHandleWorkloadCharts_UsesCanonicalWorkloadIDsForVMwareVMs(t *testing.T) func TestHandleWorkloadsSummaryCharts_UsesCanonicalWorkloadIDsForVMwareVMs(t *testing.T) { monitor, state, history := newTestMonitor(t) - now := time.Date(2026, time.April, 1, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Minute) metricID := "vc-1:vm:vm-201" resourceID := "vm-vmware-1" diff --git a/internal/api/slo_bench_test.go b/internal/api/slo_bench_test.go index 04f652275a..354524ee58 100644 --- a/internal/api/slo_bench_test.go +++ b/internal/api/slo_bench_test.go @@ -324,7 +324,7 @@ func TestSLO_ResourcesList(t *testing.T) { t.Fatalf("warmup: expected total=85, got %v", total) } - latencies := measureEndpointLatencies(t, func() { + latencies := measureEndpointAmortizedLatencies(t, resourcesListLatencyBatchSize, func() { req := httptest.NewRequest(http.MethodGet, "/api/resources", nil) rec := httptest.NewRecorder() handlers.HandleListResources(rec, req) @@ -866,7 +866,10 @@ func skipUnderRace(t *testing.T) { } } -const sloIterations = 200 +const ( + sloIterations = 200 + resourcesListLatencyBatchSize = 25 +) func effectiveAPISLOTarget(localTarget, githubActionsTarget time.Duration) time.Duration { if githubActionsTarget > 0 && os.Getenv("GITHUB_ACTIONS") == "true" { @@ -894,6 +897,34 @@ func measureEndpointLatencies(t *testing.T, fn func()) []time.Duration { return latencies } +// measureEndpointAmortizedLatencies captures per-request latency for extremely +// fast handlers by timing a small request batch and amortizing the wall time +// across that batch. This keeps micro-endpoint SLOs sensitive to real +// regressions while filtering unrelated scheduler and GC spikes from broad +// `go test ./...` runs. +func measureEndpointAmortizedLatencies(t *testing.T, batchSize int, fn func()) []time.Duration { + t.Helper() + if batchSize <= 0 { + t.Fatalf("batchSize must be positive, got %d", batchSize) + } + + for i := 0; i < 20; i++ { + for j := 0; j < batchSize; j++ { + fn() + } + } + + latencies := make([]time.Duration, sloIterations) + for i := 0; i < sloIterations; i++ { + start := time.Now() + for j := 0; j < batchSize; j++ { + fn() + } + latencies[i] = time.Since(start) / time.Duration(batchSize) + } + return latencies +} + // percentile returns the value at the given percentile (0.0–1.0) from // a slice of durations. func percentile(durations []time.Duration, pct float64) time.Duration { diff --git a/internal/config/ai.go b/internal/config/ai.go index e8d8e52008..6f3139c5f2 100644 --- a/internal/config/ai.go +++ b/internal/config/ai.go @@ -346,6 +346,18 @@ func NormalizeQuickstartModelString(model string) string { // Returns empty string if the provider is unknown. func DefaultModelForProvider(provider string) string { switch provider { + case AIProviderAnthropic: + return FormatModelString(AIProviderAnthropic, "claude-3-5-sonnet-latest") + case AIProviderOpenAI: + return FormatModelString(AIProviderOpenAI, "gpt-4o") + case AIProviderOpenRouter: + return FormatModelString(AIProviderOpenRouter, "openai/gpt-4o-mini") + case AIProviderDeepSeek: + return FormatModelString(AIProviderDeepSeek, "deepseek-chat") + case AIProviderGemini: + return FormatModelString(AIProviderGemini, "gemini-1.5-pro") + case AIProviderOllama: + return FormatModelString(AIProviderOllama, "llama3.2") case AIProviderQuickstart: return FormatModelString(AIProviderQuickstart, DefaultAIModelQuickstart) default: diff --git a/internal/config/ai_additional_test.go b/internal/config/ai_additional_test.go index c1bf5bb415..9b8e49907e 100644 --- a/internal/config/ai_additional_test.go +++ b/internal/config/ai_additional_test.go @@ -144,6 +144,36 @@ func TestDefaultModelForProvider(t *testing.T) { provider string want string }{ + { + name: "anthropic", + provider: AIProviderAnthropic, + want: FormatModelString(AIProviderAnthropic, "claude-3-5-sonnet-latest"), + }, + { + name: "openai", + provider: AIProviderOpenAI, + want: FormatModelString(AIProviderOpenAI, "gpt-4o"), + }, + { + name: "openrouter", + provider: AIProviderOpenRouter, + want: FormatModelString(AIProviderOpenRouter, "openai/gpt-4o-mini"), + }, + { + name: "deepseek", + provider: AIProviderDeepSeek, + want: FormatModelString(AIProviderDeepSeek, "deepseek-chat"), + }, + { + name: "gemini", + provider: AIProviderGemini, + want: FormatModelString(AIProviderGemini, "gemini-1.5-pro"), + }, + { + name: "ollama", + provider: AIProviderOllama, + want: FormatModelString(AIProviderOllama, "llama3.2"), + }, { name: "quickstart", provider: AIProviderQuickstart, diff --git a/internal/config/ai_config_test.go b/internal/config/ai_config_test.go index 33f09ff0bd..36d90ffde2 100644 --- a/internal/config/ai_config_test.go +++ b/internal/config/ai_config_test.go @@ -184,6 +184,30 @@ func TestAIConfig_HasProvider(t *testing.T) { } } +func TestDefaultModelForProvider_UsesCanonicalProviderFallbacks(t *testing.T) { + tests := []struct { + provider string + expected string + }{ + {provider: AIProviderAnthropic, expected: "anthropic:claude-3-5-sonnet-latest"}, + {provider: AIProviderOpenAI, expected: "openai:gpt-4o"}, + {provider: AIProviderOpenRouter, expected: "openrouter:openai/gpt-4o-mini"}, + {provider: AIProviderDeepSeek, expected: "deepseek:deepseek-chat"}, + {provider: AIProviderGemini, expected: "gemini:gemini-1.5-pro"}, + {provider: AIProviderOllama, expected: "ollama:llama3.2"}, + {provider: AIProviderQuickstart, expected: "quickstart:" + DefaultAIModelQuickstart}, + {provider: "unknown", expected: ""}, + } + + for _, tt := range tests { + t.Run(tt.provider, func(t *testing.T) { + if got := DefaultModelForProvider(tt.provider); got != tt.expected { + t.Fatalf("DefaultModelForProvider(%q) = %q, want %q", tt.provider, got, tt.expected) + } + }) + } +} + func TestAIConfig_GetConfiguredProviders(t *testing.T) { tests := []struct { name string diff --git a/internal/dockeragent/cleanup.go b/internal/dockeragent/cleanup.go index a4369ec78c..49ff7207df 100644 --- a/internal/dockeragent/cleanup.go +++ b/internal/dockeragent/cleanup.go @@ -43,7 +43,10 @@ func (a *Agent) cleanupOrphanedBackups(ctx context.Context) { } timestampStr := parts[len(parts)-1] - backupTime, err := time.Parse("20060102_150405", timestampStr) + // Backup names are currently stamped with the agent host's local wall + // clock, so cleanup must parse in the local location to avoid treating + // valid stale backups as fresh on non-UTC systems. + backupTime, err := time.ParseInLocation("20060102_150405", timestampStr, time.Local) if err != nil { // If we can't parse the timestamp, fall back to creation date as a safety created := time.Unix(c.Created, 0) diff --git a/internal/monitoring/mock_metrics_history_test.go b/internal/monitoring/mock_metrics_history_test.go index b3580630ba..7429999d4f 100644 --- a/internal/monitoring/mock_metrics_history_test.go +++ b/internal/monitoring/mock_metrics_history_test.go @@ -963,7 +963,7 @@ func TestGenerateSeededMetricSeriesForTimestamps_UsesSameTimelineAsMockRuntime(t } func TestSeedMockMetricsHistory_StaysContinuousWithSubsequentLiveMockTicks(t *testing.T) { - now := time.Date(2026, time.March, 31, 12, 0, 0, 0, time.UTC) + now := time.Now().UTC().Add(-10 * time.Minute).Truncate(time.Minute) next := now.Add(time.Minute) const storageTotal = int64(2 * 1024 * 1024 * 1024 * 1024) const k8sMetricID = "k8s:k8s-tail:pod:workloads/api-tail" @@ -1262,7 +1262,7 @@ func TestSeedMockMetricsHistory_NormalizesTerminalTimestampToCanonicalGrid(t *te } func TestRecordMockStateToMetricsHistory_UsesCanonicalMetricModelForStateBackedResources(t *testing.T) { - ts := time.Date(2026, time.April, 1, 14, 0, 0, 0, time.UTC) + ts := time.Now().UTC().Add(-5 * time.Minute).Truncate(time.Minute) const storageTotal = int64(4 * 1024 * 1024 * 1024 * 1024) graph := fixtureGraphWithState(models.StateSnapshot{ @@ -1415,7 +1415,7 @@ func TestRecordMockStateToMetricsHistory_UsesCanonicalMetricModelForStateBackedR } func TestRecordMockStateToMetricsHistory_UsesCanonicalMetricModelForPlatformFixtures(t *testing.T) { - ts := time.Date(2026, time.April, 1, 14, 37, 0, 0, time.UTC) + ts := time.Now().UTC().Add(-5 * time.Minute).Truncate(time.Minute) graph := mock.FixtureGraph{ PlatformFixtures: mock.PlatformFixtures{ diff --git a/internal/monitoring/monitor_polling_test.go b/internal/monitoring/monitor_polling_test.go index 362dc893c8..df49da06a5 100644 --- a/internal/monitoring/monitor_polling_test.go +++ b/internal/monitoring/monitor_polling_test.go @@ -529,8 +529,8 @@ func TestBuildBroadcastFrontendStateIncludesUnifiedIncidentAlerts(t *testing.T) if len(frontend.ActiveAlerts) != 1 { t.Fatalf("expected 1 active alert in frontend state, got %d", len(frontend.ActiveAlerts)) } - if frontend.ActiveAlerts[0].Type != "storage-incident" { - t.Fatalf("alert type = %q, want storage-incident", frontend.ActiveAlerts[0].Type) + if frontend.ActiveAlerts[0].Type != "resource-incident" { + t.Fatalf("alert type = %q, want resource-incident", frontend.ActiveAlerts[0].Type) } } @@ -1038,8 +1038,8 @@ func TestBuildBroadcastFrontendStatePrefersLiveAlertManagerOverSnapshotAlerts(t if got := frontend.ActiveAlerts[0].ID; got == "snapshot-stale-1" || got == "" { t.Fatalf("expected live alert manager id, got %q", got) } - if got := frontend.ActiveAlerts[0].Type; got != "storage-incident" { - t.Fatalf("alert type = %q, want storage-incident", got) + if got := frontend.ActiveAlerts[0].Type; got != "resource-incident" { + t.Fatalf("alert type = %q, want resource-incident", got) } if got := frontend.ActiveAlerts[0].Message; got == "stale snapshot alert" { t.Fatalf("expected live alert manager payload, got stale snapshot message %q", got) @@ -1086,8 +1086,8 @@ func TestActiveAlertsSnapshotPrefersLiveAlertManagerOverStateSnapshot(t *testing if got := active[0].ID; got == "snapshot-active-1" || got == "" { t.Fatalf("expected live alert manager alert id, got %q", got) } - if got := active[0].Type; got != "storage-incident" { - t.Fatalf("alert type = %q, want storage-incident", got) + if got := active[0].Type; got != "resource-incident" { + t.Fatalf("alert type = %q, want resource-incident", got) } } @@ -1136,8 +1136,8 @@ func TestRecentlyResolvedSnapshotPrefersLiveAlertManagerOverStateSnapshot(t *tes if got := resolved[0].ID; got == "snapshot-resolved-1" || got == "" { t.Fatalf("expected live resolved alert id, got %q", got) } - if got := resolved[0].Type; got != "storage-incident" { - t.Fatalf("resolved alert type = %q, want storage-incident", got) + if got := resolved[0].Type; got != "resource-incident" { + t.Fatalf("resolved alert type = %q, want resource-incident", got) } } @@ -2099,6 +2099,8 @@ func TestUpdateResourceStore_IngestsRegisteredSupplementalProvider(t *testing.T) resourceStore: store, } monitor.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, provider) + store.snapshotCalls = 0 + store.recordsBySource = nil monitor.updateResourceStore(models.StateSnapshot{}) if store.snapshotCalls != 1 { @@ -2139,6 +2141,10 @@ func TestUpdateResourceStore_UsesAtomicStoreReplacementWhenAvailable(t *testing. resourceStore: store, } monitor.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, provider) + store.snapshotCalls = 0 + store.atomicCalls = 0 + store.lastSnapshot = models.StateSnapshot{} + store.lastRecordsBySrc = nil snapshot := models.StateSnapshot{ Hosts: []models.Host{{ID: "host-1", Hostname: "minipc", Status: "online"}}, } @@ -2187,6 +2193,9 @@ func TestUpdateResourceStore_SuppressesSnapshotForRegisteredSupplementalOwnershi resourceStore: store, } monitor.SetSupplementalRecordsProvider(unifiedresources.SourceTrueNAS, provider) + store.snapshotCalls = 0 + store.recordsBySource = nil + store.lastSnapshot = models.StateSnapshot{} monitor.updateResourceStore(models.StateSnapshot{ Nodes: []models.Node{{}}, VMs: []models.VM{{}}, diff --git a/internal/monitoring/truenas_poller_test.go b/internal/monitoring/truenas_poller_test.go index af0f96716b..031861f2e0 100644 --- a/internal/monitoring/truenas_poller_test.go +++ b/internal/monitoring/truenas_poller_test.go @@ -231,8 +231,8 @@ func TestTrueNASPollerRecordsMetrics(t *testing.T) { t.Cleanup(poller.Stop) waitForCondition(t, 5*time.Second, func() bool { - return successCount.Load() > 0 - }, "expected successful requests after initial failures") + return successCount.Load() > 0 && hasTrueNASHostForOrg(poller, "default", "metrics-host") + }, "expected TrueNAS resources to appear after initial failures") poller.Stop() if !hasTrueNASHostForOrg(poller, "default", "metrics-host") { diff --git a/pkg/metrics/store_downsample_test.go b/pkg/metrics/store_downsample_test.go index d97ad99ddd..da1f92401b 100644 --- a/pkg/metrics/store_downsample_test.go +++ b/pkg/metrics/store_downsample_test.go @@ -135,18 +135,22 @@ func TestStoreMetadataHelpers(t *testing.T) { t.Fatalf("expected no max timestamp, got %d (ok=%t)", ts, ok) } + base := time.Now().UTC().Truncate(time.Second) + firstTs := base.Add(-2 * time.Second).Unix() + secondTs := base.Add(-1 * time.Second).Unix() + _, err = store.db.Exec( `INSERT INTO metrics (resource_type, resource_id, metric_type, value, timestamp, tier) VALUES ('vm','vm-1','cpu',1.0,?, 'raw'), ('vm','vm-1','cpu',2.0,?, 'raw')`, - 100, 200, + firstTs, secondTs, ) if err != nil { t.Fatalf("insert metrics returned error: %v", err) } - if ts, ok := store.getMaxTimestampForTier(TierRaw); !ok || ts != 200 { - t.Fatalf("expected max timestamp 200, got %d (ok=%t)", ts, ok) + if ts, ok := store.getMaxTimestampForTier(TierRaw); !ok || ts != secondTs { + t.Fatalf("expected max timestamp %d, got %d (ok=%t)", secondTs, ts, ok) } }