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:
@@ -75,7 +75,7 @@ agents:
|
|||||||
class: reviewer
|
class: reviewer
|
||||||
host: build.example.test
|
host: build.example.test
|
||||||
ssh: operator@build.example.test
|
ssh: operator@build.example.test
|
||||||
socket: remote-fleet
|
socket: mosaic-v1
|
||||||
connector:
|
connector:
|
||||||
kind: discord
|
kind: discord
|
||||||
discord:
|
discord:
|
||||||
@@ -253,7 +253,7 @@ describe('v1-to-v2 migration preview', (): void => {
|
|||||||
{
|
{
|
||||||
label: 'host different from reviewed fleet host',
|
label: 'host different from reviewed fleet host',
|
||||||
agentFields:
|
agentFields:
|
||||||
' host: build.example.test\n ssh: operator@build.example.test\n socket: remote-fleet',
|
' host: build.example.test\n ssh: operator@build.example.test\n socket: mosaic-v1',
|
||||||
fleetHost: 'fleet.example.test',
|
fleetHost: 'fleet.example.test',
|
||||||
expectedStatus: 'ready',
|
expectedStatus: 'ready',
|
||||||
expectedInventoryOnly: true,
|
expectedInventoryOnly: true,
|
||||||
@@ -288,7 +288,7 @@ describe('v1-to-v2 migration preview', (): void => {
|
|||||||
'classifies $label without inferring locality',
|
'classifies $label without inferring locality',
|
||||||
async ({ agentFields, fleetHost, expectedStatus, expectedInventoryOnly, expectedBlocker }) => {
|
async ({ agentFields, fleetHost, expectedStatus, expectedInventoryOnly, expectedBlocker }) => {
|
||||||
const source = v1Source.replace(
|
const source = v1Source.replace(
|
||||||
' host: build.example.test\n ssh: operator@build.example.test\n socket: remote-fleet',
|
' host: build.example.test\n ssh: operator@build.example.test\n socket: mosaic-v1',
|
||||||
agentFields,
|
agentFields,
|
||||||
);
|
);
|
||||||
const migrationDecisions = decisions();
|
const migrationDecisions = decisions();
|
||||||
@@ -358,10 +358,9 @@ describe('v1-to-v2 migration preview', (): void => {
|
|||||||
] as const)(
|
] as const)(
|
||||||
'preserves an explicit empty tmux.%s declaration as the literal default server without a decision',
|
'preserves an explicit empty tmux.%s declaration as the literal default server without a decision',
|
||||||
async (_field, tmuxBlock) => {
|
async (_field, tmuxBlock) => {
|
||||||
const source = v1Source.replace(
|
const source = v1Source
|
||||||
' socket_name: mosaic-v1\n holder_session: _holder',
|
.replace(' socket_name: mosaic-v1\n holder_session: _holder', tmuxBlock)
|
||||||
tmuxBlock,
|
.replace(' socket: mosaic-v1\n', '');
|
||||||
);
|
|
||||||
const preview = await previewV1ToV2Migration({
|
const preview = await previewV1ToV2Migration({
|
||||||
source,
|
source,
|
||||||
decisions: decisions(),
|
decisions: decisions(),
|
||||||
@@ -494,7 +493,8 @@ describe('v1-to-v2 migration preview', (): void => {
|
|||||||
it('blocks a socket-only local agent that conflicts with the canonical fleet socket', async () => {
|
it('blocks a socket-only local agent that conflicts with the canonical fleet socket', async () => {
|
||||||
const source = v1Source
|
const source = v1Source
|
||||||
.replace(' host: build.example.test\n', '')
|
.replace(' host: build.example.test\n', '')
|
||||||
.replace(' ssh: operator@build.example.test\n', '');
|
.replace(' ssh: operator@build.example.test\n', '')
|
||||||
|
.replace(' socket: mosaic-v1', ' socket: other-socket');
|
||||||
const migrationDecisions = decisions();
|
const migrationDecisions = decisions();
|
||||||
migrationDecisions.agents.remote0 = {
|
migrationDecisions.agents.remote0 = {
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
@@ -615,7 +615,7 @@ describe('v1-to-v2 migration preview', (): void => {
|
|||||||
class: reviewer
|
class: reviewer
|
||||||
host: review.example.test
|
host: review.example.test
|
||||||
ssh: operator@review.example.test
|
ssh: operator@review.example.test
|
||||||
socket: review-fleet
|
socket: mosaic-v1
|
||||||
`;
|
`;
|
||||||
const sourceA = v1Source.replace('connector:', `${remote1}connector:`);
|
const sourceA = v1Source.replace('connector:', `${remote1}connector:`);
|
||||||
const sourceB = sourceA.replace(
|
const sourceB = sourceA.replace(
|
||||||
@@ -1200,6 +1200,258 @@ describe('v1-to-v2 migration preview', (): void => {
|
|||||||
expect(JSON.stringify(preview.blockers)).not.toContain(rejectedKind);
|
expect(JSON.stringify(preview.blockers)).not.toContain(rejectedKind);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['leading whitespace', ' mosaic-v1'],
|
||||||
|
['trailing whitespace', 'mosaic-v1 '],
|
||||||
|
['unsupported characters', 'mosaic/v1'],
|
||||||
|
] as const)(
|
||||||
|
'blocks a declared root socket with %s before migration decisions can normalize it',
|
||||||
|
async (_label, rejectedSocket) => {
|
||||||
|
const source = v1Source.replace(
|
||||||
|
'socket_name: mosaic-v1',
|
||||||
|
`socket_name: ${JSON.stringify(rejectedSocket)}`,
|
||||||
|
);
|
||||||
|
const rosterPath = join(
|
||||||
|
await mkdtemp(join(tmpdir(), 'v1-root-socket-parity-')),
|
||||||
|
'roster.yaml',
|
||||||
|
);
|
||||||
|
temporaryDirectories.push(dirname(rosterPath));
|
||||||
|
await writeFile(rosterPath, source);
|
||||||
|
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||||
|
'contains unsupported targeting characters',
|
||||||
|
);
|
||||||
|
|
||||||
|
const preview = await previewV1ToV2Migration({
|
||||||
|
source,
|
||||||
|
decisions: { ...decisions(), socketName: 'mosaic-v1' },
|
||||||
|
observations: observations(),
|
||||||
|
personaDirs: await semanticDirs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.status).toBe('blocked');
|
||||||
|
expect(preview.blockers).toContainEqual({
|
||||||
|
code: 'invalid-v1-targeting-characters',
|
||||||
|
path: 'tmux.socket_name',
|
||||||
|
detail: 'Fleet tmux socket contains unsupported targeting characters.',
|
||||||
|
});
|
||||||
|
expect(preview).not.toHaveProperty('canonicalRoster');
|
||||||
|
expect(preview).not.toHaveProperty('canonicalYaml');
|
||||||
|
expect(preview.evidence).not.toHaveProperty('candidate');
|
||||||
|
expect(JSON.stringify(preview.blockers)).not.toContain(rejectedSocket);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('blocks an explicit remote socket against the source default before a decision can rename the candidate socket', async () => {
|
||||||
|
const rejectedSocket = 'mosaic-v1';
|
||||||
|
const source = v1Source.replace(' socket_name: mosaic-v1\n', '');
|
||||||
|
const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-root-socket-parity-')), 'roster.yaml');
|
||||||
|
temporaryDirectories.push(dirname(rosterPath));
|
||||||
|
await writeFile(rosterPath, source);
|
||||||
|
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||||
|
'socket must equal the fleet-wide tmux socket_name',
|
||||||
|
);
|
||||||
|
|
||||||
|
const preview = await previewV1ToV2Migration({
|
||||||
|
source,
|
||||||
|
decisions: { ...decisions(), socketName: rejectedSocket },
|
||||||
|
observations: observations(),
|
||||||
|
personaDirs: await semanticDirs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.status).toBe('blocked');
|
||||||
|
expect(preview.blockers).toContainEqual({
|
||||||
|
code: 'agent-socket-disposition-required',
|
||||||
|
path: 'agents[1].socket',
|
||||||
|
agent: 'remote0',
|
||||||
|
detail:
|
||||||
|
'Agent socket must match the canonical v1 fleet socket; independent per-agent sockets are not supported.',
|
||||||
|
});
|
||||||
|
expect(preview).not.toHaveProperty('canonicalRoster');
|
||||||
|
expect(preview).not.toHaveProperty('canonicalYaml');
|
||||||
|
expect(preview.evidence).not.toHaveProperty('candidate');
|
||||||
|
expect(JSON.stringify(preview.blockers)).not.toContain(rejectedSocket);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses the production empty default when both the source root and remote agent socket are absent', async () => {
|
||||||
|
const source = v1Source
|
||||||
|
.replace(' socket_name: mosaic-v1\n', '')
|
||||||
|
.replace(' socket: mosaic-v1\n', '');
|
||||||
|
const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-root-socket-parity-')), 'roster.yaml');
|
||||||
|
temporaryDirectories.push(dirname(rosterPath));
|
||||||
|
await writeFile(rosterPath, source);
|
||||||
|
await expect(loadFleetRoster(rosterPath)).resolves.toMatchObject({
|
||||||
|
tmux: { socketName: '' },
|
||||||
|
});
|
||||||
|
|
||||||
|
const preview = await previewV1ToV2Migration({
|
||||||
|
source,
|
||||||
|
decisions: decisions(),
|
||||||
|
observations: observations(),
|
||||||
|
personaDirs: await semanticDirs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.status).toBe('ready');
|
||||||
|
expect(preview.blockers).toEqual([]);
|
||||||
|
expect(preview.inventoryOnlyAgents).toEqual([
|
||||||
|
{
|
||||||
|
name: 'remote0',
|
||||||
|
fields: ['host', 'ssh'],
|
||||||
|
disposition: 'inventory-only',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(preview.canonicalRoster?.tmux.socketName).toBe('');
|
||||||
|
expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0']);
|
||||||
|
expect(preview.evidence.candidate).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('blocks a remote inventory-only agent socket that differs from the root socket', async () => {
|
||||||
|
const rejectedSocket = 'remote-fleet';
|
||||||
|
const source = v1Source.replace(' socket: mosaic-v1', ` socket: ${rejectedSocket}`);
|
||||||
|
const preview = await previewV1ToV2Migration({
|
||||||
|
source,
|
||||||
|
decisions: decisions(),
|
||||||
|
observations: observations(),
|
||||||
|
personaDirs: await semanticDirs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.status).toBe('blocked');
|
||||||
|
expect(preview.blockers).toEqual([
|
||||||
|
{
|
||||||
|
code: 'agent-socket-disposition-required',
|
||||||
|
path: 'agents[1].socket',
|
||||||
|
agent: 'remote0',
|
||||||
|
detail:
|
||||||
|
'Agent socket must match the canonical v1 fleet socket; independent per-agent sockets are not supported.',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(preview.inventoryOnlyAgents).toEqual([
|
||||||
|
{
|
||||||
|
name: 'remote0',
|
||||||
|
fields: ['host', 'socket', 'ssh'],
|
||||||
|
disposition: 'inventory-only',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(preview).not.toHaveProperty('canonicalRoster');
|
||||||
|
expect(preview).not.toHaveProperty('canonicalYaml');
|
||||||
|
expect(preview.evidence).not.toHaveProperty('candidate');
|
||||||
|
expect(JSON.stringify(preview.blockers)).not.toContain(rejectedSocket);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a remote agent without an explicit socket inventory-only on the default tmux socket', async () => {
|
||||||
|
const source = v1Source
|
||||||
|
.replace(' socket_name: mosaic-v1\n', " socket_name: ''\n")
|
||||||
|
.replace(' socket: mosaic-v1\n', '');
|
||||||
|
const preview = await previewV1ToV2Migration({
|
||||||
|
source,
|
||||||
|
decisions: decisions(),
|
||||||
|
observations: observations(),
|
||||||
|
personaDirs: await semanticDirs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.status).toBe('ready');
|
||||||
|
expect(preview.blockers).toEqual([]);
|
||||||
|
expect(preview.inventoryOnlyAgents).toEqual([
|
||||||
|
{
|
||||||
|
name: 'remote0',
|
||||||
|
fields: ['host', 'ssh'],
|
||||||
|
disposition: 'inventory-only',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(preview.canonicalRoster?.tmux.socketName).toBe('');
|
||||||
|
expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0']);
|
||||||
|
expect(preview.evidence.candidate).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['host', 'bad host'],
|
||||||
|
['ssh', 'bad user@remote.test'],
|
||||||
|
['socket', 'bad socket'],
|
||||||
|
['host', ' build.example.test'],
|
||||||
|
['ssh', 'operator@build.example.test '],
|
||||||
|
['socket', ' remote-fleet'],
|
||||||
|
['host', ''],
|
||||||
|
['ssh', ''],
|
||||||
|
['socket', ''],
|
||||||
|
] as const)(
|
||||||
|
'blocks malformed remote %s targeting before inventory-only exclusion',
|
||||||
|
async (field, rejectedValue) => {
|
||||||
|
const source = v1Source.replace(
|
||||||
|
new RegExp(`(${field}: )[^\\n]+`),
|
||||||
|
`$1${JSON.stringify(rejectedValue)}`,
|
||||||
|
);
|
||||||
|
const productionSource = source;
|
||||||
|
const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-targeting-parity-')), 'roster.yaml');
|
||||||
|
temporaryDirectories.push(dirname(rosterPath));
|
||||||
|
await writeFile(rosterPath, productionSource);
|
||||||
|
await expect(loadFleetRoster(rosterPath)).rejects.toThrow(
|
||||||
|
'contains unsupported targeting characters',
|
||||||
|
);
|
||||||
|
const preview = await previewV1ToV2Migration({
|
||||||
|
source,
|
||||||
|
decisions: decisions(),
|
||||||
|
observations: observations(),
|
||||||
|
personaDirs: await semanticDirs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.status).toBe('blocked');
|
||||||
|
expect(preview.blockers).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: 'invalid-v1-targeting-characters',
|
||||||
|
path: `agents[1].${field}`,
|
||||||
|
agent: 'remote0',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(preview).not.toHaveProperty('canonicalRoster');
|
||||||
|
expect(preview).not.toHaveProperty('canonicalYaml');
|
||||||
|
expect(preview.evidence).not.toHaveProperty('candidate');
|
||||||
|
if (rejectedValue) {
|
||||||
|
expect(JSON.stringify(preview.blockers)).not.toContain(rejectedValue);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('accepts the production v1 targeting character boundaries for a remote inventory-only agent', async () => {
|
||||||
|
const source = v1Source
|
||||||
|
.replace('host: build.example.test', 'host: node-1.example.test:[2001:db8::1]')
|
||||||
|
.replace(
|
||||||
|
'ssh: operator@build.example.test',
|
||||||
|
'ssh: user.name-1@node-1.example.test:[2001:db8::1]',
|
||||||
|
)
|
||||||
|
.replace('socket: mosaic-v1', 'socket: mosaic-v1');
|
||||||
|
const rosterPath = join(await mkdtemp(join(tmpdir(), 'v1-targeting-parity-')), 'roster.yaml');
|
||||||
|
temporaryDirectories.push(dirname(rosterPath));
|
||||||
|
await writeFile(rosterPath, source);
|
||||||
|
await expect(loadFleetRoster(rosterPath)).resolves.toMatchObject({
|
||||||
|
agents: [
|
||||||
|
expect.objectContaining({ name: 'coder0' }),
|
||||||
|
expect.objectContaining({
|
||||||
|
name: 'remote0',
|
||||||
|
host: 'node-1.example.test:[2001:db8::1]',
|
||||||
|
ssh: 'user.name-1@node-1.example.test:[2001:db8::1]',
|
||||||
|
socket: 'mosaic-v1',
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const preview = await previewV1ToV2Migration({
|
||||||
|
source,
|
||||||
|
decisions: decisions(),
|
||||||
|
observations: observations(),
|
||||||
|
personaDirs: await semanticDirs(),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(preview.status).toBe('ready');
|
||||||
|
expect(preview.blockers).toEqual([]);
|
||||||
|
expect(preview.inventoryOnlyAgents).toEqual([
|
||||||
|
{
|
||||||
|
name: 'remote0',
|
||||||
|
fields: ['host', 'socket', 'ssh'],
|
||||||
|
disposition: 'inventory-only',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(preview.canonicalRoster?.agents.map(({ name }) => name)).toEqual(['coder0']);
|
||||||
|
expect(preview.evidence.excludedInventory).toContain('agents.remote0');
|
||||||
|
});
|
||||||
|
|
||||||
it('blocks malformed schema-only remote and connector fields', async () => {
|
it('blocks malformed schema-only remote and connector fields', async () => {
|
||||||
const source = v1Source
|
const source = v1Source
|
||||||
.replace('host: build.example.test', 'host: 42')
|
.replace('host: build.example.test', 'host: 42')
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ const AGENT_DECISION_FIELDS = new Set([
|
|||||||
const CLASS_DISPOSITION_FIELDS = new Set(['action', 'className']);
|
const CLASS_DISPOSITION_FIELDS = new Set(['action', 'className']);
|
||||||
const OBSERVATION_FIELDS = new Set(['systemd', 'tmux']);
|
const OBSERVATION_FIELDS = new Set(['systemd', 'tmux']);
|
||||||
const AGENT_NAME_PATTERN = /^[A-Za-z0-9_.-]+$/;
|
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([
|
const AGENT_FIELDS = new Set([
|
||||||
'name',
|
'name',
|
||||||
'alias',
|
'alias',
|
||||||
@@ -347,16 +355,23 @@ export async function previewV1ToV2Migration(
|
|||||||
const hasDeclaredSnakeSocket = Object.hasOwn(tmux, 'socket_name');
|
const hasDeclaredSnakeSocket = Object.hasOwn(tmux, 'socket_name');
|
||||||
const hasDeclaredCamelSocket = Object.hasOwn(tmux, 'socketName');
|
const hasDeclaredCamelSocket = Object.hasOwn(tmux, 'socketName');
|
||||||
const hasDeclaredSocket = hasDeclaredSnakeSocket || hasDeclaredCamelSocket;
|
const hasDeclaredSocket = hasDeclaredSnakeSocket || hasDeclaredCamelSocket;
|
||||||
const declaredSocketName = hasDeclaredSnakeSocket
|
const declaredSocketValue = hasDeclaredSnakeSocket ? tmux.socket_name : tmux.socketName;
|
||||||
? trimmedString(tmux.socket_name)
|
const sourceSocketName = typeof declaredSocketValue === 'string' ? declaredSocketValue : '';
|
||||||
: hasDeclaredCamelSocket
|
const declaredSocketPath = hasDeclaredSnakeSocket ? 'tmux.socket_name' : 'tmux.socketName';
|
||||||
? trimmedString(tmux.socketName)
|
if (sourceSocketName && !V1_TMUX_SOCKET_PATTERN.test(sourceSocketName)) {
|
||||||
: undefined;
|
blockers.push(
|
||||||
const socketName = hasDeclaredSocket ? declaredSocketName : decisions.socketName;
|
blocker(
|
||||||
|
'invalid-v1-targeting-characters',
|
||||||
|
declaredSocketPath,
|
||||||
|
'Fleet tmux socket contains unsupported targeting characters.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const socketName = sourceSocketName;
|
||||||
if (
|
if (
|
||||||
hasDeclaredSocket &&
|
hasDeclaredSocket &&
|
||||||
decisions.socketName !== undefined &&
|
decisions.socketName !== undefined &&
|
||||||
decisions.socketName !== declaredSocketName
|
decisions.socketName !== sourceSocketName
|
||||||
) {
|
) {
|
||||||
blockers.push(
|
blockers.push(
|
||||||
blocker(
|
blocker(
|
||||||
@@ -377,6 +392,17 @@ export async function previewV1ToV2Migration(
|
|||||||
blockers.push(blocker('invalid-agent-name', `${path}.name`, 'Agent name is required.'));
|
blockers.push(blocker('invalid-agent-name', `${path}.name`, 'Agent name is required.'));
|
||||||
continue;
|
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 locality = classifyAgentLocality(rawAgent, decisions.fleetHost);
|
||||||
const localityFields = REMOTE_AGENT_INVENTORY_FIELDS.filter(
|
const localityFields = REMOTE_AGENT_INVENTORY_FIELDS.filter(
|
||||||
(field) => rawAgent[field] !== undefined,
|
(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];
|
const decision = decisions.agents[name];
|
||||||
if (!decision) {
|
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) {
|
if (candidateAgents.length === 0) {
|
||||||
blockers.push(
|
blockers.push(
|
||||||
blocker('no-local-candidates', 'agents', 'No local agents are eligible for v2 output.'),
|
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;
|
let canonicalYaml: string | undefined;
|
||||||
const environment: EnvironmentMigrationPreview[] = [];
|
const environment: EnvironmentMigrationPreview[] = [];
|
||||||
|
|
||||||
if (blockers.length === 0 && socketName !== undefined && decisions.defaultRuntime) {
|
if (blockers.length === 0 && decisions.defaultRuntime) {
|
||||||
const defaultRuntime = decisions.defaultRuntime;
|
const defaultRuntime = decisions.defaultRuntime;
|
||||||
try {
|
try {
|
||||||
canonicalRoster = parseRosterV2(
|
canonicalRoster = parseRosterV2(
|
||||||
@@ -966,12 +972,22 @@ function validateV1FieldCatalog(raw: Record<string, unknown>, blockers: Migratio
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const key of REMOTE_AGENT_INVENTORY_FIELDS) {
|
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(
|
blockers.push(
|
||||||
blocker(
|
blocker(
|
||||||
'invalid-v1-field-type',
|
'invalid-v1-field-type',
|
||||||
`${path}.${key}`,
|
`${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