fix(fleet): route remaining fleet dispatches through getProxyTarget for pilot-agent nodes (#1152)

* fix(fleet): route remaining fleet dispatches through getProxyTarget for pilot-agent nodes

PR #1123 migrated POST /api/fleet/nodes/:id/update to use
NodeRegistry.getProxyTarget so pilot-agent rows (no api_url / api_token)
participate in remote update via the tunnel loopback. The same bug shape
lived on at ten sibling fleet-dispatch sites: each read node.api_url and
node.api_token directly, returning "Remote node not configured." against
pilots, or silently filtered pilot rows out of a fan-out loop.

Migrate every remaining fleet-wide remote dispatch to the same pattern:

- routes/fleet.ts: fleet-stop, fleet-prune, prune/estimate, snapshot
  restore (4 sites) -> getProxyTarget + mode-aware error copy +
  conditional Authorization header
- routes/imageUpdates.ts: fleet status + fleet refresh (2 sites) -> swap
  n.api_url filter for getProxyTarget != null and use target.apiUrl, so
  pilot rows appear in the aggregated image-updates view instead of
  being silently excluded
- utils/snapshot-capture.ts: captureRemoteNodeFiles (1 site) -> same
  pattern; CaptureNode interface gains required mode field so the
  thrown error message picks the pilot-tunnel copy automatically
- services/SecretsService.ts: resolveEnvFileRemote, readEnvRemote,
  writeEnvRemote (3 sites) -> same pattern; thrown errors now use the
  shared formatNoTargetError helper instead of leaking api_url/api_token
  field names

Extract the previously-private noTargetMessage helper from fleet.ts
into utils/remoteTarget.ts as formatNoTargetError so SecretsService,
snapshot-capture, and the fleet routes share one copy of the
mode-aware error string.

Add 10 regression tests in fleet-pilot-dispatch-parity.test.ts covering
each migrated route + the snapshot-capture utility: dispatch through
the loopback target with no Authorization header for pilots, and a
mode-aware error when the tunnel is disconnected.

FleetSyncService (4 additional sites) carries an api_url-anchored
targetIdentity in the wire protocol; pilot support there needs a
protocol-level identity decision and stays as a separate follow-up.

* fix(fleet): throw tunnel-disconnected error from resolveEnvFileRemote

Codex audit flagged that resolveEnvFileRemote returned null when
getProxyTarget was null. That predates the parity migration but the
migration was the right place to fix it: the null flowed through
readExistingEnv into previewPushDiff / executePush as "env file not
found", which is wrong (the env exists, the node is unreachable).

Throwing formatNoTargetError(node) here lets the existing catch arms
in previewPushDiff (lines 450-453) surface reachable=false with the
tunnel-disconnected message on the right axis, and executePush picks
up the same shape via its outer catch.

Also drop overstated coverage claims from the parity test header
(snapshot restore + SecretsService were never actually exercised in
this file, only structurally identical via tsc), and fix two describe
labels that read /api/labels/* instead of the mounted /api/fleet/labels/*.
This commit is contained in:
Anso
2026-05-22 00:30:51 -04:00
committed by GitHub
parent 60f893a81f
commit 2f2401df68
6 changed files with 468 additions and 56 deletions
+15
View File
@@ -0,0 +1,15 @@
import type { Node } from '../services/DatabaseService';
/**
* Returns the operator-facing error message for a remote node that has
* no reachable proxy target. Pilot-mode rows get a tunnel-aware message;
* proxy-mode rows fall back to the historical credentials-missing copy.
*
* Use whenever `NodeRegistry.getProxyTarget(node.id)` returns null and
* the caller needs to surface why the dispatch was skipped.
*/
export function formatNoTargetError(node: Pick<Node, 'mode' | 'name'>): string {
return node.mode === 'pilot_agent'
? `Pilot tunnel to "${node.name}" is disconnected. Operations resume when the agent reconnects.`
: 'Remote node not configured';
}
+15 -7
View File
@@ -3,7 +3,10 @@
* and the SchedulerService for fleet-wide snapshot operations.
*/
import type { NodeMode } from '../services/DatabaseService';
import { FileSystemService } from '../services/FileSystemService';
import { NodeRegistry } from '../services/NodeRegistry';
import { formatNoTargetError } from './remoteTarget';
import { isDebugEnabled } from './debug';
export interface SnapshotNodeData {
@@ -15,12 +18,15 @@ export interface SnapshotNodeData {
}>;
}
/** Minimal node shape accepted by capture functions. */
/**
* Minimal node shape accepted by capture functions.
* `mode` is required so remote dispatch can emit a tunnel-aware error when
* the pilot-agent proxy target is null.
*/
export interface CaptureNode {
id: number;
name: string;
api_url?: string;
api_token?: string;
mode: NodeMode;
}
/**
@@ -65,13 +71,15 @@ export async function captureLocalNodeFiles(node: CaptureNode): Promise<Snapshot
* fetched are silently skipped.
*/
export async function captureRemoteNodeFiles(node: CaptureNode): Promise<SnapshotNodeData> {
if (!node.api_url || !node.api_token) {
throw new Error('Remote node not configured');
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
if (!target) {
throw new Error(formatNoTargetError(node));
}
const start = Date.now();
const baseUrl = node.api_url.replace(/\/$/, '');
const headers = { Authorization: `Bearer ${node.api_token}` };
const baseUrl = target.apiUrl.replace(/\/$/, '');
const headers: Record<string, string> = {};
if (target.apiToken) headers.Authorization = `Bearer ${target.apiToken}`;
const stacksRes = await fetch(`${baseUrl}/api/stacks`, {
headers,