fix(fleet): validate remote migration targets
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
All checks were successful
ci/woodpecker/pr/ci Pipeline was successful
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -74,6 +74,14 @@ const AGENT_DECISION_FIELDS = new Set([
|
||||
const CLASS_DISPOSITION_FIELDS = new Set(['action', 'className']);
|
||||
const OBSERVATION_FIELDS = new Set(['systemd', 'tmux']);
|
||||
const AGENT_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
||||
const V1_TMUX_SOCKET_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
||||
const V1_REMOTE_TARGET_PATTERNS: Readonly<
|
||||
Record<(typeof REMOTE_AGENT_INVENTORY_FIELDS)[number], RegExp>
|
||||
> = Object.freeze({
|
||||
host: /^[A-Za-z0-9_.:[\]-]+$/,
|
||||
ssh: /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9_.:[\]-]+$/,
|
||||
socket: V1_TMUX_SOCKET_PATTERN,
|
||||
});
|
||||
const AGENT_FIELDS = new Set([
|
||||
'name',
|
||||
'alias',
|
||||
@@ -347,16 +355,23 @@ export async function previewV1ToV2Migration(
|
||||
const hasDeclaredSnakeSocket = Object.hasOwn(tmux, 'socket_name');
|
||||
const hasDeclaredCamelSocket = Object.hasOwn(tmux, 'socketName');
|
||||
const hasDeclaredSocket = hasDeclaredSnakeSocket || hasDeclaredCamelSocket;
|
||||
const declaredSocketName = hasDeclaredSnakeSocket
|
||||
? trimmedString(tmux.socket_name)
|
||||
: hasDeclaredCamelSocket
|
||||
? trimmedString(tmux.socketName)
|
||||
: undefined;
|
||||
const socketName = hasDeclaredSocket ? declaredSocketName : decisions.socketName;
|
||||
const declaredSocketValue = hasDeclaredSnakeSocket ? tmux.socket_name : tmux.socketName;
|
||||
const sourceSocketName = typeof declaredSocketValue === 'string' ? declaredSocketValue : '';
|
||||
const declaredSocketPath = hasDeclaredSnakeSocket ? 'tmux.socket_name' : 'tmux.socketName';
|
||||
if (sourceSocketName && !V1_TMUX_SOCKET_PATTERN.test(sourceSocketName)) {
|
||||
blockers.push(
|
||||
blocker(
|
||||
'invalid-v1-targeting-characters',
|
||||
declaredSocketPath,
|
||||
'Fleet tmux socket contains unsupported targeting characters.',
|
||||
),
|
||||
);
|
||||
}
|
||||
const socketName = sourceSocketName;
|
||||
if (
|
||||
hasDeclaredSocket &&
|
||||
decisions.socketName !== undefined &&
|
||||
decisions.socketName !== declaredSocketName
|
||||
decisions.socketName !== sourceSocketName
|
||||
) {
|
||||
blockers.push(
|
||||
blocker(
|
||||
@@ -377,6 +392,17 @@ export async function previewV1ToV2Migration(
|
||||
blockers.push(blocker('invalid-agent-name', `${path}.name`, 'Agent name is required.'));
|
||||
continue;
|
||||
}
|
||||
const agentSocket = typeof rawAgent.socket === 'string' ? rawAgent.socket : undefined;
|
||||
if (agentSocket !== undefined && agentSocket !== socketName) {
|
||||
blockers.push(
|
||||
blocker(
|
||||
'agent-socket-disposition-required',
|
||||
`${path}.socket`,
|
||||
'Agent socket must match the canonical v1 fleet socket; independent per-agent sockets are not supported.',
|
||||
name,
|
||||
),
|
||||
);
|
||||
}
|
||||
const locality = classifyAgentLocality(rawAgent, decisions.fleetHost);
|
||||
const localityFields = REMOTE_AGENT_INVENTORY_FIELDS.filter(
|
||||
(field) => rawAgent[field] !== undefined,
|
||||
@@ -399,17 +425,6 @@ export async function previewV1ToV2Migration(
|
||||
),
|
||||
);
|
||||
}
|
||||
const agentSocket = stringValue(rawAgent.socket);
|
||||
if (agentSocket && (!socketName || agentSocket !== socketName)) {
|
||||
blockers.push(
|
||||
blocker(
|
||||
'agent-socket-disposition-required',
|
||||
`${path}.socket`,
|
||||
'Local agent socket must match the canonical v2 fleet socket or receive an explicit future disposition.',
|
||||
name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const decision = decisions.agents[name];
|
||||
if (!decision) {
|
||||
@@ -560,15 +575,6 @@ export async function previewV1ToV2Migration(
|
||||
),
|
||||
);
|
||||
}
|
||||
if (socketName === undefined) {
|
||||
blockers.push(
|
||||
blocker(
|
||||
'named-socket-disposition-required',
|
||||
'tmux.socket_name',
|
||||
'A socket decision is required only when both supported v1 socket fields are absent.',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (candidateAgents.length === 0) {
|
||||
blockers.push(
|
||||
blocker('no-local-candidates', 'agents', 'No local agents are eligible for v2 output.'),
|
||||
@@ -579,7 +585,7 @@ export async function previewV1ToV2Migration(
|
||||
let canonicalYaml: string | undefined;
|
||||
const environment: EnvironmentMigrationPreview[] = [];
|
||||
|
||||
if (blockers.length === 0 && socketName !== undefined && decisions.defaultRuntime) {
|
||||
if (blockers.length === 0 && decisions.defaultRuntime) {
|
||||
const defaultRuntime = decisions.defaultRuntime;
|
||||
try {
|
||||
canonicalRoster = parseRosterV2(
|
||||
@@ -966,12 +972,22 @@ function validateV1FieldCatalog(raw: Record<string, unknown>, blockers: Migratio
|
||||
);
|
||||
}
|
||||
for (const key of REMOTE_AGENT_INVENTORY_FIELDS) {
|
||||
if (agent[key] !== undefined && stringValue(agent[key]) === undefined) {
|
||||
const target = agent[key];
|
||||
if (target !== undefined && typeof target !== 'string') {
|
||||
blockers.push(
|
||||
blocker(
|
||||
'invalid-v1-field-type',
|
||||
`${path}.${key}`,
|
||||
'Remote inventory field must be a non-empty string.',
|
||||
'Remote inventory field must be a string.',
|
||||
),
|
||||
);
|
||||
} else if (typeof target === 'string' && !V1_REMOTE_TARGET_PATTERNS[key].test(target)) {
|
||||
blockers.push(
|
||||
blocker(
|
||||
'invalid-v1-targeting-characters',
|
||||
`${path}.${key}`,
|
||||
'Remote inventory field contains unsupported targeting characters.',
|
||||
name,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user