mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-07-10 00:14:38 +00:00
Fix late issue triage regressions
Refs #1341 Refs #1429 Refs #1469 Refs #1472 Refs #1476 Refs #1481
This commit is contained in:
parent
47e9eb9aea
commit
7297aae4af
40 changed files with 1026 additions and 146 deletions
|
|
@ -0,0 +1,78 @@
|
|||
# Known RC Issue Closure For GA Late Issue Integration Record
|
||||
|
||||
- Date: `2026-05-28`
|
||||
- Gate: `known-rc-issue-closure-for-ga`
|
||||
- Issues: `#1341`, `#1429`, `#1469`, `#1472`, `#1476`, `#1481`
|
||||
- Result: `fixed-local-proof`
|
||||
|
||||
## Context
|
||||
|
||||
The May 28 open-issue pass found several late reports where the right v6
|
||||
outcome was not a v5-only patch or public issue state change:
|
||||
|
||||
- `#1476` exposed a PBS setup-script auto-registration 405 after token
|
||||
creation.
|
||||
- `#1472` exposed an OIDC refresh cadence defect where five-minute access
|
||||
tokens were treated as immediately refreshable.
|
||||
- `#1481` requested a configurable Ollama `keep_alive` runtime option.
|
||||
- `#1429` remained open after v6 navigation clarification and agent reinstall
|
||||
feedback; the remaining v6-hardening gap was host-agent continuity across
|
||||
short/FQDN hostname drift after upgrade or reload.
|
||||
- `#1469` was checked against the current branch. TrueNAS decode fixes and
|
||||
unified connection runtime summaries were already present; a regression test
|
||||
now locks the expected connection-ledger presentation.
|
||||
- `#1341` received fresh reporter evidence showing a saved Ceph pool override
|
||||
at 50 percent while the agent-reported pool was above threshold and no active
|
||||
alert fired. Screenshots were inspected before treating the new evidence as
|
||||
actionable.
|
||||
|
||||
## Disposition
|
||||
|
||||
The integrated v6 fixes are:
|
||||
|
||||
- PBS setup scripts now post auto-registration to the Pulse base URL plus
|
||||
`/api/auto-register`, not the setup-script download URL plus that path.
|
||||
- OIDC session refresh now uses a relative refresh lead based on the access
|
||||
token lifetime, capped at five minutes, with a one-minute fallback for
|
||||
legacy sessions that do not yet carry issued-at metadata.
|
||||
- OIDC sessions persist access-token issued-at metadata and update it after a
|
||||
successful refresh.
|
||||
- Ollama provider settings now expose and persist `ollama_keep_alive`; the
|
||||
provider sends that value as Ollama `keep_alive`, preserves numeric values
|
||||
such as `0` and `-1`, and omits the field when the operator clears it to use
|
||||
the Ollama server default.
|
||||
- Host-agent continuity now treats short and fully-qualified hostnames as
|
||||
equivalent where one side is a short name and the other side is its FQDN, and
|
||||
host-token bindings alias the current hostname form after reload.
|
||||
- Agent-sourced Ceph pool reports now run pool alert evaluation, reconcile
|
||||
with Proxmox API reports by FSID, and preserve old `agent:<host>` pool IDs
|
||||
as threshold aliases while emitting alerts under the normalized storage ID.
|
||||
- TrueNAS connection-ledger regression coverage confirms successful runtime
|
||||
summaries do not present as `Credentials unknown` or `No activity yet`.
|
||||
|
||||
This record does not close any public GitHub issue by itself. Public issue
|
||||
closure should still wait for normal maintainer review, release publication,
|
||||
or reporter retest where the issue thread needs environment confirmation.
|
||||
|
||||
## Proof
|
||||
|
||||
- `go test ./internal/api -run 'TestPBSSetupScript_FailsClosedOnAutoRegisterSuccessDetection|TestHandleSetupScript_PBSWithAuthToken|TestHandleSetupScript_PBSTypeGeneratesScript|TestHandleCanonicalAutoRegister_PBS' -count=1`
|
||||
- `go test ./internal/api -run 'Test(ShouldRefreshOIDCSessionToken|RefreshOIDCSessionTokens|CreateOIDCSession|UpdateOIDCTokens|OIDCSessionPersistence|CreateOIDCSession_NilTokenInfo|InvalidateSession|SessionStore)' -count=1`
|
||||
- `go test ./internal/api -run 'TestAISettingsHandler_(UpdateSettings_OllamaKeepAlive|UpdateSettingsRejectsInvalidOllamaKeepAlive|GetAndUpdateSettings_RoundTrip)|TestContract_.*AI' -count=1`
|
||||
- `go test ./internal/ai/providers ./internal/config`
|
||||
- `go test ./internal/alerts -run 'Test(StorageThresholdResolutionUsesAliasIDs|ReevaluateActiveAlertsUsesSharedStorageOverrideResolution|CheckStorageOfflineUsesSharedThresholdResolution)' -count=1`
|
||||
- `go test ./internal/models -run 'Test(StateUpsertCephCluster|UpdateCephClustersForInstance)' -count=1`
|
||||
- `go test ./internal/monitoring -run 'TestApplyHostReport(FiresCephPoolAlertsForAgentSourcedCluster|MergesAgentCephWithProxmoxAPICluster|ReusesTokenBindingAcrossShortFQDNAfterReload)|TestRemoveHostAgentUnbindsToken|TestPollCephClusterChecksPoolStorageThresholds' -count=1`
|
||||
- `npm --prefix frontend-modern test -- src/components/Settings/__tests__/AISettings.test.tsx src/components/Settings/__tests__/useConnectionsLedger.test.ts src/features/patrol/__tests__/usePatrolIntelligenceState.test.ts`
|
||||
- `npm --prefix frontend-modern run type-check`
|
||||
- Browser inspection of Settings > Assistant & Patrol provider configuration for the Ollama Keep Alive field.
|
||||
|
||||
## Remaining Boundaries
|
||||
|
||||
- `#1482` is treated as a v5/configuration-specific Docker-agent-as-container
|
||||
report, not as evidence that Docker-only installs should produce a host row.
|
||||
- The storage-offline follow-up in `#1429` remains a separate storage
|
||||
connection-state lane and was not resolved by the host continuity hardening.
|
||||
- The OneDrive video linked from `#1341` was not inspected during this batch;
|
||||
the attached screenshots and pasted `/data/alerts.json` override were enough
|
||||
to validate the Ceph alert-evaluation gap addressed here.
|
||||
|
|
@ -6000,6 +6000,12 @@
|
|||
"kind": "file",
|
||||
"evidence_tier": "managed-runtime-exercise"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-late-issue-integration-2026-05-28.md",
|
||||
"kind": "file",
|
||||
"evidence_tier": "test-proof"
|
||||
},
|
||||
{
|
||||
"repo": "pulse",
|
||||
"path": "docs/release-control/v6/internal/records/known-rc-issue-closure-for-ga-metric-threshold-coloring-2026-05-01.md",
|
||||
|
|
|
|||
|
|
@ -127,6 +127,11 @@ reverse.
|
|||
19. `internal/api/agent_install_command_shared.go` shared with `api-contracts`: agent install command assembly is both an agent lifecycle control surface and a canonical API payload contract boundary.
|
||||
20. `internal/api/config_setup_handlers.go` shared with `api-contracts`: auto-register and setup handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
|
||||
21. `internal/api/setup_script_render.go` shared with `api-contracts`, `storage-recovery`: the generated Proxmox setup-script is a shared boundary across agent lifecycle (forced-command keys, install/uninstall edits), API contracts (rendered token shape and encoded rerun URL), and storage/recovery (backup visibility grants, Pulse-managed temperature SSH keys, and SMART disk-temperature collection).
|
||||
PBS setup-script auto-registration remains lifecycle-owned bootstrap
|
||||
transport: rendered scripts must post registration payloads to the canonical
|
||||
Pulse base URL plus `/api/auto-register`, not to the script download
|
||||
artifact URL, so install-time registration keeps one API root regardless of
|
||||
whether the script came from `/api/setup-script` or `/api/setup-script-url`.
|
||||
22. `internal/api/unified_agent.go` shared with `api-contracts`: unified agent download and installer handlers are both an agent lifecycle control surface and a canonical API payload contract boundary.
|
||||
23. `scripts/install.ps1` shared with `deployment-installability`: the Windows installer is both a deployment installability entry point and a canonical agent lifecycle runtime continuity boundary.
|
||||
The Windows installer must support a non-mutating download preflight that
|
||||
|
|
|
|||
|
|
@ -55,7 +55,12 @@ runtime cost control, and shared AI transport surfaces.
|
|||
## Extension Points
|
||||
|
||||
1. Add or change chat runtime, Patrol orchestration, findings generation, or remediation behavior through `internal/ai/`
|
||||
2. Add or change canonical AI provider config, provider-scoped model selection, or runtime auth/base-URL defaults through `internal/config/ai.go`
|
||||
2. Add or change canonical AI provider config, provider-scoped model selection, or runtime auth/base-URL defaults through `internal/config/ai.go`.
|
||||
Ollama request keep-alive is a provider runtime option owned by this path:
|
||||
`internal/config/ai.go` stores the value, `/api/settings/ai` exposes it,
|
||||
and `internal/ai/providers/ollama.go` is the only layer that turns it into
|
||||
the Ollama `keep_alive` request field. An empty configured value means
|
||||
Pulse omits `keep_alive` so the Ollama server default applies.
|
||||
3. Add or change Pulse Assistant request flow through `internal/api/ai_handler.go`, `frontend-modern/src/api/ai.ts`, and `frontend-modern/src/api/aiChat.ts`
|
||||
4. Add or change Patrol, alert-analysis, or remediation transport through `internal/api/ai_handlers.go`, `internal/api/ai_intelligence_handlers.go`, and `frontend-modern/src/api/patrol.ts`
|
||||
Provider preflight diagnostics returned from `internal/api/ai_handlers.go`
|
||||
|
|
|
|||
|
|
@ -1506,6 +1506,11 @@ the canonical monitored-system blocked payload.
|
|||
backend contract must keep provider test routes bound to the selected
|
||||
provider's configured model instead of whichever other provider currently
|
||||
owns the default `model` field.
|
||||
The Ollama provider payload also owns `ollama_keep_alive` as the canonical
|
||||
request keep-alive field: GET and update responses must expose the
|
||||
normalized configured value, update requests must reject malformed values,
|
||||
an empty string means omit Ollama `keep_alive`, and stored provider secrets
|
||||
remain masked independently of that runtime option.
|
||||
Discovery scheduling is part of that same AI settings payload contract:
|
||||
settings saves from `frontend-modern/src/components/Settings/useAISettingsState.ts`
|
||||
must send `discovery_enabled` and `discovery_interval_hours` together as
|
||||
|
|
@ -3879,6 +3884,10 @@ That same payload contract must also fail closed on auto-register transport:
|
|||
the generated script must use fail-fast `curl -fsS` request transport and only
|
||||
evaluate the response payload after a successful curl exit status, rather than
|
||||
parsing ambiguous stderr or HTTP-failure output as a valid registration body.
|
||||
For PBS setup scripts, the generated auto-register POST URL must be the
|
||||
canonical Pulse base URL plus `/api/auto-register`. The script may not append
|
||||
that path to the setup-script download artifact URL, because the artifact URL is
|
||||
only for fetching the script and is not the API root.
|
||||
That same setup-script payload must also preserve the canonical auth guidance:
|
||||
authentication failures in the generated script text must reference the active
|
||||
Pulse setup-token flow, not stale API-token setup instructions, because the
|
||||
|
|
@ -4374,6 +4383,11 @@ from or saved to encrypted-at-rest session payloads, and the runtime must drop
|
|||
them whenever session-store crypto is unavailable or the stored ciphertext is
|
||||
not canonically decryptable instead of preserving plaintext-at-rest session
|
||||
state.
|
||||
OIDC access-token issued-at metadata is part of the same session persistence
|
||||
contract. Short-lived access tokens must persist `oidc_access_token_issued_at`
|
||||
beside `oidc_access_token_exp` so refresh scheduling can use a relative
|
||||
lifetime window after restart instead of treating every five-minute token as
|
||||
immediately refreshable.
|
||||
Hosted signup handler payload flow now also follows an explicit shared
|
||||
boundary: `internal/api/public_signup_handlers.go` owns request/response and
|
||||
magic-link payload semantics, while `internal/hosted/provisioner.go` owns the
|
||||
|
|
|
|||
|
|
@ -1424,6 +1424,12 @@ not a replacement status card, CTA band, or page-local nested card.
|
|||
|
||||
## Current State
|
||||
|
||||
AI settings provider fields are a governed frontend primitive, not a
|
||||
provider-local form fork. The shared provider configuration section must render
|
||||
provider-specific controls from `aiSettingsModel.ts` `extraFields`, including
|
||||
Ollama `keep_alive`, so Assistant and Patrol keep one settings shape across
|
||||
labeling, help affordances, helper copy, and persistence binding.
|
||||
|
||||
Kubernetes RBAC inventory (Roles, ClusterRoles, RoleBindings,
|
||||
ClusterRoleBindings) is part of the existing Kubernetes platform-page
|
||||
Configuration tab, not a new sidebar entry or top-level route, and the
|
||||
|
|
@ -3130,6 +3136,11 @@ maintenance routes through `AIChatMaintenanceSection.tsx`, and readiness plus
|
|||
save/test actions route through `AISettingsStatusAndActions.tsx`. Future AI
|
||||
settings work must extend those section owners instead of re-inlining large
|
||||
runtime subsections into the shell.
|
||||
Provider-specific settings fields inside
|
||||
`AIProviderConfigurationSection.tsx` must remain model-driven through
|
||||
`aiSettingsModel.ts` `extraFields`, including Ollama `keep_alive`, so the
|
||||
shared provider panel owns framing, labels, help affordances, helper copy, and
|
||||
form binding instead of adding provider-local bespoke controls.
|
||||
That same AI settings boundary now also owns
|
||||
`frontend-modern/src/utils/aiSettingsPresentation.ts`, so shared loading,
|
||||
empty, OAuth, action/error, shell-description, and workload-discovery copy
|
||||
|
|
|
|||
|
|
@ -290,6 +290,12 @@ This subsystem now sits under the dedicated core monitoring runtime lane so
|
|||
discovery, metrics-history correctness, and platform-specific runtime coverage
|
||||
can be governed as first-class product work instead of staying diluted inside
|
||||
architecture coherence.
|
||||
Standalone host-agent identity continuity is part of that monitoring runtime
|
||||
contract. `internal/monitoring/monitor_agents.go` must resolve short/FQDN
|
||||
hostname aliases through the shared unified-resource equivalence rule when it
|
||||
binds tokens, matches reports, and removes ignored agents, so reconnects and
|
||||
reloads keep the same canonical host without weakening token uniqueness across
|
||||
different machines.
|
||||
That same monitoring boundary now owns agentless availability targets as a
|
||||
first-class provider, not as a settings-only helper. Saved availability targets
|
||||
load from the config persistence boundary, schedule through
|
||||
|
|
|
|||
|
|
@ -69,6 +69,11 @@ auto-registration changes that affect backup visibility permissions are
|
|||
storage/recovery-adjacent: optional PVE `/storage` grants must remain effective
|
||||
for privilege-separated tokens by assigning the same `PVEDatastoreAdmin` role to
|
||||
both the service user and the concrete token id.
|
||||
PBS generated setup-script auto-registration is storage/recovery-adjacent
|
||||
because the registration result determines whether PBS backup evidence can flow
|
||||
without manual follow-up. The rendered script must post auto-registration to the
|
||||
canonical Pulse base URL plus `/api/auto-register`; it must not derive the POST
|
||||
target from the setup-script artifact download URL.
|
||||
|
||||
Proxmox platform backup surfaces may embed source-specific backup evidence, but
|
||||
the source of truth must stay explicit. PBS source-detail tables on the Proxmox
|
||||
|
|
|
|||
|
|
@ -158,30 +158,6 @@ export const AIProviderConfigurationSection: Component<AIProviderConfigurationSe
|
|||
</button>
|
||||
<Show when={expanded()}>
|
||||
<div class="px-3 py-3 bg-surface-alt border-t border-border space-y-2">
|
||||
<Show when={config.extraField}>
|
||||
{(extraField) => (
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs text-muted inline-flex items-center gap-1">
|
||||
{extraField().label}
|
||||
<Show when={extraField().helpContentId}>
|
||||
<HelpIcon contentId={extraField().helpContentId!} size="xs" />
|
||||
</Show>
|
||||
</label>
|
||||
<input
|
||||
type="url"
|
||||
value={props.form[extraField().inputField]}
|
||||
onInput={(event) =>
|
||||
props.setForm(extraField().inputField, event.currentTarget.value)
|
||||
}
|
||||
placeholder={extraField().placeholder}
|
||||
aria-label={`${getAIProviderDisplayName(config.provider)} ${extraField().label}`}
|
||||
class={controlClass()}
|
||||
disabled={props.saving()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
<Show when={config.provider === 'ollama'}>
|
||||
<label class="text-xs text-muted inline-flex items-center gap-1">
|
||||
Server URL
|
||||
|
|
@ -206,6 +182,33 @@ export const AIProviderConfigurationSection: Component<AIProviderConfigurationSe
|
|||
disabled={props.saving()}
|
||||
/>
|
||||
|
||||
<For each={config.extraFields || []}>
|
||||
{(extraField) => (
|
||||
<div class="space-y-1">
|
||||
<label class="text-xs text-muted inline-flex items-center gap-1">
|
||||
{extraField.label}
|
||||
<Show when={extraField.helpContentId}>
|
||||
<HelpIcon contentId={extraField.helpContentId!} size="xs" />
|
||||
</Show>
|
||||
</label>
|
||||
<input
|
||||
type={extraField.type || 'text'}
|
||||
value={props.form[extraField.inputField]}
|
||||
onInput={(event) =>
|
||||
props.setForm(extraField.inputField, event.currentTarget.value)
|
||||
}
|
||||
placeholder={extraField.placeholder}
|
||||
aria-label={`${getAIProviderDisplayName(config.provider)} ${extraField.label}`}
|
||||
class={controlClass()}
|
||||
disabled={props.saving()}
|
||||
/>
|
||||
<Show when={extraField.helperText}>
|
||||
<p class="text-xs text-muted">{extraField.helperText}</p>
|
||||
</Show>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
|
||||
<Show when={config.helperText}>
|
||||
<p class="text-xs text-muted">{config.helperText}</p>
|
||||
</Show>
|
||||
|
|
|
|||
|
|
@ -100,6 +100,7 @@ const baseSettings = (): AISettingsType => ({
|
|||
gemini_configured: false,
|
||||
ollama_configured: false,
|
||||
ollama_base_url: 'http://localhost:11434',
|
||||
ollama_keep_alive: '30s',
|
||||
configured_providers: [],
|
||||
});
|
||||
|
||||
|
|
@ -635,6 +636,53 @@ describe('AISettings OpenRouter flow', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('AISettings Ollama provider options', () => {
|
||||
beforeEach(() => {
|
||||
resetAllMocks();
|
||||
setupDefaultMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('saves Ollama keep alive through the provider settings panel', async () => {
|
||||
getSettingsMock.mockResolvedValue({
|
||||
...baseSettings(),
|
||||
configured: true,
|
||||
model: 'ollama:llama3',
|
||||
ollama_configured: true,
|
||||
configured_providers: ['ollama'],
|
||||
ollama_keep_alive: '30s',
|
||||
});
|
||||
updateSettingsMock.mockImplementation(async (payload: Record<string, unknown>) => ({
|
||||
...baseSettings(),
|
||||
configured: true,
|
||||
model: 'ollama:llama3',
|
||||
ollama_configured: true,
|
||||
configured_providers: ['ollama'],
|
||||
ollama_base_url: 'http://localhost:11434',
|
||||
ollama_keep_alive: (payload.ollama_keep_alive as string) ?? '30s',
|
||||
}));
|
||||
|
||||
renderComponent();
|
||||
|
||||
fireEvent.input(await screen.findByLabelText('Ollama Keep Alive'), {
|
||||
target: { value: '24h' },
|
||||
});
|
||||
fireEvent.click(screen.getByRole('button', { name: /save changes/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
model: 'ollama:llama3',
|
||||
ollama_keep_alive: '24h',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AISettings provider save failure context', () => {
|
||||
beforeEach(() => {
|
||||
resetAllMocks();
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import settingsPageShellSource from '../SettingsPageShell.tsx?raw';
|
|||
import aiSettingsDialogsSource from '../AISettingsDialogs.tsx?raw';
|
||||
import aiChatMaintenanceSectionSource from '../AIChatMaintenanceSection.tsx?raw';
|
||||
import aiModelSelectionSectionSource from '../AIModelSelectionSection.tsx?raw';
|
||||
import aiProviderConfigurationSectionSource from '../AIProviderConfigurationSection.tsx?raw';
|
||||
import aiRuntimeControlsSectionSource from '../AIRuntimeControlsSection.tsx?raw';
|
||||
import aiSettingsModelSource from '../aiSettingsModel.ts?raw';
|
||||
import generalSettingsPanelSource from '../GeneralSettingsPanel.tsx?raw';
|
||||
|
|
@ -302,6 +303,18 @@ describe('settings architecture guardrails', () => {
|
|||
expect(aiSettingsStateSource).not.toContain('OpenRouter returned 401');
|
||||
});
|
||||
|
||||
it('keeps Assistant and Patrol provider-specific fields model-driven', () => {
|
||||
expect(aiSettingsModelSource).toContain('extraFields: [');
|
||||
expect(aiSettingsModelSource).toContain("inputField: 'ollamaKeepAlive'");
|
||||
expect(aiSettingsModelSource).toContain("helpContentId: 'ai.ollama.keepAlive'");
|
||||
expect(aiProviderConfigurationSectionSource).toContain('<For each={config.extraFields || []}>');
|
||||
expect(aiProviderConfigurationSectionSource).toContain(
|
||||
'aria-label={`${getAIProviderDisplayName(config.provider)} ${extraField.label}`}',
|
||||
);
|
||||
expect(aiProviderConfigurationSectionSource).not.toContain('<Show when={config.extraField}>');
|
||||
expect(aiProviderConfigurationSectionSource).not.toContain('extraField()');
|
||||
});
|
||||
|
||||
it('keeps Patrol tool-call preflight wired through the canonical settings state', () => {
|
||||
// The Verify Patrol button must drive the canonical
|
||||
// /api/ai/patrol/preflight endpoint via the typed runPatrolPreflight
|
||||
|
|
|
|||
|
|
@ -83,6 +83,67 @@ describe('useConnectionsLedger', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('presents successful TrueNAS runtime state as verified activity', async () => {
|
||||
const lastSeen = '2026-05-28T12:00:00Z';
|
||||
const connections: Connection[] = [
|
||||
{
|
||||
id: 'truenas:tn1',
|
||||
type: 'truenas',
|
||||
name: 'tower',
|
||||
address: 'https://truenas.local:443',
|
||||
state: 'active',
|
||||
stateReason: '',
|
||||
enabled: true,
|
||||
surfaces: ['datasets', 'pools', 'replication'],
|
||||
scope: { datasets: true, pools: true, replication: true },
|
||||
lastSeen,
|
||||
lastError: null,
|
||||
source: 'manual',
|
||||
fleet: {
|
||||
enrollmentState: 'configured',
|
||||
livenessState: 'active',
|
||||
versionDrift: 'not-applicable',
|
||||
adapterHealth: 'healthy',
|
||||
configRollout: 'configured',
|
||||
credentialStatus: 'verified',
|
||||
updateStatus: 'not-applicable',
|
||||
remoteControl: 'not-applicable',
|
||||
configDrift: { status: 'current' },
|
||||
rollout: { status: 'current' },
|
||||
credentialHealth: { status: 'verified', kind: 'api-key', lastVerifiedAt: lastSeen },
|
||||
commandPolicy: { status: 'not-applicable' },
|
||||
},
|
||||
capabilities: { supportsPause: true, supportsScope: true, supportsTest: true },
|
||||
},
|
||||
];
|
||||
const systems: ConnectionSystem[] = [
|
||||
{
|
||||
id: 'truenas:tn1',
|
||||
type: 'truenas',
|
||||
components: [{ connectionId: 'truenas:tn1', type: 'truenas', role: 'primary' }],
|
||||
},
|
||||
];
|
||||
vi.spyOn(ConnectionsAPI, 'list').mockResolvedValue({ connections, systems });
|
||||
|
||||
const { result } = renderHook(() => useConnectionsLedger());
|
||||
|
||||
await waitFor(() => expect(result.rows()).toHaveLength(1));
|
||||
expect(result.rows()[0]).toMatchObject({
|
||||
id: 'truenas:tn1',
|
||||
ownerType: 'truenas',
|
||||
name: 'tower',
|
||||
source: 'api',
|
||||
statusLabel: 'Active',
|
||||
coverageLabels: ['Datasets', 'Pools', 'Replication'],
|
||||
});
|
||||
expect(result.rows()[0].lastActivityText).not.toBe('No activity yet');
|
||||
expect(result.rows()[0].lastActivityText).not.toBe('Unknown');
|
||||
expect(result.rows()[0].problem).toBeUndefined();
|
||||
expect(result.rows()[0].fleetHighlights.map((signal) => signal.label)).not.toContain(
|
||||
'Credentials unknown',
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses large stable row models across equivalent ledger refreshes', async () => {
|
||||
const connections: Connection[] = Array.from({ length: 120 }, (_, index) => ({
|
||||
id: `agent:stable-${index}`,
|
||||
|
|
@ -352,7 +413,8 @@ describe('useConnectionsLedger', () => {
|
|||
desired: 'disabled',
|
||||
applied: 'enabled',
|
||||
enforcement: 'drifted',
|
||||
reason: 'agent still reports command execution enabled while desired policy disables it',
|
||||
reason:
|
||||
'agent still reports command execution enabled while desired policy disables it',
|
||||
},
|
||||
},
|
||||
capabilities: { supportsPause: false, supportsScope: false, supportsTest: false },
|
||||
|
|
@ -505,7 +567,8 @@ describe('useConnectionsLedger', () => {
|
|||
remoteControl: 'disabled',
|
||||
configDrift: {
|
||||
status: 'pending',
|
||||
reason: 'Pulse has not received a comparable applied agent configuration fingerprint yet',
|
||||
reason:
|
||||
'Pulse has not received a comparable applied agent configuration fingerprint yet',
|
||||
},
|
||||
rollout: {
|
||||
status: 'pending',
|
||||
|
|
@ -545,7 +608,8 @@ describe('useConnectionsLedger', () => {
|
|||
remoteControl: 'disabled',
|
||||
configDrift: {
|
||||
status: 'pending',
|
||||
reason: 'Pulse has not received a comparable applied agent configuration fingerprint yet',
|
||||
reason:
|
||||
'Pulse has not received a comparable applied agent configuration fingerprint yet',
|
||||
},
|
||||
rollout: {
|
||||
status: 'pending',
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export type AIProviderCredentialsFormState = {
|
|||
deepseekApiKey: string;
|
||||
geminiApiKey: string;
|
||||
ollamaBaseUrl: string;
|
||||
ollamaKeepAlive: string;
|
||||
openaiBaseUrl: string;
|
||||
};
|
||||
|
||||
|
|
@ -30,12 +31,14 @@ export type AIProviderConfig = {
|
|||
actionLinkHref: string;
|
||||
actionLinkSuffix?: string;
|
||||
helperText?: string;
|
||||
extraField?: {
|
||||
extraFields?: Array<{
|
||||
label: string;
|
||||
helpContentId?: string;
|
||||
inputField: 'openaiBaseUrl';
|
||||
inputField: keyof AIProviderCredentialsFormState;
|
||||
placeholder: string;
|
||||
};
|
||||
type?: 'url' | 'text';
|
||||
helperText?: string;
|
||||
}>;
|
||||
clearTitle: string;
|
||||
};
|
||||
|
||||
|
|
@ -95,12 +98,15 @@ export const AI_PROVIDER_CONFIGS: AIProviderConfig[] = [
|
|||
actionLinkLabel: 'Get API key →',
|
||||
actionLinkHref: 'https://platform.openai.com/api-keys',
|
||||
clearTitle: 'Clear API key',
|
||||
extraField: {
|
||||
label: 'Custom Base URL',
|
||||
helpContentId: 'ai.openai.baseUrl',
|
||||
inputField: 'openaiBaseUrl',
|
||||
placeholder: 'https://api.together.xyz/v1 (optional)',
|
||||
},
|
||||
extraFields: [
|
||||
{
|
||||
label: 'Custom Base URL',
|
||||
helpContentId: 'ai.openai.baseUrl',
|
||||
inputField: 'openaiBaseUrl',
|
||||
placeholder: 'https://api.together.xyz/v1 (optional)',
|
||||
type: 'url',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: 'openrouter',
|
||||
|
|
@ -149,6 +155,16 @@ export const AI_PROVIDER_CONFIGS: AIProviderConfig[] = [
|
|||
actionLinkLabel: 'Learn about Ollama →',
|
||||
actionLinkHref: 'https://ollama.ai',
|
||||
actionLinkSuffix: ' · Free & local',
|
||||
extraFields: [
|
||||
{
|
||||
label: 'Keep Alive',
|
||||
helpContentId: 'ai.ollama.keepAlive',
|
||||
inputField: 'ollamaKeepAlive',
|
||||
placeholder: '30s, 5m, 24h, 0, or blank',
|
||||
type: 'text',
|
||||
helperText: 'Clear to use the Ollama server default.',
|
||||
},
|
||||
],
|
||||
clearTitle: 'Clear Ollama URL',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ const AI_SETTINGS_PROVIDER_PAYLOAD_FIELDS: Record<AIProvider, string[]> = {
|
|||
openrouter: ['openrouter_api_key'],
|
||||
deepseek: ['deepseek_api_key'],
|
||||
gemini: ['gemini_api_key'],
|
||||
ollama: ['ollama_base_url'],
|
||||
ollama: ['ollama_base_url', 'ollama_keep_alive'],
|
||||
};
|
||||
|
||||
const compactUnique = (values: Array<string | undefined>): string[] => {
|
||||
|
|
@ -302,6 +302,7 @@ export const useAISettingsState = () => {
|
|||
deepseekApiKey: '',
|
||||
geminiApiKey: '',
|
||||
ollamaBaseUrl: 'http://localhost:11434',
|
||||
ollamaKeepAlive: '30s',
|
||||
openaiBaseUrl: '',
|
||||
costBudgetUSD30d: '',
|
||||
requestTimeoutSeconds: 300,
|
||||
|
|
@ -376,6 +377,7 @@ export const useAISettingsState = () => {
|
|||
deepseekApiKey: '',
|
||||
geminiApiKey: '',
|
||||
ollamaBaseUrl: 'http://localhost:11434',
|
||||
ollamaKeepAlive: '30s',
|
||||
openaiBaseUrl: '',
|
||||
costBudgetUSD30d: '',
|
||||
requestTimeoutSeconds: 300,
|
||||
|
|
@ -404,6 +406,7 @@ export const useAISettingsState = () => {
|
|||
deepseekApiKey: '',
|
||||
geminiApiKey: '',
|
||||
ollamaBaseUrl: data.ollama_base_url || 'http://localhost:11434',
|
||||
ollamaKeepAlive: data.ollama_keep_alive ?? '30s',
|
||||
openaiBaseUrl: data.openai_base_url || '',
|
||||
costBudgetUSD30d:
|
||||
typeof data.cost_budget_usd_30d === 'number' && data.cost_budget_usd_30d > 0
|
||||
|
|
@ -721,7 +724,13 @@ export const useAISettingsState = () => {
|
|||
error,
|
||||
payload,
|
||||
providerHealth,
|
||||
models: [form.model, form.chatModel, form.patrolModel, form.discoveryModel, form.autoFixModel],
|
||||
models: [
|
||||
form.model,
|
||||
form.chatModel,
|
||||
form.patrolModel,
|
||||
form.discoveryModel,
|
||||
form.autoFixModel,
|
||||
],
|
||||
});
|
||||
notificationStore.error(
|
||||
getAISettingsSaveProviderFailureMessage(
|
||||
|
|
@ -897,6 +906,9 @@ export const useAISettingsState = () => {
|
|||
) {
|
||||
payload.ollama_base_url = form.ollamaBaseUrl.trim();
|
||||
}
|
||||
if (form.ollamaKeepAlive.trim() !== (settings()?.ollama_keep_alive ?? '30s')) {
|
||||
payload.ollama_keep_alive = form.ollamaKeepAlive.trim();
|
||||
}
|
||||
if (form.openaiBaseUrl !== (settings()?.openai_base_url || '')) {
|
||||
payload.openai_base_url = form.openaiBaseUrl.trim();
|
||||
}
|
||||
|
|
@ -954,7 +966,13 @@ export const useAISettingsState = () => {
|
|||
error,
|
||||
payload,
|
||||
providerHealth,
|
||||
models: [selectedModel, form.chatModel, form.patrolModel, form.discoveryModel, form.autoFixModel],
|
||||
models: [
|
||||
selectedModel,
|
||||
form.chatModel,
|
||||
form.patrolModel,
|
||||
form.discoveryModel,
|
||||
form.autoFixModel,
|
||||
],
|
||||
});
|
||||
notificationStore.error(
|
||||
getAISettingsSaveProviderFailureMessage(
|
||||
|
|
|
|||
|
|
@ -47,7 +47,17 @@ export const aiHelpContent: HelpContent[] = [
|
|||
'- Alert investigation assistance\n\n' +
|
||||
'You can configure multiple providers and Pulse will use the primary provider ' +
|
||||
'with fallback to others if unavailable.',
|
||||
related: ['ai.openai.baseUrl', 'ai.ollama.baseUrl'],
|
||||
related: ['ai.openai.baseUrl', 'ai.ollama.baseUrl', 'ai.ollama.keepAlive'],
|
||||
addedInVersion: 'v4.0.0',
|
||||
},
|
||||
{
|
||||
id: 'ai.ollama.keepAlive',
|
||||
title: 'Ollama Keep Alive',
|
||||
description:
|
||||
'Controls the keep_alive value Pulse sends with Ollama chat requests.\n\n' +
|
||||
'Use a duration such as 30s, 5m, or 24h. Use 0 to unload immediately after a request, or -1 to keep the model loaded indefinitely. Leave the field blank to omit keep_alive so the Ollama server default applies.',
|
||||
examples: ['30s (Pulse default)', '24h (keep warm for a day)', 'blank (server default)'],
|
||||
related: ['ai.ollama.baseUrl'],
|
||||
addedInVersion: 'v6.0.0',
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ const settingsWithReadiness = (patrolReadiness: PatrolReadiness): AISettings =>
|
|||
gemini_configured: false,
|
||||
ollama_configured: true,
|
||||
ollama_base_url: 'http://127.0.0.1:11434',
|
||||
ollama_keep_alive: '30s',
|
||||
configured_providers: ['ollama'],
|
||||
patrol_readiness: patrolReadiness,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ export interface AISettings {
|
|||
gemini_configured: boolean; // true if Gemini API key is set
|
||||
ollama_configured: boolean; // true (always available for attempt)
|
||||
ollama_base_url: string; // Ollama server URL
|
||||
ollama_keep_alive: string; // Ollama keep_alive value; empty uses the server default
|
||||
openai_base_url?: string; // Custom OpenAI base URL
|
||||
configured_providers: AIProvider[]; // List of providers with credentials
|
||||
|
||||
|
|
@ -124,6 +125,7 @@ export interface AISettingsUpdateRequest {
|
|||
deepseek_api_key?: string; // Set DeepSeek API key
|
||||
gemini_api_key?: string; // Set Gemini API key
|
||||
ollama_base_url?: string; // Set Ollama server URL
|
||||
ollama_keep_alive?: string; // Set Ollama keep_alive; empty uses the server default
|
||||
openai_base_url?: string; // Set custom OpenAI base URL
|
||||
// Clear flags for removing credentials
|
||||
clear_anthropic_key?: boolean; // Clear Anthropic API key
|
||||
|
|
|
|||
|
|
@ -2076,7 +2076,7 @@ func (s *Service) createProviderForModel(modelStr string) (providers.StreamingPr
|
|||
return providers.NewGeminiClient(s.cfg.GeminiAPIKey, modelName, "", timeout), nil
|
||||
case "ollama":
|
||||
baseURL := s.cfg.GetBaseURLForProvider(config.AIProviderOllama)
|
||||
return providers.NewOllamaClient(modelName, baseURL, s.cfg.OllamaUsername, s.cfg.OllamaPassword, timeout)
|
||||
return providers.NewOllamaClientWithKeepAlive(modelName, baseURL, s.cfg.OllamaUsername, s.cfg.OllamaPassword, s.cfg.GetOllamaKeepAlive(), timeout)
|
||||
case config.AIProviderQuickstart:
|
||||
return nil, fmt.Errorf("quickstart provider is retired; configure a provider API key or Ollama")
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ func NewForProvider(cfg *config.AIConfig, provider, model string) (Provider, err
|
|||
|
||||
case config.AIProviderOllama:
|
||||
baseURL := cfg.GetBaseURLForProvider(config.AIProviderOllama)
|
||||
return NewOllamaClient(model, baseURL, cfg.OllamaUsername, cfg.OllamaPassword, timeout)
|
||||
return NewOllamaClientWithKeepAlive(model, baseURL, cfg.OllamaUsername, cfg.OllamaPassword, cfg.GetOllamaKeepAlive(), timeout)
|
||||
|
||||
case config.AIProviderGemini:
|
||||
apiKey := cfg.GetAPIKeyForProvider(config.AIProviderGemini)
|
||||
|
|
|
|||
|
|
@ -457,8 +457,9 @@ func TestNewFromConfig_MultiProviderFormat(t *testing.T) {
|
|||
|
||||
func TestNewForProvider_OllamaWithCustomBaseURL(t *testing.T) {
|
||||
cfg := &config.AIConfig{
|
||||
Enabled: true,
|
||||
OllamaBaseURL: "http://custom-ollama:11434",
|
||||
Enabled: true,
|
||||
OllamaBaseURL: "http://custom-ollama:11434",
|
||||
OllamaKeepAlive: "24h",
|
||||
}
|
||||
provider, err := NewForProvider(cfg, config.AIProviderOllama, "llama2")
|
||||
if err != nil {
|
||||
|
|
@ -467,6 +468,13 @@ func TestNewForProvider_OllamaWithCustomBaseURL(t *testing.T) {
|
|||
if provider.Name() != "ollama" {
|
||||
t.Errorf("Expected provider name 'ollama', got '%s'", provider.Name())
|
||||
}
|
||||
ollama, ok := provider.(*OllamaClient)
|
||||
if !ok {
|
||||
t.Fatalf("expected *OllamaClient, got %T", provider)
|
||||
}
|
||||
if ollama.keepAlive != "24h" {
|
||||
t.Errorf("expected Ollama keepAlive 24h, got %q", ollama.keepAlive)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewForProvider_OpenAIWithCustomBaseURL(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,11 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/securityutil"
|
||||
)
|
||||
|
||||
|
|
@ -27,6 +29,7 @@ type OllamaClient struct {
|
|||
baseURL string
|
||||
username string
|
||||
password string
|
||||
keepAlive string
|
||||
client *http.Client // For non-streaming requests (has overall timeout)
|
||||
streamClient *http.Client // For streaming requests (no overall timeout — relies on context)
|
||||
}
|
||||
|
|
@ -34,10 +37,21 @@ type OllamaClient struct {
|
|||
// NewOllamaClient creates a new Ollama API client
|
||||
// timeout is optional - pass 0 to use the default 5 minute timeout
|
||||
func NewOllamaClient(model, baseURL, username, password string, timeout time.Duration) (*OllamaClient, error) {
|
||||
return NewOllamaClientWithKeepAlive(model, baseURL, username, password, config.DefaultOllamaKeepAlive, timeout)
|
||||
}
|
||||
|
||||
// NewOllamaClientWithKeepAlive creates a new Ollama API client with an
|
||||
// explicit keep_alive request value. Pass an empty keepAlive string to omit
|
||||
// keep_alive from requests and let the Ollama server default apply.
|
||||
func NewOllamaClientWithKeepAlive(model, baseURL, username, password, keepAlive string, timeout time.Duration) (*OllamaClient, error) {
|
||||
normalizedBaseURL, err := normalizeOllamaBaseURL(baseURL)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizedKeepAlive, err := config.NormalizeOllamaKeepAlive(keepAlive)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("normalize Ollama keep_alive: %w", err)
|
||||
}
|
||||
if timeout <= 0 {
|
||||
timeout = 300 * time.Second // Default 5 minutes
|
||||
}
|
||||
|
|
@ -46,6 +60,7 @@ func NewOllamaClient(model, baseURL, username, password string, timeout time.Dur
|
|||
baseURL: normalizedBaseURL,
|
||||
username: username,
|
||||
password: password,
|
||||
keepAlive: normalizedKeepAlive,
|
||||
client: newOllamaHTTPClient(timeout, false),
|
||||
streamClient: newOllamaHTTPClient(timeout, true),
|
||||
}, nil
|
||||
|
|
@ -113,15 +128,19 @@ type ollamaRequest struct {
|
|||
// (#1425). Pulse passes a short value so the model unloads shortly
|
||||
// after a Patrol burst or one-shot analysis ends. Accepts duration
|
||||
// strings like "30s", "5m", or "0" for immediate unload.
|
||||
KeepAlive string `json:"keep_alive,omitempty"`
|
||||
KeepAlive any `json:"keep_alive,omitempty"`
|
||||
}
|
||||
|
||||
// ollamaKeepAlive is the duration Pulse asks Ollama to keep the model loaded
|
||||
// after a request completes. 30s is short enough that the model unloads
|
||||
// soon after a Patrol/alert-analysis burst ends, while still long enough
|
||||
// to span the small gaps between sequential calls in a single analysis
|
||||
// session so the model isn't reloaded mid-burst.
|
||||
const ollamaKeepAlive = "30s"
|
||||
func ollamaKeepAliveRequestValue(value string) any {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
if seconds, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
return seconds
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
type ollamaMessage struct {
|
||||
Role string `json:"role"`
|
||||
|
|
@ -231,7 +250,7 @@ func (c *OllamaClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse
|
|||
Model: model,
|
||||
Messages: messages,
|
||||
Stream: false, // Non-streaming for now
|
||||
KeepAlive: ollamaKeepAlive,
|
||||
KeepAlive: ollamaKeepAliveRequestValue(c.keepAlive),
|
||||
}
|
||||
|
||||
// Convert tools to Ollama format
|
||||
|
|
@ -401,7 +420,7 @@ func (c *OllamaClient) ChatStream(ctx context.Context, req ChatRequest, callback
|
|||
Model: model,
|
||||
Messages: messages,
|
||||
Stream: true, // Enable streaming
|
||||
KeepAlive: ollamaKeepAlive,
|
||||
KeepAlive: ollamaKeepAliveRequestValue(c.keepAlive),
|
||||
}
|
||||
|
||||
// Handle tools with tool_choice support (same as non-streaming)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
|
@ -39,7 +40,7 @@ func TestOllamaClient_ChatStream_Success(t *testing.T) {
|
|||
// #1425: Pulse must pass keep_alive so the model unloads shortly
|
||||
// after the request burst ends instead of refreshing Ollama's
|
||||
// 5-minute default TTL on every call.
|
||||
assert.Equal(t, ollamaKeepAlive, req.KeepAlive)
|
||||
assert.Equal(t, config.DefaultOllamaKeepAlive, req.KeepAlive)
|
||||
|
||||
w.Header().Set("Content-Type", "application/x-ndjson")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
|
@ -185,7 +186,7 @@ func TestOllamaClient_Chat_Success(t *testing.T) {
|
|||
assert.False(t, req.Stream)
|
||||
assert.Equal(t, "llama3", req.Model)
|
||||
// #1425: keep_alive must be set on non-streaming Chat too.
|
||||
assert.Equal(t, ollamaKeepAlive, req.KeepAlive)
|
||||
assert.Equal(t, config.DefaultOllamaKeepAlive, req.KeepAlive)
|
||||
require.Len(t, req.Tools, 1)
|
||||
assert.Equal(t, "function", req.Tools[0].Type)
|
||||
assert.Equal(t, "get_time", req.Tools[0].Function.Name)
|
||||
|
|
@ -230,6 +231,72 @@ func TestOllamaClient_Chat_Success(t *testing.T) {
|
|||
assert.Equal(t, 3, resp.OutputTokens)
|
||||
}
|
||||
|
||||
func TestOllamaClient_Chat_UsesConfiguredKeepAlive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req ollamaRequest
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||
assert.Equal(t, "24h", req.KeepAlive)
|
||||
|
||||
_ = json.NewEncoder(w).Encode(ollamaResponse{
|
||||
Model: "llama3",
|
||||
Message: ollamaMessageResp{Role: "assistant", Content: "Hello"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewOllamaClientWithKeepAlive("llama3", server.URL, "", "", "24h", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.Chat(context.Background(), ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "Hi"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestOllamaClient_Chat_OmitsKeepAliveForServerDefault(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var raw map[string]json.RawMessage
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&raw))
|
||||
assert.NotContains(t, raw, "keep_alive")
|
||||
|
||||
_ = json.NewEncoder(w).Encode(ollamaResponse{
|
||||
Model: "llama3",
|
||||
Message: ollamaMessageResp{Role: "assistant", Content: "Hello"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewOllamaClientWithKeepAlive("llama3", server.URL, "", "", "", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.Chat(context.Background(), ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "Hi"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestOllamaClient_Chat_EncodesNumericKeepAlive(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var raw map[string]any
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&raw))
|
||||
assert.Equal(t, float64(0), raw["keep_alive"])
|
||||
|
||||
_ = json.NewEncoder(w).Encode(ollamaResponse{
|
||||
Model: "llama3",
|
||||
Message: ollamaMessageResp{Role: "assistant", Content: "Hello"},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client, err := NewOllamaClientWithKeepAlive("llama3", server.URL, "", "", "0", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = client.Chat(context.Background(), ChatRequest{
|
||||
Messages: []Message{{Role: "user", Content: "Hi"}},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestOllamaClient_TestConnection(t *testing.T) {
|
||||
versionHits := 0
|
||||
tagsHits := 0
|
||||
|
|
|
|||
|
|
@ -1672,6 +1672,7 @@ func shouldRestartAIChat(req AISettingsUpdateRequest) bool {
|
|||
req.OllamaBaseURL != nil ||
|
||||
req.OllamaUsername != nil ||
|
||||
req.OllamaPassword != nil ||
|
||||
req.OllamaKeepAlive != nil ||
|
||||
req.OpenAIBaseURL != nil ||
|
||||
req.ClearAnthropicKey != nil ||
|
||||
req.ClearOpenAIKey != nil ||
|
||||
|
|
@ -1735,6 +1736,7 @@ func aiSettingsUpdateTouchesPatrolReadiness(req AISettingsUpdateRequest) bool {
|
|||
req.OllamaBaseURL != nil ||
|
||||
req.OllamaUsername != nil ||
|
||||
req.OllamaPassword != nil ||
|
||||
req.OllamaKeepAlive != nil ||
|
||||
req.OpenAIBaseURL != nil ||
|
||||
req.ClearAnthropicKey != nil ||
|
||||
req.ClearOpenAIKey != nil ||
|
||||
|
|
@ -2284,6 +2286,7 @@ type AISettingsResponse struct {
|
|||
OllamaBaseURL string `json:"ollama_base_url"` // Ollama server URL
|
||||
OllamaUsername string `json:"ollama_username,omitempty"` // Optional Basic Auth username for Ollama
|
||||
OllamaPasswordSet bool `json:"ollama_password_set"` // true if an Ollama password is stored
|
||||
OllamaKeepAlive string `json:"ollama_keep_alive"` // Ollama keep_alive value; empty uses server default
|
||||
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI base URL
|
||||
ConfiguredProviders []string `json:"configured_providers"` // List of provider names with credentials
|
||||
// Cost controls
|
||||
|
|
@ -2365,6 +2368,7 @@ type AISettingsUpdateRequest struct {
|
|||
OllamaBaseURL *string `json:"ollama_base_url,omitempty"` // Set Ollama server URL
|
||||
OllamaUsername *string `json:"ollama_username,omitempty"` // Set Ollama Basic Auth username
|
||||
OllamaPassword *string `json:"ollama_password,omitempty"` // Set Ollama Basic Auth password
|
||||
OllamaKeepAlive *string `json:"ollama_keep_alive,omitempty"` // Set Ollama keep_alive; empty uses server default
|
||||
OpenAIBaseURL *string `json:"openai_base_url,omitempty"` // Set custom OpenAI base URL
|
||||
// Clear flags for removing credentials
|
||||
ClearAnthropicKey *bool `json:"clear_anthropic_key,omitempty"` // Clear Anthropic API key
|
||||
|
|
@ -2501,6 +2505,7 @@ func (h *AISettingsHandler) HandleGetAISettings(w http.ResponseWriter, r *http.R
|
|||
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
|
||||
OllamaUsername: settings.OllamaUsername,
|
||||
OllamaPasswordSet: settings.OllamaPassword != "",
|
||||
OllamaKeepAlive: settings.GetOllamaKeepAlive(),
|
||||
OpenAIBaseURL: settings.OpenAIBaseURL,
|
||||
ConfiguredProviders: settings.GetConfiguredProviders(),
|
||||
CostBudgetUSD30d: settings.CostBudgetUSD30d,
|
||||
|
|
@ -2650,6 +2655,14 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
|||
} else if req.OllamaPassword != nil {
|
||||
settings.OllamaPassword = *req.OllamaPassword
|
||||
}
|
||||
if req.OllamaKeepAlive != nil {
|
||||
keepAlive, err := config.NormalizeOllamaKeepAlive(*req.OllamaKeepAlive)
|
||||
if err != nil {
|
||||
http.Error(w, "ollama_keep_alive "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
settings.OllamaKeepAlive = keepAlive
|
||||
}
|
||||
if req.OpenAIBaseURL != nil {
|
||||
settings.OpenAIBaseURL = strings.TrimSpace(*req.OpenAIBaseURL)
|
||||
}
|
||||
|
|
@ -2917,6 +2930,7 @@ func (h *AISettingsHandler) HandleUpdateAISettings(w http.ResponseWriter, r *htt
|
|||
OllamaBaseURL: settings.GetBaseURLForProvider(config.AIProviderOllama),
|
||||
OllamaUsername: settings.OllamaUsername,
|
||||
OllamaPasswordSet: settings.OllamaPassword != "",
|
||||
OllamaKeepAlive: settings.GetOllamaKeepAlive(),
|
||||
OpenAIBaseURL: settings.OpenAIBaseURL,
|
||||
ConfiguredProviders: settings.GetConfiguredProviders(),
|
||||
RequestTimeoutSeconds: settings.RequestTimeoutSeconds,
|
||||
|
|
|
|||
|
|
@ -423,11 +423,12 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
|
|||
// Update settings to enable AI via Ollama.
|
||||
{
|
||||
body, _ := json.Marshal(AISettingsUpdateRequest{
|
||||
Enabled: ptr(true),
|
||||
Model: ptr("ollama:llama3"),
|
||||
OllamaBaseURL: ptr("http://localhost:11434"),
|
||||
OllamaUsername: ptr("unai"),
|
||||
OllamaPassword: ptr("secret"),
|
||||
Enabled: ptr(true),
|
||||
Model: ptr("ollama:llama3"),
|
||||
OllamaBaseURL: ptr("http://localhost:11434"),
|
||||
OllamaUsername: ptr("unai"),
|
||||
OllamaPassword: ptr("secret"),
|
||||
OllamaKeepAlive: ptr("24h"),
|
||||
})
|
||||
req := newLoopbackRequest(http.MethodPut, "/api/settings/ai", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
|
|
@ -450,6 +451,9 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
|
|||
if resp.OllamaUsername != "unai" || !resp.OllamaPasswordSet {
|
||||
t.Fatalf("expected ollama auth state in response, got %+v", resp)
|
||||
}
|
||||
if resp.OllamaKeepAlive != "24h" {
|
||||
t.Fatalf("expected ollama keep alive in response, got %+v", resp)
|
||||
}
|
||||
responseBody := rec.Body.String()
|
||||
if !strings.Contains(responseBody, `"available_models":[]`) ||
|
||||
!strings.Contains(responseBody, `"configured_providers":[`) ||
|
||||
|
|
@ -478,6 +482,9 @@ func TestAISettingsHandler_GetAndUpdateSettings_RoundTrip(t *testing.T) {
|
|||
if resp.OllamaUsername != "unai" || !resp.OllamaPasswordSet {
|
||||
t.Fatalf("expected persisted ollama auth state, got %+v", resp)
|
||||
}
|
||||
if resp.OllamaKeepAlive != "24h" {
|
||||
t.Fatalf("expected persisted ollama keep alive, got %+v", resp)
|
||||
}
|
||||
responseBody := rec.Body.String()
|
||||
if !strings.Contains(responseBody, `"available_models":[]`) ||
|
||||
!strings.Contains(responseBody, `"configured_providers":[`) ||
|
||||
|
|
@ -518,6 +525,73 @@ func TestAISettingsHandler_GetSettingsClampsPaidControlsToEntitlements(t *testin
|
|||
require.False(t, resp.AlertTriggeredAnalysis)
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_UpdateSettings_OllamaKeepAlive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
handler := newTestAISettingsHandler(cfg, persistence, nil)
|
||||
|
||||
body, err := json.Marshal(AISettingsUpdateRequest{
|
||||
Enabled: ptr(true),
|
||||
Model: ptr("ollama:llama3"),
|
||||
OllamaBaseURL: ptr("http://127.0.0.1:11434"),
|
||||
OllamaKeepAlive: ptr(""),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := newLoopbackRequest(http.MethodPut, "/api/settings/ai/update", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleUpdateAISettings(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
var resp AISettingsResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Empty(t, resp.OllamaKeepAlive)
|
||||
|
||||
saved, err := persistence.LoadAIConfig()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, saved.OllamaKeepAlive)
|
||||
|
||||
body, err = json.Marshal(AISettingsUpdateRequest{
|
||||
OllamaKeepAlive: ptr("24h"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
req = newLoopbackRequest(http.MethodPut, "/api/settings/ai/update", bytes.NewReader(body))
|
||||
rec = httptest.NewRecorder()
|
||||
handler.HandleUpdateAISettings(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, "24h", resp.OllamaKeepAlive)
|
||||
|
||||
saved, err = persistence.LoadAIConfig()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "24h", saved.OllamaKeepAlive)
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_UpdateSettingsRejectsInvalidOllamaKeepAlive(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tmp := t.TempDir()
|
||||
cfg := &config.Config{DataPath: tmp}
|
||||
persistence := config.NewConfigPersistence(tmp)
|
||||
handler := newTestAISettingsHandler(cfg, persistence, nil)
|
||||
|
||||
body, err := json.Marshal(AISettingsUpdateRequest{
|
||||
OllamaKeepAlive: ptr("forever"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := newLoopbackRequest(http.MethodPut, "/api/settings/ai/update", bytes.NewReader(body))
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleUpdateAISettings(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
|
||||
require.Contains(t, rec.Body.String(), "ollama_keep_alive")
|
||||
}
|
||||
|
||||
func TestAISettingsHandler_GetAIService_TenantPatrolUsesCanonicalProviders(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
mtp := config.NewMultiTenantPersistence(tmp)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,12 @@ var (
|
|||
|
||||
const privilegedBrowserSessionMaxAge = 5 * time.Minute
|
||||
|
||||
const (
|
||||
oidcMaxRefreshLead = 5 * time.Minute
|
||||
oidcFallbackRefreshLead = time.Minute
|
||||
oidcRefreshLifetimePart = 5
|
||||
)
|
||||
|
||||
type authRequirementFailure struct {
|
||||
Status int
|
||||
Code string
|
||||
|
|
@ -691,9 +697,8 @@ func checkAuth(cfg *config.Config, w http.ResponseWriter, r *http.Request, write
|
|||
Msg("Rejected recovery session outside direct loopback binding")
|
||||
} else if ValidateAndExtendSession(cookie.Value) {
|
||||
username := GetSessionUsername(cookie.Value)
|
||||
if session != nil && session.OIDCRefreshToken != "" && hasEnabledSSOProvidersForAuth(cfg) {
|
||||
// Check if access token is expired or about to expire (5 min buffer)
|
||||
if time.Now().Add(5 * time.Minute).After(session.OIDCAccessTokenExp) {
|
||||
if session != nil && hasEnabledSSOProvidersForAuth(cfg) {
|
||||
if shouldRefreshOIDCSessionToken(time.Now(), session) {
|
||||
go refreshOIDCSessionTokens(cfg, cookie.Value, session)
|
||||
}
|
||||
}
|
||||
|
|
@ -1390,6 +1395,42 @@ func adminBypassEnabled() bool {
|
|||
return adminBypassState.enabled
|
||||
}
|
||||
|
||||
func oidcRefreshLead(session *SessionData) time.Duration {
|
||||
if session == nil || session.OIDCAccessTokenExp.IsZero() {
|
||||
return 0
|
||||
}
|
||||
|
||||
issuedAt := session.OIDCAccessTokenIssuedAt
|
||||
if issuedAt.IsZero() || !session.OIDCAccessTokenExp.After(issuedAt) {
|
||||
return oidcFallbackRefreshLead
|
||||
}
|
||||
|
||||
lifetime := session.OIDCAccessTokenExp.Sub(issuedAt)
|
||||
lead := lifetime / oidcRefreshLifetimePart
|
||||
if lead > oidcMaxRefreshLead {
|
||||
return oidcMaxRefreshLead
|
||||
}
|
||||
return lead
|
||||
}
|
||||
|
||||
func shouldRefreshOIDCSessionToken(now time.Time, session *SessionData) bool {
|
||||
if session == nil || session.OIDCRefreshToken == "" || session.OIDCTokenRefreshing {
|
||||
return false
|
||||
}
|
||||
if session.OIDCAccessTokenExp.IsZero() {
|
||||
return false
|
||||
}
|
||||
if !now.Before(session.OIDCAccessTokenExp) {
|
||||
return true
|
||||
}
|
||||
|
||||
lead := oidcRefreshLead(session)
|
||||
if lead <= 0 {
|
||||
return false
|
||||
}
|
||||
return !now.Add(lead).Before(session.OIDCAccessTokenExp)
|
||||
}
|
||||
|
||||
// oidcRefreshMutex prevents concurrent refresh attempts for the same session
|
||||
var oidcRefreshMutex sync.Map
|
||||
|
||||
|
|
|
|||
|
|
@ -244,6 +244,49 @@ func TestRefreshOIDCSessionTokens_OIDCDisabledDoesNotInvalidate(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestShouldRefreshOIDCSessionToken_DoesNotRefreshFreshFiveMinuteToken(t *testing.T) {
|
||||
now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
session := &SessionData{
|
||||
OIDCRefreshToken: "refresh",
|
||||
OIDCAccessTokenIssuedAt: now,
|
||||
OIDCAccessTokenExp: now.Add(5 * time.Minute),
|
||||
}
|
||||
if shouldRefreshOIDCSessionToken(now, session) {
|
||||
t.Fatal("fresh 300-second access token should not refresh immediately")
|
||||
}
|
||||
|
||||
legacySession := &SessionData{
|
||||
OIDCRefreshToken: "refresh",
|
||||
OIDCAccessTokenExp: now.Add(5 * time.Minute),
|
||||
}
|
||||
if shouldRefreshOIDCSessionToken(now, legacySession) {
|
||||
t.Fatal("fresh 300-second access token without issued-at metadata should not refresh immediately")
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldRefreshOIDCSessionToken_RefreshesNearExpiryAndExpiredToken(t *testing.T) {
|
||||
now := time.Date(2026, 5, 28, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
nearExpiry := &SessionData{
|
||||
OIDCRefreshToken: "refresh",
|
||||
OIDCAccessTokenIssuedAt: now,
|
||||
OIDCAccessTokenExp: now.Add(5 * time.Minute),
|
||||
}
|
||||
if !shouldRefreshOIDCSessionToken(now.Add(4*time.Minute), nearExpiry) {
|
||||
t.Fatal("300-second access token should refresh inside its final relative refresh window")
|
||||
}
|
||||
|
||||
expired := &SessionData{
|
||||
OIDCRefreshToken: "refresh",
|
||||
OIDCAccessTokenIssuedAt: now.Add(-5 * time.Minute),
|
||||
OIDCAccessTokenExp: now.Add(-time.Second),
|
||||
}
|
||||
if !shouldRefreshOIDCSessionToken(now, expired) {
|
||||
t.Fatal("expired access token should refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSSOAuthSnapshotNormalizesEmptyProviders(t *testing.T) {
|
||||
snapshot := buildSSOAuthSnapshot(nil)
|
||||
if snapshot.OIDCProviders == nil {
|
||||
|
|
|
|||
|
|
@ -1107,9 +1107,18 @@ func TestPBSSetupScript_FailsClosedOnAutoRegisterSuccessDetection(t *testing.T)
|
|||
if !containsString(script, `curl -fsS -X POST "$PULSE_URL/api/auto-register"`) {
|
||||
t.Fatalf("expected fail-fast PBS auto-register transport in setup script, got: %s", truncate(script, 900))
|
||||
}
|
||||
if !containsString(script, `"source": "script"`) {
|
||||
if !containsString(script, `"source":"script"`) {
|
||||
t.Fatalf("expected PBS setup script to use canonical /api/auto-register source marker, got: %s", truncate(script, 900))
|
||||
}
|
||||
if !containsString(script, `PULSE_URL="http://pulse.local:7656"`) {
|
||||
t.Fatalf("expected PBS auto-registration to target the Pulse base URL, got: %s", truncate(script, 1200))
|
||||
}
|
||||
if containsString(script, `PULSE_URL="http://pulse.local:7656/api/setup-script?`) {
|
||||
t.Fatalf("expected PBS auto-registration URL not to reuse the setup-script download URL, got: %s", truncate(script, 1200))
|
||||
}
|
||||
if containsString(script, `"source": "script",`) || containsString(script, `"source":"script",`) {
|
||||
t.Fatalf("expected PBS auto-register JSON not to preserve a trailing comma after source, got: %s", truncate(script, 1200))
|
||||
}
|
||||
if !containsString(script, `REGISTER_RC=$?`) {
|
||||
t.Fatalf("expected explicit PBS auto-register curl exit-code handling in setup script, got: %s", truncate(script, 900))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1265,6 +1265,7 @@ func TestContract_AISettingsUpdateProviderResolutionJSONSnapshot(t *testing.T) {
|
|||
"ollama_configured":true,
|
||||
"ollama_base_url":%q,
|
||||
"ollama_password_set":false,
|
||||
"ollama_keep_alive":"30s",
|
||||
"configured_providers":["ollama"],
|
||||
"control_level":"read_only",
|
||||
"protected_guests":[],
|
||||
|
|
@ -1406,12 +1407,13 @@ func TestContract_AISettingsBYOKOverrideDoesNotExposeQuickstartInventoryJSONSnap
|
|||
"ollama_configured":false,
|
||||
"ollama_base_url":"http://localhost:11434",
|
||||
"ollama_password_set":false,
|
||||
"configured_providers":["openai"],
|
||||
"control_level":"read_only",
|
||||
"protected_guests":[],
|
||||
"discovery_enabled":false,
|
||||
"patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]}
|
||||
}`
|
||||
"ollama_keep_alive":"30s",
|
||||
"configured_providers":["openai"],
|
||||
"control_level":"read_only",
|
||||
"protected_guests":[],
|
||||
"discovery_enabled":false,
|
||||
"patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]}
|
||||
}`
|
||||
|
||||
assertJSONSnapshot(t, rec.Body.Bytes(), want)
|
||||
}
|
||||
|
|
@ -3408,6 +3410,7 @@ func TestContract_HostedAISettingsDoesNotAutoBootstrapQuickstartJSONSnapshot(t *
|
|||
"ollama_configured":false,
|
||||
"ollama_base_url":"http://localhost:11434",
|
||||
"ollama_password_set":false,
|
||||
"ollama_keep_alive":"30s",
|
||||
"configured_providers":[],
|
||||
"control_level":"read_only",
|
||||
"protected_guests":[],
|
||||
|
|
@ -3468,6 +3471,7 @@ func TestContract_AISettingsRetiredQuickstartAliasJSONSnapshot(t *testing.T) {
|
|||
"ollama_configured":false,
|
||||
"ollama_base_url":"http://localhost:11434",
|
||||
"ollama_password_set":false,
|
||||
"ollama_keep_alive":"30s",
|
||||
"configured_providers":[],
|
||||
"control_level":"read_only",
|
||||
"protected_guests":[],
|
||||
|
|
@ -3533,12 +3537,13 @@ func TestContract_AISettingsOllamaAuthJSONSnapshot(t *testing.T) {
|
|||
"ollama_base_url":"http://ollama.example:11434",
|
||||
"ollama_username":"unai",
|
||||
"ollama_password_set":true,
|
||||
"configured_providers":["ollama"],
|
||||
"control_level":"read_only",
|
||||
"protected_guests":[],
|
||||
"discovery_enabled":false,
|
||||
"patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]}
|
||||
}`
|
||||
"ollama_keep_alive":"30s",
|
||||
"configured_providers":["ollama"],
|
||||
"control_level":"read_only",
|
||||
"protected_guests":[],
|
||||
"discovery_enabled":false,
|
||||
"patrol_readiness":{"status":"not_ready","ready":false,"cause":"service_unavailable","summary":"Pulse Patrol service is not available.","checks":[{"id":"service","status":"not_ready","cause":"service_unavailable","label":"Patrol service","message":"Pulse Patrol service is not available.","action":"restart_service"}]}
|
||||
}`
|
||||
|
||||
assertJSONSnapshot(t, rec.Body.Bytes(), want)
|
||||
}
|
||||
|
|
@ -4027,6 +4032,7 @@ func TestContract_HostedTenantAISettingsDoesNotAutoBootstrapQuickstartJSONSnapsh
|
|||
"ollama_configured":false,
|
||||
"ollama_base_url":"http://localhost:11434",
|
||||
"ollama_password_set":false,
|
||||
"ollama_keep_alive":"30s",
|
||||
"configured_providers":[],
|
||||
"control_level":"read_only",
|
||||
"protected_guests":[],
|
||||
|
|
|
|||
|
|
@ -54,6 +54,13 @@ func TestCreateOIDCSession(t *testing.T) {
|
|||
if session.OIDCAccessTokenExp.IsZero() {
|
||||
t.Error("AccessTokenExp should not be zero")
|
||||
}
|
||||
|
||||
if session.OIDCAccessTokenIssuedAt.IsZero() {
|
||||
t.Error("AccessTokenIssuedAt should not be zero")
|
||||
}
|
||||
if !session.OIDCAccessTokenIssuedAt.Before(session.OIDCAccessTokenExp) {
|
||||
t.Error("AccessTokenIssuedAt should be before AccessTokenExp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateOIDCTokens(t *testing.T) {
|
||||
|
|
@ -77,6 +84,7 @@ func TestUpdateOIDCTokens(t *testing.T) {
|
|||
store.CreateOIDCSession(token, 24*time.Hour, "TestAgent", "127.0.0.1", "testuser", oidcInfo)
|
||||
|
||||
// Update the tokens (simulating a refresh)
|
||||
beforeRefresh := time.Now()
|
||||
newExpiry := time.Now().Add(2 * time.Hour)
|
||||
store.UpdateOIDCTokens(token, "new-refresh-token", newExpiry)
|
||||
|
||||
|
|
@ -93,6 +101,10 @@ func TestUpdateOIDCTokens(t *testing.T) {
|
|||
if !session.OIDCAccessTokenExp.After(originalExpiry) {
|
||||
t.Error("AccessTokenExp should be updated to new expiry")
|
||||
}
|
||||
|
||||
if session.OIDCAccessTokenIssuedAt.Before(beforeRefresh) {
|
||||
t.Error("AccessTokenIssuedAt should update when tokens refresh")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCSessionPersistence(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -40,11 +40,12 @@ type sessionPersisted struct {
|
|||
IP string `json:"ip,omitempty"`
|
||||
OriginalDuration time.Duration `json:"original_duration,omitempty"`
|
||||
// OIDC token fields for refresh token support
|
||||
OIDCRefreshToken string `json:"oidc_refresh_token,omitempty"`
|
||||
OIDCAccessTokenExp time.Time `json:"oidc_access_token_exp,omitempty"`
|
||||
OIDCIssuer string `json:"oidc_issuer,omitempty"`
|
||||
OIDCClientID string `json:"oidc_client_id,omitempty"`
|
||||
OIDCTokenRefreshing bool `json:"-"` // transient, not persisted
|
||||
OIDCRefreshToken string `json:"oidc_refresh_token,omitempty"`
|
||||
OIDCAccessTokenExp time.Time `json:"oidc_access_token_exp,omitempty"`
|
||||
OIDCAccessTokenIssuedAt time.Time `json:"oidc_access_token_issued_at,omitempty"`
|
||||
OIDCIssuer string `json:"oidc_issuer,omitempty"`
|
||||
OIDCClientID string `json:"oidc_client_id,omitempty"`
|
||||
OIDCTokenRefreshing bool `json:"-"` // transient, not persisted
|
||||
// SAML session fields for Single Logout (SLO) support
|
||||
SAMLProviderID string `json:"saml_provider_id,omitempty"`
|
||||
SAMLNameID string `json:"saml_name_id,omitempty"`
|
||||
|
|
@ -61,11 +62,12 @@ type SessionData struct {
|
|||
IP string `json:"ip,omitempty"`
|
||||
OriginalDuration time.Duration `json:"original_duration,omitempty"` // Track original duration for sliding expiration
|
||||
// OIDC token fields for refresh token support
|
||||
OIDCRefreshToken string `json:"oidc_refresh_token,omitempty"` // Encrypted at rest
|
||||
OIDCAccessTokenExp time.Time `json:"oidc_access_token_exp,omitempty"` // When the access token expires
|
||||
OIDCIssuer string `json:"oidc_issuer,omitempty"` // IdP issuer URL
|
||||
OIDCClientID string `json:"oidc_client_id,omitempty"` // OIDC client ID
|
||||
OIDCTokenRefreshing bool `json:"-"` // Prevents concurrent refresh attempts
|
||||
OIDCRefreshToken string `json:"oidc_refresh_token,omitempty"` // Encrypted at rest
|
||||
OIDCAccessTokenExp time.Time `json:"oidc_access_token_exp,omitempty"` // When the access token expires
|
||||
OIDCAccessTokenIssuedAt time.Time `json:"oidc_access_token_issued_at,omitempty"` // When this access token was received
|
||||
OIDCIssuer string `json:"oidc_issuer,omitempty"` // IdP issuer URL
|
||||
OIDCClientID string `json:"oidc_client_id,omitempty"` // OIDC client ID
|
||||
OIDCTokenRefreshing bool `json:"-"` // Prevents concurrent refresh attempts
|
||||
// SAML session fields for Single Logout (SLO) support
|
||||
SAMLProviderID string `json:"saml_provider_id,omitempty"` // SAML IdP provider ID
|
||||
SAMLNameID string `json:"saml_name_id,omitempty"` // SAML NameID from assertion
|
||||
|
|
@ -100,20 +102,21 @@ func (s *SessionStore) loadHashedSessions(persisted []sessionPersisted, now time
|
|||
}
|
||||
|
||||
s.sessions[entry.Key] = &SessionData{
|
||||
Username: entry.Username,
|
||||
RecoveryBypass: entry.RecoveryBypass,
|
||||
ExpiresAt: entry.ExpiresAt,
|
||||
CreatedAt: entry.CreatedAt,
|
||||
UserAgent: entry.UserAgent,
|
||||
IP: entry.IP,
|
||||
OriginalDuration: entry.OriginalDuration,
|
||||
OIDCRefreshToken: refreshToken,
|
||||
OIDCAccessTokenExp: entry.OIDCAccessTokenExp,
|
||||
OIDCIssuer: entry.OIDCIssuer,
|
||||
OIDCClientID: entry.OIDCClientID,
|
||||
SAMLProviderID: entry.SAMLProviderID,
|
||||
SAMLNameID: entry.SAMLNameID,
|
||||
SAMLSessionIndex: entry.SAMLSessionIndex,
|
||||
Username: entry.Username,
|
||||
RecoveryBypass: entry.RecoveryBypass,
|
||||
ExpiresAt: entry.ExpiresAt,
|
||||
CreatedAt: entry.CreatedAt,
|
||||
UserAgent: entry.UserAgent,
|
||||
IP: entry.IP,
|
||||
OriginalDuration: entry.OriginalDuration,
|
||||
OIDCRefreshToken: refreshToken,
|
||||
OIDCAccessTokenExp: entry.OIDCAccessTokenExp,
|
||||
OIDCAccessTokenIssuedAt: entry.OIDCAccessTokenIssuedAt,
|
||||
OIDCIssuer: entry.OIDCIssuer,
|
||||
OIDCClientID: entry.OIDCClientID,
|
||||
SAMLProviderID: entry.SAMLProviderID,
|
||||
SAMLNameID: entry.SAMLNameID,
|
||||
SAMLSessionIndex: entry.SAMLSessionIndex,
|
||||
}
|
||||
loaded++
|
||||
}
|
||||
|
|
@ -247,6 +250,7 @@ func (s *SessionStore) CreateRecoverySession(token string, duration time.Duratio
|
|||
type OIDCTokenInfo struct {
|
||||
RefreshToken string
|
||||
AccessTokenExp time.Time
|
||||
IssuedAt time.Time
|
||||
Issuer string
|
||||
ClientID string
|
||||
}
|
||||
|
|
@ -256,20 +260,26 @@ func (s *SessionStore) CreateOIDCSession(token string, duration time.Duration, u
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
key := sessionHash(token)
|
||||
session := &SessionData{
|
||||
Username: username,
|
||||
RecoveryBypass: false,
|
||||
ExpiresAt: time.Now().Add(duration),
|
||||
CreatedAt: time.Now(),
|
||||
ExpiresAt: now.Add(duration),
|
||||
CreatedAt: now,
|
||||
UserAgent: userAgent,
|
||||
IP: ip,
|
||||
OriginalDuration: duration,
|
||||
}
|
||||
|
||||
if oidc != nil {
|
||||
issuedAt := oidc.IssuedAt
|
||||
if issuedAt.IsZero() {
|
||||
issuedAt = now
|
||||
}
|
||||
session.OIDCRefreshToken = oidc.RefreshToken
|
||||
session.OIDCAccessTokenExp = oidc.AccessTokenExp
|
||||
session.OIDCAccessTokenIssuedAt = issuedAt
|
||||
session.OIDCIssuer = oidc.Issuer
|
||||
session.OIDCClientID = oidc.ClientID
|
||||
}
|
||||
|
|
@ -352,6 +362,7 @@ func (s *SessionStore) UpdateOIDCTokens(token string, refreshToken string, acces
|
|||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
now := time.Now()
|
||||
key := sessionHash(token)
|
||||
session, exists := s.sessions[key]
|
||||
if !exists {
|
||||
|
|
@ -360,11 +371,12 @@ func (s *SessionStore) UpdateOIDCTokens(token string, refreshToken string, acces
|
|||
|
||||
session.OIDCRefreshToken = refreshToken
|
||||
session.OIDCAccessTokenExp = accessTokenExp
|
||||
session.OIDCAccessTokenIssuedAt = now
|
||||
session.OIDCTokenRefreshing = false
|
||||
|
||||
// Also extend the session expiry since the token is still valid
|
||||
if session.OriginalDuration > 0 {
|
||||
session.ExpiresAt = time.Now().Add(session.OriginalDuration)
|
||||
session.ExpiresAt = now.Add(session.OriginalDuration)
|
||||
}
|
||||
|
||||
// Save immediately after token refresh
|
||||
|
|
@ -486,21 +498,22 @@ func (s *SessionStore) saveUnsafe() {
|
|||
}
|
||||
|
||||
persisted = append(persisted, sessionPersisted{
|
||||
Key: key,
|
||||
Username: session.Username,
|
||||
RecoveryBypass: session.RecoveryBypass,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
CreatedAt: session.CreatedAt,
|
||||
UserAgent: session.UserAgent,
|
||||
IP: session.IP,
|
||||
OriginalDuration: session.OriginalDuration,
|
||||
OIDCRefreshToken: refreshToken,
|
||||
OIDCAccessTokenExp: session.OIDCAccessTokenExp,
|
||||
OIDCIssuer: session.OIDCIssuer,
|
||||
OIDCClientID: session.OIDCClientID,
|
||||
SAMLProviderID: session.SAMLProviderID,
|
||||
SAMLNameID: session.SAMLNameID,
|
||||
SAMLSessionIndex: session.SAMLSessionIndex,
|
||||
Key: key,
|
||||
Username: session.Username,
|
||||
RecoveryBypass: session.RecoveryBypass,
|
||||
ExpiresAt: session.ExpiresAt,
|
||||
CreatedAt: session.CreatedAt,
|
||||
UserAgent: session.UserAgent,
|
||||
IP: session.IP,
|
||||
OriginalDuration: session.OriginalDuration,
|
||||
OIDCRefreshToken: refreshToken,
|
||||
OIDCAccessTokenExp: session.OIDCAccessTokenExp,
|
||||
OIDCAccessTokenIssuedAt: session.OIDCAccessTokenIssuedAt,
|
||||
OIDCIssuer: session.OIDCIssuer,
|
||||
OIDCClientID: session.OIDCClientID,
|
||||
SAMLProviderID: session.SAMLProviderID,
|
||||
SAMLNameID: session.SAMLNameID,
|
||||
SAMLSessionIndex: session.SAMLSessionIndex,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -204,6 +204,38 @@ func TestSessionStore_CreateRecoverySession_PersistsRecoveryBypass(t *testing.T)
|
|||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_CreateOIDCSession_PersistsAccessTokenIssuedAt(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
issuedAt := time.Unix(1_700_000_000, 0).UTC()
|
||||
expiresAt := issuedAt.Add(5 * time.Minute)
|
||||
token := "oidc-issued-at-token"
|
||||
|
||||
store := NewSessionStore(tmpDir)
|
||||
store.CreateOIDCSession(token, time.Hour, "TestAgent", "127.0.0.1", "testuser", &OIDCTokenInfo{
|
||||
RefreshToken: "refresh-token",
|
||||
AccessTokenExp: expiresAt,
|
||||
IssuedAt: issuedAt,
|
||||
Issuer: "https://issuer.example",
|
||||
ClientID: "client-id",
|
||||
})
|
||||
store.Shutdown()
|
||||
|
||||
reloaded := NewSessionStore(tmpDir)
|
||||
defer reloaded.Shutdown()
|
||||
|
||||
session := reloaded.GetSession(token)
|
||||
if session == nil {
|
||||
t.Fatal("expected reloaded OIDC session to exist")
|
||||
}
|
||||
if !session.OIDCAccessTokenIssuedAt.Equal(issuedAt) {
|
||||
t.Fatalf("OIDCAccessTokenIssuedAt = %s, want %s", session.OIDCAccessTokenIssuedAt, issuedAt)
|
||||
}
|
||||
if !session.OIDCAccessTokenExp.Equal(expiresAt) {
|
||||
t.Fatalf("OIDCAccessTokenExp = %s, want %s", session.OIDCAccessTokenExp, expiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionStore_ValidateSession_NonExistent(t *testing.T) {
|
||||
store := &SessionStore{
|
||||
sessions: make(map[string]*SessionData),
|
||||
|
|
|
|||
|
|
@ -1258,26 +1258,13 @@ else
|
|||
echo ""
|
||||
|
||||
# Construct registration request with setup token
|
||||
REGISTER_JSON=$(cat <<EOF
|
||||
{
|
||||
"type": "pbs",
|
||||
"host": "$HOST_URL",
|
||||
"serverName": "$SERVER_HOSTNAME",
|
||||
"tokenId": "$PULSE_TOKEN_ID",
|
||||
"tokenValue": "$TOKEN_VALUE",
|
||||
"authToken": "$PULSE_SETUP_TOKEN",
|
||||
"source": "script",
|
||||
}
|
||||
EOF
|
||||
)
|
||||
# Remove newlines from JSON
|
||||
REGISTER_JSON=$(echo "$REGISTER_JSON" | tr -d '\n')
|
||||
REGISTER_JSON='{"type":"pbs","host":"'"$HOST_URL"'","serverName":"'"$SERVER_HOSTNAME"'","tokenId":"'"$PULSE_TOKEN_ID"'","tokenValue":"'"$TOKEN_VALUE"'","authToken":"'"$PULSE_SETUP_TOKEN"'","source":"script"}'
|
||||
|
||||
# Send registration with setup token
|
||||
REGISTER_ATTEMPTED=true
|
||||
REGISTER_RESPONSE=$(curl -fsS -X POST "$PULSE_URL/api/auto-register" \
|
||||
REGISTER_RESPONSE=$(echo "$REGISTER_JSON" | curl -fsS -X POST "$PULSE_URL/api/auto-register" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$REGISTER_JSON" 2>&1)
|
||||
-d @- 2>&1)
|
||||
REGISTER_RC=$?
|
||||
else
|
||||
echo "⚠️ Auto-registration skipped: no setup token provided"
|
||||
|
|
@ -1362,5 +1349,5 @@ if [ "$AUTO_REG_SUCCESS" != true ] && [ "$SETUP_TOKEN_INVALID" != true ]; then
|
|||
fi
|
||||
fi
|
||||
`, ctx.ServerName, time.Now().Format("2006-01-02 15:04:05"), ctx.TokenMatchPrefix,
|
||||
ctx.TokenName, ctx.ServerHost, ctx.SetupToken, ctx.Artifact.URL)
|
||||
ctx.TokenName, ctx.ServerHost, ctx.SetupToken, ctx.PulseURL)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
|
@ -40,6 +43,7 @@ type AIConfig struct {
|
|||
OllamaBaseURL string `json:"ollama_base_url,omitempty"` // Ollama server URL (default: http://localhost:11434)
|
||||
OllamaUsername string `json:"ollama_username,omitempty"` // Optional Basic Auth username for Ollama
|
||||
OllamaPassword string `json:"ollama_password,omitempty"` // Optional Basic Auth password for Ollama
|
||||
OllamaKeepAlive string `json:"ollama_keep_alive"` // Ollama keep_alive value; empty uses the server default
|
||||
OpenAIBaseURL string `json:"openai_base_url,omitempty"` // Custom OpenAI-compatible base URL (optional)
|
||||
|
||||
// OAuth fields for Claude Pro/Max subscription authentication
|
||||
|
|
@ -140,6 +144,7 @@ const (
|
|||
// Pulse-hosted model aliases from pre-GA config.
|
||||
DefaultAIModelQuickstart = "pulse-hosted"
|
||||
DefaultOllamaBaseURL = "http://localhost:11434"
|
||||
DefaultOllamaKeepAlive = "30s"
|
||||
DefaultOpenRouterBaseURL = "https://openrouter.ai/api/v1/chat/completions"
|
||||
DefaultDeepSeekBaseURL = "https://api.deepseek.com/chat/completions"
|
||||
DefaultGeminiBaseURL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
|
|
@ -188,6 +193,9 @@ func NewDefaultAIConfig() *AIConfig {
|
|||
Enabled: false,
|
||||
Model: "",
|
||||
AuthMethod: AuthMethodAPIKey,
|
||||
// Pulse keeps the v6 cost-control default explicit. Operators can
|
||||
// clear this value to let the Ollama server's own default apply.
|
||||
OllamaKeepAlive: DefaultOllamaKeepAlive,
|
||||
// Patrol defaults - enabled when AI is enabled
|
||||
// Default to 6 hour intervals (much more token-efficient than 15 min)
|
||||
PatrolEnabled: true,
|
||||
|
|
@ -205,6 +213,36 @@ func NewDefaultAIConfig() *AIConfig {
|
|||
}
|
||||
}
|
||||
|
||||
// NormalizeOllamaKeepAlive validates the value Pulse sends as Ollama's
|
||||
// keep_alive request option. Empty is intentional and means "omit keep_alive"
|
||||
// so the Ollama server default applies.
|
||||
func NormalizeOllamaKeepAlive(value string) (string, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return "", nil
|
||||
}
|
||||
if numeric, err := strconv.ParseFloat(trimmed, 64); err == nil {
|
||||
if math.IsInf(numeric, 0) || math.IsNaN(numeric) {
|
||||
return "", fmt.Errorf("must be a finite duration or second count")
|
||||
}
|
||||
return trimmed, nil
|
||||
}
|
||||
if _, err := time.ParseDuration(trimmed); err == nil {
|
||||
return trimmed, nil
|
||||
}
|
||||
return "", fmt.Errorf("must be a duration such as 30s, 5m, or 24h; seconds such as 3600; -1 to keep loaded; 0 to unload; or empty to use the Ollama server default")
|
||||
}
|
||||
|
||||
// GetOllamaKeepAlive returns the configured keep_alive value. A nil config
|
||||
// keeps the Pulse default; an explicitly empty config value is preserved so
|
||||
// callers can omit keep_alive and defer to the Ollama server.
|
||||
func (c *AIConfig) GetOllamaKeepAlive() string {
|
||||
if c == nil {
|
||||
return DefaultOllamaKeepAlive
|
||||
}
|
||||
return strings.TrimSpace(c.OllamaKeepAlive)
|
||||
}
|
||||
|
||||
// IsConfigured returns true if the AI config has enough info to make API calls
|
||||
// For multi-provider setup, returns true if ANY provider is configured
|
||||
func (c *AIConfig) IsConfigured() bool {
|
||||
|
|
|
|||
|
|
@ -392,6 +392,44 @@ func TestAIConfig_GetBaseURLForProvider(t *testing.T) {
|
|||
|
||||
}
|
||||
|
||||
func TestAIConfig_OllamaKeepAliveDefaultsAndValidation(t *testing.T) {
|
||||
t.Run("default config keeps pulse keep alive", func(t *testing.T) {
|
||||
cfg := NewDefaultAIConfig()
|
||||
if got := cfg.GetOllamaKeepAlive(); got != DefaultOllamaKeepAlive {
|
||||
t.Fatalf("GetOllamaKeepAlive() = %q, want %q", got, DefaultOllamaKeepAlive)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("empty preserves server default intent", func(t *testing.T) {
|
||||
cfg := &AIConfig{OllamaKeepAlive: ""}
|
||||
if got := cfg.GetOllamaKeepAlive(); got != "" {
|
||||
t.Fatalf("GetOllamaKeepAlive() = %q, want empty", got)
|
||||
}
|
||||
})
|
||||
|
||||
valid := []string{"30s", "5m", "24h", "0", "-1", "3600"}
|
||||
for _, value := range valid {
|
||||
t.Run("valid "+value, func(t *testing.T) {
|
||||
got, err := NormalizeOllamaKeepAlive(" " + value + " ")
|
||||
if err != nil {
|
||||
t.Fatalf("NormalizeOllamaKeepAlive(%q) returned error: %v", value, err)
|
||||
}
|
||||
if got != value {
|
||||
t.Fatalf("NormalizeOllamaKeepAlive(%q) = %q", value, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
invalid := []string{"later", "forever", "NaN", "+Inf"}
|
||||
for _, value := range invalid {
|
||||
t.Run("invalid "+value, func(t *testing.T) {
|
||||
if _, err := NormalizeOllamaKeepAlive(value); err == nil {
|
||||
t.Fatalf("NormalizeOllamaKeepAlive(%q) returned nil error", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAIConfig_IsUsingOAuth(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/unifiedresources"
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
|
|
@ -214,7 +215,7 @@ func (s *HostContinuityStore) Match(
|
|||
if matchedByAlias {
|
||||
continue
|
||||
}
|
||||
if hostname == "" || !strings.EqualFold(entry.Hostname, hostname) {
|
||||
if !hostContinuityHostnameMatches(entry.Hostname, hostname) {
|
||||
continue
|
||||
}
|
||||
if tokenID != "" && (entry.TokenID == "" || entry.TokenID != tokenID) {
|
||||
|
|
@ -229,6 +230,15 @@ func (s *HostContinuityStore) Match(
|
|||
return best, matched
|
||||
}
|
||||
|
||||
func hostContinuityHostnameMatches(left, right string) bool {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
if left == "" || right == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left, right) || unifiedresources.HostnamesEquivalent(left, right)
|
||||
}
|
||||
|
||||
func uniqueTrimmedStrings(values ...string) []string {
|
||||
out := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
|
|
|
|||
|
|
@ -41,6 +41,16 @@ func TestHostContinuityStoreRoundTripAndMatch(t *testing.T) {
|
|||
t.Fatalf("matched host ID = %q, want %q", entry.HostID, "host-1")
|
||||
}
|
||||
|
||||
if entry, ok := reloaded.Match("", "", "", "host-1", "token-1", now.Add(-time.Minute)); !ok {
|
||||
t.Fatal("expected match by equivalent short hostname and token")
|
||||
} else if entry.HostID != "host-1" {
|
||||
t.Fatalf("matched host ID = %q, want %q", entry.HostID, "host-1")
|
||||
}
|
||||
|
||||
if _, ok := reloaded.Match("", "", "", "host-1.example", "token-1", now.Add(-time.Minute)); ok {
|
||||
t.Fatal("expected distinct fully-qualified hostnames not to match")
|
||||
}
|
||||
|
||||
if _, ok := reloaded.Match("", "", "", "host-1.local", "token-2", now.Add(-time.Minute)); ok {
|
||||
t.Fatal("expected token mismatch not to match")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1957,6 +1957,11 @@ func (c *ConfigPersistence) SaveAIConfig(settings AIConfig) error {
|
|||
|
||||
settings.NormalizePatrolEventTriggerSettings()
|
||||
settings.NormalizeQuickstartModelAliases()
|
||||
ollamaKeepAlive, err := NormalizeOllamaKeepAlive(settings.OllamaKeepAlive)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid Ollama keep-alive setting: %w", err)
|
||||
}
|
||||
settings.OllamaKeepAlive = ollamaKeepAlive
|
||||
|
||||
if err := c.EnsureConfigDir(); err != nil {
|
||||
return fmt.Errorf("prepare config directory for ai config: %w", err)
|
||||
|
|
|
|||
|
|
@ -165,6 +165,39 @@ func TestPersistence_AIConfig(t *testing.T) {
|
|||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestPersistence_AIConfig_OllamaKeepAliveDefaultsAndExplicitServerDefault(t *testing.T) {
|
||||
t.Run("missing field loads pulse default", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
p := NewConfigPersistence(tempDir)
|
||||
|
||||
legacy := map[string]interface{}{
|
||||
"enabled": true,
|
||||
"model": "ollama:llama3",
|
||||
"ollama_base_url": "http://127.0.0.1:11434",
|
||||
}
|
||||
data, err := json.Marshal(legacy)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, os.WriteFile(filepath.Join(tempDir, "ai.enc"), data, 0o600))
|
||||
|
||||
loaded, err := p.LoadAIConfig()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DefaultOllamaKeepAlive, loaded.OllamaKeepAlive)
|
||||
})
|
||||
|
||||
t.Run("empty field survives reload as server default", func(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
p := NewConfigPersistence(tempDir)
|
||||
|
||||
cfg := NewDefaultAIConfig()
|
||||
cfg.OllamaKeepAlive = ""
|
||||
require.NoError(t, p.SaveAIConfig(*cfg))
|
||||
|
||||
loaded, err := p.LoadAIConfig()
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, loaded.OllamaKeepAlive)
|
||||
})
|
||||
}
|
||||
|
||||
func TestPersistence_HasAIConfig(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
p := NewConfigPersistence(tempDir)
|
||||
|
|
|
|||
|
|
@ -177,14 +177,21 @@ func (m *Monitor) RemoveHostAgent(hostID string) (models.Host, error) {
|
|||
}
|
||||
|
||||
if hostname != "" {
|
||||
key := fmt.Sprintf("%s:%s", tokenID, hostname)
|
||||
if _, exists := m.hostTokenBindings[key]; exists {
|
||||
key := hostTokenBindingKey(tokenID, hostname)
|
||||
delete(m.hostTokenBindings, key)
|
||||
}
|
||||
|
||||
prefix := tokenID + ":"
|
||||
for key, boundID := range m.hostTokenBindings {
|
||||
if !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(boundID) == hostID {
|
||||
delete(m.hostTokenBindings, key)
|
||||
}
|
||||
}
|
||||
|
||||
if tokenRemoved != nil {
|
||||
prefix := tokenID + ":"
|
||||
for key := range m.hostTokenBindings {
|
||||
if strings.HasPrefix(key, prefix) {
|
||||
delete(m.hostTokenBindings, key)
|
||||
|
|
@ -289,7 +296,7 @@ func (m *Monitor) lookupRemovedHostAgent(identifier, hostname string) (time.Time
|
|||
if strings.TrimSpace(entry.ID) == identifier {
|
||||
return entry.RemovedAt, true
|
||||
}
|
||||
if strings.TrimSpace(entry.Hostname) == hostname {
|
||||
if hostAgentHostnamesMatch(entry.Hostname, hostname) {
|
||||
return entry.RemovedAt, true
|
||||
}
|
||||
}
|
||||
|
|
@ -735,6 +742,51 @@ func (m *Monitor) hostContinuitySince(now time.Time) time.Time {
|
|||
return now.Add(-hostContinuityRetention)
|
||||
}
|
||||
|
||||
func hostTokenBindingKey(tokenID, hostname string) string {
|
||||
tokenID = strings.TrimSpace(tokenID)
|
||||
hostname = strings.TrimSpace(hostname)
|
||||
if tokenID == "" || hostname == "" {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%s:%s", tokenID, hostname)
|
||||
}
|
||||
|
||||
func lookupHostTokenBinding(bindings map[string]string, tokenID, hostname string) string {
|
||||
if len(bindings) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
bindingKey := hostTokenBindingKey(tokenID, hostname)
|
||||
if bindingKey == "" {
|
||||
return ""
|
||||
}
|
||||
if boundID := strings.TrimSpace(bindings[bindingKey]); boundID != "" {
|
||||
return boundID
|
||||
}
|
||||
|
||||
prefix := strings.TrimSpace(tokenID) + ":"
|
||||
for key, boundID := range bindings {
|
||||
boundID = strings.TrimSpace(boundID)
|
||||
if boundID == "" || !strings.HasPrefix(key, prefix) {
|
||||
continue
|
||||
}
|
||||
boundHostname := strings.TrimSpace(strings.TrimPrefix(key, prefix))
|
||||
if hostAgentHostnamesMatch(boundHostname, hostname) {
|
||||
return boundID
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func hostAgentHostnamesMatch(left, right string) bool {
|
||||
left = strings.TrimSpace(left)
|
||||
right = strings.TrimSpace(right)
|
||||
if left == "" || right == "" {
|
||||
return false
|
||||
}
|
||||
return strings.EqualFold(left, right) || unifiedresources.HostnamesEquivalent(left, right)
|
||||
}
|
||||
|
||||
func (m *Monitor) matchPersistedHostContinuity(
|
||||
report agentshost.Report,
|
||||
tokenRecord *config.APITokenRecord,
|
||||
|
|
@ -909,7 +961,7 @@ func (m *Monitor) RebuildTokenBindings() {
|
|||
if hostname == "" || agentID == "" {
|
||||
continue
|
||||
}
|
||||
newHostBindings[fmt.Sprintf("%s:%s", tokenID, hostname)] = agentID
|
||||
newHostBindings[hostTokenBindingKey(tokenID, hostname)] = agentID
|
||||
}
|
||||
|
||||
// Log what changed
|
||||
|
|
@ -1613,13 +1665,16 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
|
|||
identifier := baseIdentifier
|
||||
if tokenRecord != nil && strings.TrimSpace(tokenRecord.ID) != "" {
|
||||
tokenID := strings.TrimSpace(tokenRecord.ID)
|
||||
bindingKey := fmt.Sprintf("%s:%s", tokenID, hostname)
|
||||
bindingKey := hostTokenBindingKey(tokenID, hostname)
|
||||
|
||||
m.mu.Lock()
|
||||
if m.hostTokenBindings == nil {
|
||||
m.hostTokenBindings = make(map[string]string)
|
||||
}
|
||||
boundID := strings.TrimSpace(m.hostTokenBindings[bindingKey])
|
||||
boundID := lookupHostTokenBinding(m.hostTokenBindings, tokenID, hostname)
|
||||
if boundID != "" {
|
||||
m.hostTokenBindings[bindingKey] = boundID
|
||||
}
|
||||
m.mu.Unlock()
|
||||
|
||||
// If we already have a binding for this token+hostname, use it to keep host IDs stable
|
||||
|
|
@ -1632,7 +1687,7 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
|
|||
if candidate == nil || candidate.AgentID() != bindingID {
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(candidate.Hostname()) == hostname && strings.TrimSpace(candidate.TokenID()) == tokenID {
|
||||
if hostAgentHostnamesMatch(candidate.Hostname(), hostname) && strings.TrimSpace(candidate.TokenID()) == tokenID {
|
||||
break
|
||||
}
|
||||
|
||||
|
|
@ -1655,7 +1710,8 @@ func (m *Monitor) ApplyHostReport(report agentshost.Report, tokenRecord *config.
|
|||
if m.hostTokenBindings == nil {
|
||||
m.hostTokenBindings = make(map[string]string)
|
||||
}
|
||||
if existing := strings.TrimSpace(m.hostTokenBindings[bindingKey]); existing != "" {
|
||||
if existing := lookupHostTokenBinding(m.hostTokenBindings, tokenID, hostname); existing != "" {
|
||||
m.hostTokenBindings[bindingKey] = existing
|
||||
identifier = existing
|
||||
} else {
|
||||
m.hostTokenBindings[bindingKey] = bindingID
|
||||
|
|
|
|||
|
|
@ -657,6 +657,62 @@ func TestApplyHostReportReusesPersistedContinuityAcrossRestart(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestApplyHostReportReusesTokenBindingAcrossShortFQDNAfterReload(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
monitor := &Monitor{
|
||||
state: models.NewState(),
|
||||
alertManager: alerts.NewManager(),
|
||||
hostTokenBindings: make(map[string]string),
|
||||
config: &config.Config{},
|
||||
rateTracker: NewRateTracker(),
|
||||
}
|
||||
t.Cleanup(func() { monitor.alertManager.Stop() })
|
||||
|
||||
now := time.Now().UTC()
|
||||
token := &config.APITokenRecord{ID: "token-host-continuity", Name: "Host Token"}
|
||||
monitor.state.UpsertHost(models.Host{
|
||||
ID: "host-v5",
|
||||
Hostname: "docker-lxc.lab",
|
||||
DisplayName: "docker-lxc",
|
||||
TokenID: token.ID,
|
||||
Status: "online",
|
||||
AgentVersion: "5.1.30",
|
||||
IntervalSeconds: 30,
|
||||
LastSeen: now.Add(-time.Minute),
|
||||
})
|
||||
monitor.hostTokenBindings[token.ID+":docker-lxc.lab"] = "host-v5"
|
||||
|
||||
report := agentshost.Report{
|
||||
Agent: agentshost.AgentInfo{
|
||||
Version: "6.0.0-rc.4",
|
||||
IntervalSeconds: 30,
|
||||
},
|
||||
Host: agentshost.HostInfo{
|
||||
Hostname: "docker-lxc",
|
||||
Platform: "linux",
|
||||
OSName: "debian",
|
||||
},
|
||||
Timestamp: now,
|
||||
}
|
||||
|
||||
host, err := monitor.ApplyHostReport(report, token)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyHostReport: %v", err)
|
||||
}
|
||||
if host.ID != "host-v5" {
|
||||
t.Fatalf("expected equivalent hostname token binding to preserve host ID host-v5, got %q", host.ID)
|
||||
}
|
||||
|
||||
snapshot := monitor.state.GetSnapshot()
|
||||
if got := len(snapshot.Hosts); got != 1 {
|
||||
t.Fatalf("expected host report to update existing host instead of creating duplicate, got %d hosts", got)
|
||||
}
|
||||
if got := monitor.hostTokenBindings[token.ID+":docker-lxc"]; got != "host-v5" {
|
||||
t.Fatalf("expected current hostname alias to bind to host-v5, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyHostReportDisambiguatesCollidingIdentifiersAcrossTokens(t *testing.T) {
|
||||
t.Helper()
|
||||
|
||||
|
|
@ -759,6 +815,7 @@ func TestRemoveHostAgentUnbindsToken(t *testing.T) {
|
|||
TokenID: tokenID,
|
||||
})
|
||||
monitor.hostTokenBindings[tokenID+":remove.me"] = hostID
|
||||
monitor.hostTokenBindings[tokenID+":remove.me.local"] = hostID
|
||||
monitor.hostTokenBindings[tokenID] = hostID
|
||||
|
||||
if _, err := monitor.RemoveHostAgent(hostID); err != nil {
|
||||
|
|
@ -771,6 +828,9 @@ func TestRemoveHostAgentUnbindsToken(t *testing.T) {
|
|||
if _, exists := monitor.hostTokenBindings[tokenID]; exists {
|
||||
t.Fatalf("expected legacy token binding to be cleared after host removal")
|
||||
}
|
||||
if _, exists := monitor.hostTokenBindings[tokenID+":remove.me.local"]; exists {
|
||||
t.Fatalf("expected equivalent hostname token binding to be cleared after host removal")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveHostAgent_PreservesLinkedGuestIdentityInRemovedState(t *testing.T) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue