mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-26 21:47:20 +00:00
Reconcile reviewed maintenance with batch-start upstream fixes
Incorporate the supplied upstream frontier without rewriting reviewed history. Later upstream reconciliation belongs to the publication gate. Change-source: pulse-maintainer
This commit is contained in:
@@ -8298,6 +8298,33 @@ in `internal/api/deploy_handlers_test.go` replays that sequence: the identity
|
||||
enrollment handed out, or the agent's own, then the acknowledged identity after
|
||||
a restart.
|
||||
|
||||
### Reports name the agent on a hostname-bound token
|
||||
|
||||
Deploy enrollment, Proxmox auto-registration, and pre-v6.1.1 installs leave a
|
||||
token bound to the node's hostname and to no agent ID until a command
|
||||
registration names the agent. An agent whose token lacks `agent:exec`, or that
|
||||
never connects the command channel, never names itself that way, and the
|
||||
safe-profile authority reduction refuses it, because it matches the agent ID
|
||||
on the token. Every applied report therefore offers its identity: when the
|
||||
agent presented the same ID Pulse resolved and acknowledges in `agentId`
|
||||
(which the agent persists and the installer sends to the reduction), that ID
|
||||
is recorded on a token that has none, under the reporting-side rule in the
|
||||
security-privacy contract. The report grants no command authority, and when
|
||||
the presented and resolved IDs differ the token is left to the command
|
||||
channel's first registration. The first identity recorded is final, as it is
|
||||
for a command registration, so an agent later restarted under a different
|
||||
configured `--agent-id` is refused under that ID on the same token.
|
||||
`TestContractDeployEnrolledCollectorReducesItsAuthorityAfterItsFirstReport`
|
||||
in `internal/api/contract_test.go` replays enrollment without commands, the
|
||||
first report, and the reduction, which was refused before it;
|
||||
`TestReportedIdentityIsFinalForALaterReconfiguredAgent` in
|
||||
`internal/api/security_regression_test.go` pins the final-identity trade-off;
|
||||
and `TestApplyHostReportStampsTheTokenOnlyOnHostsItHolds` in
|
||||
`internal/monitoring/monitor_host_agents_test.go` pins the host properties the
|
||||
report path relies on: an applied report carries its token's ID, a report the
|
||||
ordering watermark rejects returns the stored host and hostname, and mock
|
||||
mode's discard acknowledgement carries no token.
|
||||
|
||||
### Alert push routing does not create an agent lifecycle channel
|
||||
|
||||
The shared API router may classify an external-probe outage for specialized
|
||||
|
||||
@@ -522,6 +522,14 @@ collector baseline, removes every scope outside the exact collector allowlist
|
||||
from that token, persists before success, restores the full token inventory on
|
||||
persistence failure, and invalidates only the exact matching collector session
|
||||
after persistence.
|
||||
A token bound only to a hostname carries no agent ID for that match until
|
||||
something names the agent. `POST /api/agents/agent/report` names it: when the
|
||||
reporting agent presented the same ID the response acknowledges in `agentId`,
|
||||
that ID is recorded on a hostname-bound token that has none, so a collector
|
||||
that never connected the command channel can still reduce its authority. The
|
||||
report request and response shapes are unchanged.
|
||||
`TestContractDeployEnrolledCollectorReducesItsAuthorityAfterItsFirstReport`
|
||||
in `internal/api/contract_test.go` pins report, then reduction.
|
||||
|
||||
## Canonical Files
|
||||
|
||||
|
||||
@@ -2438,6 +2438,17 @@ not a replacement status card, CTA band, or page-local nested card.
|
||||
shared owner must also cap concurrent numeric animations and snap overflow
|
||||
readouts to their current target so a realtime estate update cannot make
|
||||
animation cost grow with the number of visible resources.
|
||||
Background colours come from the `@theme inline` surface tokens in
|
||||
`frontend-modern/src/index.css` and the `bg-page` utility beside them.
|
||||
`bg-page` paints the page backdrop (`--color-bg-base`) for a well that
|
||||
should read as the page behind a surface card: command and JSON blocks,
|
||||
the branding header preview, and the segmented filter track. A block that
|
||||
sits directly on a `bg-surface-alt` body, such as resource drawer content,
|
||||
uses `bg-surface` instead. No theme colour may be named `base`: it also
|
||||
colours the `text-base` font-size utility, and without it Tailwind 4
|
||||
renders `bg-base` as nothing, which left those wells and the alert
|
||||
selection bar transparent. `src/__tests__/App.architecture.test.ts` fails
|
||||
if either comes back.
|
||||
Shared primitives must not reintroduce app-shell monitored-system capacity
|
||||
banners. Monitored-system grouping and ledger presentation belongs in the
|
||||
owned settings surfaces, while commercial plan explanation belongs in
|
||||
|
||||
@@ -1494,6 +1494,40 @@ the agent config gate consumes the same decision.
|
||||
`TestVersionlessDeployPlaceholderCannotBeMovedTwice`, and
|
||||
`TestCurrentDeployTokenNeverMovesOnceBound` in
|
||||
`internal/api/security_regression_test.go` pin that boundary.
|
||||
Authenticated reports also name the agent on a token bound only to its
|
||||
hostname, so a token without `agent:exec`, or whose agent never connects the
|
||||
command channel, still carries the identity the safe-profile authority
|
||||
reduction matches. After `/api/agents/agent/report` applies a report,
|
||||
`recordReportedAgentIdentity` in `internal/api/agent_exec_token_binding.go`
|
||||
writes the server-resolved host ID as `bound_agent_id`, with `bound_at` and the
|
||||
current binding version, only when the token has a `bound_hostname` and no
|
||||
`bound_agent_id`, the report presented exactly the ID Pulse resolved for it
|
||||
(in the same run, the ID the agent's command registration presents), and
|
||||
`agentbinding.EvaluateReportedIdentity` finds that the shared `Evaluate`
|
||||
decision would first-bind, legacy-migrate, or backfill that ID for the
|
||||
reported hostname. The token must also run command sessions in the request's
|
||||
organization, be bound to at most one organization, and carry a collector,
|
||||
legacy full-trust, or no runtime role, and the host must be one the monitor
|
||||
resolved and holds under that token, so mock mode's discarded, unresolved
|
||||
acknowledgements never qualify. The path never changes scopes, runtime role,
|
||||
command policy, or `bound_hostname`, and never moves a recorded ID or repairs
|
||||
a deploy placeholder. The binding version makes the recorded ID immutable to
|
||||
every later registration, and a recorded identity grants no execution
|
||||
authority: command admission still requires `agent:exec` and the same ID.
|
||||
As with a command registration, the first identity recorded is final: an
|
||||
agent later restarted under a different configured `--agent-id`, or replaying
|
||||
a report buffered by an earlier run, meets a token already bound to its first
|
||||
reported identity and is refused under the new one. When the presented and
|
||||
resolved IDs differ (a configured agent ID, a continuity fork), nothing is
|
||||
recorded and the command channel's first registration decides as before;
|
||||
reconciling that disagreement belongs to command-channel identity
|
||||
convergence. A token bound to no hostname is left to command-channel first
|
||||
use. The write runs against the live record under the token lock command
|
||||
admission holds, and a failed save restores the previous metadata.
|
||||
`TestEvaluateReportedIdentityRecordsOnlyWhatCommandRegistrationWouldBind` in
|
||||
`internal/api/agentbinding/policy_test.go`, and the `TestReportedIdentity*`
|
||||
tests and `TestReportHandlerOffersOnlyQualifyingLiveReportsToTheRecorder` in
|
||||
`internal/api/security_regression_test.go`, pin that boundary.
|
||||
Pulse-minted install tokens now carry a server-authored command-policy intent
|
||||
in addition to first-use binding metadata. That intent grants no independent
|
||||
authority: first-report convergence requires the same shared binding decision
|
||||
|
||||
@@ -6497,6 +6497,17 @@ write never leaves a half-moved token. This adds no backup, restore, retention,
|
||||
recovery authority. `internal/api/security_regression_test.go` pins the
|
||||
persisted binding.
|
||||
|
||||
### Reported agent identity is one persisted token transition
|
||||
|
||||
Recording a report's agent identity on a hostname-bound token writes
|
||||
`bound_agent_id`, `bound_at`, and the binding version through the existing
|
||||
API-token persistence, under the token lock, in one save. When that save
|
||||
fails, the previous metadata is restored, the report itself is still
|
||||
acknowledged, and the next report retries, so a failed write never leaves a
|
||||
half-bound token. This adds no backup, restore, retention, snapshot, or
|
||||
recovery authority. `TestReportedIdentityRestoresTheTokenWhenTheSaveFails` in
|
||||
`internal/api/security_regression_test.go` pins the restore and the retry.
|
||||
|
||||
### Destination severity policy preserves notification recovery identity
|
||||
|
||||
Email, webhook, Apprise, and Relay minimum-severity floors persist through
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
{
|
||||
"version": 1,
|
||||
"base_sha": "8d77c0e2772fe7c023f4be963721c4c7709c57e0",
|
||||
"verified_at": "2026-10-05T22:54:59Z",
|
||||
"result": "passed",
|
||||
"changed_paths": [
|
||||
"frontend-modern/src/App.tsx",
|
||||
"frontend-modern/src/AppLayout.tsx",
|
||||
"frontend-modern/src/components/Alerts/ResourceTable.tsx",
|
||||
"frontend-modern/src/components/AppBootstrapStatus.tsx",
|
||||
"frontend-modern/src/components/ErrorBoundary.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx",
|
||||
"frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx",
|
||||
"frontend-modern/src/components/Login.tsx",
|
||||
"frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx",
|
||||
"frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx",
|
||||
"frontend-modern/src/components/Settings/BrandingSettingsCard.tsx",
|
||||
"frontend-modern/src/components/Settings/CopyCommandBlock.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx",
|
||||
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx",
|
||||
"frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx",
|
||||
"frontend-modern/src/components/Settings/QuickSecuritySetup.tsx",
|
||||
"frontend-modern/src/components/Settings/SSOProvidersPanel.tsx",
|
||||
"frontend-modern/src/components/Settings/SuggestProfileModal.tsx",
|
||||
"frontend-modern/src/components/Settings/UpdateInstallGuide.tsx",
|
||||
"frontend-modern/src/components/Settings/updatesSettingsModel.ts",
|
||||
"frontend-modern/src/components/SetupWizard/SetupWizard.tsx",
|
||||
"frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx",
|
||||
"frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx",
|
||||
"frontend-modern/src/components/patrol/RunToolCallTrace.tsx",
|
||||
"frontend-modern/src/components/shared/Card.tsx",
|
||||
"frontend-modern/src/components/shared/CommandPaletteModal.tsx",
|
||||
"frontend-modern/src/components/shared/EmptyState.tsx",
|
||||
"frontend-modern/src/components/shared/filterButtonGroupModel.ts",
|
||||
"frontend-modern/src/features/alerts/AlertDeadManDestinationSection.tsx",
|
||||
"frontend-modern/src/index.css"
|
||||
],
|
||||
"content_sha256": {
|
||||
"frontend-modern/src/App.tsx": "00a405e01fc87c3f738fb744876e16f13e68d50cb8f31d469f040c637a6f59f0",
|
||||
"frontend-modern/src/AppLayout.tsx": "3cb3f11a0f39adcd33cef84e7e99451395909313f1898383aa9fcc221dcb0f49",
|
||||
"frontend-modern/src/components/Alerts/ResourceTable.tsx": "760ff1b760ac8c910582c5b81900a15788fa1ec5a08edd989c54cfafd0a55f49",
|
||||
"frontend-modern/src/components/AppBootstrapStatus.tsx": "f33978b847c89626e24698dbe5c27db587425a4968573eb75a92f2f75b35f726",
|
||||
"frontend-modern/src/components/ErrorBoundary.tsx": "ac334eb9bd8aebdb8d8c5fe62e82c399d393a1c670f30f600457278bf98ca45e",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceChangeSummary.tsx": "4f3f035d2c87e562680dcf61c88cfbc0bb4f2daced31c0bda8427194fe25d992",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceCorrelationSummary.tsx": "cf77b97ce7bfce0aa642668f4f0123a9e592196e7b5e83a9791ef8e95dca33e5",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerDebugTab.tsx": "77ebc85ed5b7de3624c87846288dadc887b5555bac325b98f347246222d7b4c4",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerOverviewTab.tsx": "63192be4ab8de0e2aa920207cec14d593571f282d8bde1c8951a2d1413c00fc8",
|
||||
"frontend-modern/src/components/Infrastructure/ResourceDetailDrawerSupportDisclosure.tsx": "c0f3aaac14865c0944969b2fbfc898bd311def49f25db6938ccbadac9bc822e3",
|
||||
"frontend-modern/src/components/Infrastructure/ResourcePolicySummary.tsx": "7d87ab5e6ea2948160bf275b6d9fec7ff4ee38ce457f75b85d24c1dbb2c95203",
|
||||
"frontend-modern/src/components/Login.tsx": "0790413c0199b2bd0e0fcb9dc53cfa0fc6ac84717acbfa4f5140e725cf3d2ff2",
|
||||
"frontend-modern/src/components/Settings/AIProviderConfigurationSection.tsx": "686708cbbddce209b141e1f9b4c60b0ad57079432be77e384c0d0c4e88dbf7e4",
|
||||
"frontend-modern/src/components/Settings/AgentIntegrationsPanel.tsx": "45b3473d4a67cf505651c483616a0724085bbaa2f3f1e53c2021c5cfda2d6d0c",
|
||||
"frontend-modern/src/components/Settings/BackupTransferDialogs.tsx": "a873e334d4eaf5b25bc31ba8922b07119fe16da169b91966c591b8f3a60cf06b",
|
||||
"frontend-modern/src/components/Settings/BrandingSettingsCard.tsx": "c36f0f518c9674eb05c5050e2e23b9598673a5adf00d039c143cf180a8f21f7f",
|
||||
"frontend-modern/src/components/Settings/CopyCommandBlock.tsx": "084835c7beb84d0800f9b5590fcb899f7e6088513fc1251b2a74f15e2225b411",
|
||||
"frontend-modern/src/components/Settings/InfrastructureAgentDoctorPage.tsx": "6064372d8a07257b222888e47c075e5428a6a9392ffbd257963855d0fbe31d38",
|
||||
"frontend-modern/src/components/Settings/InfrastructureInstallerSection.tsx": "9787d5c0615787f6e0a3fc1caefc3ebef93f4618f6f74b4b2470a0fa0c40e37f",
|
||||
"frontend-modern/src/components/Settings/NodeModalSetupGuideSection.tsx": "0b8b4600e20bcbd5235a5a7fecffd99c9752d8417955b23c8ce7be473f18042c",
|
||||
"frontend-modern/src/components/Settings/QuickSecuritySetup.tsx": "8884d86ac12281a5805c33ef608fb0f86f6a61166a6eb72edcc2412b477781b5",
|
||||
"frontend-modern/src/components/Settings/SSOProvidersPanel.tsx": "2f56e9f1635b34581457c00a60a49166caceeeed2209a187d032704b3d2ca716",
|
||||
"frontend-modern/src/components/Settings/SuggestProfileModal.tsx": "b54c5c7449fe824f1ba13676bd3bac151bc0b6a880d831b211480dc41f6806d1",
|
||||
"frontend-modern/src/components/Settings/UpdateInstallGuide.tsx": "65877c2b8a4706721881545dd4f769a41485d3c8df2ede5f9022c337c77d6e92",
|
||||
"frontend-modern/src/components/Settings/updatesSettingsModel.ts": "7c8cbb83097f65bb1e17fbab859fd0711f870bb0aaa1e9c91fcc71cfd165bb49",
|
||||
"frontend-modern/src/components/SetupWizard/SetupWizard.tsx": "c96e980aec912bfae0976132168121d45fe2ba8b8e92af9c998e6cf7d69edf56",
|
||||
"frontend-modern/src/components/SetupWizard/steps/SecurityStep.tsx": "2ae173c4fc65b7aa528d1ca4196d085547081c9742e4f504d00a0baeccbc20b1",
|
||||
"frontend-modern/src/components/SetupWizard/steps/WelcomeStep.tsx": "4849c5fe13442131a5c6427d81f3c366c94e48a9701f41d1c927476b7af17dbc",
|
||||
"frontend-modern/src/components/patrol/RunToolCallTrace.tsx": "88b4bedf474d2fa23f56d59db0c746c83e004b920489c86bb6827d50133a7366",
|
||||
"frontend-modern/src/components/shared/Card.tsx": "03e4ff8a482b6dd086eb4b2765fa517edc229c02ff27105b0e572523c07a7d5f",
|
||||
"frontend-modern/src/components/shared/CommandPaletteModal.tsx": "8e0a8aaa1bbd2151ddb578e65c1230ef710b2666647cec3ab55a27fc23ac28c3",
|
||||
"frontend-modern/src/components/shared/EmptyState.tsx": "ee47b2b011fd67fc79913be1c86d8526ffaea8b49716965e0b8f358ebbc2dd74",
|
||||
"frontend-modern/src/components/shared/filterButtonGroupModel.ts": "a180ecf16e3bb84dadbd6f59a036190960082a527272551648fa9a6e9dd168ab",
|
||||
"frontend-modern/src/features/alerts/AlertDeadManDestinationSection.tsx": "57b1d0468a61a3e382a72649cb3fd5764e7d00e25f529fa156706bb54121e099",
|
||||
"frontend-modern/src/index.css": "8ee3a5cfffdf960243238f037147d817a950c65cadb5d36fdc59d58d3837572b"
|
||||
},
|
||||
"routes": [
|
||||
"/settings/pulse-intelligence/assistant",
|
||||
"/settings/infrastructure/agent-doctor",
|
||||
"/settings/system-recovery",
|
||||
"/standalone/machines",
|
||||
"/proxmox/overview",
|
||||
"/alerts/notifications",
|
||||
"/alerts/thresholds",
|
||||
"/alerts/schedule",
|
||||
"/settings/system-updates",
|
||||
"/settings",
|
||||
"/machines",
|
||||
"/settings/security/api",
|
||||
"/patrol",
|
||||
"/settings/system-general",
|
||||
"/settings/pulse-intelligence/provider",
|
||||
"/settings/infrastructure",
|
||||
"/settings/security-sso"
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"width": 1440,
|
||||
"height": 1000
|
||||
},
|
||||
{
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"states": [
|
||||
"Private mock stack (backend 7781, Vite 5281). Every capture was taken on main and on the change in the same session by swapping the files under Vite, in light and dark",
|
||||
"External agents connector setup with every details panel open: install and client-config command blocks, the capability chip, the access-model panels and their lists were transparent on the white card; they now read as slate-100 wells in light and slate-900 wells in dark",
|
||||
"Agent Doctor command blocks, the export-configuration options box, the Ollama pull command, the agent installer commands, the Proxmox node setup-guide command and the SAML metadata XML preview: transparent before, page-coloured wells after",
|
||||
"Update install guide for systemd and Docker (version response set to that deployment type): the wells now paint, and the green and blue command text gained light-mode shades, because green-400 on a light well measured about 1.6:1",
|
||||
"Dead Man's Switch: the \"Not configured\" pill had no fill and read as plain text; it is now a white chip with a border ring in light and a ringed chip in dark. The detail grid is a recessed well",
|
||||
"Alert threshold bulk-selection bar after ticking two storage rows: before, light mode showed white count text on a see-through pill (the count was invisible) and dark mode showed the table through it; after, it is an opaque bg-surface pill with readable text in both themes",
|
||||
"Command palette shortcut chips read as keycaps; the palette input already painted bg-surface, so its redundant class was removed",
|
||||
"Patrol mode segmented control (ai_autofix capability supplied): the selected option was a white chip on a white track; the track is now page-coloured and the selection stands out in both themes",
|
||||
"Branding header preview (white_label capability supplied): the preview header bar now sits on a page-coloured backdrop instead of disappearing into the card",
|
||||
"Muted Card tone (API token hint, alert configuration summary): slate-100 in light, unchanged in dark. The unused glass tone was removed",
|
||||
"Machine drawer Debug tab: JSON blocks sit on the drawer's bg-surface-alt body, so they use bg-surface like the drawer's other cards (white on slate-100 in light, flat in dark as before)",
|
||||
"Setup wizard (security status set to no authentication, bootstrap validation fulfilled with a dummy value): Welcome command wells with legible text and copy buttons, and the Security step wells and footer band, in both themes",
|
||||
"Login, wizard root, app scroll shell and the framed 404 empty state dropped a class that generated no CSS: the Login page and the empty-state element are pixel-identical before and after, and the shells sit directly on body, which already paints the page colour",
|
||||
"Hover-only drawer buttons, measured with their exact classes in the live drawer: hover:bg-base never changed the colour; hover:bg-surface-hover goes to rgb(248,250,252) in light and rgb(51,65,85) in dark",
|
||||
"Not reachable on the mock stack and judged from their containers: the drawer change-history filters, change reason and metadata, the correlation and change summary cards and the coloured drawer buttons (the compact drawer fetches no history in mock), Quick Security Setup (unauthenticated sessions go to the wizard), the agent-profile suggestion JSON, the Patrol tool-call trace, other node-modal tabs, the needs-setup nav tab, the bootstrap/error/init shells, and ResourcePolicySummary, which has no production caller",
|
||||
"Phone at 390: Dead Man's Switch grid, External agents command block, update steps and wizard command wells render the same wells in both themes"
|
||||
],
|
||||
"interactions": [
|
||||
"Opened connector setup and expanded every details panel; opened the machine drawer and its Debug tab; opened the command palette with Cmd/Ctrl+K",
|
||||
"Expanded all threshold groups and ticked two storage rows to raise the bulk-edit bar",
|
||||
"Opened Create Backup, Add infrastructure > Install Pulse Agent > Generate token, Add infrastructure > Proxmox VE with an endpoint entered, Add SAML > Preview metadata, and the Ollama provider section",
|
||||
"Advanced the setup wizard to the Security step; showed the Login page via the logged-out flag",
|
||||
"Hovered the old and new drawer button classes in light and dark; repeated the key surfaces at 390px"
|
||||
]
|
||||
}
|
||||
@@ -527,7 +527,7 @@ function App() {
|
||||
<Show
|
||||
when={runtime.enhancedStore()}
|
||||
fallback={
|
||||
<div class="min-h-screen flex items-center justify-center bg-base">
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="text-muted">Initializing...</div>
|
||||
</div>
|
||||
}
|
||||
@@ -559,7 +559,7 @@ function App() {
|
||||
{/* Main content area - shrinks when AI panel is open, scrolls independently */}
|
||||
<div
|
||||
ref={setAppScrollShellRef}
|
||||
class={`app-scroll-shell isolate flex-1 min-w-0 overflow-y-scroll bg-base text-base-content font-sans py-1 sm:py-6 transition-all duration-300`}
|
||||
class={`app-scroll-shell isolate flex-1 min-w-0 overflow-y-scroll text-base-content font-sans py-1 sm:py-6 transition-all duration-300`}
|
||||
>
|
||||
<AppLayout
|
||||
connectionStatus={runtime.connectionStatus}
|
||||
@@ -615,7 +615,7 @@ function App() {
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div class="min-h-screen bg-base text-base-content font-sans">
|
||||
<div class="min-h-screen text-base-content font-sans">
|
||||
<div class="mx-auto w-full max-w-6xl px-4 py-6 sm:px-6">{props.children}</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
@@ -694,7 +694,7 @@ export function AppLayout(props: AppLayoutProps) {
|
||||
return `${baseClasses} bg-surface text-blue-600 dark:text-blue-400 border-border border-b border-b-surface shadow-xs font-semibold`;
|
||||
}
|
||||
if (needsSetup()) {
|
||||
return `${baseClasses} text-muted opacity-70 bg-base hover:bg-surface-hover`;
|
||||
return `${baseClasses} text-muted opacity-70 hover:bg-surface-hover`;
|
||||
}
|
||||
return `${baseClasses} text-muted hover:text-base-content hover:bg-surface-hover`;
|
||||
};
|
||||
|
||||
@@ -1019,30 +1019,30 @@ describe('App architecture', () => {
|
||||
});
|
||||
});
|
||||
|
||||
function collectFrontendSources(dir: string): Array<{ path: string; source: string }> {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const entryPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return collectFrontendSources(entryPath);
|
||||
}
|
||||
// Only runtime sources matter; a test may quote an old shape in order to
|
||||
// assert against it, as the guards below do.
|
||||
if (!/\.(css|ts|tsx)$/.test(entry.name)) {
|
||||
return [];
|
||||
}
|
||||
if (entryPath.includes('__tests__') || /\.(test|spec)\./.test(entry.name)) {
|
||||
return [];
|
||||
}
|
||||
return [{ path: entryPath, source: readFileSync(entryPath, 'utf8') }];
|
||||
});
|
||||
}
|
||||
|
||||
describe('mobile bottom navigation clearance', () => {
|
||||
const NAV_HEIGHT_VARIABLE = '--pulse-mobile-nav-height';
|
||||
// Matches the shape every drifted copy used, spaced or not. The lookbehind
|
||||
// keeps the declared 2.5rem fallback from matching its own guard.
|
||||
const HARDCODED_BAR_HEIGHT = /(?<![\d.])5rem\s*\+\s*env\(\s*safe-area-inset-bottom/;
|
||||
|
||||
function collectFrontendSources(dir: string): Array<{ path: string; source: string }> {
|
||||
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
||||
const entryPath = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
return collectFrontendSources(entryPath);
|
||||
}
|
||||
// Only runtime sources matter; a test may quote the old shape in order
|
||||
// to assert against it, as this one does.
|
||||
if (!/\.(css|ts|tsx)$/.test(entry.name)) {
|
||||
return [];
|
||||
}
|
||||
if (entryPath.includes('__tests__') || /\.(test|spec)\./.test(entry.name)) {
|
||||
return [];
|
||||
}
|
||||
return [{ path: entryPath, source: readFileSync(entryPath, 'utf8') }];
|
||||
});
|
||||
}
|
||||
|
||||
it('declares the bar height as a single custom property', () => {
|
||||
expect(appStylesSource).toContain(NAV_HEIGHT_VARIABLE);
|
||||
});
|
||||
@@ -1062,6 +1062,33 @@ describe('mobile bottom navigation clearance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('page background utility', () => {
|
||||
// Tailwind 4 emits nothing for a colour it does not know, so `bg-base`
|
||||
// rendered transparent without any error: command blocks, the alert
|
||||
// selection bar and segmented-control tracks all lost their backgrounds.
|
||||
// A theme colour named `base` is not the fix either, because it also gives
|
||||
// the `text-base` font-size utility a colour and paints text in the page
|
||||
// colour. The page backdrop is the bg-page utility.
|
||||
const DEAD_BASE_BACKGROUND = /(?<![\w-])bg-base(?![\w-])/;
|
||||
// A comment may name the old class to explain a fix, as toggleModel does.
|
||||
const COMMENTS = /\/\*[\s\S]*?\*\/|(^|[^:])\/\/.*$/gm;
|
||||
|
||||
it('declares the page backdrop as a utility, never as a colour named base', () => {
|
||||
expect(appStylesSource).toMatch(
|
||||
/@utility bg-page \{\s*background-color: var\(--color-bg-base\);\s*\}/,
|
||||
);
|
||||
expect(appStylesSource).not.toMatch(/--color-base\s*:/);
|
||||
});
|
||||
|
||||
it('keeps every runtime class off the bg-base utility that renders nothing', () => {
|
||||
const offenders = collectFrontendSources(join(process.cwd(), 'src'))
|
||||
.filter(({ source }) => DEAD_BASE_BACKGROUND.test(source.replace(COMMENTS, '$1')))
|
||||
.map(({ path }) => path.slice(process.cwd().length + 1));
|
||||
|
||||
expect(offenders).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Docker phone update reflow boundary', () => {
|
||||
it('scopes label wrapping to narrow Docker cells without restoring a nested scrollport', () => {
|
||||
const phoneStyles = appStylesSource.slice(appStylesSource.indexOf('/* Phone update states'));
|
||||
|
||||
@@ -145,9 +145,9 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||
</Show>
|
||||
|
||||
<Show when={selectedIds().size > 0 && props.onBulkEdit}>
|
||||
<div class="fixed bottom-8 left-1/2 -translate-x-1/2 bg-base border border-border shadow-2xl rounded-full px-5 py-3 flex items-center gap-6 z-100 animate-in slide-in-from-bottom-5">
|
||||
<span class="text-sm font-medium text-white">
|
||||
{selectedIds().size} <span class="text-slate-400">selected</span>
|
||||
<div class="fixed bottom-8 left-1/2 -translate-x-1/2 bg-surface border border-border shadow-2xl rounded-full px-5 py-3 flex items-center gap-6 z-100 animate-in slide-in-from-bottom-5">
|
||||
<span class="text-sm font-medium text-base-content">
|
||||
{selectedIds().size} <span class="text-muted">selected</span>
|
||||
</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
@@ -163,7 +163,7 @@ export function ResourceTable(props: ResourceTableProps) {
|
||||
{getAlertBulkEditOpenLabel()}
|
||||
</button>
|
||||
<ActionIconButton
|
||||
class="rounded-full bg-surface text-slate-400 hover:bg-slate-700 hover:text-white focus-visible:ring-offset-0"
|
||||
class="rounded-full focus-visible:ring-offset-0"
|
||||
onClick={clearSelectedIds}
|
||||
label={ALERT_BULK_EDIT_CLEAR_LABEL}
|
||||
size="sm"
|
||||
|
||||
@@ -30,7 +30,7 @@ export const AppBootstrapStatus: Component<AppBootstrapStatusProps> = (props) =>
|
||||
|
||||
return (
|
||||
<main
|
||||
class="flex min-h-screen items-center justify-center bg-base px-4 py-8 text-base-content"
|
||||
class="flex min-h-screen items-center justify-center px-4 py-8 text-base-content"
|
||||
aria-labelledby="app-bootstrap-title"
|
||||
>
|
||||
<div class="w-full max-w-md rounded-md border border-border bg-surface p-6 text-center shadow-xs sm:p-8">
|
||||
|
||||
@@ -14,7 +14,7 @@ interface ErrorBoundaryProps {
|
||||
|
||||
const DefaultErrorFallback: Component<{ error: Error; reset: () => void }> = (props) => {
|
||||
return (
|
||||
<div class="min-h-screen flex items-center justify-center bg-base p-4">
|
||||
<div class="min-h-screen flex items-center justify-center p-4">
|
||||
<div class="max-w-md w-full bg-surface rounded-md shadow-xs p-6">
|
||||
<div class="flex items-center mb-4">
|
||||
<AlertTriangleIcon class="mr-3 h-12 w-12 text-red-500" aria-hidden="true" />
|
||||
|
||||
@@ -92,7 +92,7 @@ export const ResourceChangeSummary: Component<ResourceChangeSummaryProps> = (pro
|
||||
: null;
|
||||
|
||||
return (
|
||||
<li class={`rounded-md border border-border-subtle bg-base ${itemPadding()}`}>
|
||||
<li class={`rounded-md border border-border-subtle bg-page ${itemPadding()}`}>
|
||||
<div class={`flex flex-wrap items-start justify-between ${gapSize()}`}>
|
||||
<div class="min-w-0">
|
||||
<p class={headlineText()}>{headline}</p>
|
||||
|
||||
@@ -56,7 +56,7 @@ export const ResourceCorrelationSummary: Component<ResourceCorrelationSummaryPro
|
||||
<Show when={hasContent()}>
|
||||
<div
|
||||
data-testid={props.dataTestId}
|
||||
class={`rounded-md border border-border-subtle bg-base p-4 ${className()}`.trim()}
|
||||
class={`rounded-md border border-border-subtle bg-surface p-4 ${className()}`.trim()}
|
||||
>
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
|
||||
@@ -37,7 +37,7 @@ export const ResourceDetailDrawerDebugTab: Component<ResourceDetailDrawerDebugTa
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
|
||||
Unified Resource
|
||||
</div>
|
||||
<pre class="max-h-[280px] overflow-auto rounded-md bg-base p-3 text-[11px] text-base-content">
|
||||
<pre class="max-h-[280px] overflow-auto rounded-md bg-surface p-3 text-[11px] text-base-content">
|
||||
{JSON.stringify(props.resource, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -46,7 +46,7 @@ export const ResourceDetailDrawerDebugTab: Component<ResourceDetailDrawerDebugTa
|
||||
<div class="text-[11px] font-medium uppercase tracking-wide text-base-content mb-2">
|
||||
Identity Matching
|
||||
</div>
|
||||
<pre class="max-h-[220px] overflow-auto rounded-md bg-base p-3 text-[11px] text-base-content">
|
||||
<pre class="max-h-[220px] overflow-auto rounded-md bg-surface p-3 text-[11px] text-base-content">
|
||||
{JSON.stringify(
|
||||
{
|
||||
identity: props.resource.identity,
|
||||
@@ -81,7 +81,7 @@ export const ResourceDetailDrawerDebugTab: Component<ResourceDetailDrawerDebugTa
|
||||
{status?.error}
|
||||
</div>
|
||||
</Show>
|
||||
<pre class="mt-3 max-h-[220px] overflow-auto rounded-md bg-base p-3 text-[11px] text-base-content">
|
||||
<pre class="mt-3 max-h-[220px] overflow-auto rounded-md bg-surface p-3 text-[11px] text-base-content">
|
||||
{JSON.stringify(section.payload ?? {}, null, 2)}
|
||||
</pre>
|
||||
</details>
|
||||
|
||||
@@ -322,7 +322,7 @@ export const ResourceAccessDisclosure: Component<{
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => props.drawer.setShowDiscoveryContext((value) => !value)}
|
||||
class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[10px] font-medium text-base-content transition-colors hover:bg-base"
|
||||
class="inline-flex items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[10px] font-medium text-base-content transition-colors hover:bg-surface-hover"
|
||||
>
|
||||
{props.drawer.showDiscoveryContext() ? 'Hide analysis' : 'Open analysis'}
|
||||
</button>
|
||||
@@ -473,7 +473,7 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
label="Change kind"
|
||||
fieldBaseClass="space-y-1 text-[10px]"
|
||||
labelClass="text-muted"
|
||||
selectBaseClass="w-full rounded-sm border border-border bg-base px-2 py-1 text-[11px] text-base-content"
|
||||
selectBaseClass="w-full rounded-sm border border-border bg-surface px-2 py-1 text-[11px] text-base-content"
|
||||
value={drawer.timelineKindFilter()}
|
||||
onChange={(event) =>
|
||||
drawer.setTimelineKindFilter(
|
||||
@@ -489,7 +489,7 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
label="Source type"
|
||||
fieldBaseClass="space-y-1 text-[10px]"
|
||||
labelClass="text-muted"
|
||||
selectBaseClass="w-full rounded-sm border border-border bg-base px-2 py-1 text-[11px] text-base-content"
|
||||
selectBaseClass="w-full rounded-sm border border-border bg-surface px-2 py-1 text-[11px] text-base-content"
|
||||
value={drawer.timelineSourceTypeFilter()}
|
||||
onChange={(event) =>
|
||||
drawer.setTimelineSourceTypeFilter(
|
||||
@@ -505,7 +505,7 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
label="Source adapter"
|
||||
fieldBaseClass="space-y-1 text-[10px]"
|
||||
labelClass="text-muted"
|
||||
selectBaseClass="w-full rounded-sm border border-border bg-base px-2 py-1 text-[11px] text-base-content"
|
||||
selectBaseClass="w-full rounded-sm border border-border bg-surface px-2 py-1 text-[11px] text-base-content"
|
||||
value={drawer.timelineSourceAdapterFilter()}
|
||||
onChange={(event) =>
|
||||
drawer.setTimelineSourceAdapterFilter(
|
||||
@@ -618,12 +618,12 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={change.reason}>
|
||||
<div class="mt-1 rounded-sm border border-border bg-base px-2 py-1 text-[10px] text-base-content">
|
||||
<div class="mt-1 rounded-sm border border-border bg-page px-2 py-1 text-[10px] text-base-content">
|
||||
{change.reason}
|
||||
</div>
|
||||
</Show>
|
||||
<Show when={hasMetadataEntries(change.metadata)}>
|
||||
<details class="mt-1 rounded-sm border border-border bg-base px-2 py-1">
|
||||
<details class="mt-1 rounded-sm border border-border bg-page px-2 py-1">
|
||||
<summary class="cursor-pointer list-none text-[10px] font-medium text-muted">
|
||||
Metadata
|
||||
</summary>
|
||||
@@ -867,7 +867,7 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
<button
|
||||
type="button"
|
||||
onClick={drawer.toggleDockerUpdateControls}
|
||||
class="inline-flex items-center rounded-md border border-sky-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-sky-700 transition-colors hover:bg-base dark:border-sky-700 dark:text-sky-300"
|
||||
class="inline-flex items-center rounded-md border border-sky-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-sky-700 transition-colors hover:bg-surface-hover dark:border-sky-700 dark:text-sky-300"
|
||||
>
|
||||
{drawer.showDockerUpdateControls() ? 'Hide actions' : 'Show actions'}
|
||||
</button>
|
||||
@@ -1114,7 +1114,7 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => drawer.setShowPbsJobDetail((value) => !value)}
|
||||
class="inline-flex items-center rounded-md border border-indigo-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-indigo-700 transition-colors hover:bg-base dark:border-indigo-700 dark:text-indigo-300"
|
||||
class="inline-flex items-center rounded-md border border-indigo-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-indigo-700 transition-colors hover:bg-surface-hover dark:border-indigo-700 dark:text-indigo-300"
|
||||
>
|
||||
{drawer.showPbsJobDetail() ? 'Hide jobs' : 'Show jobs'}
|
||||
</button>
|
||||
@@ -1242,7 +1242,7 @@ export const ResourceDetailDrawerOverviewTab: Component<ResourceDetailDrawerOver
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => drawer.setShowPmgMailFlowDetail((value) => !value)}
|
||||
class="inline-flex items-center rounded-md border border-rose-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-rose-700 transition-colors hover:bg-base dark:border-rose-700 dark:text-rose-300"
|
||||
class="inline-flex items-center rounded-md border border-rose-200 bg-surface px-2.5 py-1 text-[10px] font-medium text-rose-700 transition-colors hover:bg-surface-hover dark:border-rose-700 dark:text-rose-300"
|
||||
>
|
||||
{drawer.showPmgMailFlowDetail() ? 'Hide mail flow' : 'Show mail flow'}
|
||||
</button>
|
||||
|
||||
+1
-1
@@ -48,7 +48,7 @@ export const ResourceDetailDrawerSupportDisclosure: Component<
|
||||
onClick={props.onToggle}
|
||||
class={
|
||||
props.buttonClass ??
|
||||
'inline-flex min-h-11 shrink-0 items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[10px] font-medium text-base-content transition-colors hover:bg-base sm:min-h-0'
|
||||
'inline-flex min-h-11 shrink-0 items-center rounded-md border border-border bg-surface px-2.5 py-1 text-[10px] font-medium text-base-content transition-colors hover:bg-surface-hover sm:min-h-0'
|
||||
}
|
||||
>
|
||||
{props.expanded ? props.hideLabel : props.showLabel}
|
||||
|
||||
@@ -24,7 +24,7 @@ export const ResourcePolicySummary: Component<ResourcePolicySummaryProps> = (pro
|
||||
return (
|
||||
<Show when={posture()}>
|
||||
{(value) => (
|
||||
<div class={`rounded-md border border-border-subtle bg-base p-4 ${className()}`.trim()}>
|
||||
<div class={`rounded-md border border-border-subtle bg-surface p-4 ${className()}`.trim()}>
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-base-content">
|
||||
|
||||
@@ -344,7 +344,7 @@ export const Login: Component<LoginProps> = (props) => {
|
||||
<Show
|
||||
when={!loadingAuth()}
|
||||
fallback={
|
||||
<div class="min-h-screen flex items-center justify-center bg-base">
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<LoadingSpinner size="lg" tone="info" label="Checking authentication" class="mb-4" />
|
||||
<p class="text-muted">Checking authentication...</p>
|
||||
@@ -379,7 +379,7 @@ export const Login: Component<LoginProps> = (props) => {
|
||||
>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div class="min-h-screen flex items-center justify-center bg-base">
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<LoadingSpinner size="lg" tone="info" label="Loading setup" class="mb-4" />
|
||||
<p class="text-muted">Loading setup...</p>
|
||||
@@ -438,7 +438,7 @@ const LoginForm: Component<{
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div class="min-h-screen flex items-center justify-center bg-base py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="min-h-screen flex items-center justify-center py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
{/* Demo Credentials Banner */}
|
||||
<Show when={demoModeEnabled}>
|
||||
|
||||
@@ -297,7 +297,7 @@ export const AIProviderConfigurationSection: Component<AIProviderConfigurationSe
|
||||
</p>
|
||||
<CopyCommandBlock
|
||||
command={`ollama pull ${suggestedModel()}`}
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-base p-3 pr-10 font-mono text-xs text-base-content"
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-page p-3 pr-10 font-mono text-xs text-base-content"
|
||||
/>
|
||||
<Show when={suggestedModelNote()}>
|
||||
<p class="text-xs text-muted">{suggestedModelNote()}</p>
|
||||
|
||||
@@ -309,7 +309,7 @@ export const AgentIntegrationsPanel: Component = () => {
|
||||
<CopyCommandBlock
|
||||
command={mcpInstallShellCommand}
|
||||
onCopy={handleCopySnippet}
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-base p-3 font-mono text-xs text-base-content"
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-page p-3 font-mono text-xs text-base-content"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@@ -319,7 +319,7 @@ export const AgentIntegrationsPanel: Component = () => {
|
||||
<CopyCommandBlock
|
||||
command={mcpInstallPowerShellCommand}
|
||||
onCopy={handleCopySnippet}
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-base p-3 font-mono text-xs text-base-content"
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-page p-3 font-mono text-xs text-base-content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -353,7 +353,7 @@ export const AgentIntegrationsPanel: Component = () => {
|
||||
<CopyCommandBlock
|
||||
command={openCodeMcpConfig()}
|
||||
onCopy={handleCopySnippet}
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-base p-3 font-mono text-xs text-base-content"
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-page p-3 font-mono text-xs text-base-content"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
@@ -371,7 +371,7 @@ export const AgentIntegrationsPanel: Component = () => {
|
||||
<CopyCommandBlock
|
||||
command={claudeMcpConfig()}
|
||||
onCopy={handleCopySnippet}
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-base p-3 font-mono text-xs text-base-content"
|
||||
codeClass="block whitespace-pre overflow-x-auto rounded-md border border-border bg-page p-3 font-mono text-xs text-base-content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -395,7 +395,7 @@ export const AgentIntegrationsPanel: Component = () => {
|
||||
<Show when={mcpSurfaceToolPosture()}>
|
||||
{(posture) => (
|
||||
<div
|
||||
class="flex w-fit flex-wrap items-center gap-1.5 rounded-md border border-border bg-base px-2 py-1 text-[11px] font-medium text-muted"
|
||||
class="flex w-fit flex-wrap items-center gap-1.5 rounded-md border border-border bg-page px-2 py-1 text-[11px] font-medium text-muted"
|
||||
title={posture().title}
|
||||
data-testid="agent-mcp-tool-posture"
|
||||
>
|
||||
@@ -411,7 +411,7 @@ export const AgentIntegrationsPanel: Component = () => {
|
||||
)}
|
||||
</Show>
|
||||
<Show when={surfaceContractEntries().length > 0}>
|
||||
<details class="rounded-md border border-border bg-base p-3">
|
||||
<details class="rounded-md border border-border bg-page p-3">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-base-content">
|
||||
Patrol access model
|
||||
</summary>
|
||||
@@ -432,7 +432,7 @@ export const AgentIntegrationsPanel: Component = () => {
|
||||
</span>
|
||||
<For each={entry.badges}>
|
||||
{(badge) => (
|
||||
<span class="rounded-full border border-border bg-base px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted">
|
||||
<span class="rounded-full border border-border bg-page px-2 py-0.5 text-[10px] uppercase tracking-wide text-muted">
|
||||
{badge}
|
||||
</span>
|
||||
)}
|
||||
@@ -449,7 +449,7 @@ export const AgentIntegrationsPanel: Component = () => {
|
||||
</details>
|
||||
</Show>
|
||||
<Show when={hasManifestInventory()}>
|
||||
<details class="rounded-md border border-border bg-base p-3">
|
||||
<details class="rounded-md border border-border bg-page p-3">
|
||||
<summary class="cursor-pointer text-sm font-semibold text-base-content">
|
||||
Live manifest details
|
||||
</summary>
|
||||
@@ -468,7 +468,7 @@ export const AgentIntegrationsPanel: Component = () => {
|
||||
</span>
|
||||
</summary>
|
||||
<div class="mt-3 space-y-2">
|
||||
<ul class="divide-y divide-border rounded-md border border-border bg-base">
|
||||
<ul class="divide-y divide-border rounded-md border border-border bg-page">
|
||||
<For each={workflowPrompts()}>
|
||||
{(prompt) => (
|
||||
<li class="space-y-1 p-3">
|
||||
@@ -548,7 +548,7 @@ export const AgentIntegrationsPanel: Component = () => {
|
||||
<For each={errorCodeSummaries()}>
|
||||
{(summary) => (
|
||||
<span
|
||||
class="rounded-md border border-border bg-base px-2 py-1"
|
||||
class="rounded-md border border-border bg-page px-2 py-1"
|
||||
title={`Declared by ${summary.capabilityNames.join(', ')}`}
|
||||
>
|
||||
<code class="font-mono">{summary.code}</code>
|
||||
@@ -591,7 +591,7 @@ export const AgentIntegrationsPanel: Component = () => {
|
||||
<p class="text-xs text-muted">{section.description}</p>
|
||||
</Show>
|
||||
</div>
|
||||
<ul class="divide-y divide-border rounded-md border border-border bg-base">
|
||||
<ul class="divide-y divide-border rounded-md border border-border bg-page">
|
||||
<For each={section.entries}>
|
||||
{(cap) => (
|
||||
<li class="space-y-1 p-3">
|
||||
|
||||
@@ -45,7 +45,7 @@ export const BackupTransferDialogs: Component<BackupTransferDialogsProps> = (pro
|
||||
|
||||
<div class="space-y-4">
|
||||
<Show when={props.securityStatus()?.hasAuthentication}>
|
||||
<div class="bg-base rounded-md p-4 border border-border">
|
||||
<div class="bg-page rounded-md p-4 border border-border">
|
||||
<div class="space-y-3">
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -152,7 +152,7 @@ export const BrandingSettingsCard: Component<BrandingSettingsCardProps> = (props
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex min-h-28 flex-col justify-between gap-4 rounded-md border border-border bg-base p-4">
|
||||
<div class="flex min-h-28 flex-col justify-between gap-4 rounded-md border border-border bg-page p-4">
|
||||
<div>
|
||||
<p class="text-xs font-medium uppercase tracking-wide text-muted">Header preview</p>
|
||||
<div class="mt-3 flex min-h-10 items-center justify-center gap-2 overflow-hidden rounded-sm bg-surface px-3 py-2">
|
||||
|
||||
@@ -11,7 +11,7 @@ interface CopyCommandBlockProps {
|
||||
|
||||
const DEFAULT_CONTAINER_CLASS = 'relative group';
|
||||
const DEFAULT_CODE_CLASS =
|
||||
'block rounded-md border border-border bg-base p-3 font-mono text-sm text-base-content';
|
||||
'block rounded-md border border-border bg-page p-3 font-mono text-sm text-base-content';
|
||||
|
||||
export function CopyCommandBlock(props: CopyCommandBlockProps) {
|
||||
const handleCopy = () => {
|
||||
|
||||
@@ -654,7 +654,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
}
|
||||
label="Copy safe-profile inspection command"
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<pre class="overflow-x-auto rounded-md bg-page p-3 pr-12 text-xs text-base-content">
|
||||
<code>
|
||||
{buildSafeCollectorInspectCommand({
|
||||
pulseUrl: operations.selectedAgentUrl(),
|
||||
@@ -682,7 +682,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
}
|
||||
label="Copy safe-profile apply command"
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<pre class="overflow-x-auto rounded-md bg-page p-3 pr-12 text-xs text-base-content">
|
||||
<code>
|
||||
{buildSafeCollectorApplyCommand({
|
||||
pulseUrl: operations.selectedAgentUrl(),
|
||||
@@ -767,7 +767,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
onClick={() => void copyCommand(tokenFileCommand())}
|
||||
label="Copy private token-file command"
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<pre class="overflow-x-auto rounded-md bg-page p-3 pr-12 text-xs text-base-content">
|
||||
<code>{tokenFileCommand()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
@@ -782,7 +782,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
title="Copy one-time action-runner credential"
|
||||
label={`Copy action-runner credential for ${target.displayName}`}
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<pre class="overflow-x-auto rounded-md bg-page p-3 pr-12 text-xs text-base-content">
|
||||
<code>{reveal().token}</code>
|
||||
</pre>
|
||||
</div>
|
||||
@@ -796,7 +796,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
onClick={() => void copyCommand(installCommand())}
|
||||
label="Copy action-runner installer command"
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<pre class="overflow-x-auto rounded-md bg-page p-3 pr-12 text-xs text-base-content">
|
||||
<code>{installCommand()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
@@ -914,7 +914,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
title="Copy host-local agent repair command"
|
||||
label={`Copy ${target.needsCredentialRepair ? 'authentication repair' : 'update'} command for ${target.displayName}`}
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<pre class="overflow-x-auto rounded-md bg-page p-3 pr-12 text-xs text-base-content">
|
||||
<code>{command()}</code>
|
||||
</pre>
|
||||
<Show
|
||||
@@ -964,7 +964,7 @@ export const InfrastructureAgentDoctorPage: Component<InfrastructureAgentDoctorP
|
||||
title="Copy host-local agent uninstall command"
|
||||
label={`Copy ${entry.label} uninstall command for ${target.displayName}`}
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<pre class="overflow-x-auto rounded-md bg-page p-3 pr-12 text-xs text-base-content">
|
||||
<code>
|
||||
{operations.getPlatformUninstallCommand(
|
||||
entry.platform,
|
||||
|
||||
@@ -715,7 +715,7 @@ export const InfrastructureInstallerSection: Component<InfrastructureInstallerSe
|
||||
title="Copy command"
|
||||
label={`Copy ${snippet.label} command`}
|
||||
/>
|
||||
<pre class="overflow-x-auto rounded-md bg-base p-3 pr-12 text-xs text-base-content">
|
||||
<pre class="overflow-x-auto rounded-md bg-page p-3 pr-12 text-xs text-base-content">
|
||||
<code>{copyCommand()}</code>
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -165,7 +165,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
|
||||
<p class="text-blue-800 dark:text-blue-200 font-medium">
|
||||
Run this command on your Proxmox VE node:
|
||||
</p>
|
||||
<div class="relative bg-base rounded-md p-3 font-mono text-xs overflow-x-auto">
|
||||
<div class="relative bg-page rounded-md p-3 font-mono text-xs overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
disabled={state.loadingAgentCommand() || setupHandoffDisabled()}
|
||||
@@ -279,7 +279,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="relative bg-base rounded-md p-3 font-mono text-xs overflow-x-auto">
|
||||
<div class="relative bg-page rounded-md p-3 font-mono text-xs overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
@@ -653,7 +653,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
|
||||
<p class="text-blue-800 dark:text-blue-200 text-xs mt-3">
|
||||
Run this command on your PBS node:
|
||||
</p>
|
||||
<div class="relative bg-base rounded-md p-3 font-mono text-xs overflow-x-auto">
|
||||
<div class="relative bg-page rounded-md p-3 font-mono text-xs overflow-x-auto">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
@@ -750,7 +750,7 @@ export const NodeModalSetupGuideSection: Component<NodeModalSetupGuideSectionPro
|
||||
</p>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="relative bg-base rounded-md p-3 font-mono text-xs overflow-x-auto">
|
||||
<div class="relative bg-page rounded-md p-3 font-mono text-xs overflow-x-auto">
|
||||
<Show when={state.formData().host && state.formData().host.trim() !== ''}>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -217,7 +217,7 @@ Important:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-base rounded-md p-3 space-y-3">
|
||||
<div class="bg-page rounded-md p-3 space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class={labelClass()}>Password Setup</span>
|
||||
<div class="flex items-center space-x-2">
|
||||
@@ -390,7 +390,7 @@ Important:
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="bg-base rounded-md p-3">
|
||||
<div class="bg-page rounded-md p-3">
|
||||
<span class={labelClass('text-xs')}>Username</span>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<code class="flex-1 font-mono text-sm bg-surface px-3 py-2 rounded-sm border border-border">
|
||||
@@ -406,7 +406,7 @@ Important:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-base rounded-md p-3">
|
||||
<div class="bg-page rounded-md p-3">
|
||||
<span class={labelClass('text-xs')}>Password</span>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<code class="flex-1 font-mono text-sm bg-surface px-3 py-2 rounded-sm border border-border break-all">
|
||||
@@ -422,7 +422,7 @@ Important:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-base rounded-md p-3">
|
||||
<div class="bg-page rounded-md p-3">
|
||||
<span class={labelClass('text-xs')}>API token</span>
|
||||
<div class="mt-1 flex items-center gap-2">
|
||||
<code class="flex-1 font-mono text-sm bg-surface px-3 py-2 rounded-sm border border-border break-all">
|
||||
|
||||
@@ -940,7 +940,7 @@ export const SSOProvidersPanel: Component<SSOProvidersPanelProps> = (props) => {
|
||||
<span>Copy</span>
|
||||
</CopyValueButton>
|
||||
</div>
|
||||
<pre class="text-xs bg-base text-base-content p-4 rounded-md overflow-x-auto whitespace-pre-wrap break-all font-mono">
|
||||
<pre class="text-xs bg-page text-base-content p-4 rounded-md overflow-x-auto whitespace-pre-wrap break-all font-mono">
|
||||
{metadataPreview()?.xml}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -525,8 +525,8 @@ export const SuggestProfileModal: Component<SuggestProfileModalProps> = (props)
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<h5 class="text-sm font-medium text-base-content">Raw JSON</h5>
|
||||
<div class="bg-base rounded-md p-3 overflow-x-auto">
|
||||
<pre class="text-xs text-slate-300 font-mono">
|
||||
<div class="bg-page rounded-md p-3 overflow-x-auto">
|
||||
<pre class="text-xs text-base-content font-mono">
|
||||
{JSON.stringify(sugg().config, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -57,7 +57,7 @@ function InstallStep(props: { step: UpdateInstallStep; index: number }) {
|
||||
containerClass="ml-0 sm:ml-8 relative group"
|
||||
codeClass={
|
||||
props.step.commandCodeClass ??
|
||||
'block rounded-md border border-border bg-base p-3 font-mono text-sm text-green-400'
|
||||
'block rounded-md border border-border bg-page p-3 font-mono text-sm text-green-700 dark:text-green-400'
|
||||
}
|
||||
/>
|
||||
</Show>
|
||||
@@ -181,7 +181,7 @@ export const UpdateInstallGuide: Component<UpdateInstallGuideProps> = (props) =>
|
||||
<div class="space-y-2">
|
||||
<CopyCommandBlock
|
||||
command={buildIdleDockerComposeCommand()}
|
||||
codeClass="block rounded-md border border-border bg-base p-2.5 font-mono text-xs text-blue-400"
|
||||
codeClass="block rounded-md border border-border bg-page p-2.5 font-mono text-xs text-blue-700 dark:text-blue-400"
|
||||
/>
|
||||
<p class="text-[10px] text-blue-600 dark:text-blue-400">
|
||||
Not using Compose?{' '}
|
||||
|
||||
+2
-2
@@ -54,9 +54,9 @@ const makeDockerUpdate = (overrides: Partial<DockerUpdateCommands> = {}): Docker
|
||||
});
|
||||
|
||||
const PRO_STEP_CODE_CLASS =
|
||||
'block rounded-md border border-border bg-base p-3 font-mono text-sm text-green-400 whitespace-pre-wrap break-all';
|
||||
'block rounded-md border border-border bg-page p-3 font-mono text-sm text-green-700 dark:text-green-400 whitespace-pre-wrap break-all';
|
||||
const SYSTEMD_DOWNLOAD_CODE_CLASS =
|
||||
'block rounded-md border border-border bg-base p-3 font-mono text-sm text-base-content whitespace-pre-wrap break-all';
|
||||
'block rounded-md border border-border bg-page p-3 font-mono text-sm text-base-content whitespace-pre-wrap break-all';
|
||||
|
||||
// ---- buildIdleDockerComposeCommand -----------------------------------------
|
||||
// Single return path; assert the exact constant.
|
||||
|
||||
+1
-1
@@ -52,7 +52,7 @@ const makeDockerUpdate = (overrides: Partial<DockerUpdateCommands> = {}): Docker
|
||||
});
|
||||
|
||||
const PRO_STEP_CODE_CLASS =
|
||||
'block rounded-md border border-border bg-base p-3 font-mono text-sm text-green-400 whitespace-pre-wrap break-all';
|
||||
'block rounded-md border border-border bg-page p-3 font-mono text-sm text-green-700 dark:text-green-400 whitespace-pre-wrap break-all';
|
||||
|
||||
// ---- buildUpdateInstallGuide (complementary branch-coverage) ----------------
|
||||
|
||||
|
||||
@@ -70,14 +70,14 @@ function buildProDockerUpdateSteps(dockerUpdate: DockerUpdateCommands): UpdateIn
|
||||
title: 'Pull the new Pulse Pro image (pinned to this release’s digest)',
|
||||
command: dockerUpdate.composePullCommand,
|
||||
commandCodeClass:
|
||||
'block rounded-md border border-border bg-base p-3 font-mono text-sm text-green-400 whitespace-pre-wrap break-all',
|
||||
'block rounded-md border border-border bg-page p-3 font-mono text-sm text-green-700 dark:text-green-400 whitespace-pre-wrap break-all',
|
||||
},
|
||||
{
|
||||
id: 'docker-pro-up',
|
||||
title: 'Recreate the container on the new image',
|
||||
command: dockerUpdate.composeUpCommand,
|
||||
commandCodeClass:
|
||||
'block rounded-md border border-border bg-base p-3 font-mono text-sm text-green-400 whitespace-pre-wrap break-all',
|
||||
'block rounded-md border border-border bg-page p-3 font-mono text-sm text-green-700 dark:text-green-400 whitespace-pre-wrap break-all',
|
||||
},
|
||||
];
|
||||
if (dockerUpdate.loginCommand) {
|
||||
@@ -192,7 +192,7 @@ export function buildUpdateInstallGuide(
|
||||
title: 'Download and extract the new version',
|
||||
command: systemdDownloadCommand,
|
||||
commandCodeClass:
|
||||
'block rounded-md border border-border bg-base p-3 font-mono text-sm text-base-content whitespace-pre-wrap break-all',
|
||||
'block rounded-md border border-border bg-page p-3 font-mono text-sm text-base-content whitespace-pre-wrap break-all',
|
||||
},
|
||||
{ id: 'systemd-start', title: 'Start the service', command: 'sudo systemctl start pulse' },
|
||||
],
|
||||
|
||||
@@ -67,11 +67,7 @@ export const SetupWizard: Component<SetupWizardProps> = (props) => {
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
class="min-h-screen bg-base flex flex-col"
|
||||
role="main"
|
||||
aria-label={t('setup.wizard.ariaLabel')}
|
||||
>
|
||||
<div class="min-h-screen flex flex-col" role="main" aria-label={t('setup.wizard.ariaLabel')}>
|
||||
{/* Background decoration */}
|
||||
<div class="fixed inset-0 overflow-hidden pointer-events-none" aria-hidden="true"></div>
|
||||
|
||||
|
||||
@@ -229,7 +229,7 @@ export const SecurityStep: Component<SecurityStepProps> = (props) => {
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="bg-base rounded-md p-4 border border-border text-left">
|
||||
<div class="bg-page rounded-md p-4 border border-border text-left">
|
||||
<Toggle
|
||||
checked={shareUsageStatistics()}
|
||||
onChange={() => setShareUsageStatistics(!shareUsageStatistics())}
|
||||
@@ -238,7 +238,7 @@ export const SecurityStep: Component<SecurityStepProps> = (props) => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="bg-base rounded-md p-4 border border-border text-left">
|
||||
<div class="bg-page rounded-md p-4 border border-border text-left">
|
||||
<div class="text-[11px] font-semibold uppercase tracking-wide text-muted mb-2">
|
||||
{t('setup.security.nextScreen.title')}
|
||||
</div>
|
||||
@@ -250,7 +250,7 @@ export const SecurityStep: Component<SecurityStepProps> = (props) => {
|
||||
</div>
|
||||
</div>
|
||||
{/* Actions */}
|
||||
<div class="p-8 bg-base flex gap-4 border-t border-border relative z-10">
|
||||
<div class="p-8 bg-page flex gap-4 border-t border-border relative z-10">
|
||||
<button
|
||||
onClick={props.onBack}
|
||||
class="px-6 py-3.5 bg-surface border border-border hover:bg-surface-hover text-base-content font-medium rounded-md transition-colors"
|
||||
|
||||
@@ -176,20 +176,20 @@ export const WelcomeStep: Component<WelcomeStepProps> = (props) => {
|
||||
<div class="mb-5 space-y-2">
|
||||
<For each={bootstrapCommands()}>
|
||||
{(item) => (
|
||||
<div class="bg-base rounded-md border border-border-subtle px-3 py-2.5">
|
||||
<div class="bg-page rounded-md border border-border-subtle px-3 py-2.5">
|
||||
<div class="mb-1.5 text-[11px] font-semibold text-muted">{item.label}</div>
|
||||
<div class="flex items-center justify-between gap-3 font-mono text-sm text-emerald-400">
|
||||
<div class="flex items-center justify-between gap-3 font-mono text-sm text-emerald-700 dark:text-emerald-400">
|
||||
<div class="flex min-w-0 items-center space-x-3 overflow-x-auto scrollbar-hide">
|
||||
<Terminal class="h-4 w-4 shrink-0" />
|
||||
<code class="whitespace-nowrap select-all">{item.command}</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => void copyCommand(item.id, item.command)}
|
||||
class="shrink-0 rounded-md bg-surface p-2 text-slate-300 transition-colors hover:bg-slate-700 hover:text-white focus:outline-hidden focus:ring-0"
|
||||
class="shrink-0 rounded-md bg-surface p-2 text-muted transition-colors hover:bg-surface-hover hover:text-base-content focus:outline-hidden focus:ring-0"
|
||||
title={`${t('setup.welcome.copyCommandTitle')}: ${item.label}`}
|
||||
>
|
||||
<Show when={copiedCommand() === item.id} fallback={<Copy class="h-4 w-4" />}>
|
||||
<Check class="h-4 w-4 text-emerald-400" />
|
||||
<Check class="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
||||
</Show>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -200,7 +200,7 @@ export const RunToolCallTrace: Component<RunToolCallTraceProps> = (props) => {
|
||||
<Show when={call.input}>
|
||||
<div>
|
||||
<div class="text-[10px] font-medium text-muted mb-1">Input</div>
|
||||
<pre class="text-[11px] font-mono bg-base rounded-sm p-2 max-h-32 overflow-auto whitespace-pre-wrap text-base-content">
|
||||
<pre class="text-[11px] font-mono bg-page rounded-sm p-2 max-h-32 overflow-auto whitespace-pre-wrap text-base-content">
|
||||
{truncate(call.input, 500)}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -208,7 +208,7 @@ export const RunToolCallTrace: Component<RunToolCallTraceProps> = (props) => {
|
||||
<Show when={call.output}>
|
||||
<div>
|
||||
<div class="text-[10px] font-medium text-muted mb-1">Output</div>
|
||||
<pre class="text-[11px] font-mono bg-base rounded-sm p-2 max-h-32 overflow-auto whitespace-pre-wrap text-base-content">
|
||||
<pre class="text-[11px] font-mono bg-page rounded-sm p-2 max-h-32 overflow-auto whitespace-pre-wrap text-base-content">
|
||||
{truncate(call.output, 500)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { JSX, splitProps, mergeProps } from 'solid-js';
|
||||
|
||||
type Tone = 'default' | 'muted' | 'info' | 'success' | 'warning' | 'danger' | 'card' | 'glass';
|
||||
type Tone = 'default' | 'muted' | 'info' | 'success' | 'warning' | 'danger' | 'card';
|
||||
type Padding = 'none' | 'sm' | 'md' | 'lg';
|
||||
|
||||
export type CardProps = {
|
||||
@@ -12,13 +12,12 @@ export type CardProps = {
|
||||
|
||||
const toneClassMap: Record<Tone, string> = {
|
||||
default: 'bg-surface',
|
||||
muted: 'bg-base',
|
||||
muted: 'bg-surface-alt',
|
||||
info: 'bg-blue-50 dark:bg-blue-900/25',
|
||||
success: 'bg-emerald-50 dark:bg-emerald-900/25',
|
||||
warning: 'bg-amber-50 dark:bg-amber-900/25',
|
||||
danger: 'bg-red-50 dark:bg-red-900/25',
|
||||
card: 'bg-surface',
|
||||
glass: 'bg-base',
|
||||
};
|
||||
|
||||
const paddingClassMap: Record<Padding, string> = {
|
||||
|
||||
@@ -39,7 +39,6 @@ export function CommandPaletteModal(props: CommandPaletteModalProps) {
|
||||
onKeyDown={commandPalette.handleInputKeyDown}
|
||||
placeholder="Type a command or search..."
|
||||
class="w-full"
|
||||
inputClass="bg-base"
|
||||
clearOnFocusedEscape={false}
|
||||
shortcutHint="Cmd+K"
|
||||
role="combobox"
|
||||
@@ -84,7 +83,7 @@ export function CommandPaletteModal(props: CommandPaletteModalProps) {
|
||||
</Show>
|
||||
</div>
|
||||
<Show when={command.shortcut}>
|
||||
<span class="rounded-sm border border-border-subtle bg-base px-2 py-1 text-[10px] font-medium text-base-content">
|
||||
<span class="rounded-sm border border-border-subtle bg-page px-2 py-1 text-[10px] font-medium text-base-content">
|
||||
{command.shortcut}
|
||||
</span>
|
||||
</Show>
|
||||
|
||||
@@ -34,7 +34,7 @@ export function EmptyState(props: EmptyStateProps) {
|
||||
const containerClass = [
|
||||
'flex w-full animate-fade-in flex-col',
|
||||
variant === 'framed'
|
||||
? 'rounded-md border border-dashed border-border bg-base px-6 py-10 sm:px-8 sm:py-16'
|
||||
? 'rounded-md border border-dashed border-border px-6 py-10 sm:px-8 sm:py-16'
|
||||
: 'px-4 py-8',
|
||||
alignment === 'center' ? 'items-center text-center' : 'items-start text-left',
|
||||
local.class ?? '',
|
||||
|
||||
@@ -39,7 +39,7 @@ const groupClassByVariant: Record<FilterButtonGroupVariant, string> = {
|
||||
'inline-flex items-center gap-1 bg-surface-hover rounded-md p-0.5 ring-1 ring-border-subtle overflow-x-auto scrollbar-hide',
|
||||
prominent: 'grid grid-cols-1 gap-2',
|
||||
segmented:
|
||||
'flex items-center gap-1 rounded-md border border-border bg-base p-1 shadow-inner overflow-x-auto scrollbar-hide',
|
||||
'flex items-center gap-1 rounded-md border border-border bg-page p-1 shadow-inner overflow-x-auto scrollbar-hide',
|
||||
};
|
||||
|
||||
const labelClassByVariant: Partial<Record<FilterButtonGroupVariant, string>> = {
|
||||
|
||||
@@ -15,7 +15,10 @@ interface AlertDeadManDestinationSectionProps {
|
||||
const REDACTED_PING_URL = '***REDACTED***';
|
||||
|
||||
const statusPresentation: Record<DeadManStatus['state'], { label: string; class: string }> = {
|
||||
disabled: { label: 'Not configured', class: 'bg-base text-muted' },
|
||||
disabled: {
|
||||
label: 'Not configured',
|
||||
class: 'bg-surface text-muted ring-1 ring-inset ring-border',
|
||||
},
|
||||
starting: {
|
||||
label: 'Starting',
|
||||
class: 'bg-blue-100 text-blue-800 dark:bg-blue-900/25 dark:text-blue-100',
|
||||
@@ -153,7 +156,7 @@ export function AlertDeadManDestinationSection(props: AlertDeadManDestinationSec
|
||||
|
||||
<Show when={status()}>
|
||||
{(current) => (
|
||||
<div class="grid gap-3 rounded-md border border-border bg-base p-3 text-xs sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="grid gap-3 rounded-md border border-border bg-page p-3 text-xs sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div>
|
||||
<div class="font-medium text-muted">Last success</div>
|
||||
<div class="mt-1 text-base-content">
|
||||
|
||||
@@ -119,6 +119,15 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* The page backdrop as a background, for wells inside a surface card (code
|
||||
blocks, previews, segmented-control tracks) that should read as the page
|
||||
behind the card. It is a utility rather than a theme colour on purpose: a
|
||||
colour named `base` also gave `text-base` a colour and painted text in the
|
||||
page colour, and without that colour the old class rendered nothing. */
|
||||
@utility bg-page {
|
||||
background-color: var(--color-bg-base);
|
||||
}
|
||||
|
||||
@utility animate-slideDown {
|
||||
animation: slideDown 0.3s ease-out;
|
||||
}
|
||||
|
||||
@@ -347,6 +347,88 @@ func (r *Router) admitAgentExecToken(token string, agentID string, hostname stri
|
||||
return agentexec.AgentAdmission{}, false
|
||||
}
|
||||
|
||||
// recordReportedAgentIdentity is the reporting-side identity binding. After an
|
||||
// authenticated report is applied, a token bound to the report's hostname but
|
||||
// to no agent ID records the server-resolved host ID when the agent presented
|
||||
// that same ID (agentbinding.EvaluateReportedIdentity). Without it, a token
|
||||
// that lacks agent:exec, or whose agent never connects the command channel,
|
||||
// never names its agent, and the collector-authority reduction, which needs
|
||||
// that name, can never run for it.
|
||||
//
|
||||
// Only bound_agent_id, bound_at and the binding version are written, the
|
||||
// binding version so the recorded ID is immutable to every later
|
||||
// registration. Scopes, runtime role and command policy are left alone: a
|
||||
// recorded identity grants no execution authority, and a command registration
|
||||
// still needs agent:exec and the same identity. It runs under the token lock
|
||||
// that command admission holds, against the live record, so the two paths
|
||||
// cannot race each other into different identities. A failed save restores
|
||||
// the previous metadata and the next report retries.
|
||||
func (r *Router) recordReportedAgentIdentity(organizationID, tokenID, presentedID, hostID, hostname string) {
|
||||
if r == nil || r.config == nil {
|
||||
return
|
||||
}
|
||||
organizationID = strings.TrimSpace(organizationID)
|
||||
if organizationID == "" {
|
||||
organizationID = "default"
|
||||
}
|
||||
tokenID = strings.TrimSpace(tokenID)
|
||||
hostID = strings.TrimSpace(hostID)
|
||||
if tokenID == "" || hostID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
config.Mu.Lock()
|
||||
defer config.Mu.Unlock()
|
||||
for index := range r.config.APITokens {
|
||||
record := &r.config.APITokens[index]
|
||||
if record.ID != tokenID {
|
||||
continue
|
||||
}
|
||||
if record.IsExpired() {
|
||||
return
|
||||
}
|
||||
// The host ID was resolved in the request's organization. Record it only
|
||||
// on a token whose command sessions would run in that same organization,
|
||||
// derived exactly as command admission derives it.
|
||||
tokenOrganizationID := "default"
|
||||
if orgs := record.GetBoundOrgs(); len(orgs) == 1 && strings.TrimSpace(orgs[0]) != "" {
|
||||
tokenOrganizationID = strings.TrimSpace(orgs[0])
|
||||
}
|
||||
if tokenOrganizationID != organizationID {
|
||||
return
|
||||
}
|
||||
if !agentbinding.EvaluateReportedIdentity(record, presentedID, hostID, hostname) {
|
||||
return
|
||||
}
|
||||
previousMetadata := snapshotAgentExecMetadata(
|
||||
record.Metadata,
|
||||
"bound_agent_id",
|
||||
"bound_at",
|
||||
agentExecBindingVersionKey,
|
||||
)
|
||||
record.Metadata["bound_agent_id"] = hostID
|
||||
record.Metadata["bound_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||
record.Metadata[agentExecBindingVersionKey] = agentExecBindingVersion
|
||||
if r.persistence != nil {
|
||||
if err := r.persistence.SaveAPITokens(r.config.APITokens); err != nil {
|
||||
restoreAgentExecMetadata(record.Metadata, previousMetadata)
|
||||
log.Warn().
|
||||
Err(err).
|
||||
Str("token_id", tokenID).
|
||||
Str("agent_id", hostID).
|
||||
Msg("Failed to persist reported agent identity on its token; the next report retries")
|
||||
return
|
||||
}
|
||||
}
|
||||
log.Info().
|
||||
Str("token_id", tokenID).
|
||||
Str("agent_id", hostID).
|
||||
Str("hostname", strings.TrimSpace(record.Metadata["bound_hostname"])).
|
||||
Msg("Bound hostname-bound agent token to the agent identity its reports resolve to")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Router) validateAgentExecSession(admission agentexec.AgentAdmission) bool {
|
||||
if r == nil || r.config == nil {
|
||||
return false
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
@@ -44,6 +45,10 @@ type UnifiedAgentHandlers struct {
|
||||
// check. Empty (the default) omits it from acks.
|
||||
serverVersion string
|
||||
|
||||
// recordReportedIdentity records a report's server-resolved identity on
|
||||
// its hostname-bound token. Nil (the default) records nothing.
|
||||
recordReportedIdentity func(organizationID, tokenID, presentedID, hostID, hostname string)
|
||||
|
||||
configFetchAudits *agentConfigFetchAuditTracker
|
||||
}
|
||||
|
||||
@@ -54,6 +59,12 @@ func (h *UnifiedAgentHandlers) SetServerVersion(version string) {
|
||||
h.serverVersion = strings.TrimSpace(version)
|
||||
}
|
||||
|
||||
// SetReportedIdentityRecorder supplies the canonical token-store binding that
|
||||
// records the agent identity a report resolves to. Call once at wiring time.
|
||||
func (h *UnifiedAgentHandlers) SetReportedIdentityRecorder(record func(organizationID, tokenID, presentedID, hostID, hostname string)) {
|
||||
h.recordReportedIdentity = record
|
||||
}
|
||||
|
||||
func trimUnifiedAgentRoutePath(path string) string {
|
||||
for _, prefix := range []string{agentRoutePrefix, legacyHostRoutePrefix} {
|
||||
if strings.HasPrefix(path, prefix) {
|
||||
@@ -96,6 +107,23 @@ func (h *UnifiedAgentHandlers) HandleReport(w http.ResponseWriter, r *http.Reque
|
||||
return
|
||||
}
|
||||
|
||||
// Name the agent on a token bound only to its hostname, so the token
|
||||
// carries an identity even if this agent never registers a command channel.
|
||||
// Only a host the monitor resolved and holds under this token carries its
|
||||
// token ID; mock mode's discard acknowledgement echoes the presented ID
|
||||
// unresolved and carries none, whenever mock mode flips. The hostname is
|
||||
// the report's own, which is what the agent's command registration
|
||||
// presents: a report the ordering watermark rejects returns the stored
|
||||
// host, whose hostname may come from a different report. The request's
|
||||
// token snapshot screens out tokens that are already bound or do not
|
||||
// qualify without taking the exclusive token lock on every report; the
|
||||
// recorder decides again on the live record.
|
||||
reportedHostname := strings.TrimSpace(report.Host.Hostname)
|
||||
if h.recordReportedIdentity != nil && tokenRecord != nil && host.TokenID == tokenRecord.ID &&
|
||||
agentbinding.EvaluateReportedIdentity(tokenRecord, report.Agent.ID, host.ID, reportedHostname) {
|
||||
h.recordReportedIdentity(GetOrgID(r.Context()), tokenRecord.ID, report.Agent.ID, host.ID, reportedHostname)
|
||||
}
|
||||
|
||||
// A freshly generated install token is the server's durable record of the
|
||||
// operator's command-policy choice. Project that choice onto the stable host
|
||||
// identity before returning remote config, otherwise an old false override
|
||||
|
||||
@@ -108,6 +108,53 @@ func Evaluate(record *config.APITokenRecord, requestedID, requestedHost string)
|
||||
}
|
||||
}
|
||||
|
||||
// EvaluateReportedIdentity reports whether an authenticated agent report may
|
||||
// record the reporting agent's identity on its token. It covers only a token
|
||||
// bound to a hostname but not yet to an agent ID, which is how deploy
|
||||
// enrollment, Proxmox auto-registration and pre-v6.1.1 installs leave it until
|
||||
// a command registration, so a token without agent:exec, or whose agent never
|
||||
// connects the command channel, would otherwise never name its agent.
|
||||
//
|
||||
// The identity the agent presented in the report must equal the identity the
|
||||
// server resolved for it. In the same run that is the ID the agent's command
|
||||
// registration presents, so recording it binds what the command channel would
|
||||
// bind, and a disagreement (a configured agent ID, a continuity fork) records
|
||||
// nothing. The token must also take a first bind, legacy migration, or
|
||||
// backfill under Evaluate, the command channel's own decision, so a token
|
||||
// command registration would refuse records nothing either. As with a command
|
||||
// registration, the first identity recorded is final: an agent later restarted
|
||||
// under a different configured ID, or replaying a report buffered by an
|
||||
// earlier run, meets a bound token. Bound IDs never move, deploy placeholders
|
||||
// are never repaired, hostnames never change, and only credentials that report
|
||||
// under a single organization qualify.
|
||||
func EvaluateReportedIdentity(record *config.APITokenRecord, presentedID, resolvedID, reportedHost string) bool {
|
||||
if record == nil {
|
||||
return false
|
||||
}
|
||||
presentedID = strings.TrimSpace(presentedID)
|
||||
resolvedID = strings.TrimSpace(resolvedID)
|
||||
if resolvedID == "" || presentedID != resolvedID {
|
||||
return false
|
||||
}
|
||||
if strings.TrimSpace(record.Metadata["bound_agent_id"]) != "" ||
|
||||
strings.TrimSpace(record.Metadata["bound_hostname"]) == "" {
|
||||
return false
|
||||
}
|
||||
if len(record.GetBoundOrgs()) > 1 {
|
||||
return false
|
||||
}
|
||||
switch strings.TrimSpace(record.Metadata[agenttokens.RuntimeRoleMetadataKey]) {
|
||||
case "", agenttokens.CredentialKindLegacyFullTrust, agenttokens.CredentialKindMonitoringCollector:
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// With no bound ID and a bound hostname, Evaluate admits only through a
|
||||
// first bind, a legacy migration or an ID backfill, never a rebind or a
|
||||
// placeholder repair; the explicit check keeps that true if Evaluate grows.
|
||||
decision := Evaluate(record, resolvedID, reportedHost)
|
||||
return decision.Admit && (decision.FirstBind || decision.LegacyMigrate || decision.BackfillID)
|
||||
}
|
||||
|
||||
// historicalDeployPlaceholder reports a runtime token that deploy enrollment
|
||||
// bound to the agent-<hostname> identity it used to invent. No agent keeps
|
||||
// that identity past its first acknowledged report, so such a token may move
|
||||
|
||||
@@ -160,3 +160,97 @@ func TestEvaluateCurrentDeployTokenBackfillsTheAgentIdentity(t *testing.T) {
|
||||
t.Fatalf("current deploy token admitted another host: %+v", decision)
|
||||
}
|
||||
}
|
||||
|
||||
// A report may name the agent on a token bound only to its hostname, but only
|
||||
// with the identity the command channel would bind for that same agent, and
|
||||
// never by moving, repairing, or rehosting a binding.
|
||||
func TestEvaluateReportedIdentityRecordsOnlyWhatCommandRegistrationWouldBind(t *testing.T) {
|
||||
deployToken := func() *config.APITokenRecord {
|
||||
return &config.APITokenRecord{OrgID: "default", Metadata: map[string]string{
|
||||
"bound_hostname": "delly2",
|
||||
"deploy_job_id": "dep_1",
|
||||
VersionKey: Version,
|
||||
DeployIdentityKey: DeployIdentityAgent,
|
||||
}}
|
||||
}
|
||||
autoRegistered := func() *config.APITokenRecord {
|
||||
return &config.APITokenRecord{OrgID: "default", Metadata: map[string]string{
|
||||
"install_type": "pve",
|
||||
"issued_via": IssuedViaConfig,
|
||||
"bound_hostname": "pve1",
|
||||
agenttokens.RuntimeRoleMetadataKey: agenttokens.CredentialKindMonitoringCollector,
|
||||
}}
|
||||
}
|
||||
legacyHostnameOnly := func() *config.APITokenRecord {
|
||||
return &config.APITokenRecord{Metadata: map[string]string{"bound_hostname": "node.example"}}
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
record *config.APITokenRecord
|
||||
host string
|
||||
}{
|
||||
{name: "deploy runtime token", record: deployToken(), host: "delly2"},
|
||||
{name: "deploy runtime token on an equivalent hostname", record: deployToken(), host: "delly2.lan"},
|
||||
{name: "Proxmox auto-registered collector", record: autoRegistered(), host: "pve1"},
|
||||
{name: "pre-v6.1.1 hostname-only token", record: legacyHostnameOnly(), host: "NODE.example"},
|
||||
} {
|
||||
if !EvaluateReportedIdentity(test.record, "machine-id", "machine-id", test.host) {
|
||||
t.Fatalf("%s: report identity not recorded", test.name)
|
||||
}
|
||||
if EvaluateReportedIdentity(test.record, "configured-id", "machine-id", test.host) {
|
||||
t.Fatalf("%s: recorded the resolved identity although the agent presented another", test.name)
|
||||
}
|
||||
if EvaluateReportedIdentity(test.record, "", "", test.host) {
|
||||
t.Fatalf("%s: recorded an empty identity", test.name)
|
||||
}
|
||||
}
|
||||
|
||||
with := func(record *config.APITokenRecord, mutate func(*config.APITokenRecord)) *config.APITokenRecord {
|
||||
mutate(record)
|
||||
return record
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
record *config.APITokenRecord
|
||||
host string
|
||||
}{
|
||||
{name: "another hostname", record: deployToken(), host: "other-node"},
|
||||
{name: "an already bound ID", host: "delly2", record: with(deployToken(), func(r *config.APITokenRecord) {
|
||||
r.Metadata["bound_agent_id"] = "other-machine"
|
||||
})},
|
||||
{name: "an ID already recorded", host: "delly2", record: with(deployToken(), func(r *config.APITokenRecord) {
|
||||
r.Metadata["bound_agent_id"] = "machine-id"
|
||||
})},
|
||||
// Evaluate would legacy-migrate this one on command registration; the
|
||||
// report path never moves an ID.
|
||||
{name: "a versionless token already bound to an ID", host: "node.example", record: with(legacyHostnameOnly(), func(r *config.APITokenRecord) {
|
||||
r.Metadata["bound_agent_id"] = "other-machine"
|
||||
})},
|
||||
{name: "a historical deploy placeholder", host: "delly2", record: with(deployToken(), func(r *config.APITokenRecord) {
|
||||
r.Metadata["bound_agent_id"] = "agent-delly2"
|
||||
delete(r.Metadata, DeployIdentityKey)
|
||||
})},
|
||||
{name: "a token bound to no hostname", host: "node.example", record: &config.APITokenRecord{OrgID: "default", Metadata: map[string]string{
|
||||
"install_type": "host",
|
||||
"issued_via": IssuedViaConfig,
|
||||
}}},
|
||||
{name: "a token bound to several organizations", host: "delly2", record: with(deployToken(), func(r *config.APITokenRecord) {
|
||||
r.OrgID = ""
|
||||
r.OrgIDs = []string{"org-a", "org-b"}
|
||||
})},
|
||||
{name: "an action-runner credential", host: "delly2", record: with(deployToken(), func(r *config.APITokenRecord) {
|
||||
r.Metadata[agenttokens.RuntimeRoleMetadataKey] = agenttokens.CredentialKindActionRunner
|
||||
})},
|
||||
{name: "an unsupported runtime role", host: "delly2", record: with(deployToken(), func(r *config.APITokenRecord) {
|
||||
r.Metadata[agenttokens.RuntimeRoleMetadataKey] = "future-role"
|
||||
})},
|
||||
} {
|
||||
if EvaluateReportedIdentity(test.record, "machine-id", "machine-id", test.host) {
|
||||
t.Fatalf("%s: report identity recorded", test.name)
|
||||
}
|
||||
}
|
||||
if EvaluateReportedIdentity(nil, "machine-id", "machine-id", "delly2") {
|
||||
t.Fatal("nil token recorded an identity")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
@@ -37,6 +38,7 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/providers"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/ai/unified"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/alerts"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/alerting"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/chartapi"
|
||||
@@ -25466,3 +25468,80 @@ func TestRouterPlatformConnectionAlertsFollowRuntime(t *testing.T) {
|
||||
}
|
||||
t.Log("real TrueNAS inventory recovery reaches the ledger and clears only after monitor confirmations")
|
||||
}
|
||||
|
||||
func TestContractDeployEnrolledCollectorReducesItsAuthorityAfterItsFirstReport(t *testing.T) {
|
||||
h, store := newEnrollTestHandlers(t)
|
||||
jobID, targetID := seedEnrollJobAndTarget(t, store, deploy.TargetEnrolling)
|
||||
bootstrap := mintTestBootstrapToken(t, h.config, jobID, targetID, "pve-node2")
|
||||
h.persistence = config.NewConfigPersistence(h.config.DataPath)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/agents/agent/enroll", enrollJSON(t, "pve-node2"))
|
||||
attachAPITokenRecord(req, bootstrap)
|
||||
rr := httptest.NewRecorder()
|
||||
h.HandleEnroll(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("enroll status = %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var enrolledResp map[string]any
|
||||
if err := json.Unmarshal(rr.Body.Bytes(), &enrolledResp); err != nil {
|
||||
t.Fatalf("decode enroll: %v", err)
|
||||
}
|
||||
runtimeToken, _ := enrolledResp["runtimeToken"].(string)
|
||||
runtimeTokenID, _ := enrolledResp["runtimeTokenId"].(string)
|
||||
if runtimeToken == "" || runtimeTokenID == "" {
|
||||
t.Fatalf("enrollment returned no runtime token: %v", enrolledResp)
|
||||
}
|
||||
enrolled := reportIdentityToken(t, h.config, runtimeTokenID)
|
||||
if enrolled.HasScope(config.ScopeAgentExec) || enrolled.Metadata["bound_agent_id"] != "" {
|
||||
t.Fatalf("precondition: enrolled token = %+v, want hostname-bound without agent:exec", enrolled)
|
||||
}
|
||||
|
||||
router := newReportIdentityRouter(t, h.config)
|
||||
if code := postCollectorReduction(t, router, runtimeToken, reportIdentityMachineID, "pve-node2"); code != http.StatusForbidden {
|
||||
t.Fatalf("reduction before any report = %d, want 403: the token names no agent yet", code)
|
||||
}
|
||||
|
||||
ack := postReportIdentityReport(t, router, runtimeToken,
|
||||
reportIdentityReport(reportIdentityMachineID, reportIdentityMachineID, "pve-node2"))
|
||||
if ack != reportIdentityMachineID {
|
||||
t.Fatalf("acknowledged agentId = %q, want %q", ack, reportIdentityMachineID)
|
||||
}
|
||||
|
||||
bound := reportIdentityToken(t, h.config, runtimeTokenID)
|
||||
if got := bound.Metadata["bound_agent_id"]; got != ack {
|
||||
t.Fatalf("bound_agent_id after the first report = %q, want the acknowledged %q", got, ack)
|
||||
}
|
||||
if got := bound.Metadata["bound_hostname"]; got != "pve-node2" {
|
||||
t.Fatalf("bound_hostname = %q, want it untouched", got)
|
||||
}
|
||||
if got := bound.Metadata[agentExecBindingVersionKey]; got != agentExecBindingVersion {
|
||||
t.Fatalf("binding version = %q, want %q", got, agentExecBindingVersion)
|
||||
}
|
||||
if got := bound.Metadata[agentbinding.DeployIdentityKey]; got != agentbinding.DeployIdentityAgent {
|
||||
t.Fatalf("deploy identity marker = %q, want it untouched", got)
|
||||
}
|
||||
if !slices.Equal(bound.Scopes, enrolled.Scopes) {
|
||||
t.Fatalf("scopes after the report = %v, want unchanged %v", bound.Scopes, enrolled.Scopes)
|
||||
}
|
||||
if got := bound.Metadata[agenttokens.RuntimeRoleMetadataKey]; got != enrolled.Metadata[agenttokens.RuntimeRoleMetadataKey] {
|
||||
t.Fatalf("runtime role after the report = %q, want unchanged %q", got, enrolled.Metadata[agenttokens.RuntimeRoleMetadataKey])
|
||||
}
|
||||
persisted, err := config.NewConfigPersistence(h.config.DataPath).LoadAPITokens()
|
||||
if err != nil {
|
||||
t.Fatalf("load persisted tokens: %v", err)
|
||||
}
|
||||
if len(persisted) != 1 || persisted[0].Metadata["bound_agent_id"] != ack {
|
||||
t.Fatalf("persisted tokens = %+v, want the runtime token bound to %s", persisted, ack)
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(runtimeToken, ack, "pve-node2"); ok {
|
||||
t.Fatal("recording the agent identity granted command execution to a token without agent:exec")
|
||||
}
|
||||
|
||||
if code := postCollectorReduction(t, router, runtimeToken, ack, "pve-node2"); code != http.StatusNoContent {
|
||||
t.Fatalf("reduction with the acknowledged identity = %d, want 204", code)
|
||||
}
|
||||
reduced := reportIdentityToken(t, h.config, runtimeTokenID)
|
||||
if got := reduced.Metadata[agenttokens.RuntimeRoleMetadataKey]; got != agenttokens.CredentialKindMonitoringCollector {
|
||||
t.Fatalf("runtime role after reduction = %q, want %q", got, agenttokens.CredentialKindMonitoringCollector)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -503,6 +503,7 @@ func (r *Router) setupRoutes() {
|
||||
r.kubernetesAgentHandlers = NewKubernetesAgentHandlers(r.mtMonitor, r.monitor, r.wsHub)
|
||||
r.unifiedAgentHandlers = NewUnifiedAgentHandlers(r.mtMonitor, r.monitor, r.wsHub)
|
||||
r.unifiedAgentHandlers.SetServerVersion(r.serverVersion)
|
||||
r.unifiedAgentHandlers.SetReportedIdentityRecorder(r.recordReportedAgentIdentity)
|
||||
r.kubernetesAgentHandlers.SetRecoveryIngestor(r.recoveryHandlers)
|
||||
r.resourceHandlers = NewResourceHandlers(r.config)
|
||||
r.resourceHandlers.SetOperatorStateChanged(func(_ string, resourceID string) {
|
||||
|
||||
@@ -22,10 +22,12 @@ import (
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/agentbinding"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/api/agenttokens"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/config"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/mock"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/models"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
|
||||
"github.com/rcourtman/pulse-go-rewrite/internal/servicediscovery"
|
||||
pulsews "github.com/rcourtman/pulse-go-rewrite/internal/websocket"
|
||||
agentshost "github.com/rcourtman/pulse-go-rewrite/pkg/agents/host"
|
||||
"github.com/rcourtman/pulse-go-rewrite/pkg/auth"
|
||||
)
|
||||
|
||||
@@ -5633,3 +5635,414 @@ func TestCurrentDeployTokenNeverMovesOnceBound(t *testing.T) {
|
||||
t.Fatal("a current deploy token moved after its first binding")
|
||||
}
|
||||
}
|
||||
|
||||
const reportIdentityMachineID = "49a2dbb3-3cc8-43b7-ad57-eb1711142454"
|
||||
|
||||
// reportIdentityReport is a report as pulse-agent sends it: the host carries
|
||||
|
||||
// the machine-derived ID and the agent block the ID it presents everywhere,
|
||||
|
||||
// including on command registration.
|
||||
|
||||
func reportIdentityReport(presentedID, machineID, hostname string) agentshost.Report {
|
||||
return agentshost.Report{
|
||||
Agent: agentshost.AgentInfo{
|
||||
ID: presentedID,
|
||||
Version: "6.5.0",
|
||||
Type: "unified",
|
||||
IntervalSeconds: 30,
|
||||
Hostname: hostname,
|
||||
},
|
||||
Host: agentshost.HostInfo{
|
||||
ID: machineID,
|
||||
Hostname: hostname,
|
||||
MachineID: machineID,
|
||||
Platform: "linux",
|
||||
OSName: "debian",
|
||||
},
|
||||
Timestamp: time.Now().UTC(),
|
||||
}
|
||||
}
|
||||
|
||||
func newReportIdentityRouter(t *testing.T, cfg *config.Config) *Router {
|
||||
t.Helper()
|
||||
monitor, err := monitoring.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("new monitor: %v", err)
|
||||
}
|
||||
t.Cleanup(monitor.Stop)
|
||||
return NewRouter(cfg, monitor, nil, nil, func() error { return nil }, "6.5.0")
|
||||
}
|
||||
|
||||
func reportIdentityToken(t *testing.T, cfg *config.Config, tokenID string) config.APITokenRecord {
|
||||
t.Helper()
|
||||
config.Mu.Lock()
|
||||
defer config.Mu.Unlock()
|
||||
for index := range cfg.APITokens {
|
||||
if cfg.APITokens[index].ID == tokenID {
|
||||
return cfg.APITokens[index].Clone()
|
||||
}
|
||||
}
|
||||
t.Fatalf("token %s not found", tokenID)
|
||||
return config.APITokenRecord{}
|
||||
}
|
||||
|
||||
func postReportIdentityReport(t *testing.T, router *Router, token string, report agentshost.Report) string {
|
||||
t.Helper()
|
||||
code, payload := issue1753PostReport(t, router, token, report)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("report rejected with %d (%v)", code, payload)
|
||||
}
|
||||
ack, _ := payload["agentId"].(string)
|
||||
return ack
|
||||
}
|
||||
|
||||
func postCollectorReduction(t *testing.T, router *Router, token, agentID, hostname string) int {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(collectorAuthorityReductionRequest{AgentID: agentID, Hostname: hostname})
|
||||
if err != nil {
|
||||
t.Fatalf("marshal reduction: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/agents/collector/reduce-authority", bytes.NewReader(body))
|
||||
req.Header.Set("X-API-Token", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.Handler().ServeHTTP(rec, req)
|
||||
return rec.Code
|
||||
}
|
||||
|
||||
// A deployed agent enrolled without commands never registers a command
|
||||
|
||||
// channel. Its runtime token stayed bound to the node's hostname alone, so the
|
||||
|
||||
// safe-profile migration's authority reduction, which needs the agent's ID on
|
||||
|
||||
// the token, was refused with "Collector credential binding mismatch" for as
|
||||
|
||||
// long as the agent ran. The first acknowledged report now names the agent.
|
||||
|
||||
func autoRegisteredInstallToken(t *testing.T, raw, hostname string, enableCommands bool) config.APITokenRecord {
|
||||
t.Helper()
|
||||
role := agenttokens.CredentialKindMonitoringCollector
|
||||
if enableCommands {
|
||||
role = agenttokens.CredentialKindLegacyFullTrust
|
||||
}
|
||||
record := newTokenRecord(t, raw, agenttokens.ProxmoxScopes(enableCommands), map[string]string{
|
||||
"install_type": "pve",
|
||||
"issued_via": agentbinding.IssuedViaConfig,
|
||||
"bound_hostname": hostname,
|
||||
"bound_at": time.Now().UTC().Format(time.RFC3339),
|
||||
agenttokens.RuntimeRoleMetadataKey: role,
|
||||
})
|
||||
record.OrgID = "default"
|
||||
return record
|
||||
}
|
||||
|
||||
// The report path and the command channel bind the same identity, and once
|
||||
|
||||
// either has named the agent the token never takes another identity, whatever
|
||||
|
||||
// later reports resolve.
|
||||
|
||||
func TestReportedIdentityMatchesCommandRegistrationAndNeverMoves(t *testing.T) {
|
||||
const raw = "report-identity-pve1-token.12345678"
|
||||
record := autoRegisteredInstallToken(t, raw, "pve1", true)
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := newReportIdentityRouter(t, cfg)
|
||||
|
||||
postReportIdentityReport(t, router, raw, reportIdentityReport("machine-a", "machine-a", "pve1"))
|
||||
bound := reportIdentityToken(t, cfg, record.ID)
|
||||
if got := bound.Metadata["bound_agent_id"]; got != "machine-a" {
|
||||
t.Fatalf("bound_agent_id = %q, want machine-a", got)
|
||||
}
|
||||
if got := bound.Metadata[agenttokens.RuntimeRoleMetadataKey]; got != agenttokens.CredentialKindLegacyFullTrust {
|
||||
t.Fatalf("runtime role = %q, want it untouched", got)
|
||||
}
|
||||
// Without the binding version a versionless token would still accept any
|
||||
// identity on its hostname through the legacy migration.
|
||||
if got := bound.Metadata[agentExecBindingVersionKey]; got != agentExecBindingVersion {
|
||||
t.Fatalf("binding version = %q, want %q so the recorded ID is immutable", got, agentExecBindingVersion)
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(raw, "machine-b", "pve1"); ok {
|
||||
t.Fatal("another identity's command registration moved the identity the report recorded")
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(raw, "machine-a", "pve1"); !ok {
|
||||
t.Fatal("the reporting agent's own command registration was refused")
|
||||
}
|
||||
|
||||
// Another machine presenting this token under the same hostname reports
|
||||
// under its own identity, but the token stays with the agent it named.
|
||||
issue1753PostReport(t, router, raw, reportIdentityReport("machine-b", "machine-b", "pve1"))
|
||||
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "machine-a" {
|
||||
t.Fatalf("bound_agent_id after another machine reported = %q, want machine-a", got)
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(raw, "machine-b", "pve1"); ok {
|
||||
t.Fatal("a second identity was admitted on a token the report path had already bound")
|
||||
}
|
||||
|
||||
// A token the command channel bound first keeps that identity even when the
|
||||
// server resolves this agent's reports to another one.
|
||||
const deployRaw = "report-identity-deploy-token.12345678"
|
||||
deployRecord := newTokenRecord(t, deployRaw, agenttokens.HostScopes(true), map[string]string{
|
||||
"bound_hostname": "pve-node3",
|
||||
"deploy_job_id": "dep_1",
|
||||
agentExecBindingVersionKey: agentExecBindingVersion,
|
||||
agentbinding.DeployIdentityKey: agentbinding.DeployIdentityAgent,
|
||||
})
|
||||
deployRecord.OrgID = "default"
|
||||
config.Mu.Lock()
|
||||
cfg.UpsertAPIToken(deployRecord)
|
||||
config.Mu.Unlock()
|
||||
if _, ok := router.admitAgentExecToken(deployRaw, "configured-id", "pve-node3"); !ok {
|
||||
t.Fatal("first command registration refused")
|
||||
}
|
||||
ack := postReportIdentityReport(t, router, deployRaw, reportIdentityReport("configured-id", reportIdentityMachineID, "pve-node3"))
|
||||
if ack == "configured-id" {
|
||||
t.Fatalf("precondition: the server resolved the presented ID %q", ack)
|
||||
}
|
||||
if got := reportIdentityToken(t, cfg, deployRecord.ID).Metadata["bound_agent_id"]; got != "configured-id" {
|
||||
t.Fatalf("bound_agent_id = %q, want the command channel's configured-id", got)
|
||||
}
|
||||
}
|
||||
|
||||
// As with a command registration, the first identity a token records is
|
||||
|
||||
// final. An agent that reported under its machine ID with commands off, then
|
||||
|
||||
// restarts under a configured --agent-id with commands on, meets a token
|
||||
|
||||
// already bound to the machine ID, exactly as it would had its first run
|
||||
|
||||
// registered a command channel.
|
||||
|
||||
func TestReportedIdentityIsFinalForALaterReconfiguredAgent(t *testing.T) {
|
||||
const raw = "report-identity-reconfigured-token.12345678"
|
||||
record := autoRegisteredInstallToken(t, raw, "pve9", true)
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := newReportIdentityRouter(t, cfg)
|
||||
|
||||
postReportIdentityReport(t, router, raw, reportIdentityReport(reportIdentityMachineID, reportIdentityMachineID, "pve9"))
|
||||
ack := postReportIdentityReport(t, router, raw, reportIdentityReport("configured-id", reportIdentityMachineID, "pve9"))
|
||||
if ack != reportIdentityMachineID {
|
||||
t.Fatalf("precondition: acknowledged agentId = %q, want the machine ID", ack)
|
||||
}
|
||||
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != reportIdentityMachineID {
|
||||
t.Fatalf("bound_agent_id = %q, want the first reported identity", got)
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(raw, "configured-id", "pve9"); ok {
|
||||
t.Fatal("a reconfigured identity moved a token the report path had bound")
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(raw, reportIdentityMachineID, "pve9"); !ok {
|
||||
t.Fatal("the recorded identity's command registration was refused")
|
||||
}
|
||||
}
|
||||
|
||||
// An agent started with a configured --agent-id presents that ID on its
|
||||
|
||||
// command channel, while Pulse resolves its reports to the machine ID. The
|
||||
|
||||
// report path cannot tell which identity the token should carry, so it records
|
||||
|
||||
// nothing and leaves the command channel's first registration unchanged.
|
||||
|
||||
func TestReportedIdentityRecordsNothingWhenTheResolvedIdentityIsNotThePresentedOne(t *testing.T) {
|
||||
const raw = "report-identity-configured-token.12345678"
|
||||
record := newTokenRecord(t, raw, agenttokens.HostScopes(true), map[string]string{
|
||||
"bound_hostname": "pve-node4",
|
||||
"deploy_job_id": "dep_1",
|
||||
agentExecBindingVersionKey: agentExecBindingVersion,
|
||||
agentbinding.DeployIdentityKey: agentbinding.DeployIdentityAgent,
|
||||
})
|
||||
record.OrgID = "default"
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := newReportIdentityRouter(t, cfg)
|
||||
|
||||
ack := postReportIdentityReport(t, router, raw, reportIdentityReport("configured-id", reportIdentityMachineID, "pve-node4"))
|
||||
if ack != reportIdentityMachineID {
|
||||
t.Fatalf("precondition: acknowledged agentId = %q, want the machine ID", ack)
|
||||
}
|
||||
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" {
|
||||
t.Fatalf("bound_agent_id = %q, want nothing recorded while the identities disagree", got)
|
||||
}
|
||||
if _, ok := router.admitAgentExecToken(raw, "configured-id", "pve-node4"); !ok {
|
||||
t.Fatal("the agent's command registration under its configured ID was refused")
|
||||
}
|
||||
}
|
||||
|
||||
// A report the ordering watermark rejects is answered with the stored host,
|
||||
|
||||
// whose hostname came from another report. The binding must be judged on the
|
||||
|
||||
// rejected report's own hostname, the one its agent's command registration
|
||||
|
||||
// presents, never on the stored one.
|
||||
|
||||
func TestReportedIdentityJudgesAReplayedReportByItsOwnHostname(t *testing.T) {
|
||||
const raw = "report-identity-replay-token.12345678"
|
||||
record := newTokenRecord(t, raw, agenttokens.HostScopes(false), map[string]string{
|
||||
"bound_hostname": "node.site-a",
|
||||
"deploy_job_id": "dep_1",
|
||||
agentExecBindingVersionKey: agentExecBindingVersion,
|
||||
agentbinding.DeployIdentityKey: agentbinding.DeployIdentityAgent,
|
||||
})
|
||||
record.OrgID = "default"
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := newReportIdentityRouter(t, cfg)
|
||||
|
||||
start := time.Now().UTC().Add(-time.Minute)
|
||||
retired := reportIdentityReport(reportIdentityMachineID, reportIdentityMachineID, "node.site-b")
|
||||
retired.Timestamp = start
|
||||
postReportIdentityReport(t, router, raw, retired)
|
||||
newer := reportIdentityReport("configured-id", reportIdentityMachineID, "node")
|
||||
newer.Timestamp = start.Add(30 * time.Second)
|
||||
postReportIdentityReport(t, router, raw, newer)
|
||||
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" {
|
||||
t.Fatalf("precondition: bound_agent_id = %q before the replay", got)
|
||||
}
|
||||
|
||||
postReportIdentityReport(t, router, raw, retired)
|
||||
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" {
|
||||
t.Fatalf("bound_agent_id = %q from a replayed report whose own hostname the token refuses", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A failed save leaves the token exactly as it was and the next report
|
||||
|
||||
// records the identity.
|
||||
|
||||
func TestReportedIdentityRestoresTheTokenWhenTheSaveFails(t *testing.T) {
|
||||
const raw = "report-identity-save-token.12345678"
|
||||
record := autoRegisteredInstallToken(t, raw, "pve5", false)
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := newReportIdentityRouter(t, cfg)
|
||||
|
||||
blocked := filepath.Join(t.TempDir(), "blocked-state")
|
||||
failing := config.NewConfigPersistence(blocked)
|
||||
if err := os.RemoveAll(blocked); err != nil {
|
||||
t.Fatalf("remove persistence directory: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(blocked, []byte("not a directory"), 0o600); err != nil {
|
||||
t.Fatalf("create persistence blocker: %v", err)
|
||||
}
|
||||
working := router.persistence
|
||||
router.persistence = failing
|
||||
|
||||
report := reportIdentityReport("machine-e", "machine-e", "pve5")
|
||||
postReportIdentityReport(t, router, raw, report)
|
||||
unchanged := reportIdentityToken(t, cfg, record.ID)
|
||||
for _, key := range []string{"bound_agent_id", agentExecBindingVersionKey} {
|
||||
if _, present := unchanged.Metadata[key]; present {
|
||||
t.Fatalf("%s = %q after a failed save, want it absent", key, unchanged.Metadata[key])
|
||||
}
|
||||
}
|
||||
if got := unchanged.Metadata["bound_at"]; got != record.Metadata["bound_at"] {
|
||||
t.Fatalf("bound_at = %q after a failed save, want %q", got, record.Metadata["bound_at"])
|
||||
}
|
||||
|
||||
router.persistence = working
|
||||
postReportIdentityReport(t, router, raw, report)
|
||||
retried := reportIdentityToken(t, cfg, record.ID)
|
||||
if got := retried.Metadata["bound_agent_id"]; got != "machine-e" {
|
||||
t.Fatalf("bound_agent_id after the retry = %q, want machine-e", got)
|
||||
}
|
||||
if got := retried.Metadata[agentExecBindingVersionKey]; got != agentExecBindingVersion {
|
||||
t.Fatalf("binding version after the retry = %q, want %q", got, agentExecBindingVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// The ID is resolved in the request's organization, so only a token whose
|
||||
|
||||
// command sessions run in that organization records it.
|
||||
|
||||
func TestReportedIdentityRequiresTheTokensOwnOrganization(t *testing.T) {
|
||||
const raw = "report-identity-org-token.12345678"
|
||||
record := autoRegisteredInstallToken(t, raw, "pve6", false)
|
||||
record.OrgID = "org-a"
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := &Router{config: cfg, persistence: config.NewConfigPersistence(cfg.DataPath)}
|
||||
|
||||
router.recordReportedAgentIdentity("default", record.ID, "machine-f", "machine-f", "pve6")
|
||||
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" {
|
||||
t.Fatalf("bound_agent_id = %q from another organization's report", got)
|
||||
}
|
||||
router.recordReportedAgentIdentity("org-a", record.ID, "machine-f", "machine-f", "pve6")
|
||||
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "machine-f" {
|
||||
t.Fatalf("bound_agent_id = %q, want machine-f from the token's own organization", got)
|
||||
}
|
||||
}
|
||||
|
||||
// A token that expired after it authenticated the report records nothing.
|
||||
|
||||
func TestReportedIdentityIgnoresAnExpiredToken(t *testing.T) {
|
||||
record := autoRegisteredInstallToken(t, "report-identity-expired-token.12345678", "pve8", false)
|
||||
expired := time.Now().Add(-time.Minute)
|
||||
record.ExpiresAt = &expired
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
router := &Router{config: cfg, persistence: config.NewConfigPersistence(cfg.DataPath)}
|
||||
|
||||
router.recordReportedAgentIdentity("default", record.ID, "machine-h", "machine-h", "pve8")
|
||||
if got := reportIdentityToken(t, cfg, record.ID).Metadata["bound_agent_id"]; got != "" {
|
||||
t.Fatalf("bound_agent_id = %q on an expired token", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The handler offers the recorder only reports the monitor applied under the
|
||||
|
||||
// token, and only when the token snapshot qualifies, so a token that never will
|
||||
|
||||
// (here, an agent presenting a configured ID) does not take the exclusive token
|
||||
|
||||
// lock on every report. Mock mode discards reports and acknowledges the
|
||||
|
||||
// presented ID unresolved, so it must not record anything on a real token.
|
||||
|
||||
func TestReportHandlerOffersOnlyQualifyingLiveReportsToTheRecorder(t *testing.T) {
|
||||
const raw = "report-identity-mock-token.12345678"
|
||||
record := autoRegisteredInstallToken(t, raw, "pve7", false)
|
||||
cfg := newTestConfigWithTokens(t, record)
|
||||
monitor, err := monitoring.New(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("new monitor: %v", err)
|
||||
}
|
||||
defer monitor.Stop()
|
||||
handler := NewUnifiedAgentHandlers(nil, monitor, nil)
|
||||
type recorded struct{ organizationID, tokenID, presentedID, hostID, hostname string }
|
||||
var calls []recorded
|
||||
handler.SetReportedIdentityRecorder(func(organizationID, tokenID, presentedID, hostID, hostname string) {
|
||||
calls = append(calls, recorded{organizationID, tokenID, presentedID, hostID, hostname})
|
||||
})
|
||||
post := func(presentedID string) {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(reportIdentityReport(presentedID, "machine-g", "pve7"))
|
||||
if err != nil {
|
||||
t.Fatalf("marshal report: %v", err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/agents/agent/report", bytes.NewReader(body))
|
||||
attachAPITokenRecord(req, &record)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.HandleReport(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("report status = %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
post("configured-id")
|
||||
if len(calls) != 0 {
|
||||
t.Fatalf("recorder offered a report whose presented ID was not resolved: %+v", calls)
|
||||
}
|
||||
post("machine-g")
|
||||
want := recorded{"default", record.ID, "machine-g", "machine-g", "pve7"}
|
||||
if len(calls) != 1 || calls[0] != want {
|
||||
t.Fatalf("recorder calls = %+v, want one call %+v", calls, want)
|
||||
}
|
||||
|
||||
previous := mock.IsMockEnabled()
|
||||
if err := mock.SetEnabled(true); err != nil {
|
||||
t.Fatalf("enable mock mode: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = mock.SetEnabled(previous) })
|
||||
post("machine-g")
|
||||
if len(calls) != 1 {
|
||||
t.Fatalf("recorder calls in mock mode = %+v, want none beyond the live report", calls[1:])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5641,6 +5641,66 @@ func TestMockModeDiscardsRealHostReports(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
// Report ingest records an agent's identity on its token only for a host the
|
||||
// monitor resolved and holds under that token, judged on the report's own
|
||||
// hostname. That rests on three properties of the returned host, pinned here:
|
||||
// an applied report carries the token's ID, a report the ordering watermark
|
||||
// rejects returns the stored host with the stored hostname rather than its
|
||||
// own, and mock mode's discard acknowledgement carries no token at all.
|
||||
func TestApplyHostReportStampsTheTokenOnlyOnHostsItHolds(t *testing.T) {
|
||||
token := &config.APITokenRecord{ID: "token-a", Name: "agent token"}
|
||||
newMonitor := func(t *testing.T) *Monitor {
|
||||
t.Helper()
|
||||
return &Monitor{
|
||||
state: models.NewState(),
|
||||
resourceStore: unifiedresources.NewMonitorAdapter(unifiedresources.NewRegistry(nil)),
|
||||
hostContinuityStore: config.NewHostContinuityStore(t.TempDir(), nil),
|
||||
rateTracker: NewRateTracker(),
|
||||
}
|
||||
}
|
||||
report := func(hostname string, at time.Time) agentshost.Report {
|
||||
return agentshost.Report{
|
||||
Agent: agentshost.AgentInfo{ID: "machine-a", Version: "6.5.0", IntervalSeconds: 30},
|
||||
Host: agentshost.HostInfo{ID: "machine-a", MachineID: "machine-a", Hostname: hostname, Platform: "linux"},
|
||||
Timestamp: at,
|
||||
}
|
||||
}
|
||||
|
||||
previous := mock.IsMockEnabled()
|
||||
mustSetMockEnabled(t, false)
|
||||
t.Cleanup(func() { _ = mock.SetEnabled(previous) })
|
||||
|
||||
m := newMonitor(t)
|
||||
start := time.Now().UTC().Add(-time.Minute)
|
||||
first := report("node.site-b", start)
|
||||
applied, err := m.ApplyHostReport(first, token)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyHostReport: %v", err)
|
||||
}
|
||||
if applied.ID != "machine-a" || applied.TokenID != token.ID || applied.Hostname != "node.site-b" {
|
||||
t.Fatalf("applied host = %q token %q hostname %q, want machine-a under %q as node.site-b", applied.ID, applied.TokenID, applied.Hostname, token.ID)
|
||||
}
|
||||
if _, err := m.ApplyHostReport(report("node", start.Add(30*time.Second)), token); err != nil {
|
||||
t.Fatalf("ApplyHostReport newer: %v", err)
|
||||
}
|
||||
replayed, err := m.ApplyHostReport(first, token)
|
||||
if err != nil {
|
||||
t.Fatalf("ApplyHostReport replay: %v", err)
|
||||
}
|
||||
if replayed.TokenID != token.ID || replayed.Hostname != "node" {
|
||||
t.Fatalf("replayed report returned token %q hostname %q, want the stored host under %q as node", replayed.TokenID, replayed.Hostname, token.ID)
|
||||
}
|
||||
|
||||
mustSetMockEnabled(t, true)
|
||||
discarded, err := newMonitor(t).ApplyHostReport(report("node", time.Now().UTC()), token)
|
||||
if err != nil {
|
||||
t.Fatalf("mock mode ApplyHostReport: %v", err)
|
||||
}
|
||||
if discarded.ID != "machine-a" || discarded.TokenID != "" {
|
||||
t.Fatalf("mock acknowledgement = %q token %q, want the echoed ID with no token", discarded.ID, discarded.TokenID)
|
||||
}
|
||||
}
|
||||
|
||||
// The agent-authored privilege profile must survive ingest into model state
|
||||
// exactly, and a report without one must not invent a profile.
|
||||
func TestApplyHostReportCarriesAgentPrivilegeProfile(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user