From 76b6f7ea974d4c40f039093182ae63fc2ca074c5 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:43:02 +0100 Subject: [PATCH 1/3] Install cluster member agents in one step from a covered sibling Adding the Pulse Agent to a Proxmox cluster member meant generating a token in the UI and pasting it into an interactive prompt on the host, even though Pulse already knows the cluster and has an agent on its siblings. The server-side cluster deploy that avoids this was still shipped, but its wizard was deleted as an orphan in May, and it could not reach members discovered by hostname: targets were parsed from the API URL, so a name like "delly2" hit the agent's literal-IP guard and failed preflight. Deploy targets now resolve to a literal IP in the member's API URL or its cluster endpoint's effective IP, matched on the member's own connection, and members with no known address come back undeployable with a reason. The Settings > Infrastructure install actions open one dialog that checks, installs, and confirms each node is reporting, falling back to the scoped installer when no sibling agent is connected or a node cannot be reached. The deploy routes are keyed on the cluster name, so they now refuse a name shared by two connections instead of merging two sites, and job creation admits one install per cluster at a time under a lock, which lets the dialog keep a run going after it is closed. The agent-lifecycle, api-contracts, frontend-primitives, and storage-recovery contracts record the new install path, its address rule, and the persisted, time-bounded admission check. --- .../v6/internal/subsystems/agent-lifecycle.md | 52 +- .../v6/internal/subsystems/api-contracts.md | 15 + .../subsystems/frontend-primitives.md | 9 + .../v6/internal/subsystems/registry.json | 26 + .../internal/subsystems/storage-recovery.md | 13 + .../src/api/__tests__/agentDeploy.test.ts | 64 +++ frontend-modern/src/api/agentDeploy.ts | 126 +++++ .../Settings/ClusterAgentDeployDialog.tsx | 518 ++++++++++++++++++ .../Settings/InfrastructureSourceManager.tsx | 80 ++- .../ClusterAgentDeployDialog.test.tsx | 399 ++++++++++++++ .../InfrastructureSourceManager.test.tsx | 191 ++++++- .../__tests__/settingsArchitecture.test.ts | 7 + .../Settings/connectionsTableModel.ts | 2 + .../Settings/useConnectionsLedger.ts | 1 + internal/api/deploy_handlers.go | 134 ++++- internal/api/deploy_handlers_test.go | 330 +++++++++++ internal/deploy/store.go | 25 + internal/deploy/store_queryplan_test.go | 9 + internal/deploy/store_test.go | 38 ++ 19 files changed, 2008 insertions(+), 31 deletions(-) create mode 100644 frontend-modern/src/api/__tests__/agentDeploy.test.ts create mode 100644 frontend-modern/src/api/agentDeploy.ts create mode 100644 frontend-modern/src/components/Settings/ClusterAgentDeployDialog.tsx create mode 100644 frontend-modern/src/components/Settings/__tests__/ClusterAgentDeployDialog.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index 469e7d2737..fd11a8f17a 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -6400,25 +6400,41 @@ frontend-local merge of raw unified-resource facets and removed runtime arrays, and v6 clients no longer treat those removed runtime arrays as a parallel settings contract, so lifecycle scope and reconnect behavior stay canonical across host, Docker, and Kubernetes reporting. -deploy results surface: `ResultsStep` must request the canonical backend -install command from `/api/agent-install-command` for failed deploy targets +Cluster members install in one step. On a PVE cluster where at least one +member already runs a connected Pulse Agent, the Settings → Infrastructure +cluster, member, and coverage-summary install actions open +`ClusterAgentDeployDialog`, which drives the server-side cluster deploy API +(`/api/clusters/{cluster}/agent-deploy` candidates, preflights, and jobs). The +server mints a per-node bootstrap credential for every target, so the dialog +never renders, copies, or reuses a token and the operator never pastes one. +The dialog folds target statuses into checking, installing, connecting, +reporting, and failed, and it reports a node as installed only after the +candidates projection shows that node with an agent. A finished deploy job +alone is not success, because targets can still be enrolling. Clusters with no +connected agent, members Pulse has no address for, and failed targets hand off +to the scoped manual installer (`/settings/infrastructure?add=linux-host`) instead of rebuilding a local shell snippet that can drift from the governed -installer contract. That fallback surface must consume the shared validated -`NodesAPI.getAgentInstallCommand` response, so malformed backend payloads fail -closed and the raw backend install token stays inside the shared client -boundary rather than leaking into deploy UI state. -Deploy wizard target tables are lifecycle-owned presentation surfaces: -`CandidatesStep`, `ConfirmStep`, `PreflightStep`, `DeployingStep`, and -`ResultsStep` must use the shared frontend `Table` primitive for scroll and -table semantics instead of raw table markup or step-local scroll frames. -Deploy selection and retry UI must not consume retired monitored-system -capacity boundaries. Lifecycle UI must avoid workspace-capacity, legacy -license-slot, and plan-upgrade language in deploy confirmation, preflight, or -retry surfaces. -That same deploy wizard boundary must also stay on the direct -`deploy-fallback-install-surface` proof path, rather than relying only on the -shared install helper or downstream deploy tests to catch lifecycle drift in -the infrastructure fallback surface. +installer contract. Deploy targets must resolve to a literal IP through +`DeployHandlers.deployTargetIP`: a literal IP in the member's API URL first, +then the cluster endpoint's `EffectiveIP()` (the operator's connection-address +override, else the address Proxmox reports for the member). Members that Pulse +discovered by hostname must never reach the agent's `validateNodeIP` injection +guard as hostnames, and the candidates response marks a member with no known +address `deployable: false` with reason `no_address` so the UI can explain it +before any install starts. The deploy routes are keyed on the cluster name, so +they must refuse with `ambiguous_cluster` when more than one Proxmox connection +reports that name instead of merging two sites' members, and the address +resolver must match the member's own connection by exact instance name with no +cluster-name fallback. The jobs route must refuse with `deploy_in_progress` +while a recent install job on the same cluster is unfinished, checking and +inserting under one admission lock so concurrent requests cannot both pass, which is what +lets the dialog keep an install running after it is closed without a reopened +dialog or second tab starting an overlapping one. Deploy selection UI must not consume retired +monitored-system capacity boundaries, and lifecycle UI must avoid +workspace-capacity, legacy license-slot, and plan-upgrade language in deploy +surfaces. `frontend-modern/src/components/Settings/__tests__/ClusterAgentDeployDialog.test.tsx` +and `InfrastructureSourceManager.test.tsx` pin the dialog flow and its routing, +and `internal/api/deploy_handlers_test.go` pins the address resolution. The same Windows install, upgrade, and uninstall copies must also preserve operator-selected transport and capability toggles: if the settings surface enables insecure TLS mode or Pulse command execution, the PowerShell path must diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 4cc867961f..a18344be02 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -3527,6 +3527,21 @@ a new API state machine, queue contract, or verification-accounting field. as compatibility input into that same canonical `agent-` binding, and unbound agent-exec tokens must fail closed instead of being treated as global command authority. + The cluster agent deploy payloads in `internal/api/deploy_handlers.go` + are a typed client contract consumed through + `frontend-modern/src/api/agentDeploy.ts`. Candidate and preflight target + addresses must be literal IPs resolved by + `DeployHandlers.deployTargetIP` (a literal IP in the member's API URL, + else the cluster endpoint's `EffectiveIP()`), never a hostname parsed out + of the API URL, and a member with no known address must come back + `deployable: false` with reason `no_address` rather than failing later in + the source agent's preflight. Candidates and preflights must answer + `409 ambiguous_cluster` when more than one Proxmox connection reports the + cluster name, and job creation must answer `409 deploy_in_progress` while + a recent install job on the cluster is unfinished, admitting atomically so + concurrent requests start at most one install. + `internal/api/deploy_handlers_test.go` and + `frontend-modern/src/api/__tests__/agentDeploy.test.ts` pin that shape. Proxmox-side LXC Docker detection and inventory wiring in `internal/api/router.go` shares that agent-exec transport boundary: router startup may configure the monitoring checker or collector only when diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index bd47fb5512..f967936bb5 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -2608,6 +2608,15 @@ Agent`), with the plain-language source phrase available through accessible first-run/readiness actions, or the discovery band's explicit review action, and the direct address-probe utility may appear as first-run setup guidance instead of a second saved-network-scan command. + That same shared shell boundary owns the cluster-member install action. + `InfrastructureSourceManager.tsx` routes the cluster, member, and + coverage-summary `Install agent` actions for a PVE cluster with a connected + sibling agent into `ClusterAgentDeployDialog.tsx`, mounted once under a + keyed `` so an install that outlives the dialog never reads a stale + accessor, while every other row keeps the scoped installer hand-off. + `useConnectionsLedger.ts` carries the Proxmox cluster name on cluster rows + as `clusterName`, so the dialog keys the deploy API on the real cluster name + rather than on a display label. Discovered API-backed candidates stay visible in the same platform-group table as configured sources, using the existing tree/table hierarchy instead of spawning a second discovery-only page or card stack. diff --git a/docs/release-control/v6/internal/subsystems/registry.json b/docs/release-control/v6/internal/subsystems/registry.json index b8ddb8e624..2e7901ea3c 100644 --- a/docs/release-control/v6/internal/subsystems/registry.json +++ b/docs/release-control/v6/internal/subsystems/registry.json @@ -25,6 +25,14 @@ "api-contracts" ] }, + { + "path": "frontend-modern/src/api/agentDeploy.ts", + "rationale": "the cluster agent deploy client is both the agent lifecycle one-step cluster member install surface and a canonical API payload contract boundary", + "subsystems": [ + "agent-lifecycle", + "api-contracts" + ] + }, { "path": "frontend-modern/src/api/agentProfiles.ts", "rationale": "the agent profiles frontend client is both an agent lifecycle control surface and a canonical API payload contract boundary", @@ -1334,10 +1342,12 @@ "cmd/pulse-agent/collector_lifecycle.go", "cmd/pulse-agent/main.go", "cmd/pulse-agent/service_windows.go", + "frontend-modern/src/api/agentDeploy.ts", "frontend-modern/src/api/agentProfiles.ts", "frontend-modern/src/api/nodes.ts", "frontend-modern/src/components/Settings/agentProfileSettings.ts", "frontend-modern/src/components/Settings/AgentProfilesPanel.tsx", + "frontend-modern/src/components/Settings/ClusterAgentDeployDialog.tsx", "frontend-modern/src/components/Settings/ConnectionEditor/AddressProbeStep.tsx", "frontend-modern/src/components/Settings/ConnectionEditor/ConnectionEditor.tsx", "frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx", @@ -1819,6 +1829,22 @@ "frontend-modern/src/utils/__tests__/agentProfilesPresentation.test.ts" ] }, + { + "id": "cluster-agent-deploy-surface", + "label": "one-step cluster member agent install proof", + "match_prefixes": [], + "match_files": [ + "frontend-modern/src/api/agentDeploy.ts", + "frontend-modern/src/components/Settings/ClusterAgentDeployDialog.tsx" + ], + "allow_same_subsystem_tests": false, + "test_prefixes": [], + "exact_files": [ + "frontend-modern/src/api/__tests__/agentDeploy.test.ts", + "frontend-modern/src/components/Settings/__tests__/ClusterAgentDeployDialog.test.tsx", + "frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx" + ] + }, { "id": "frontend-install-command-helper", "label": "frontend install command helper proof", diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index ed3f3e4803..c22d8b3b1a 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -6444,6 +6444,19 @@ successful enrollment response from describing credential state that a restart would undo. Their success and forced-write-failure proofs live in `internal/api/deploy_handlers_test.go`. +### Deploy job admission reads persisted state, bounded in time + +Install-job admission on a cluster reads the persisted deploy store, not +in-memory state. `HandleCreateJob` refuses a new install while +`Store.UnfinishedJobsForCluster` returns an install job for the same +organization and cluster created within the last 30 minutes, and it checks and +inserts under one admission lock. A job left unfinished by a crash or a +vanished source agent therefore keeps blocking overlapping installs across a +restart, but only until it ages out of that window, so a stale row never locks +a cluster out for good. This adds no backup, restore, retention, snapshot, or +recovery authority. `internal/api/deploy_handlers_test.go` and +`internal/deploy/store_test.go` pin the admission and the query. + ### Destination severity policy preserves notification recovery identity Email, webhook, Apprise, and Relay minimum-severity floors persist through diff --git a/frontend-modern/src/api/__tests__/agentDeploy.test.ts b/frontend-modern/src/api/__tests__/agentDeploy.test.ts new file mode 100644 index 0000000000..437ebb3705 --- /dev/null +++ b/frontend-modern/src/api/__tests__/agentDeploy.test.ts @@ -0,0 +1,64 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { ClusterAgentDeployAPI, isClusterDeployJobActive } from '../agentDeploy'; +import { apiFetchJSON } from '@/utils/apiClient'; + +vi.mock('@/utils/apiClient', () => ({ + apiFetchJSON: vi.fn(), +})); + +describe('ClusterAgentDeployAPI', () => { + beforeEach(() => { + vi.mocked(apiFetchJSON).mockReset(); + vi.mocked(apiFetchJSON).mockResolvedValue({}); + }); + + it('keys cluster calls on the encoded Proxmox cluster name', async () => { + await ClusterAgentDeployAPI.getCandidates('home lab/1'); + expect(apiFetchJSON).toHaveBeenCalledWith( + '/api/clusters/home%20lab%2F1/agent-deploy/candidates', + ); + }); + + it('sends the source agent and targets for a preflight', async () => { + await ClusterAgentDeployAPI.createPreflight('homelab', 'agent-delly', ['n-delly2']); + const [url, options] = vi.mocked(apiFetchJSON).mock.calls[0]; + expect(url).toBe('/api/clusters/homelab/agent-deploy/preflights'); + expect(options?.method).toBe('POST'); + expect(JSON.parse(String(options?.body))).toEqual({ + sourceAgentId: 'agent-delly', + targetNodeIds: ['n-delly2'], + maxParallel: 2, + }); + }); + + it('creates a job from a passed preflight', async () => { + await ClusterAgentDeployAPI.createJob('homelab', 'agent-delly', 'pf_1', ['n-delly2']); + const [url, options] = vi.mocked(apiFetchJSON).mock.calls[0]; + expect(url).toBe('/api/clusters/homelab/agent-deploy/jobs'); + expect(JSON.parse(String(options?.body))).toMatchObject({ + sourceAgentId: 'agent-delly', + preflightId: 'pf_1', + targetNodeIds: ['n-delly2'], + }); + }); + + it('reads preflights and jobs from their own status routes', async () => { + await ClusterAgentDeployAPI.getPreflight('pf_1'); + await ClusterAgentDeployAPI.getJob('dep_1'); + expect(apiFetchJSON).toHaveBeenNthCalledWith(1, '/api/agent-deploy/preflights/pf_1'); + expect(apiFetchJSON).toHaveBeenNthCalledWith(2, '/api/agent-deploy/jobs/dep_1'); + }); +}); + +describe('isClusterDeployJobActive', () => { + it('treats only in-flight job states as active', () => { + expect(isClusterDeployJobActive('queued')).toBe(true); + expect(isClusterDeployJobActive('waiting_source')).toBe(true); + expect(isClusterDeployJobActive('running')).toBe(true); + expect(isClusterDeployJobActive('canceling')).toBe(true); + expect(isClusterDeployJobActive('succeeded')).toBe(false); + expect(isClusterDeployJobActive('partial_success')).toBe(false); + expect(isClusterDeployJobActive('failed')).toBe(false); + expect(isClusterDeployJobActive('canceled')).toBe(false); + }); +}); diff --git a/frontend-modern/src/api/agentDeploy.ts b/frontend-modern/src/api/agentDeploy.ts new file mode 100644 index 0000000000..7c67cee93b --- /dev/null +++ b/frontend-modern/src/api/agentDeploy.ts @@ -0,0 +1,126 @@ +import { apiFetchJSON } from '@/utils/apiClient'; + +/** + * Cluster agent deploy: Pulse installs its agent on uncovered Proxmox cluster + * members by asking a member that already runs the agent to install it on its + * peers over the cluster's own SSH trust. The server mints a per-node bootstrap + * credential for each target, so no token is ever shown to, copied by, or + * reused across operators or hosts. + */ + +export type ClusterDeployCandidateReason = 'already_agent' | 'no_address' | (string & {}); + +export interface ClusterDeployCandidateNode { + nodeId: string; + name: string; + ip?: string; + hasAgent: boolean; + deployable: boolean; + reason?: ClusterDeployCandidateReason; +} + +export interface ClusterDeploySourceAgent { + agentId: string; + nodeId: string; + online: boolean; +} + +export interface ClusterDeployCandidates { + clusterId: string; + clusterName: string; + sourceAgents: ClusterDeploySourceAgent[] | null; + nodes: ClusterDeployCandidateNode[] | null; +} + +export type ClusterDeployJobStatus = + | 'queued' + | 'waiting_source' + | 'running' + | 'succeeded' + | 'partial_success' + | 'failed' + | 'canceling' + | 'canceled'; + +export type ClusterDeployTargetStatus = + | 'pending' + | 'preflighting' + | 'ready' + | 'installing' + | 'enrolling' + | 'verifying' + | 'succeeded' + | 'failed_retryable' + | 'failed_permanent' + | 'skipped_already_agent' + | 'canceled'; + +export interface ClusterDeployTarget { + id: string; + nodeId: string; + nodeName: string; + nodeIP: string; + status: ClusterDeployTargetStatus; + errorMessage?: string; +} + +export interface ClusterDeployJob { + id: string; + clusterId: string; + status: ClusterDeployJobStatus; + targets?: ClusterDeployTarget[] | null; +} + +export interface ClusterDeployPreflightCreated { + preflightId: string; + status: ClusterDeployJobStatus; +} + +export interface ClusterDeployJobCreated { + jobId: string; + acceptedTargets: string[] | null; + skippedTargets: { nodeId: string; reason: string }[] | null; +} + +const ACTIVE_JOB_STATUSES: ReadonlySet = new Set([ + 'queued', + 'waiting_source', + 'running', + 'canceling', +]); + +export const isClusterDeployJobActive = (status: ClusterDeployJobStatus): boolean => + ACTIVE_JOB_STATUSES.has(status); + +const clusterPath = (clusterName: string) => + `/api/clusters/${encodeURIComponent(clusterName)}/agent-deploy`; + +export const ClusterAgentDeployAPI = { + getCandidates: (clusterName: string) => + apiFetchJSON(`${clusterPath(clusterName)}/candidates`), + + createPreflight: (clusterName: string, sourceAgentId: string, targetNodeIds: string[]) => + apiFetchJSON(`${clusterPath(clusterName)}/preflights`, { + method: 'POST', + body: JSON.stringify({ sourceAgentId, targetNodeIds, maxParallel: 2 }), + }), + + getPreflight: (preflightId: string) => + apiFetchJSON( + `/api/agent-deploy/preflights/${encodeURIComponent(preflightId)}`, + ), + + createJob: ( + clusterName: string, + sourceAgentId: string, + preflightId: string, + targetNodeIds: string[], + ) => + apiFetchJSON(`${clusterPath(clusterName)}/jobs`, { + method: 'POST', + body: JSON.stringify({ sourceAgentId, preflightId, targetNodeIds, maxParallel: 2 }), + }), + + getJob: (jobId: string) => + apiFetchJSON(`/api/agent-deploy/jobs/${encodeURIComponent(jobId)}`), +}; diff --git a/frontend-modern/src/components/Settings/ClusterAgentDeployDialog.tsx b/frontend-modern/src/components/Settings/ClusterAgentDeployDialog.tsx new file mode 100644 index 0000000000..9f2335b62f --- /dev/null +++ b/frontend-modern/src/components/Settings/ClusterAgentDeployDialog.tsx @@ -0,0 +1,518 @@ +import { For, Show, createEffect, createMemo, createSignal } from 'solid-js'; +import type { Component } from 'solid-js'; +import { Button } from '@/components/shared/Button'; +import { Card } from '@/components/shared/Card'; +import { Dialog } from '@/components/shared/Dialog'; +import { LoadingSpinner } from '@/components/shared/LoadingSpinner'; +import { SectionHeader } from '@/components/shared/SectionHeader'; +import { + ClusterAgentDeployAPI, + isClusterDeployJobActive, + type ClusterDeployCandidateNode, + type ClusterDeployJob, + type ClusterDeployTargetStatus, +} from '@/api/agentDeploy'; + +/** + * One-step install of the Pulse Agent on uncovered Proxmox cluster members. + * + * Pulse already knows the cluster's members and has an agent on at least one + * of them, so it asks that agent to install on its peers over the SSH trust + * the cluster nodes already share. The server mints a per-node bootstrap + * credential, so the operator never copies, pastes, or reuses a token. When + * the cluster has no reporting agent to install from, or a member cannot be + * reached, the dialog hands off to the scoped manual installer. + */ + +export type ClusterAgentDeployNodeState = + 'idle' | 'checking' | 'installing' | 'connecting' | 'reporting' | 'failed'; + +export interface ClusterAgentDeployNodeProgress { + state: ClusterAgentDeployNodeState; + detail?: string; +} + +/** Maps a server target status onto the four steps the operator cares about. */ +export const clusterDeployNodeStateFromTarget = ( + status: ClusterDeployTargetStatus, +): ClusterAgentDeployNodeState => { + switch (status) { + case 'pending': + case 'preflighting': + case 'ready': + return 'checking'; + case 'installing': + return 'installing'; + case 'enrolling': + case 'verifying': + case 'succeeded': + case 'skipped_already_agent': + return 'connecting'; + case 'failed_retryable': + case 'failed_permanent': + case 'canceled': + return 'failed'; + default: + return 'checking'; + } +}; + +const NODE_STATE_PRESENTATION: Record< + ClusterAgentDeployNodeState, + { label: string; badgeClass: string } +> = { + idle: { label: 'Not installed', badgeClass: 'bg-surface-alt text-muted' }, + checking: { + label: 'Checking', + badgeClass: 'bg-blue-100 text-blue-800 dark:bg-blue-900/25 dark:text-blue-200', + }, + installing: { + label: 'Installing', + badgeClass: 'bg-blue-100 text-blue-800 dark:bg-blue-900/25 dark:text-blue-200', + }, + connecting: { + label: 'Connecting to Pulse', + badgeClass: 'bg-blue-100 text-blue-800 dark:bg-blue-900/25 dark:text-blue-200', + }, + reporting: { + label: 'Reporting', + badgeClass: 'bg-green-100 text-green-800 dark:bg-green-900/25 dark:text-green-300', + }, + failed: { + label: 'Failed', + badgeClass: 'bg-red-100 text-red-800 dark:bg-red-900/25 dark:text-red-300', + }, +}; + +export const clusterDeployUnavailableReason = (node: ClusterDeployCandidateNode): string => { + if (node.reason === 'no_address') { + return 'Pulse has no IP address for this node. Set its connection address under Manage.'; + } + return 'This node cannot be installed from here.'; +}; + +type Phase = 'loading' | 'select' | 'running' | 'done' | 'unavailable' | 'error'; + +const POLL_INTERVAL_MS = 2000; +const REPORTING_TIMEOUT_MS = 90_000; +const JOB_TIMEOUT_MS = 20 * 60_000; +const REQUEST_TIMEOUT_MS = 30_000; + +interface ClusterAgentDeployDialogProps { + isOpen: boolean; + /** The Proxmox cluster name, which the deploy API is keyed on. */ + clusterName: string; + /** Member to preselect when opened from that member's row. */ + preselectNodeName?: string; + onClose: () => void; + /** Hands off to the scoped manual installer. */ + onUseInstaller: () => void; + /** Called once at least one node is reporting, so the caller can refresh. */ + onInstalled?: () => void; + /** Overridable for tests. */ + pollIntervalMs?: number; + /** Overridable for tests. */ + jobTimeoutMs?: number; + /** Overridable for tests. */ + requestTimeoutMs?: number; +} + +const errorMessage = (error: unknown, fallback: string): string => + error instanceof Error && error.message ? error.message : fallback; + +export const ClusterAgentDeployDialog: Component = (props) => { + const [phase, setPhase] = createSignal('loading'); + const [nodes, setNodes] = createSignal([]); + const [sourceAgentId, setSourceAgentId] = createSignal(''); + const [sourceNodeName, setSourceNodeName] = createSignal(''); + const [selected, setSelected] = createSignal>(new Set()); + const [progress, setProgress] = createSignal>({}); + const [failure, setFailure] = createSignal(''); + + // A run keeps going after the dialog closes. The install continues on the + // server, and abandoning the browser side mid-run would skip starting the + // install after a passed check. Reopening starts a fresh view of the + // cluster, and the server refuses an overlapping install on it. + let runId = 0; + const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + + const uncoveredNodes = createMemo(() => nodes().filter((node) => !node.hasAgent)); + const deployableNodes = createMemo(() => uncoveredNodes().filter((node) => node.deployable)); + const selectedCount = createMemo( + () => deployableNodes().filter((node) => selected().has(node.nodeId)).length, + ); + + const setNodeProgress = (nodeId: string, next: ClusterAgentDeployNodeProgress) => + setProgress((current) => ({ ...current, [nodeId]: next })); + + const loadCandidates = async (run: number) => { + setPhase('loading'); + setFailure(''); + setProgress({}); + try { + const candidates = await ClusterAgentDeployAPI.getCandidates(props.clusterName); + if (run !== runId) return; + const candidateNodes = candidates.nodes ?? []; + setNodes(candidateNodes); + const source = (candidates.sourceAgents ?? []).find((agent) => agent.online); + if (!source) { + setPhase('unavailable'); + return; + } + setSourceAgentId(source.agentId); + setSourceNodeName( + candidateNodes.find((node) => node.nodeId === source.nodeId)?.name ?? 'a cluster node', + ); + const deployable = candidateNodes.filter((node) => !node.hasAgent && node.deployable); + // Opened from one member's row means that member only. If it cannot be + // installed from here, nothing is preselected rather than its siblings. + const preselect = props.preselectNodeName?.trim().toLowerCase(); + const chosen = preselect + ? deployable.filter((node) => node.name.toLowerCase() === preselect) + : deployable; + setSelected(new Set(chosen.map((node) => node.nodeId))); + setPhase('select'); + } catch (error) { + if (run !== runId) return; + setFailure(errorMessage(error, 'Pulse could not load this cluster’s nodes.')); + setPhase('error'); + } + }; + + createEffect(() => { + if (props.isOpen) { + runId += 1; + void loadCandidates(runId); + } + }); + + // Everything a run needs is captured when it starts. The run outlives the + // dialog when it is closed mid-install, and props can no longer be read once + // the parent has unmounted it. + interface RunConfig { + run: number; + clusterName: string; + sourceAgentId: string; + pollMs: number; + jobTimeoutMs: number; + requestTimeoutMs: number; + onInstalled?: () => void; + } + + // Bounds each request so a stalled one cannot hold the run past its + // deadline or skip the final sweep. + const withRequestDeadline = (cfg: RunConfig, request: Promise): Promise => + new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error('Pulse did not answer in time. Check this page again shortly.')), + cfg.requestTimeoutMs, + ); + request.then( + (value) => { + clearTimeout(timer); + resolve(value); + }, + (error: unknown) => { + clearTimeout(timer); + reject(error); + }, + ); + }); + + const pollUntilSettled = async ( + cfg: RunConfig, + fetchJob: () => Promise, + ): Promise => { + const deadline = Date.now() + cfg.jobTimeoutMs; + for (;;) { + await wait(cfg.pollMs); + if (cfg.run !== runId) return null; + if (Date.now() > deadline) { + throw new Error( + 'Pulse stopped waiting after 20 minutes. The install may still finish, so check this page again shortly.', + ); + } + const job = await withRequestDeadline(cfg, fetchJob()); + if (cfg.run !== runId) return null; + for (const target of job.targets ?? []) { + setNodeProgress(target.nodeId, { + state: clusterDeployNodeStateFromTarget(target.status), + detail: target.errorMessage, + }); + } + if (!isClusterDeployJobActive(job.status)) return job; + } + }; + + // A finished job only means the agent was installed and started enrolling. + // The outcome the operator wants is Pulse counting the node, so confirm it. + const confirmReporting = async (cfg: RunConfig, nodeIds: string[]) => { + const pending = new Set(nodeIds); + const deadline = Date.now() + REPORTING_TIMEOUT_MS; + while (pending.size > 0 && Date.now() < deadline) { + await wait(cfg.pollMs); + if (cfg.run !== runId) return; + const candidates = await withRequestDeadline( + cfg, + ClusterAgentDeployAPI.getCandidates(cfg.clusterName), + ); + if (cfg.run !== runId) return; + for (const node of candidates.nodes ?? []) { + if (pending.has(node.nodeId) && node.hasAgent) { + pending.delete(node.nodeId); + setNodeProgress(node.nodeId, { state: 'reporting' }); + } + } + } + for (const nodeId of pending) { + setNodeProgress(nodeId, { + state: 'failed', + detail: 'The agent was installed but has not reported to Pulse yet.', + }); + } + }; + + const install = async () => { + const cfg: RunConfig = { + run: runId, + clusterName: props.clusterName, + sourceAgentId: sourceAgentId(), + pollMs: props.pollIntervalMs ?? POLL_INTERVAL_MS, + jobTimeoutMs: props.jobTimeoutMs ?? JOB_TIMEOUT_MS, + requestTimeoutMs: props.requestTimeoutMs ?? REQUEST_TIMEOUT_MS, + onInstalled: props.onInstalled, + }; + const targetIds = deployableNodes() + .filter((node) => selected().has(node.nodeId)) + .map((node) => node.nodeId); + if (targetIds.length === 0) return; + setPhase('running'); + setFailure(''); + for (const nodeId of targetIds) setNodeProgress(nodeId, { state: 'checking' }); + + try { + const preflight = await withRequestDeadline( + cfg, + ClusterAgentDeployAPI.createPreflight(cfg.clusterName, cfg.sourceAgentId, targetIds), + ); + const checked = await pollUntilSettled(cfg, () => + ClusterAgentDeployAPI.getPreflight(preflight.preflightId), + ); + if (!checked) return; + const checkedTargets = checked.targets ?? []; + const readyIds = checkedTargets + .filter((target) => target.status === 'ready') + .map((target) => target.nodeId); + // A node that gained an agent since the dialog opened is skipped by the + // check. It still needs confirming, not installing. + const toConfirm = checkedTargets + .filter((target) => target.status === 'skipped_already_agent') + .map((target) => target.nodeId); + if (readyIds.length > 0) { + for (const nodeId of readyIds) setNodeProgress(nodeId, { state: 'installing' }); + const job = await withRequestDeadline( + cfg, + ClusterAgentDeployAPI.createJob( + cfg.clusterName, + cfg.sourceAgentId, + preflight.preflightId, + readyIds, + ), + ); + const finished = await pollUntilSettled(cfg, () => ClusterAgentDeployAPI.getJob(job.jobId)); + if (!finished) return; + toConfirm.push( + ...(finished.targets ?? []) + .filter((target) => clusterDeployNodeStateFromTarget(target.status) === 'connecting') + .map((target) => target.nodeId), + ); + } + if (toConfirm.length > 0) await confirmReporting(cfg, toConfirm); + } catch (error) { + if (cfg.run !== runId) return; + setFailure(errorMessage(error, 'The install could not be started.')); + for (const nodeId of targetIds) { + if (progress()[nodeId]?.state !== 'reporting') { + setNodeProgress(nodeId, { state: 'failed' }); + } + } + } + if (cfg.run !== runId) return; + // A node the preflight dropped or the job skipped never gets a terminal + // status from the server, so close it out here rather than leaving it + // looking busy forever. + for (const nodeId of targetIds) { + const state = progress()[nodeId]?.state; + if (state === 'checking' || state === 'installing' || state === 'connecting') { + setNodeProgress(nodeId, { + state: 'failed', + detail: 'Pulse could not finish installing on this node.', + }); + } + } + setPhase('done'); + if (Object.values(progress()).some((entry) => entry.state === 'reporting')) { + cfg.onInstalled?.(); + } + }; + + const toggle = (nodeId: string, checked: boolean) => + setSelected((current) => { + const next = new Set(current); + if (checked) next.add(nodeId); + else next.delete(nodeId); + return next; + }); + + const nodeState = (node: ClusterDeployCandidateNode): ClusterAgentDeployNodeState => + progress()[node.nodeId]?.state ?? 'idle'; + + const anyFailed = createMemo(() => + Object.values(progress()).some((entry) => entry.state === 'failed'), + ); + + const useInstaller = () => { + props.onClose(); + props.onUseInstaller(); + }; + + return ( + + + + + +
+ + Looking up the cluster’s nodes… +
+
+ + +

+ Pulse installs from a node that already runs the Pulse Agent, and none of this cluster’s + nodes has one connected right now. Use the installer to add the first one. After that, + the rest can be installed from here. +

+
+ + + + + + +

+ Pulse will install the agent from {sourceNodeName()} over the SSH access your cluster + nodes already share. There is no token to copy. +

+ + 0} + fallback={ +

Every node in this cluster has the agent.

+ } + > +
    + + {(node) => ( +
  • +
    + + + + + + {NODE_STATE_PRESENTATION[nodeState(node)].label} + +
    + +

    {clusterDeployUnavailableReason(node)}

    +
    + +

    + {progress()[node.nodeId]?.detail} +

    +
    +
  • + )} +
    +
+
+ + +

+ You can close this. The install keeps going, and each node shows up as covered once it + reports to Pulse. +

+
+ + + + +
+ +
+ + + + + + + + + + +
+
+
+ ); +}; diff --git a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx index 33e8800370..c6f8bdc6f3 100644 --- a/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx +++ b/frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx @@ -52,6 +52,7 @@ import { type InfrastructureSourcePickerRouteStep, } from '@/utils/infrastructureOnboardingPresentation'; import { getAgentHostProfileFamily } from '@/utils/platformSupportManifest'; +import { ClusterAgentDeployDialog } from './ClusterAgentDeployDialog'; interface InfrastructureSourceManagerProps { rows: Accessor; @@ -467,6 +468,33 @@ export const InfrastructureSourceManager: Component(null); + const canInstallFromCluster = (row: InfrastructureSystemRow): boolean => + !props.readOnly && + row.isCluster && + Boolean(row.clusterName) && + row.members.some((member) => Boolean(member.agentConnection)); + const handleInstallAgent = ( + row: InfrastructureSystemRow, + member?: InfrastructureSystemMemberRow, + ) => { + if (canInstallFromCluster(row)) { + setClusterDeploy({ + clusterName: row.clusterName!, + nodeName: member?.nativeName || member?.name, + }); + return; + } + handleInstallAgentShortcut(); + }; + const hasAnyConfigured = createMemo(() => infrastructureRows().length > 0); const hasAnyDiscovered = createMemo(() => props.discoveredNodes().length > 0); @@ -571,11 +599,18 @@ export const InfrastructureSourceManager: Component 0 && (props.onAddSourceStep || props.onAddSource)) { const namesText = uncoveredAgentTargetNamesText(); const target = namesText ?? formatCount(uncoveredAgentTargetCount(), 'Proxmox host'); + // When every uncovered host sits in one cluster that can install from a + // sibling, the summary action opens that one-step install directly. + const uncoveredRows = infrastructureRows().filter((row) => rowNeedsAgentCoverage(row)); + const soleCluster = + uncoveredRows.length === 1 && canInstallFromCluster(uncoveredRows[0]) + ? uncoveredRows[0] + : undefined; return { kind: 'agent', label: uncoveredAgentTargetCount() === 1 ? 'Install agent' : 'Install agents', detail: `Install one Pulse Agent on each uncovered Proxmox host (${target}) to add node-local telemetry such as temperatures, SMART data, and host identity.`, - onClick: handleInstallAgentShortcut, + onClick: soleCluster ? () => handleInstallAgent(soleCluster) : handleInstallAgentShortcut, }; } @@ -1090,11 +1125,13 @@ export const InfrastructureSourceManager: Component handleInstallAgent(row)} aria-label={installAgentActionLabel(row)} title={ row.isCluster - ? 'Generate an installer command to run once on each uncovered Proxmox node.' + ? canInstallFromCluster(row) + ? 'Install Pulse Agent on the uncovered nodes from a node that already runs it.' + : 'Generate an installer command to run once on each uncovered Proxmox node.' : 'Install Pulse Agent on this system to add node-local telemetry (temperatures, SMART, host identity).' } > @@ -1255,9 +1292,15 @@ export const InfrastructureSourceManager: Component + handleInstallAgent(row, member) + } aria-label={`Install agent on ${member.name}`} - title={`Generate an installer command to run on ${member.name}.`} + title={ + canInstallFromCluster(row) + ? `Install Pulse Agent on ${member.name} from a node that already runs it.` + : `Generate an installer command to run on ${member.name}.` + } > Install agent @@ -1580,9 +1623,13 @@ export const InfrastructureSourceManager: Component handleInstallAgent(row, member)} aria-label={`Install agent on ${member.name}`} - title={`Generate an installer command to run on ${member.name}.`} + title={ + canInstallFromCluster(row) + ? `Install Pulse Agent on ${member.name} from a node that already runs it.` + : `Generate an installer command to run on ${member.name}.` + } > Install agent @@ -1669,11 +1716,13 @@ export const InfrastructureSourceManager: Component handleInstallAgent(row)} aria-label={installAgentActionLabel(row)} title={ row.isCluster - ? 'Generate an installer command to run once on each uncovered Proxmox node.' + ? canInstallFromCluster(row) + ? 'Install Pulse Agent on the uncovered nodes from a node that already runs it.' + : 'Generate an installer command to run once on each uncovered Proxmox node.' : 'Install Pulse Agent on this system to add node-local telemetry (temperatures, SMART, host identity).' } > @@ -1795,6 +1844,19 @@ export const InfrastructureSourceManager: Component
{discoveryMonitorBand()} + {/* Keyed so the dialog gets plain values: an install it started keeps + running after close, and must not read a stale accessor. */} + + {(target) => ( + setClusterDeploy(null)} + onUseInstaller={handleInstallAgentShortcut} + /> + )} + ); diff --git a/frontend-modern/src/components/Settings/__tests__/ClusterAgentDeployDialog.test.tsx b/frontend-modern/src/components/Settings/__tests__/ClusterAgentDeployDialog.test.tsx new file mode 100644 index 0000000000..321346bb79 --- /dev/null +++ b/frontend-modern/src/components/Settings/__tests__/ClusterAgentDeployDialog.test.tsx @@ -0,0 +1,399 @@ +import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { ClusterDeployCandidates, ClusterDeployJob } from '@/api/agentDeploy'; +import { + ClusterAgentDeployDialog, + clusterDeployNodeStateFromTarget, +} from '../ClusterAgentDeployDialog'; + +const api = vi.hoisted(() => ({ + getCandidates: vi.fn(), + createPreflight: vi.fn(), + getPreflight: vi.fn(), + createJob: vi.fn(), + getJob: vi.fn(), +})); + +vi.mock('@/api/agentDeploy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ClusterAgentDeployAPI: api }; +}); + +const candidates = (overrides: Partial = {}): ClusterDeployCandidates => ({ + clusterId: 'homelab', + clusterName: 'homelab', + sourceAgents: [{ agentId: 'agent-delly', nodeId: 'n-delly', online: true }], + nodes: [ + { + nodeId: 'n-delly', + name: 'delly', + ip: '192.168.0.5', + hasAgent: true, + deployable: false, + reason: 'already_agent', + }, + { nodeId: 'n-delly2', name: 'delly2', ip: '192.168.0.111', hasAgent: false, deployable: true }, + { nodeId: 'n-minipc', name: 'minipc', ip: '192.168.0.134', hasAgent: false, deployable: true }, + { nodeId: 'n-pi', name: 'pi', hasAgent: false, deployable: false, reason: 'no_address' }, + ], + ...overrides, +}); + +const job = ( + id: string, + status: ClusterDeployJob['status'], + targets: ClusterDeployJob['targets'], +): ClusterDeployJob => ({ id, clusterId: 'homelab', status, targets }); + +const renderDialog = (props: Partial[0]> = {}) => { + const onClose = vi.fn(); + const onUseInstaller = vi.fn(); + const onInstalled = vi.fn(); + render(() => ( + + )); + return { onClose, onUseInstaller, onInstalled }; +}; + +describe('ClusterAgentDeployDialog', () => { + beforeEach(() => { + Object.values(api).forEach((fn) => fn.mockReset()); + }); + afterEach(() => cleanup()); + + it('lists uncovered nodes, preselects the installable ones, and explains the rest', async () => { + api.getCandidates.mockResolvedValue(candidates()); + renderDialog(); + + const list = await screen.findByRole('list', { name: 'Cluster nodes without the Pulse Agent' }); + expect(within(list).queryByText('delly')).toBeNull(); + expect(within(list).getByRole('checkbox', { name: /delly2/ })).toBeChecked(); + expect(within(list).getByRole('checkbox', { name: /minipc/ })).toBeChecked(); + const pi = within(list).getByRole('checkbox', { name: /pi/ }); + expect(pi).toBeDisabled(); + expect(pi).not.toBeChecked(); + expect(screen.getByText(/Pulse has no IP address for this node/)).toBeInTheDocument(); + expect(screen.getByText(/install the agent from delly/)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Install on 2 nodes' })).toBeEnabled(); + expect(screen.getByRole('button', { name: 'Use the installer instead' })).toBeInTheDocument(); + }); + + it('selects only the member the operator clicked install on', async () => { + api.getCandidates.mockResolvedValue(candidates()); + renderDialog({ preselectNodeName: 'delly2' }); + + expect(await screen.findByRole('checkbox', { name: /delly2/ })).toBeChecked(); + expect(screen.getByRole('checkbox', { name: /minipc/ })).not.toBeChecked(); + expect(screen.getByRole('button', { name: 'Install on 1 node' })).toBeEnabled(); + }); + + it('checks, installs, and confirms the node is reporting before calling it done', async () => { + api.getCandidates.mockResolvedValueOnce(candidates()).mockResolvedValue( + candidates({ + nodes: candidates().nodes!.map((node) => + node.nodeId === 'n-delly2' ? { ...node, hasAgent: true, deployable: false } : node, + ), + }), + ); + api.createPreflight.mockResolvedValue({ preflightId: 'pf_1', status: 'running' }); + api.getPreflight.mockResolvedValue( + job('pf_1', 'succeeded', [ + { + id: 't1', + nodeId: 'n-delly2', + nodeName: 'delly2', + nodeIP: '192.168.0.111', + status: 'ready', + }, + ]), + ); + api.createJob.mockResolvedValue({ + jobId: 'dep_1', + acceptedTargets: ['n-delly2'], + skippedTargets: [], + }); + api.getJob.mockResolvedValue( + job('dep_1', 'succeeded', [ + { + id: 't2', + nodeId: 'n-delly2', + nodeName: 'delly2', + nodeIP: '192.168.0.111', + status: 'enrolling', + }, + ]), + ); + const { onInstalled } = renderDialog({ preselectNodeName: 'delly2' }); + + fireEvent.click(await screen.findByRole('button', { name: 'Install on 1 node' })); + + await waitFor(() => expect(screen.getByText('Reporting')).toBeInTheDocument()); + expect(api.createPreflight).toHaveBeenCalledWith('homelab', 'agent-delly', ['n-delly2']); + expect(api.createJob).toHaveBeenCalledWith('homelab', 'agent-delly', 'pf_1', ['n-delly2']); + expect(onInstalled).toHaveBeenCalledTimes(1); + expect(screen.getByRole('button', { name: 'Close' })).toBeInTheDocument(); + }); + + it('closes out a node the job never reported on instead of leaving it busy', async () => { + const reported = candidates({ + nodes: candidates().nodes!.map((node) => + node.nodeId === 'n-delly2' ? { ...node, hasAgent: true, deployable: false } : node, + ), + }); + api.getCandidates.mockResolvedValueOnce(candidates()).mockResolvedValue(reported); + api.createPreflight.mockResolvedValue({ preflightId: 'pf_3', status: 'running' }); + api.getPreflight.mockResolvedValue( + job('pf_3', 'succeeded', [ + { + id: 't1', + nodeId: 'n-delly2', + nodeName: 'delly2', + nodeIP: '192.168.0.111', + status: 'ready', + }, + { + id: 't2', + nodeId: 'n-minipc', + nodeName: 'minipc', + nodeIP: '192.168.0.134', + status: 'ready', + }, + ]), + ); + api.createJob.mockResolvedValue({ + jobId: 'dep_3', + acceptedTargets: ['n-delly2'], + skippedTargets: [{ nodeId: 'n-minipc', reason: 'preflight_status_failed' }], + }); + api.getJob.mockResolvedValue( + job('dep_3', 'succeeded', [ + { + id: 't3', + nodeId: 'n-delly2', + nodeName: 'delly2', + nodeIP: '192.168.0.111', + status: 'succeeded', + }, + ]), + ); + renderDialog(); + + fireEvent.click(await screen.findByRole('button', { name: 'Install on 2 nodes' })); + + expect( + await screen.findByText('Pulse could not finish installing on this node.'), + ).toBeInTheDocument(); + expect(screen.getByText('Reporting')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Use the installer instead' })).toBeInTheDocument(); + }); + + it('stops before installing when the check fails and offers the installer', async () => { + api.getCandidates.mockResolvedValue(candidates()); + api.createPreflight.mockResolvedValue({ preflightId: 'pf_2', status: 'running' }); + api.getPreflight.mockResolvedValue( + job('pf_2', 'failed', [ + { + id: 't1', + nodeId: 'n-delly2', + nodeName: 'delly2', + nodeIP: '192.168.0.111', + status: 'failed_permanent', + errorMessage: 'SSH to 192.168.0.111 was refused', + }, + ]), + ); + const { onUseInstaller, onClose } = renderDialog({ preselectNodeName: 'delly2' }); + + fireEvent.click(await screen.findByRole('button', { name: 'Install on 1 node' })); + + expect(await screen.findByText('SSH to 192.168.0.111 was refused')).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(api.createJob).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', { name: 'Use the installer instead' })); + expect(onClose).toHaveBeenCalled(); + expect(onUseInstaller).toHaveBeenCalled(); + }); + + it('hands off to the installer when no cluster node has a connected agent', async () => { + api.getCandidates.mockResolvedValue(candidates({ sourceAgents: [] })); + const { onUseInstaller } = renderDialog(); + + expect( + await screen.findByText(/none of this cluster’s nodes has one connected/), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Install on/ })).toBeNull(); + fireEvent.click(screen.getByRole('button', { name: 'Use the installer instead' })); + expect(onUseInstaller).toHaveBeenCalled(); + }); +}); + +describe('ClusterAgentDeployDialog edge cases', () => { + beforeEach(() => { + Object.values(api).forEach((fn) => fn.mockReset()); + }); + afterEach(() => cleanup()); + + it('selects nothing when the clicked member cannot be installed from here', async () => { + api.getCandidates.mockResolvedValue(candidates()); + renderDialog({ preselectNodeName: 'pi' }); + + expect(await screen.findByRole('checkbox', { name: /delly2/ })).not.toBeChecked(); + expect(screen.getByRole('checkbox', { name: /minipc/ })).not.toBeChecked(); + expect(screen.getByRole('button', { name: 'Install on 0 nodes' })).toBeDisabled(); + expect(screen.getByText(/Pulse has no IP address for this node/)).toBeInTheDocument(); + }); + + it('confirms a node that gained an agent during the check without installing it', async () => { + const covered = candidates({ + nodes: candidates().nodes!.map((node) => + node.nodeId === 'n-delly2' ? { ...node, hasAgent: true, deployable: false } : node, + ), + }); + api.getCandidates.mockResolvedValueOnce(candidates()).mockResolvedValue(covered); + api.createPreflight.mockResolvedValue({ preflightId: 'pf_4', status: 'running' }); + api.getPreflight.mockResolvedValue( + job('pf_4', 'succeeded', [ + { + id: 't1', + nodeId: 'n-delly2', + nodeName: 'delly2', + nodeIP: '192.168.0.111', + status: 'skipped_already_agent', + }, + ]), + ); + const { onInstalled } = renderDialog({ preselectNodeName: 'delly2' }); + + fireEvent.click(await screen.findByRole('button', { name: 'Install on 1 node' })); + + await waitFor(() => expect(screen.getByText('Reporting')).toBeInTheDocument()); + expect(api.createJob).not.toHaveBeenCalled(); + expect(onInstalled).toHaveBeenCalledTimes(1); + }); + + it('gives up on a stalled install with an explanation and the installer', async () => { + api.getCandidates.mockResolvedValue(candidates()); + api.createPreflight.mockResolvedValue({ preflightId: 'pf_5', status: 'running' }); + api.getPreflight.mockResolvedValue( + job('pf_5', 'succeeded', [ + { + id: 't1', + nodeId: 'n-delly2', + nodeName: 'delly2', + nodeIP: '192.168.0.111', + status: 'ready', + }, + ]), + ); + api.createJob.mockResolvedValue({ + jobId: 'dep_5', + acceptedTargets: ['n-delly2'], + skippedTargets: [], + }); + api.getJob.mockResolvedValue( + job('dep_5', 'running', [ + { + id: 't2', + nodeId: 'n-delly2', + nodeName: 'delly2', + nodeIP: '192.168.0.111', + status: 'installing', + }, + ]), + ); + renderDialog({ preselectNodeName: 'delly2', jobTimeoutMs: 20 }); + + fireEvent.click(await screen.findByRole('button', { name: 'Install on 1 node' })); + + expect(await screen.findByText(/Pulse stopped waiting after 20 minutes/)).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Use the installer instead' })).toBeInTheDocument(); + }); + + it('gives up on a status request that never answers', async () => { + api.getCandidates.mockResolvedValue(candidates()); + api.createPreflight.mockResolvedValue({ preflightId: 'pf_7', status: 'running' }); + api.getPreflight.mockReturnValue(new Promise(() => {})); + renderDialog({ preselectNodeName: 'delly2', requestTimeoutMs: 20 }); + + fireEvent.click(await screen.findByRole('button', { name: 'Install on 1 node' })); + + expect(await screen.findByText(/Pulse did not answer in time/)).toBeInTheDocument(); + expect(screen.getByText('Failed')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Use the installer instead' })).toBeInTheDocument(); + }); + + it('keeps installing after the dialog is closed mid-run', async () => { + api.getCandidates.mockResolvedValue(candidates()); + api.createPreflight.mockResolvedValue({ preflightId: 'pf_6', status: 'running' }); + api.getPreflight.mockResolvedValue( + job('pf_6', 'succeeded', [ + { + id: 't1', + nodeId: 'n-delly2', + nodeName: 'delly2', + nodeIP: '192.168.0.111', + status: 'ready', + }, + ]), + ); + api.createJob.mockResolvedValue({ + jobId: 'dep_6', + acceptedTargets: ['n-delly2'], + skippedTargets: [], + }); + // The job ends on its first poll so the orphaned run finishes instead of + // polling on in the background after this test. + api.getJob.mockResolvedValue( + job('dep_6', 'failed', [ + { + id: 't2', + nodeId: 'n-delly2', + nodeName: 'delly2', + nodeIP: '192.168.0.111', + status: 'failed_permanent', + }, + ]), + ); + renderDialog({ preselectNodeName: 'delly2' }); + + fireEvent.click(await screen.findByRole('button', { name: 'Install on 1 node' })); + cleanup(); + + await waitFor(() => + expect(api.createJob).toHaveBeenCalledWith('homelab', 'agent-delly', 'pf_6', ['n-delly2']), + ); + }); + + it('shows the server refusal for an ambiguous cluster and offers the installer', async () => { + api.getCandidates.mockRejectedValue( + new Error('More than one Proxmox connection reports a cluster named "homelab".'), + ); + const { onUseInstaller } = renderDialog(); + + expect(await screen.findByRole('alert')).toHaveTextContent(/More than one Proxmox connection/); + fireEvent.click(screen.getByRole('button', { name: 'Use the installer instead' })); + expect(onUseInstaller).toHaveBeenCalled(); + }); +}); + +describe('clusterDeployNodeStateFromTarget', () => { + it('folds server target statuses into the steps an operator sees', () => { + expect(clusterDeployNodeStateFromTarget('preflighting')).toBe('checking'); + expect(clusterDeployNodeStateFromTarget('ready')).toBe('checking'); + expect(clusterDeployNodeStateFromTarget('installing')).toBe('installing'); + expect(clusterDeployNodeStateFromTarget('enrolling')).toBe('connecting'); + expect(clusterDeployNodeStateFromTarget('succeeded')).toBe('connecting'); + expect(clusterDeployNodeStateFromTarget('failed_retryable')).toBe('failed'); + expect(clusterDeployNodeStateFromTarget('canceled')).toBe('failed'); + }); +}); diff --git a/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx b/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx index 641b5b1e34..89b27335d3 100644 --- a/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx +++ b/frontend-modern/src/components/Settings/__tests__/InfrastructureSourceManager.test.tsx @@ -1,4 +1,4 @@ -import { cleanup, fireEvent, render, screen, within } from '@solidjs/testing-library'; +import { cleanup, fireEvent, render, screen, waitFor, within } from '@solidjs/testing-library'; import { afterEach, describe, expect, it, vi } from 'vitest'; import infrastructureSourceManagerSource from '../InfrastructureSourceManager.tsx?raw'; import { @@ -13,6 +13,19 @@ import { } from '../connectionsTableModel'; import type { Connection } from '@/api/connections'; +const clusterDeployApi = vi.hoisted(() => ({ + getCandidates: vi.fn(), + createPreflight: vi.fn(), + getPreflight: vi.fn(), + createJob: vi.fn(), + getJob: vi.fn(), +})); + +vi.mock('@/api/agentDeploy', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, ClusterAgentDeployAPI: clusterDeployApi }; +}); + const connectionFixture = (overrides: Partial = {}): Connection => ({ id: 'agent:host-1', type: 'agent', @@ -569,6 +582,182 @@ describe('InfrastructureSourceManager setup summary', () => { expect(onAddSourceStep).toHaveBeenCalledWith('linux-host'); }); + it('installs uncovered cluster members in one step when a sibling already runs the agent', async () => { + clusterDeployApi.getCandidates.mockResolvedValue({ + clusterId: 'remote', + clusterName: 'remote', + sourceAgents: [{ agentId: 'agent-pve-a', nodeId: 'n-a', online: true }], + nodes: [ + { nodeId: 'n-a', name: 'pve-a', ip: '10.15.5.11', hasAgent: true, deployable: false }, + { nodeId: 'n-b', name: 'pve-b', ip: '10.15.5.12', hasAgent: false, deployable: true }, + ], + }); + const onAddSourceStep = vi.fn(); + const attachedAgent = connectionFixture({ id: 'agent:pve-a', name: 'pve-a', address: 'pve-a' }); + + render(() => ( + [ + row({ + id: 'pve:remote-cluster', + ownerType: 'pve', + name: 'remote', + clusterName: 'remote', + subtitle: 'Cluster · 2 nodes', + source: 'both', + isCluster: true, + canEdit: true, + connection: connectionFixture({ + id: 'pve:remote-cluster', + type: 'pve', + name: 'remote', + source: 'manual', + capabilities: { supportsPause: true, supportsScope: true, supportsTest: true }, + }), + attachedConnections: [attachedAgent], + members: [ + member({ + id: 'pve-a', + name: 'pve-a', + source: 'both', + agentConnection: attachedAgent, + }), + member({ + id: 'pve-b', + name: 'pve-b', + source: 'api', + coverageLabels: [], + agentConnection: undefined, + }), + ], + }), + ]} + discoveredNodes={() => []} + discoveryEnabled={false} + discoveryScanStatus={() => ({ scanning: false })} + readOnly={false} + onAddSourceStep={onAddSourceStep} + onOpenConnection={vi.fn()} + /> + )); + + fireEvent.click(screen.getByRole('button', { name: 'Show 2 nodes for remote' })); + fireEvent.click(screen.getByRole('button', { name: 'Install agent on pve-b' })); + + const dialog = await screen.findByRole('dialog', { + name: 'Install Pulse Agent on remote nodes', + }); + expect(await within(dialog).findByRole('checkbox', { name: /pve-b/ })).toBeChecked(); + expect(clusterDeployApi.getCandidates).toHaveBeenCalledWith('remote'); + expect(onAddSourceStep).not.toHaveBeenCalled(); + }); + + it('keeps a started cluster install going after the dialog is closed', async () => { + clusterDeployApi.getCandidates.mockResolvedValue({ + clusterId: 'remote', + clusterName: 'remote', + sourceAgents: [{ agentId: 'agent-pve-a', nodeId: 'n-a', online: true }], + nodes: [ + { nodeId: 'n-a', name: 'pve-a', ip: '10.15.5.11', hasAgent: true, deployable: false }, + { nodeId: 'n-b', name: 'pve-b', ip: '10.15.5.12', hasAgent: false, deployable: true }, + ], + }); + clusterDeployApi.createPreflight.mockResolvedValue({ preflightId: 'pf_r', status: 'running' }); + clusterDeployApi.getPreflight.mockResolvedValue({ + id: 'pf_r', + clusterId: 'remote', + status: 'succeeded', + targets: [ + { id: 't1', nodeId: 'n-b', nodeName: 'pve-b', nodeIP: '10.15.5.12', status: 'ready' }, + ], + }); + clusterDeployApi.createJob.mockResolvedValue({ + jobId: 'dep_r', + acceptedTargets: ['n-b'], + skippedTargets: [], + }); + clusterDeployApi.getJob.mockResolvedValue({ + id: 'dep_r', + clusterId: 'remote', + status: 'failed', + targets: [ + { + id: 't2', + nodeId: 'n-b', + nodeName: 'pve-b', + nodeIP: '10.15.5.12', + status: 'failed_permanent', + }, + ], + }); + const attachedAgent = connectionFixture({ id: 'agent:pve-a', name: 'pve-a', address: 'pve-a' }); + + render(() => ( + [ + row({ + id: 'pve:remote-cluster', + ownerType: 'pve', + name: 'remote', + clusterName: 'remote', + subtitle: 'Cluster · 2 nodes', + source: 'both', + isCluster: true, + canEdit: true, + connection: connectionFixture({ + id: 'pve:remote-cluster', + type: 'pve', + name: 'remote', + source: 'manual', + capabilities: { supportsPause: true, supportsScope: true, supportsTest: true }, + }), + attachedConnections: [attachedAgent], + members: [ + member({ + id: 'pve-a', + name: 'pve-a', + source: 'both', + agentConnection: attachedAgent, + }), + member({ + id: 'pve-b', + name: 'pve-b', + source: 'api', + coverageLabels: [], + agentConnection: undefined, + }), + ], + }), + ]} + discoveredNodes={() => []} + discoveryEnabled={false} + discoveryScanStatus={() => ({ scanning: false })} + readOnly={false} + onAddSourceStep={vi.fn()} + onOpenConnection={vi.fn()} + /> + )); + + fireEvent.click(screen.getByRole('button', { name: 'Show 2 nodes for remote' })); + fireEvent.click(screen.getByRole('button', { name: 'Install agent on pve-b' })); + const dialog = await screen.findByRole('dialog', { + name: 'Install Pulse Agent on remote nodes', + }); + fireEvent.click(await within(dialog).findByRole('button', { name: 'Install on 1 node' })); + fireEvent.click(await within(dialog).findByRole('button', { name: 'Close' })); + + // The parent has unmounted the dialog; the run must still start the install + // with the cluster it captured, rather than reading a stale accessor. + expect(screen.queryByRole('dialog')).toBeNull(); + await waitFor( + () => + expect(clusterDeployApi.createJob).toHaveBeenCalledWith('remote', 'agent-pve-a', 'pf_r', [ + 'n-b', + ]), + { timeout: 6000 }, + ); + }); + it('offers the same uncovered-node onboarding in the narrow card layout', () => { Object.defineProperty(window, 'innerWidth', { configurable: true, value: 600 }); const onAddSourceStep = vi.fn(); diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index ee9677155d..acff1fc1b9 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1650,6 +1650,13 @@ describe('settings architecture guardrails', () => { expect(infrastructureSourceManagerSource).toContain('Manage'); expect(infrastructureSourceManagerSource).not.toContain('Detect address'); expect(infrastructureSourceManagerSource).toContain("'Install agent'"); + // Cluster members install in one step through the server-side deploy, so + // the install actions route through the canonical dialog and keep the + // scoped installer as the fallback instead of a hand-copied token. + expect(infrastructureSourceManagerSource).toContain("from './ClusterAgentDeployDialog'"); + expect(infrastructureSourceManagerSource).toContain(''); expect(infrastructureSourceManagerSource).toContain('Add infrastructure'); expect(infrastructureSourceManagerSource).not.toContain('Monitor endpoint'); expect(infrastructureSourceManagerSource).toContain('getInfrastructureEmptyStateSummary'); diff --git a/frontend-modern/src/components/Settings/connectionsTableModel.ts b/frontend-modern/src/components/Settings/connectionsTableModel.ts index 241aabb4f5..5c1c874426 100644 --- a/frontend-modern/src/components/Settings/connectionsTableModel.ts +++ b/frontend-modern/src/components/Settings/connectionsTableModel.ts @@ -1017,6 +1017,8 @@ export interface InfrastructureSystemRow { canRemove: boolean; isAgent: boolean; isCluster: boolean; + /** Proxmox cluster name for cluster rows; the cluster agent deploy API is keyed on it. */ + clusterName?: string; attachedConnections: Connection[]; members: InfrastructureSystemMemberRow[]; connection: Connection; diff --git a/frontend-modern/src/components/Settings/useConnectionsLedger.ts b/frontend-modern/src/components/Settings/useConnectionsLedger.ts index edb7508a99..2ab8538b99 100644 --- a/frontend-modern/src/components/Settings/useConnectionsLedger.ts +++ b/frontend-modern/src/components/Settings/useConnectionsLedger.ts @@ -430,6 +430,7 @@ const buildRow = ( canRemove: primaryConnection.type !== 'docker' && primaryConnection.type !== 'kubernetes', isAgent: primaryConnection.type === 'agent', isCluster, + ...(isCluster ? { clusterName } : {}), attachedConnections, members, connection: primaryConnection, diff --git a/internal/api/deploy_handlers.go b/internal/api/deploy_handlers.go index 5004e93c50..219193f8dc 100644 --- a/internal/api/deploy_handlers.go +++ b/internal/api/deploy_handlers.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "net" "net/http" "net/url" "strings" @@ -34,6 +35,8 @@ type DeployHandlers struct { config *config.Config persistence *config.ConfigPersistence + // jobAdmissionMu serializes install-job admission (see HandleCreateJob). + jobAdmissionMu sync.Mutex // Active preflight SSE subscriptions keyed by preflightID. sseMu sync.Mutex sseSubs map[string]*deploySSESub @@ -122,6 +125,7 @@ func (h *DeployHandlers) HandleCandidates(w http.ResponseWriter, r *http.Request nodes []candidateNode sourceAgents []sourceAgentInfo ) + connections := make(map[string]bool) for _, node := range readState.Nodes() { if node == nil { @@ -134,6 +138,7 @@ func (h *DeployHandlers) HandleCandidates(w http.ResponseWriter, r *http.Request if node.ClusterName() != clusterID { continue } + connections[node.Instance()] = true if clusterName == "" { clusterName = node.ClusterName() } @@ -142,7 +147,7 @@ func (h *DeployHandlers) HandleCandidates(w http.ResponseWriter, r *http.Request cn := candidateNode{ NodeID: node.ID(), Name: nodeName(node), - IP: nodeIP(node.HostURL()), + IP: h.deployTargetIP(node), HasAgent: hasAgent, } @@ -159,6 +164,12 @@ func (h *DeployHandlers) HandleCandidates(w http.ResponseWriter, r *http.Request Online: true, }) } + } else if cn.IP == "" { + // Preflight would reject the target anyway; say why up front so + // the UI can point the operator at the member's connection + // address instead of offering an install that cannot start. + cn.Deployable = false + cn.Reason = "no_address" } else { cn.Deployable = true } @@ -166,6 +177,11 @@ func (h *DeployHandlers) HandleCandidates(w http.ResponseWriter, r *http.Request nodes = append(nodes, cn) } + if len(connections) > 1 { + writeAmbiguousClusterError(w, clusterID) + return + } + resp := candidatesResponse{ ClusterID: clusterID, ClusterName: clusterName, @@ -241,12 +257,14 @@ func (h *DeployHandlers) HandleCreatePreflight(w http.ResponseWriter, r *http.Re clusterName := "" sourceNodeID := "" nodesByID := make(map[string]*unifiedresources.NodeView) + connections := make(map[string]bool) for _, node := range readState.Nodes() { if node == nil { continue } if node.ClusterName() == clusterID && node.IsClusterMember() { nodesByID[node.ID()] = node + connections[node.Instance()] = true if clusterName == "" { clusterName = node.ClusterName() } @@ -256,6 +274,11 @@ func (h *DeployHandlers) HandleCreatePreflight(w http.ResponseWriter, r *http.Re } } + if len(connections) > 1 { + writeAmbiguousClusterError(w, clusterID) + return + } + if sourceNodeID == "" { writeErrorResponse(w, http.StatusBadRequest, "source_not_in_cluster", "Source agent is not linked to a node in this cluster", nil) @@ -295,9 +318,9 @@ func (h *DeployHandlers) HandleCreatePreflight(w http.ResponseWriter, r *http.Re if !ok { continue // skip nodes not in cluster } - ip := nodeIP(node.HostURL()) + ip := h.deployTargetIP(node) if ip == "" { - continue // skip nodes without IP + continue // skip nodes without a literal IP the agent can reach } targetID := generateID("tgt") @@ -1058,6 +1081,24 @@ func (h *DeployHandlers) HandleCreateJob(w http.ResponseWriter, r *http.Request) "Preflight source agent does not match request source agent", nil) return } + // Hold admission from the active-job check through the new job's insert, + // so two concurrent requests cannot both pass and start overlapping + // installs. The deploy store is this server's own SQLite file, so an + // in-process lock serializes every admission against it. + h.jobAdmissionMu.Lock() + defer h.jobAdmissionMu.Unlock() + activeJobID, err := h.activeDeployJobForCluster(ctx, orgID, clusterID) + if err != nil { + log.Error().Err(err).Str("cluster_id", clusterID).Msg("Failed to check for an active deploy job") + writeErrorResponse(w, http.StatusInternalServerError, "store_error", "Failed to check for an active deploy job", nil) + return + } + if activeJobID != "" { + writeErrorResponse(w, http.StatusConflict, "deploy_in_progress", + "An agent install is already running on this cluster. Wait for it to finish, then try again.", + map[string]string{"jobId": activeJobID}) + return + } // Get preflight targets — filter requested nodeIDs against Ready targets. pfTargets, err := h.store.GetTargetsForJob(ctx, req.PreflightID) @@ -1748,6 +1789,93 @@ func extractPathSuffix(path, prefix string) string { return strings.TrimSpace(s) } +// deployTargetIP returns the literal IP the source agent should SSH to for a +// cluster member, or "" when Pulse knows of none. The agent only accepts +// literal IPs (validateNodeIP guards the SSH command against injection), but +// Pulse often discovers members by name, so the API URL can carry a hostname +// such as "pve-b" that would fail preflight. Mirror the monitor's endpoint +// precedence: a literal IP in the API URL first, then the cluster endpoint's +// effective IP (the operator override, else the address Proxmox reports). +func (h *DeployHandlers) deployTargetIP(node *unifiedresources.NodeView) string { + if node == nil { + return "" + } + if host := nodeIP(node.HostURL()); net.ParseIP(host) != nil { + return host + } + if endpoint, ok := h.clusterEndpointForNode(node); ok { + if ip := strings.TrimSpace(endpoint.EffectiveIP()); net.ParseIP(ip) != nil { + return ip + } + } + return "" +} + +// clusterEndpointForNode finds the configured cluster endpoint for a member, +// matching the PVE connection by exact instance name and the endpoint by +// Proxmox node name. There is deliberately no cluster-name fallback: two +// sites can share a cluster name, and a wrong match would send a source agent +// privileged SSH work against another site's address. +func (h *DeployHandlers) clusterEndpointForNode(node *unifiedresources.NodeView) (config.ClusterEndpoint, bool) { + if h == nil || h.config == nil || node == nil { + return config.ClusterEndpoint{}, false + } + memberName := strings.TrimSpace(node.NodeName()) + if memberName == "" { + memberName = strings.TrimSpace(node.Name()) + } + instance := node.Instance() + if memberName == "" || instance == "" { + return config.ClusterEndpoint{}, false + } + for _, inst := range h.config.PVEInstances { + if !inst.IsCluster || inst.Name != instance { + continue + } + for _, endpoint := range inst.ClusterEndpoints { + if strings.EqualFold(strings.TrimSpace(endpoint.NodeName), memberName) { + return endpoint, true + } + } + } + return config.ClusterEndpoint{}, false +} + +// writeAmbiguousClusterError refuses a deploy request whose cluster name is +// reported by more than one Proxmox connection. The deploy routes are keyed +// on the cluster name, so serving such a request would merge two sites' +// members and could send a source agent to the other site's addresses. +func writeAmbiguousClusterError(w http.ResponseWriter, clusterID string) { + writeErrorResponse(w, http.StatusConflict, "ambiguous_cluster", + fmt.Sprintf("More than one Proxmox connection reports a cluster named %q, so Pulse cannot tell which nodes to install on. Use the installer for these nodes instead.", clusterID), + nil) +} + +// deployActiveWindow bounds how long an unfinished install job blocks the next +// one on the same cluster. A job whose source agent vanished mid-run can stay +// "running" in the store and must not lock the cluster out indefinitely. +const deployActiveWindow = 30 * time.Minute + +// activeDeployJobForCluster returns the ID of a recent install job on the +// cluster that has not finished, so overlapping installs (a reopened dialog, +// a second tab) are refused instead of racing over the same targets. +// Callers must hold jobAdmissionMu so the check and the new job's insert are +// atomic with respect to a concurrent request for the same cluster. +func (h *DeployHandlers) activeDeployJobForCluster(ctx context.Context, orgID, clusterID string) (string, error) { + jobs, err := h.store.UnfinishedJobsForCluster(ctx, orgID, clusterID) + if err != nil { + return "", err + } + cutoff := time.Now().Add(-deployActiveWindow) + for _, job := range jobs { + // Preflights share the jobs table; only install jobs conflict. + if strings.HasPrefix(job.ID, "dep_") && !job.CreatedAt.Before(cutoff) { + return job.ID, nil + } + } + return "", nil +} + // nodeIP extracts the hostname/IP from a node host URL (e.g. "https://198.51.100.2:8006" -> "198.51.100.2"). func nodeIP(hostURL string) string { raw := strings.TrimSpace(hostURL) diff --git a/internal/api/deploy_handlers_test.go b/internal/api/deploy_handlers_test.go index a79461f8a6..99312fc2f0 100644 --- a/internal/api/deploy_handlers_test.go +++ b/internal/api/deploy_handlers_test.go @@ -9,6 +9,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" "time" @@ -1808,3 +1809,332 @@ func TestGetTarget(t *testing.T) { t.Fatalf("got Arch=%q, want amd64", target.Arch) } } + +// clusterDeployAddressFixture models a cluster whose members Pulse discovered by +// name: the API URLs carry hostnames, while the cluster endpoints record the +// addresses Proxmox reports (and, for pve-c, an operator override). +func clusterDeployAddressFixture(t *testing.T) *DeployHandlers { + t.Helper() + nodes := []models.Node{ + { + ID: "node_pve-a", Name: "pve-a", Host: "https://10.0.0.1:8006", Instance: "lab-conn", + IsClusterMember: true, ClusterName: "lab", LinkedAgentID: "host-a", + }, + { + ID: "node_pve-b", Name: "pve-b", Host: "https://pve-b:8006", Instance: "lab-conn", + IsClusterMember: true, ClusterName: "lab", + }, + { + ID: "node_pve-c", Name: "pve-c", Host: "https://pve-c:8006", Instance: "lab-conn", + IsClusterMember: true, ClusterName: "lab", + }, + { + ID: "node_pve-d", Name: "pve-d", Host: "https://pve-d:8006", Instance: "lab-conn", + IsClusterMember: true, ClusterName: "lab", + }, + { + ID: "node_pve-e", Name: "pve-e", Host: "https://10.0.0.5:8006", Instance: "lab-conn", + IsClusterMember: true, ClusterName: "lab", + }, + } + h := newTestDeployHandlers(t, nodes, nil) + h.config.PVEInstances = []config.PVEInstance{{ + Name: "lab-conn", + IsCluster: true, + ClusterName: "lab", + ClusterEndpoints: []config.ClusterEndpoint{ + {NodeName: "pve-a", Host: "https://10.0.0.1:8006", IP: "10.0.0.1"}, + {NodeName: "pve-b", Host: "https://pve-b:8006", IP: "10.0.0.2"}, + {NodeName: "pve-c", Host: "https://pve-c:8006", IP: "10.0.0.3", IPOverride: "10.0.9.3"}, + // pve-d has no recorded address at all. + {NodeName: "pve-d", Host: "https://pve-d:8006"}, + // pve-e's API URL already holds a literal IP; that wins. + {NodeName: "pve-e", Host: "https://10.0.0.5:8006", IP: "10.0.0.55"}, + }, + }} + return h +} + +func TestHandleCandidatesResolvesHostnameMembersToClusterAddress(t *testing.T) { + h := clusterDeployAddressFixture(t) + req := httptest.NewRequest(http.MethodGet, "/api/clusters/lab/agent-deploy/candidates", nil) + rec := httptest.NewRecorder() + + h.HandleCandidates(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected 200, got %d. Body: %s", rec.Code, rec.Body.String()) + } + var resp candidatesResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v", err) + } + + want := map[string]struct { + ip string + deployable bool + reason string + }{ + "pve-a": {ip: "10.0.0.1", deployable: false, reason: "already_agent"}, + "pve-b": {ip: "10.0.0.2", deployable: true}, + "pve-c": {ip: "10.0.9.3", deployable: true}, + "pve-d": {ip: "", deployable: false, reason: "no_address"}, + "pve-e": {ip: "10.0.0.5", deployable: true}, + } + if len(resp.Nodes) != len(want) { + t.Fatalf("expected %d nodes, got %d: %+v", len(want), len(resp.Nodes), resp.Nodes) + } + for _, n := range resp.Nodes { + w, ok := want[n.Name] + if !ok { + t.Errorf("unexpected node %q", n.Name) + continue + } + if n.IP != w.ip || n.Deployable != w.deployable || n.Reason != w.reason { + t.Errorf("%s: got ip=%q deployable=%v reason=%q, want ip=%q deployable=%v reason=%q", + n.Name, n.IP, n.Deployable, n.Reason, w.ip, w.deployable, w.reason) + } + } +} + +// candidateNodeID returns the unified node ID the candidates endpoint reports +// for a member name, which is what the UI sends back as a preflight target. +func candidateNodeID(t *testing.T, h *DeployHandlers, name string) string { + t.Helper() + rec := httptest.NewRecorder() + h.HandleCandidates(rec, httptest.NewRequest(http.MethodGet, "/api/clusters/lab/agent-deploy/candidates", nil)) + var resp candidatesResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode candidates: %v", err) + } + for _, n := range resp.Nodes { + if n.Name == name { + return n.NodeID + } + } + t.Fatalf("candidate %q not found in %+v", name, resp.Nodes) + return "" +} + +func TestHandleCreatePreflightSendsClusterAddressForHostnameMember(t *testing.T) { + h := clusterDeployAddressFixture(t) + t.Cleanup(h.execServer.TestRegisterAgent("host-a", "host-a")) + + body := `{"sourceAgentId":"host-a","targetNodeIds":["` + candidateNodeID(t, h, "pve-b") + `"],"maxParallel":1}` + req := httptest.NewRequest(http.MethodPost, "/api/clusters/lab/agent-deploy/preflights", strings.NewReader(body)) + rec := httptest.NewRecorder() + + h.HandleCreatePreflight(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("expected 202, got %d: %s", rec.Code, rec.Body.String()) + } + var resp createPreflightResponse + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + targets, err := h.store.GetTargetsForJob(context.Background(), resp.PreflightID) + if err != nil { + t.Fatalf("get targets: %v", err) + } + if len(targets) != 1 { + t.Fatalf("expected 1 target, got %d", len(targets)) + } + if targets[0].NodeIP != "10.0.0.2" { + t.Fatalf("target NodeIP = %q, want the cluster-reported 10.0.0.2 rather than the hostname", targets[0].NodeIP) + } +} + +func TestHandleCreatePreflightRejectsMemberWithoutAddress(t *testing.T) { + h := clusterDeployAddressFixture(t) + t.Cleanup(h.execServer.TestRegisterAgent("host-a", "host-a")) + + body := `{"sourceAgentId":"host-a","targetNodeIds":["` + candidateNodeID(t, h, "pve-d") + `"],"maxParallel":1}` + req := httptest.NewRequest(http.MethodPost, "/api/clusters/lab/agent-deploy/preflights", strings.NewReader(body)) + rec := httptest.NewRecorder() + + h.HandleCreatePreflight(rec, req) + + if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "no_valid_targets") { + t.Fatalf("expected 400 no_valid_targets, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestDeployRefusesClusterNameSharedByTwoConnections(t *testing.T) { + // Two sites whose clusters happen to share a name must not be merged: the + // deploy routes are keyed on the name, so serving them would let a source + // agent at one site be pointed at the other site's member addresses. + nodes := []models.Node{ + { + ID: "node_a1", Name: "pve-a", Host: "https://10.0.0.1:8006", Instance: "site-a", + IsClusterMember: true, ClusterName: "lab", LinkedAgentID: "host-a", + }, + { + ID: "node_b1", Name: "pve-b", Host: "https://10.9.0.1:8006", Instance: "site-b", + IsClusterMember: true, ClusterName: "lab", + }, + } + h := newTestDeployHandlers(t, nodes, nil) + t.Cleanup(h.execServer.TestRegisterAgent("host-a", "host-a")) + + rec := httptest.NewRecorder() + h.HandleCandidates(rec, httptest.NewRequest(http.MethodGet, "/api/clusters/lab/agent-deploy/candidates", nil)) + if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "ambiguous_cluster") { + t.Fatalf("candidates: expected 409 ambiguous_cluster, got %d: %s", rec.Code, rec.Body.String()) + } + + body := `{"sourceAgentId":"host-a","targetNodeIds":["anything"],"maxParallel":1}` + rec = httptest.NewRecorder() + h.HandleCreatePreflight(rec, httptest.NewRequest(http.MethodPost, "/api/clusters/lab/agent-deploy/preflights", strings.NewReader(body))) + if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "ambiguous_cluster") { + t.Fatalf("preflight: expected 409 ambiguous_cluster, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestDeployTargetIPNeverBorrowsAnotherConnectionsEndpoint(t *testing.T) { + nodes := []models.Node{ + { + ID: "node_pve-a", Name: "pve-a", Host: "https://10.0.0.1:8006", Instance: "site-a", + IsClusterMember: true, ClusterName: "lab", LinkedAgentID: "host-a", + }, + { + ID: "node_pve-b", Name: "pve-b", Host: "https://pve-b:8006", Instance: "site-a", + IsClusterMember: true, ClusterName: "lab", + }, + } + h := newTestDeployHandlers(t, nodes, nil) + // site-b is listed first and has a same-named cluster and member; its + // address must never be used for site-a's pve-b. + h.config.PVEInstances = []config.PVEInstance{ + { + Name: "site-b", IsCluster: true, ClusterName: "lab", + ClusterEndpoints: []config.ClusterEndpoint{{NodeName: "pve-b", IP: "10.9.0.2"}}, + }, + { + Name: "site-a", IsCluster: true, ClusterName: "lab", + ClusterEndpoints: []config.ClusterEndpoint{{NodeName: "pve-b", IP: "10.0.0.2"}}, + }, + } + + rec := httptest.NewRecorder() + h.HandleCandidates(rec, httptest.NewRequest(http.MethodGet, "/api/clusters/lab/agent-deploy/candidates", nil)) + var resp candidatesResponse + if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil { + t.Fatalf("decode: %v (body %s)", err, rec.Body.String()) + } + for _, n := range resp.Nodes { + if n.Name == "pve-b" && n.IP != "10.0.0.2" { + t.Fatalf("pve-b resolved to %q, want its own connection's 10.0.0.2", n.IP) + } + } +} + +func TestHandleCreateJob_RefusesWhileAnotherInstallIsRunning(t *testing.T) { + nodes := []models.Node{ + { + ID: "node_pve-a", Name: "pve-a", Host: "https://10.0.0.1:8006", + IsClusterMember: true, ClusterName: "lab", LinkedAgentID: "host-a", + }, + { + ID: "node_pve-b", Name: "pve-b", Host: "https://10.0.0.2:8006", + IsClusterMember: true, ClusterName: "lab", + }, + } + h := newTestDeployHandlers(t, nodes, nil) + ctx := context.Background() + t.Cleanup(h.execServer.TestRegisterAgent("host-a", "host-a")) + + now := time.Now().UTC() + for _, j := range []*deploy.Job{ + {ID: "pf_ok", ClusterID: "lab", ClusterName: "lab", SourceAgentID: "host-a", SourceNodeID: "node_pve-a", + OrgID: "default", Status: deploy.JobSucceeded, MaxParallel: 1, CreatedAt: now, UpdatedAt: now}, + // A stale install that never finished must not block anything. + {ID: "dep_stale", ClusterID: "lab", ClusterName: "lab", SourceAgentID: "host-a", SourceNodeID: "node_pve-a", + OrgID: "default", Status: deploy.JobRunning, MaxParallel: 1, + CreatedAt: now.Add(-2 * deployActiveWindow), UpdatedAt: now.Add(-2 * deployActiveWindow)}, + } { + if err := h.store.CreateJob(ctx, j); err != nil { + t.Fatalf("create job %s: %v", j.ID, err) + } + } + if err := h.store.CreateTarget(ctx, &deploy.Target{ + ID: "tgt_b", JobID: "pf_ok", NodeID: "node_pve-b", NodeName: "pve-b", NodeIP: "10.0.0.2", + Arch: "amd64", Status: deploy.TargetReady, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("create target: %v", err) + } + body := `{"sourceAgentId":"host-a","preflightId":"pf_ok","targetNodeIds":["node_pve-b"]}` + + rec := httptest.NewRecorder() + h.HandleCreateJob(rec, httptest.NewRequest(http.MethodPost, "/api/clusters/lab/agent-deploy/jobs", strings.NewReader(body))) + if rec.Code != http.StatusAccepted { + t.Fatalf("first install: expected 202 despite the stale job, got %d: %s", rec.Code, rec.Body.String()) + } + + rec = httptest.NewRecorder() + h.HandleCreateJob(rec, httptest.NewRequest(http.MethodPost, "/api/clusters/lab/agent-deploy/jobs", strings.NewReader(body))) + if rec.Code != http.StatusConflict || !strings.Contains(rec.Body.String(), "deploy_in_progress") { + t.Fatalf("overlapping install: expected 409 deploy_in_progress, got %d: %s", rec.Code, rec.Body.String()) + } +} + +func TestHandleCreateJob_ConcurrentRequestsAdmitOneInstall(t *testing.T) { + nodes := []models.Node{ + { + ID: "node_pve-a", Name: "pve-a", Host: "https://10.0.0.1:8006", + IsClusterMember: true, ClusterName: "lab", LinkedAgentID: "host-a", + }, + { + ID: "node_pve-b", Name: "pve-b", Host: "https://10.0.0.2:8006", + IsClusterMember: true, ClusterName: "lab", + }, + } + h := newTestDeployHandlers(t, nodes, nil) + ctx := context.Background() + t.Cleanup(h.execServer.TestRegisterAgent("host-a", "host-a")) + + now := time.Now().UTC() + if err := h.store.CreateJob(ctx, &deploy.Job{ + ID: "pf_ok", ClusterID: "lab", ClusterName: "lab", SourceAgentID: "host-a", SourceNodeID: "node_pve-a", + OrgID: "default", Status: deploy.JobSucceeded, MaxParallel: 1, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("create preflight: %v", err) + } + if err := h.store.CreateTarget(ctx, &deploy.Target{ + ID: "tgt_b", JobID: "pf_ok", NodeID: "node_pve-b", NodeName: "pve-b", NodeIP: "10.0.0.2", + Arch: "amd64", Status: deploy.TargetReady, CreatedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("create target: %v", err) + } + body := `{"sourceAgentId":"host-a","preflightId":"pf_ok","targetNodeIds":["node_pve-b"]}` + + const requests = 8 + codes := make(chan int, requests) + var wg sync.WaitGroup + for i := 0; i < requests; i++ { + wg.Add(1) + go func() { + defer wg.Done() + rec := httptest.NewRecorder() + h.HandleCreateJob(rec, httptest.NewRequest(http.MethodPost, "/api/clusters/lab/agent-deploy/jobs", strings.NewReader(body))) + codes <- rec.Code + }() + } + wg.Wait() + close(codes) + + accepted, refused := 0, 0 + for code := range codes { + switch code { + case http.StatusAccepted: + accepted++ + case http.StatusConflict: + refused++ + default: + t.Errorf("unexpected status %d", code) + } + } + if accepted != 1 || refused != requests-1 { + t.Fatalf("accepted=%d refused=%d, want exactly one install admitted", accepted, refused) + } +} diff --git a/internal/deploy/store.go b/internal/deploy/store.go index d7b2b24970..e7dd31b591 100644 --- a/internal/deploy/store.go +++ b/internal/deploy/store.go @@ -211,6 +211,31 @@ func (s *Store) ListJobs(ctx context.Context, orgID string, limit int) ([]Job, e return jobs, rows.Err() } +// UnfinishedJobsForCluster returns the organization's jobs on one cluster that +// are still queued, waiting for their source, running, or canceling, newest +// first. Unlike ListJobs it has no row limit, so a busy organization's newer +// preflights cannot push an unfinished install out of view. +func (s *Store) UnfinishedJobsForCluster(ctx context.Context, orgID, clusterID string) ([]Job, error) { + rows, err := s.db.QueryContext(ctx, + `SELECT id, cluster_id, cluster_name, source_agent_id, source_node_id, org_id, status, max_parallel, retry_max, created_at, updated_at, completed_at + FROM deploy_jobs WHERE org_id = ? AND cluster_id = ? AND status IN (?, ?, ?, ?) ORDER BY created_at DESC`, + orgID, clusterID, string(JobQueued), string(JobWaitingSource), string(JobRunning), string(JobCanceling)) + if err != nil { + return nil, err + } + defer rows.Close() + + var jobs []Job + for rows.Next() { + j, err := scanJobRow(rows) + if err != nil { + return nil, err + } + jobs = append(jobs, j) + } + return jobs, rows.Err() +} + // --- Targets --- // GetTarget retrieves a single deployment target by ID. diff --git a/internal/deploy/store_queryplan_test.go b/internal/deploy/store_queryplan_test.go index 7a960b9897..985c1feb1b 100644 --- a/internal/deploy/store_queryplan_test.go +++ b/internal/deploy/store_queryplan_test.go @@ -36,6 +36,15 @@ func TestDeployStoreQueryPlansUseIndexes(t *testing.T) { args: []any{"org-03", 50}, wantIndex: "idx_deploy_jobs_org_created", }, + { + name: "unfinished jobs for cluster uses org created index", + query: ` + SELECT id, cluster_id, cluster_name, source_agent_id, source_node_id, org_id, status, max_parallel, retry_max, created_at, updated_at, completed_at + FROM deploy_jobs WHERE org_id = ? AND cluster_id = ? AND status IN (?, ?, ?, ?) ORDER BY created_at DESC + `, + args: []any{"org-03", "cluster-01", "queued", "waiting_source", "running", "canceling"}, + wantIndex: "idx_deploy_jobs_org_created", + }, { name: "get targets for job uses job created index", query: ` diff --git a/internal/deploy/store_test.go b/internal/deploy/store_test.go index 17e7c33563..b74bdd02a9 100644 --- a/internal/deploy/store_test.go +++ b/internal/deploy/store_test.go @@ -557,3 +557,41 @@ func TestResetTargetsForRetry_EmptyList(t *testing.T) { t.Fatalf("expected 0, got %d", count) } } + +func TestUnfinishedJobsForClusterFiltersByOrgClusterAndStatus(t *testing.T) { + s := testStore(t) + ctx := context.Background() + now := time.Now().UTC().Truncate(time.Second) + + for i, j := range []struct { + id, org, cluster string + status JobStatus + }{ + {"dep-running", "org-1", "lab", JobRunning}, + {"dep-queued", "org-1", "lab", JobQueued}, + {"dep-done", "org-1", "lab", JobSucceeded}, + {"dep-other-cluster", "org-1", "prod", JobRunning}, + {"dep-other-org", "org-2", "lab", JobRunning}, + } { + created := now.Add(time.Duration(i) * time.Second) + if err := s.CreateJob(ctx, &Job{ + ID: j.id, ClusterID: j.cluster, ClusterName: j.cluster, SourceAgentID: "agent-1", + SourceNodeID: "node-1", OrgID: j.org, Status: j.status, MaxParallel: 1, + CreatedAt: created, UpdatedAt: created, + }); err != nil { + t.Fatalf("CreateJob %s: %v", j.id, err) + } + } + + jobs, err := s.UnfinishedJobsForCluster(ctx, "org-1", "lab") + if err != nil { + t.Fatalf("UnfinishedJobsForCluster: %v", err) + } + var ids []string + for _, j := range jobs { + ids = append(ids, j.ID) + } + if len(ids) != 2 || ids[0] != "dep-queued" || ids[1] != "dep-running" { + t.Fatalf("got %v, want [dep-queued dep-running] (newest first, other org/cluster/finished excluded)", ids) + } +} From 8fcccfb0157e4182ff4022fd1f2cbce8ce7aea07 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:31:06 +0100 Subject: [PATCH 2/3] Record the browser pass for the cluster agent install dialog Prettier also reflowed the settings architecture assertions added with the dialog. --- .../11da51e13f5c84fd.json | 50 +++++++++++++++++++ .../__tests__/settingsArchitecture.test.ts | 4 +- 2 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 frontend-modern/browser-verification/11da51e13f5c84fd.json diff --git a/frontend-modern/browser-verification/11da51e13f5c84fd.json b/frontend-modern/browser-verification/11da51e13f5c84fd.json new file mode 100644 index 0000000000..6066adc814 --- /dev/null +++ b/frontend-modern/browser-verification/11da51e13f5c84fd.json @@ -0,0 +1,50 @@ +{ + "version": 1, + "base_sha": "76b6f7ea974d4c40f039093182ae63fc2ca074c5", + "verified_at": "2026-10-05T14:30:57Z", + "result": "passed", + "changed_paths": [ + "frontend-modern/src/api/agentDeploy.ts", + "frontend-modern/src/components/Settings/ClusterAgentDeployDialog.tsx", + "frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx", + "frontend-modern/src/components/Settings/connectionsTableModel.ts", + "frontend-modern/src/components/Settings/useConnectionsLedger.ts" + ], + "content_sha256": { + "frontend-modern/src/api/agentDeploy.ts": "ffd3f69a1cec222b64e055f99dedac99a6560f0e8fc14a51074bca4d052ee365", + "frontend-modern/src/components/Settings/ClusterAgentDeployDialog.tsx": "a5eabaacff3c5912f9c23ee1b9e33507f474c4e6681ea98d1956650ee98c156f", + "frontend-modern/src/components/Settings/InfrastructureSourceManager.tsx": "9f0379ddd85e53a60f222cb1d23f2570569583cad930846faf3dfb179477fd24", + "frontend-modern/src/components/Settings/connectionsTableModel.ts": "a74685970f9d094f9dd2ebc87ac94a1228e885a1c99c6775a355b22af1e66aac", + "frontend-modern/src/components/Settings/useConnectionsLedger.ts": "cae45614e0793e974de6f13ed681be3fcbeb06416d464cde5b3b5a5a24562225" + }, + "routes": [ + "/settings/infrastructure", + "/settings/infrastructure?add=linux-host" + ], + "viewports": [ + { + "width": 1280, + "height": 800 + }, + { + "width": 375, + "height": 812 + } + ], + "states": [ + "Private mock stack from this tree (backend :7711 in mock mode, Vite :5231), driven by headless Chromium through Playwright. Mock mode has no PVE cluster connection and no connected source agent, so Playwright routes injected one cluster (homelab: delly with a connected agent, delly2 and pi without) into the real /api/connections response and answered /api/clusters/homelab/agent-deploy/* and /api/agent-deploy/* with recorded payload shapes. The dialog, ledger and routing code under test ran unmodified.", + "Ledger: the homelab cluster row expanded to three members; delly showed its agent, delly2 and pi offered Install agent, and the cluster summary offered installing node agents on 2 uncovered nodes.", + "Select: Install Pulse Agent on homelab opened from delly2's row with delly2 (192.168.0.111) checked and Not installed, pi disabled with 'Pulse has no IP address for this node. Set its connection address under Manage.', and the footer read Use the installer instead, Cancel, Install on 1 node.", + "Running and Reporting: the check and install phases advanced delly2 through Checking and Installing to Reporting; the checkbox locked during the run and the footer collapsed to Close.", + "Failure: a preflight refusal showed delly2 as Failed with 'SSH to 192.168.0.111 was refused' in red and offered Use the installer instead beside Close.", + "No source: with no connected agent in the cluster the dialog explained that the installer must add the first one, with focus on Close.", + "Phone 375x812: the dialog fits the viewport with no horizontal overflow; footer buttons wrap so Install on 1 node takes its own row. The only console errors were WebSocket 403s on /ws from the mock stack's auth, unrelated to this change. This is not a real-cluster or release acceptance: the same deploy endpoints installed an agent on a real Proxmox cluster member, but on the released backend, not this tree." + ], + "interactions": [ + "Clicked delly2's Install agent at 1280 and 375 widths; pressed Escape and the dialog closed.", + "Clicked the cluster summary install action; only delly2 was preselected because pi has no address.", + "Clicked Install on 1 node and waited for Reporting; clicked Close.", + "Re-ran the install with a refused preflight; read the per-node error; pressed Escape.", + "Opened the dialog with no connected source agent and clicked Use the installer instead; the URL became /settings/infrastructure?add=linux-host." + ] +} diff --git a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts index acff1fc1b9..5ed24fe4ae 100644 --- a/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts +++ b/frontend-modern/src/components/Settings/__tests__/settingsArchitecture.test.ts @@ -1655,7 +1655,9 @@ describe('settings architecture guardrails', () => { // scoped installer as the fallback instead of a hand-copied token. expect(infrastructureSourceManagerSource).toContain("from './ClusterAgentDeployDialog'"); expect(infrastructureSourceManagerSource).toContain(''); expect(infrastructureSourceManagerSource).toContain('Add infrastructure'); expect(infrastructureSourceManagerSource).not.toContain('Monitor endpoint'); From a4f88dfd9991628ae603bb6840666008d4037b9a Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Mon, 5 Oct 2026 15:37:46 +0100 Subject: [PATCH 3/3] List the cluster agent deploy client under both contracts' shared boundaries --- docs/release-control/v6/internal/subsystems/agent-lifecycle.md | 1 + docs/release-control/v6/internal/subsystems/api-contracts.md | 1 + 2 files changed, 2 insertions(+) diff --git a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md index fd11a8f17a..b641c507c6 100644 --- a/docs/release-control/v6/internal/subsystems/agent-lifecycle.md +++ b/docs/release-control/v6/internal/subsystems/agent-lifecycle.md @@ -1159,6 +1159,7 @@ The scheduler may enumerate tenant organization IDs so each workspace can run its own reports, but that enumeration is not agent enrollment, install, update, profile rollout, command reachability, or fleet-control authority. +1. `frontend-modern/src/api/agentDeploy.ts` shared with `api-contracts`: the cluster agent deploy client is both the agent lifecycle one-step cluster member install surface and a canonical API payload contract boundary. 1. `frontend-modern/src/api/agentProfiles.ts` shared with `api-contracts`: the agent profiles frontend client is both an agent lifecycle control surface and a canonical API payload contract boundary. 2. `frontend-modern/src/api/nodes.ts` shared with `api-contracts`: the shared Proxmox node client is both an agent lifecycle setup/install control surface and a canonical API payload contract boundary. 3. `frontend-modern/src/components/Settings/ConnectionEditor/CredentialSlots/NodeCredentialSlot.tsx` shared with `api-contracts`: the inline node credential slot is both an agent lifecycle control surface and a shared API-backed install/setup contract boundary. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index a18344be02..2eb624632a 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1544,6 +1544,7 @@ payload shape change when the portal presents compact client rows. client rules that Assistant tool execution and reference clients also consume. 3. `frontend-modern/src/api/agentCapabilities.ts` shared with `ai-runtime`: the agent capabilities frontend client is both the Pulse Intelligence external-agent manifest consumer and a canonical API payload contract boundary. +4. `frontend-modern/src/api/agentDeploy.ts` shared with `agent-lifecycle`: the cluster agent deploy client is both the agent lifecycle one-step cluster member install surface and a canonical API payload contract boundary. 4. `frontend-modern/src/api/agentProfiles.ts` shared with `agent-lifecycle`: the agent profiles frontend client is both an agent lifecycle control surface and a canonical API payload contract boundary. 5. `frontend-modern/src/api/ai.ts` shared with `ai-runtime`: the AI frontend client is both an AI runtime control surface and a canonical API payload contract boundary. `/api/settings/ai` is the provider-registry projection for Assistant and