fg-tracker-mcp 0.4.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +2 -0
- package/dist/docs.d.ts +1 -1
- package/dist/docs.js +12 -1
- package/dist/docs.js.map +1 -1
- package/dist/infra/aws.d.ts +97 -0
- package/dist/infra/aws.js +557 -0
- package/dist/infra/aws.js.map +1 -0
- package/dist/infra/diff.d.ts +15 -0
- package/dist/infra/diff.js +108 -0
- package/dist/infra/diff.js.map +1 -0
- package/dist/infra/files.d.ts +16 -0
- package/dist/infra/files.js +115 -0
- package/dist/infra/files.js.map +1 -0
- package/dist/infra/handover.d.ts +47 -0
- package/dist/infra/handover.js +437 -0
- package/dist/infra/handover.js.map +1 -0
- package/dist/infra/iac.d.ts +56 -0
- package/dist/infra/iac.js +378 -0
- package/dist/infra/iac.js.map +1 -0
- package/dist/infra/instrument.d.ts +6 -0
- package/dist/infra/instrument.js +140 -0
- package/dist/infra/instrument.js.map +1 -0
- package/dist/infra/tools.d.ts +10 -0
- package/dist/infra/tools.js +700 -0
- package/dist/infra/tools.js.map +1 -0
- package/dist/server.d.ts +1 -1
- package/dist/server.js +5 -1
- package/dist/server.js.map +1 -1
- package/package.json +5 -3
|
@@ -0,0 +1,700 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tools de identificação e transferência de infraestrutura (CONTRATO.md §27.3, §27.9;
|
|
3
|
+
* plano-infraestrutura.md §5–§7). Nenhuma tool altera recursos na AWS: a descoberta usa só
|
|
4
|
+
* chamadas de leitura e as tools de PR escrevem arquivos locais e devolvem comandos git/gh.
|
|
5
|
+
*/
|
|
6
|
+
import { stat, writeFile } from 'node:fs/promises';
|
|
7
|
+
import { join, resolve } from 'node:path';
|
|
8
|
+
import { z } from 'zod';
|
|
9
|
+
import { TrackApiError } from '../client.js';
|
|
10
|
+
import { compact, guard, json, text } from '../format.js';
|
|
11
|
+
import { ENVIRONMENT_LABEL, describeAwsError, detectEnvironments, detectRegionsInUse, discoverAws, readStackOutputs, serviceLabel } from './aws.js';
|
|
12
|
+
import { DOC_PATH, GUARD_PATH, GUARD_SCRIPT, HANDOVER_DIR, HANDOVER_README, LOCK_PATH, PATCH_PATH, buildHandoverEdits, formatChanges, handoverDoc, handoverReadme, lockFile, writeHandoverFiles, } from './handover.js';
|
|
13
|
+
import { analyzeIac, formatIac } from './iac.js';
|
|
14
|
+
import { buildInstrumentPlan } from './instrument.js';
|
|
15
|
+
const projectIdSchema = z.string().uuid().describe('ID (uuid) do projeto — veja track_list_projects');
|
|
16
|
+
const regionSchema = z.string().regex(/^[a-z]{2}(-gov)?-[a-z]+-\d$/, 'região AWS inválida (ex.: us-east-1)');
|
|
17
|
+
const SEVERITY_RANK = { critical: 0, high: 1, medium: 2, low: 3 };
|
|
18
|
+
const PILLAR_LABEL = { performance: 'Performance', security: 'Segurança', scalability: 'Escalabilidade', reliability: 'Confiabilidade' };
|
|
19
|
+
function listOf(value) {
|
|
20
|
+
if (Array.isArray(value))
|
|
21
|
+
return value;
|
|
22
|
+
const items = value?.items;
|
|
23
|
+
return Array.isArray(items) ? items : [];
|
|
24
|
+
}
|
|
25
|
+
function usd(v) {
|
|
26
|
+
return v === null || v === undefined || Number.isNaN(v) ? '—' : `US$ ${v >= 100 ? Math.round(v) : v.toFixed(2)}`;
|
|
27
|
+
}
|
|
28
|
+
async function resolveRoot(path) {
|
|
29
|
+
const root = resolve(process.cwd(), path ?? '.');
|
|
30
|
+
const s = await stat(root).catch(() => null);
|
|
31
|
+
if (!s?.isDirectory())
|
|
32
|
+
throw new Error(`Diretório não encontrado: ${root}. Passe \`path\` com o caminho absoluto do repositório.`);
|
|
33
|
+
return root;
|
|
34
|
+
}
|
|
35
|
+
function shellQuote(s) {
|
|
36
|
+
return /^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
37
|
+
}
|
|
38
|
+
export function registerInfraTools(server, client, version) {
|
|
39
|
+
const base = (projectId) => `/v1/projects/${projectId}/infra`;
|
|
40
|
+
const getProject = (projectId) => client.get(`/v1/projects/${projectId}`);
|
|
41
|
+
const getConnections = async (projectId) => {
|
|
42
|
+
try {
|
|
43
|
+
return listOf(await client.get(`${base(projectId)}/connections`));
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
const infraRouteMissing = (err) => err instanceof TrackApiError && err.status === 404 && /Cannot (GET|POST)/.test(err.message);
|
|
50
|
+
const wrapInfra = (err) => {
|
|
51
|
+
if (infraRouteMissing(err)) {
|
|
52
|
+
throw new TrackApiError(`A API em ${client.baseUrl} não expõe as rotas de infraestrutura (/v1/projects/:id/infra/*). Atualize a API do FG Tracker ou verifique INFRA_ENABLED.`, 404, err.path);
|
|
53
|
+
}
|
|
54
|
+
throw err;
|
|
55
|
+
};
|
|
56
|
+
/* 0 ---------------- detectar contas/ambientes/regiões ---------------- */
|
|
57
|
+
server.registerTool('infra_detect_environments', {
|
|
58
|
+
title: 'Detectar contas, ambientes e regiões AWS (credenciais locais)',
|
|
59
|
+
description: 'Somente leitura e local: lista os perfis de ~/.aws/config e ~/.aws/credentials (+ AWS_PROFILE e a cadeia padrão), chama sts:GetCallerIdentity em cada um (até 10; falhas são puladas com o motivo), deduplica por conta, classifica o ambiente pelo nome do perfil/ARN (prod/prd → production; stag/hml/homolog/qa → staging; dev/sandbox/test → development; senão null = pergunte ao usuário) e detecta as regiões em uso (índices do Resource Explorer ou sondagem com CloudFormation ListStacks + Tagging GetResources). Não chama a API do FG Tracker. Depois: confirme com o usuário e chame infra_connect_account por ambiente.',
|
|
60
|
+
inputSchema: {
|
|
61
|
+
profiles: z.array(z.string().min(1)).max(20).optional().describe('Perfis a testar (default: todos os perfis encontrados, até 10)'),
|
|
62
|
+
includeRegions: z.array(regionSchema).max(20).optional().describe('Regiões extras a sempre considerar além das candidatas'),
|
|
63
|
+
},
|
|
64
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
65
|
+
}, async ({ profiles, includeRegions }) => guard(async () => {
|
|
66
|
+
const result = await detectEnvironments({ profiles, includeRegions });
|
|
67
|
+
const accounts = result.accounts.map((a) => ({
|
|
68
|
+
profile: a.profile,
|
|
69
|
+
otherProfiles: a.otherProfiles,
|
|
70
|
+
accountId: a.accountId,
|
|
71
|
+
arn: a.arn,
|
|
72
|
+
environment: a.environment,
|
|
73
|
+
regionsInUse: a.regionsInUse,
|
|
74
|
+
stacksPerRegion: a.stacksPerRegion,
|
|
75
|
+
regionSource: a.regionSource,
|
|
76
|
+
suggestedConnectionName: a.suggestedConnectionName,
|
|
77
|
+
warnings: a.warnings,
|
|
78
|
+
}));
|
|
79
|
+
const lines = [];
|
|
80
|
+
if (!accounts.length) {
|
|
81
|
+
lines.push(`Nenhuma conta AWS acessível com as credenciais locais (perfis encontrados: ${result.profilesFound.length ? result.profilesFound.join(', ') : 'nenhum'}).`, 'Peça ao usuário para rodar `aws sso login --profile <perfil>` ou `aws configure` e chame de novo (ou passe `profiles`).');
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
lines.push(`${accounts.length} conta(s) AWS detectada(s):`);
|
|
85
|
+
for (const a of accounts) {
|
|
86
|
+
const env = a.environment ? `${a.environment} (${ENVIRONMENT_LABEL[a.environment]})` : 'ambiente desconhecido — pergunte ao usuário';
|
|
87
|
+
const stacks = Object.entries(a.stacksPerRegion).map(([r, n]) => `${r}:${n}`).join(', ');
|
|
88
|
+
lines.push(`- conta ${a.accountId} · perfil ${a.profile ?? '(cadeia padrão)'}${a.otherProfiles.length ? ` (também: ${a.otherProfiles.join(', ')})` : ''} · ${env} · regiões ${a.regionsInUse.length ? a.regionsInUse.join(', ') : 'nenhuma detectada'}${stacks ? ` · stacks ${stacks}` : ''} · fonte ${a.regionSource}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (result.skipped.length)
|
|
92
|
+
lines.push('', 'Perfis ignorados:', ...result.skipped.map((s) => `- ${s.profile ?? '(cadeia padrão)'}: ${s.reason}`));
|
|
93
|
+
lines.push('', 'Próximos passos:', '1. Mostre a lista ao usuário e confirme quais contas/ambientes conectar (pergunte o ambiente só quando for desconhecido; confirme as regiões).', '2. Para cada ambiente escolhido: `infra_connect_account { projectId, level, name: "<nome> · <Ambiente>", environment, regions, profile }` → peça confirmação → rode o comando de deploy devolvido → `infra_verify_connection { projectId, connectionId, profile }` → `infra_discover { projectId, profile, regions }`.');
|
|
94
|
+
return json({ accounts, skipped: result.skipped, profilesFound: result.profilesFound }, lines.join('\n'));
|
|
95
|
+
}));
|
|
96
|
+
/* 1 ---------------- conectar conta ---------------- */
|
|
97
|
+
server.registerTool('infra_connect_account', {
|
|
98
|
+
title: 'Conectar conta AWS ao projeto',
|
|
99
|
+
description: 'Cria a conexão AWS do projeto (POST /v1/projects/:id/infra/connections, mode role), salva o template em ./fg-tracker-connection-<ambiente>.json e devolve o comando exato `aws cloudformation deploy ... --profile <perfil> --region <região>`. Sem `regions`, detecta as regiões em uso com o perfil (somente leitura). Não cria nada na AWS: o agente deve pedir confirmação ao usuário antes de rodar o comando. Depois: infra_verify_connection.',
|
|
100
|
+
inputSchema: {
|
|
101
|
+
projectId: projectIdSchema,
|
|
102
|
+
level: z.enum(['read', 'operate']).describe('read = diagrama, monitoramento, custo e notas; operate = também alterar infra e implantar releases pelos stacks transferidos'),
|
|
103
|
+
environment: z.enum(['production', 'staging', 'development']).optional().describe('Ambiente da conta (veja infra_detect_environments)'),
|
|
104
|
+
regions: z.array(regionSchema).min(1).max(20).optional().describe('Regiões usadas (default: detectadas com o perfil; a primeira recebe as roles)'),
|
|
105
|
+
name: z.string().min(1).max(120).optional().describe('Nome da conexão (ex.: "<nome> · Produção"; default: <slug>-<ambiente>)'),
|
|
106
|
+
profile: z.string().min(1).optional().describe('Perfil do ~/.aws da conta (usado na detecção de regiões e no comando de deploy)'),
|
|
107
|
+
},
|
|
108
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
109
|
+
}, async ({ projectId, level, environment, regions, name, profile }) => guard(async () => {
|
|
110
|
+
const project = await getProject(projectId);
|
|
111
|
+
const notes = [];
|
|
112
|
+
let useRegions = regions;
|
|
113
|
+
if (!useRegions?.length) {
|
|
114
|
+
try {
|
|
115
|
+
const detected = await detectRegionsInUse({ profile });
|
|
116
|
+
useRegions = detected.regionsInUse.slice(0, 20);
|
|
117
|
+
notes.push(`Regiões detectadas (${detected.source}) com ${profile ? `o perfil ${profile}` : 'a cadeia padrão'}: ${useRegions.length ? useRegions.join(', ') : 'nenhuma'}.`, ...detected.warnings);
|
|
118
|
+
}
|
|
119
|
+
catch (err) {
|
|
120
|
+
notes.push(`Detecção de regiões falhou: ${describeAwsError(err)}.`);
|
|
121
|
+
}
|
|
122
|
+
if (!useRegions?.length) {
|
|
123
|
+
useRegions = [process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1'];
|
|
124
|
+
notes.push(`Usando ${useRegions[0]} — confirme as regiões com o usuário (dá para editar a conexão depois).`);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
const res = await client
|
|
128
|
+
.post(`${base(projectId)}/connections`, {
|
|
129
|
+
name: name ?? `${project.slug}-${environment ?? 'aws'}`,
|
|
130
|
+
level,
|
|
131
|
+
...(environment ? { environment } : {}),
|
|
132
|
+
regions: useRegions,
|
|
133
|
+
mode: 'role',
|
|
134
|
+
})
|
|
135
|
+
.catch(wrapInfra);
|
|
136
|
+
const connectionId = res.connection?.id;
|
|
137
|
+
const stackName = `fg-tracker-${project.slug}`;
|
|
138
|
+
const tpl = res.template ?? {};
|
|
139
|
+
const region = useRegions[0] ?? 'us-east-1';
|
|
140
|
+
const profileFlag = profile ? ` --profile ${shellQuote(profile)}` : '';
|
|
141
|
+
const lines = [
|
|
142
|
+
`Conexão criada: ${res.connection?.name ?? name ?? stackName} (id ${connectionId}) · nível ${level}${environment ? ` · ${environment}` : ''} · regiões ${useRegions.join(', ')} · status ${res.connection?.status ?? 'pending'}.`,
|
|
143
|
+
...notes,
|
|
144
|
+
res.externalId ? `External ID: ${res.externalId} (já embutido no template; não precisa ser segredo, mas não o publique).` : '',
|
|
145
|
+
'',
|
|
146
|
+
];
|
|
147
|
+
let deployCommand = null;
|
|
148
|
+
if (tpl.body !== undefined && tpl.body !== null) {
|
|
149
|
+
const body = typeof tpl.body === 'string' ? tpl.body : JSON.stringify(tpl.body, null, 2);
|
|
150
|
+
const suffix = environment ?? (connectionId ? connectionId.slice(0, 8) : 'aws');
|
|
151
|
+
const file = join(process.cwd(), `fg-tracker-connection-${suffix}.json`);
|
|
152
|
+
await writeFile(file, body.endsWith('\n') ? body : `${body}\n`, 'utf8');
|
|
153
|
+
let params = [];
|
|
154
|
+
try {
|
|
155
|
+
const parsed = (typeof tpl.body === 'string' ? JSON.parse(tpl.body) : tpl.body);
|
|
156
|
+
params = Object.keys(parsed.Parameters ?? {});
|
|
157
|
+
}
|
|
158
|
+
catch {
|
|
159
|
+
/* template YAML */
|
|
160
|
+
}
|
|
161
|
+
if (tpl.cliCommand) {
|
|
162
|
+
deployCommand = tpl.cliCommand
|
|
163
|
+
.split('\n')
|
|
164
|
+
.filter(Boolean)
|
|
165
|
+
.map((line) => `${line.replace(/--template-file\s+\S+/, `--template-file ${shellQuote(file)}`)}${profile && !/--profile\b/.test(line) ? profileFlag : ''}`)
|
|
166
|
+
.join('\n');
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
const overrides = res.externalId ? params.filter((p) => /external\s*id/i.test(p)).map((p) => `${p}=${res.externalId}`) : [];
|
|
170
|
+
deployCommand = [
|
|
171
|
+
'aws cloudformation deploy',
|
|
172
|
+
` --template-file ${shellQuote(file)}`,
|
|
173
|
+
` --stack-name ${stackName}`,
|
|
174
|
+
' --capabilities CAPABILITY_NAMED_IAM',
|
|
175
|
+
` --region ${region}`,
|
|
176
|
+
...(profile ? [` --profile ${shellQuote(profile)}`] : []),
|
|
177
|
+
...(overrides.length ? [` --parameter-overrides ${overrides.join(' ')}`] : []),
|
|
178
|
+
].join(' \\\n');
|
|
179
|
+
}
|
|
180
|
+
lines.push(`Template salvo em \`${file}\` (não commite).`);
|
|
181
|
+
}
|
|
182
|
+
else if (tpl.cliCommand) {
|
|
183
|
+
deployCommand = tpl.cliCommand.split('\n').filter(Boolean).map((line) => `${line}${profile && !/--profile\b/.test(line) ? profileFlag : ''}`).join('\n');
|
|
184
|
+
}
|
|
185
|
+
lines.push(`## Criar o stack de conexão \`${stackName}\` (conta${profile ? ` do perfil ${profile}` : ''}, região ${region})`);
|
|
186
|
+
if (deployCommand) {
|
|
187
|
+
lines.push('**Peça confirmação explícita ao usuário antes de rodar** — é a única mudança na AWS do fluxo (cria as roles IAM do FG Tracker). Mostre o comando e, só com o "sim", rode:', '```bash', deployCommand, '```');
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
lines.push('A API não devolveu template nem comando. Baixe-o na tela Infraestrutura → Conexões.');
|
|
191
|
+
}
|
|
192
|
+
if (tpl.quickCreateUrl)
|
|
193
|
+
lines.push('', 'Alternativa pelo console (o usuário abre e cria):', tpl.quickCreateUrl);
|
|
194
|
+
lines.push('', '## Depois do stack criado (CREATE_COMPLETE / sem mudanças)', `1. \`infra_verify_connection { projectId: "${projectId}", connectionId: "${connectionId}"${profile ? `, profile: "${profile}"` : ''} }\` — os ARNs das roles são lidos dos outputs do stack \`${stackName}\` (somente leitura).`, `2. \`infra_discover { projectId: "${projectId}"${profile ? `, profile: "${profile}"` : ''}, regions: ${JSON.stringify(useRegions)} }\`.`);
|
|
195
|
+
return text(lines.filter((l, i) => l !== '' || lines[i - 1] !== '').join('\n'));
|
|
196
|
+
}));
|
|
197
|
+
/* 2 ---------------- verificar conexão ---------------- */
|
|
198
|
+
server.registerTool('infra_verify_connection', {
|
|
199
|
+
title: 'Verificar conexão AWS',
|
|
200
|
+
description: 'Testa a conexão (POST .../infra/connections/:connectionId/verify: AssumeRole + GetCallerIdentity no FG Tracker). Sem ARNs informados, lê os outputs do stack local `fg-tracker-<slug>` com DescribeStacks (somente leitura, credenciais locais) e os envia.',
|
|
201
|
+
inputSchema: {
|
|
202
|
+
projectId: projectIdSchema,
|
|
203
|
+
connectionId: z.string().uuid().describe('id devolvido por infra_connect_account'),
|
|
204
|
+
readRoleArn: z.string().startsWith('arn:').optional(),
|
|
205
|
+
operateRoleArn: z.string().startsWith('arn:').optional(),
|
|
206
|
+
executionRoleArn: z.string().startsWith('arn:').optional(),
|
|
207
|
+
stackName: z.string().optional().describe('Stack de conexão (default fg-tracker-<slug>)'),
|
|
208
|
+
region: regionSchema.optional().describe('Região do stack (default: primeira região da conexão)'),
|
|
209
|
+
profile: z.string().optional().describe('Perfil do ~/.aws para ler os outputs'),
|
|
210
|
+
},
|
|
211
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
212
|
+
}, async ({ projectId, connectionId, readRoleArn, operateRoleArn, executionRoleArn, stackName, region, profile }) => guard(async () => {
|
|
213
|
+
const notes = [];
|
|
214
|
+
const body = {};
|
|
215
|
+
if (readRoleArn)
|
|
216
|
+
body.readRoleArn = readRoleArn;
|
|
217
|
+
if (operateRoleArn)
|
|
218
|
+
body.operateRoleArn = operateRoleArn;
|
|
219
|
+
if (executionRoleArn)
|
|
220
|
+
body.executionRoleArn = executionRoleArn;
|
|
221
|
+
if (!Object.keys(body).length) {
|
|
222
|
+
const project = await getProject(projectId);
|
|
223
|
+
const conn = (await getConnections(projectId)).find((c) => c.id === connectionId);
|
|
224
|
+
const stack = stackName ?? `fg-tracker-${project.slug}`;
|
|
225
|
+
const r = region ?? conn?.regions?.[0] ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1';
|
|
226
|
+
try {
|
|
227
|
+
const outputs = await readStackOutputs(stack, r, profile);
|
|
228
|
+
const entries = Object.entries(outputs).filter(([, v]) => v.startsWith('arn:'));
|
|
229
|
+
const pick = (re, not) => entries.find(([k]) => re.test(k) && !(not?.test(k)))?.[1];
|
|
230
|
+
const read = pick(/read/i, /operate|exec/i);
|
|
231
|
+
const operate = pick(/operate/i);
|
|
232
|
+
const exec = pick(/exec/i);
|
|
233
|
+
if (read)
|
|
234
|
+
body.readRoleArn = read;
|
|
235
|
+
if (operate)
|
|
236
|
+
body.operateRoleArn = operate;
|
|
237
|
+
if (exec)
|
|
238
|
+
body.executionRoleArn = exec;
|
|
239
|
+
notes.push(`Outputs de \`${stack}\` (${r}): ${Object.keys(body).length ? Object.entries(body).map(([k, v]) => `${k}=${v}`).join(', ') : 'nenhum ARN de role reconhecido'}.`);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
notes.push(`Não consegui ler o stack \`${stack}\` em ${r}: ${describeAwsError(err)}. Verificando sem ARNs (a API usa o que já tem gravado).`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const result = await client.post(`${base(projectId)}/connections/${connectionId}/verify`, body).catch(wrapInfra);
|
|
246
|
+
const status = result.status ?? result.connection?.status;
|
|
247
|
+
const accountId = result.accountId ?? result.connection?.accountId;
|
|
248
|
+
const ok = status === 'connected';
|
|
249
|
+
return json(compact(result), [
|
|
250
|
+
...notes,
|
|
251
|
+
ok ? `Conexão OK — conta ${accountId ?? '?'}.` : `Conexão com status ${status ?? '?'}${result.statusError ? `: ${result.statusError}` : ''}.`,
|
|
252
|
+
ok ? `Próximo passo: infra_discover { projectId: "${projectId}" }.` : 'Confira se o stack terminou (CREATE_COMPLETE), a região e se as roles confiam no principal do FG Tracker com o External ID.',
|
|
253
|
+
].join('\n'));
|
|
254
|
+
}));
|
|
255
|
+
/* 3 ---------------- descoberta local ---------------- */
|
|
256
|
+
server.registerTool('infra_discover', {
|
|
257
|
+
title: 'Descobrir recursos AWS (credenciais locais)',
|
|
258
|
+
description: 'Inventário com as credenciais AWS do usuário (cadeia padrão ou `profile`), só leitura: GetCallerIdentity; por região Resource Explorer (Search "*", até 2000) ou Tagging API; CloudFormation ListStacks/ListStackResources para stacks e logical ids. Envia o snapshot para POST /v1/projects/:id/infra/snapshots e devolve resumo por serviço/região.',
|
|
259
|
+
inputSchema: {
|
|
260
|
+
projectId: projectIdSchema,
|
|
261
|
+
regions: z.array(regionSchema).min(1).max(20).optional().describe('Default: regiões das conexões do projeto, ou AWS_REGION'),
|
|
262
|
+
profile: z.string().optional().describe('Perfil do ~/.aws (default: cadeia padrão de credenciais)'),
|
|
263
|
+
path: z.string().optional().describe('Repositório para anexar a análise de IaC ao snapshot (opcional)'),
|
|
264
|
+
},
|
|
265
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
266
|
+
}, async ({ projectId, regions, profile, path }) => guard(async () => {
|
|
267
|
+
let useRegions = regions;
|
|
268
|
+
if (!useRegions?.length) {
|
|
269
|
+
const fromConnections = [...new Set((await getConnections(projectId)).flatMap((c) => c.regions ?? []))];
|
|
270
|
+
useRegions = fromConnections.length ? fromConnections : [process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION ?? 'us-east-1'];
|
|
271
|
+
}
|
|
272
|
+
let discovery;
|
|
273
|
+
try {
|
|
274
|
+
discovery = await discoverAws({ regions: useRegions, profile });
|
|
275
|
+
}
|
|
276
|
+
catch (err) {
|
|
277
|
+
throw new Error(describeAwsError(err));
|
|
278
|
+
}
|
|
279
|
+
let iac;
|
|
280
|
+
if (path)
|
|
281
|
+
iac = await analyzeIac(await resolveRoot(path));
|
|
282
|
+
const payload = { accountId: discovery.accountId, regions: discovery.regions, resources: discovery.resources, ...(iac ? { iac } : {}) };
|
|
283
|
+
let response;
|
|
284
|
+
let route = `${base(projectId)}/snapshots`;
|
|
285
|
+
try {
|
|
286
|
+
response = await client.post(route, payload);
|
|
287
|
+
}
|
|
288
|
+
catch (err) {
|
|
289
|
+
if (err instanceof TrackApiError && (err.status === 401 || err.status === 403 || err.status === 404) && client.hasApiKey) {
|
|
290
|
+
route = '/v1/infra/snapshots';
|
|
291
|
+
response = await client.post(route, { projectId, ...payload }).catch(wrapInfra);
|
|
292
|
+
}
|
|
293
|
+
else
|
|
294
|
+
wrapInfra(err);
|
|
295
|
+
}
|
|
296
|
+
const byService = new Map();
|
|
297
|
+
const byRegion = new Map();
|
|
298
|
+
for (const r of discovery.resources) {
|
|
299
|
+
const svc = serviceLabel(r.resourceType, r.arn);
|
|
300
|
+
byService.set(svc, (byService.get(svc) ?? 0) + 1);
|
|
301
|
+
byRegion.set(r.region, (byRegion.get(r.region) ?? 0) + 1);
|
|
302
|
+
}
|
|
303
|
+
const count = (v) => (typeof v === 'number' ? v : Array.isArray(v) ? v.length : '?');
|
|
304
|
+
const resp = response;
|
|
305
|
+
const lines = [
|
|
306
|
+
`Conta ${discovery.accountId} (${discovery.callerArn}) · ${discovery.resources.length} recursos em ${discovery.regions.join(', ')}.`,
|
|
307
|
+
`Fontes: ${Object.entries(discovery.sources).map(([r, s]) => `${r}=${s}`).join(', ')}.`,
|
|
308
|
+
`Snapshot enviado (${route}): ${count(resp?.nodes)} nós, ${count(resp?.edges)} arestas${iac ? `, IaC com ${iac.stacks.length} stack(s)` : ''}.`,
|
|
309
|
+
'',
|
|
310
|
+
'Por região: ' + [...byRegion].map(([r, n]) => `${r} ${n}`).join(' · '),
|
|
311
|
+
'Por serviço:',
|
|
312
|
+
...[...byService].sort((a, b) => b[1] - a[1]).slice(0, 25).map(([s, n]) => `- ${s}: ${n}`),
|
|
313
|
+
];
|
|
314
|
+
if (byService.size > 25)
|
|
315
|
+
lines.push(`- … +${byService.size - 25} serviços`);
|
|
316
|
+
if (discovery.stacks.length) {
|
|
317
|
+
lines.push('', `Stacks CloudFormation (${discovery.stacks.length}):`, ...discovery.stacks.slice(0, 25).map((s) => `- ${s.name} (${s.region}, ${s.status}) · ${s.resources} recursos, ${s.mapped} ligados a ARN`));
|
|
318
|
+
if (discovery.stacks.length > 25)
|
|
319
|
+
lines.push(`- … +${discovery.stacks.length - 25} stacks`);
|
|
320
|
+
}
|
|
321
|
+
if (discovery.warnings.length)
|
|
322
|
+
lines.push('', 'Avisos:', ...discovery.warnings.map((w) => `- ${w}`));
|
|
323
|
+
lines.push('', 'Próximos passos: `infra_analyze_iac` (liga stacks ao código) → `infra_assign_resources` (confirme com o usuário o que é do projeto e o que é compartilhado).');
|
|
324
|
+
return text(lines.join('\n'));
|
|
325
|
+
}));
|
|
326
|
+
/* 4 ---------------- análise de IaC ---------------- */
|
|
327
|
+
server.registerTool('infra_analyze_iac', {
|
|
328
|
+
title: 'Analisar IaC do repositório',
|
|
329
|
+
description: 'Lê o repositório local (sem executar nada): cdk.json e entrada do app, stacks `new XStack(app, "Nome", { env })`, constructs `aws-cdk-lib/aws-*`, assets (Code.fromAsset, NodejsFunction, DockerImageFunction, ContainerImage.fromAsset), contextos, SAM, Serverless, Terraform (somente leitura) e comandos de deploy do CI. Devolve viabilidade de handover por stack.',
|
|
330
|
+
inputSchema: { path: z.string().optional().describe('Caminho do repositório (default: diretório atual)') },
|
|
331
|
+
annotations: { readOnlyHint: true, openWorldHint: false },
|
|
332
|
+
}, async ({ path }) => guard(async () => text(formatIac(await analyzeIac(await resolveRoot(path))))));
|
|
333
|
+
/* 5 ---------------- atribuir recursos ---------------- */
|
|
334
|
+
server.registerTool('infra_assign_resources', {
|
|
335
|
+
title: 'Confirmar recursos do projeto',
|
|
336
|
+
description: 'Confirma (include) ou remove (exclude) recursos do projeto por ARN: resolve os nós em GET .../infra/graph e chama POST .../infra/nodes/:nodeId/assign { projectOwned }. Use só depois de o usuário confirmar a lista.',
|
|
337
|
+
inputSchema: {
|
|
338
|
+
projectId: projectIdSchema,
|
|
339
|
+
include: z.array(z.string().startsWith('arn:')).max(1000).describe('ARNs que pertencem ao projeto'),
|
|
340
|
+
exclude: z.array(z.string().startsWith('arn:')).max(1000).optional().describe('ARNs que não pertencem (compartilhados/de outros projetos)'),
|
|
341
|
+
},
|
|
342
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
343
|
+
}, async ({ projectId, include, exclude }) => guard(async () => {
|
|
344
|
+
const graph = await client.get(`${base(projectId)}/graph`).catch(wrapInfra);
|
|
345
|
+
const byArn = new Map((graph.nodes ?? []).map((n) => [n.arn, n]));
|
|
346
|
+
const jobs = [...include.map((arn) => ({ arn, projectOwned: true })), ...(exclude ?? []).map((arn) => ({ arn, projectOwned: false }))];
|
|
347
|
+
const notFound = [];
|
|
348
|
+
const failed = [];
|
|
349
|
+
let assigned = 0;
|
|
350
|
+
let removed = 0;
|
|
351
|
+
const queue = [...jobs];
|
|
352
|
+
const worker = async () => {
|
|
353
|
+
for (let job = queue.shift(); job; job = queue.shift()) {
|
|
354
|
+
const node = byArn.get(job.arn);
|
|
355
|
+
if (!node) {
|
|
356
|
+
notFound.push(job.arn);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
try {
|
|
360
|
+
await client.post(`${base(projectId)}/nodes/${node.id}/assign`, { projectOwned: job.projectOwned });
|
|
361
|
+
if (job.projectOwned)
|
|
362
|
+
assigned++;
|
|
363
|
+
else
|
|
364
|
+
removed++;
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
failed.push(`${job.arn}: ${err instanceof TrackApiError ? err.message : String(err)}`);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
await Promise.all(Array.from({ length: 5 }, worker));
|
|
372
|
+
const lines = [`${assigned} recurso(s) confirmados no projeto, ${removed} removido(s).`];
|
|
373
|
+
if (notFound.length)
|
|
374
|
+
lines.push(`${notFound.length} ARN(s) não estão no grafo (rode infra_discover antes):`, ...notFound.slice(0, 25).map((a) => `- ${a}`));
|
|
375
|
+
if (failed.length)
|
|
376
|
+
lines.push(`${failed.length} falha(s):`, ...failed.slice(0, 25).map((f) => `- ${f}`));
|
|
377
|
+
lines.push('', `Próximo passo: infra_instrument { projectId: "${projectId}" } e infra_validate.`);
|
|
378
|
+
return { ...text(lines.join('\n')), ...(failed.length && !assigned && !removed ? { isError: true } : {}) };
|
|
379
|
+
}));
|
|
380
|
+
/* 6 ---------------- instrumentação ---------------- */
|
|
381
|
+
server.registerTool('infra_instrument', {
|
|
382
|
+
title: 'Plano de instrumentação de infra',
|
|
383
|
+
description: 'Varre o repositório e devolve um plano de patches (não aplicado): instalar fg-tracker-sdk, tracker.instrumentAws(new SQSClient()) nos clientes SQS/SNS/EventBridge/DynamoDB/Kinesis/SFN, tracker.instrumentPgPool(pool), tracker.startInfraReporter({ ecs: true }) em apps ECS, camada da extensão + FG_TRACKER_URL/FG_TRACKER_KEY nas Lambdas do CDK e tracker.wrapLambda(handler), com arquivos e trechos de código.',
|
|
384
|
+
inputSchema: { projectId: projectIdSchema, path: z.string().optional().describe('Caminho do repositório (default: diretório atual)') },
|
|
385
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
386
|
+
}, async ({ projectId, path }) => guard(async () => {
|
|
387
|
+
const root = await resolveRoot(path);
|
|
388
|
+
const project = await getProject(projectId);
|
|
389
|
+
const conn = (await getConnections(projectId)).find((c) => c.accountId) ?? null;
|
|
390
|
+
const layerArn = conn?.accountId ? `arn:aws:lambda:${conn.regions?.[0] ?? '<região>'}:${conn.accountId}:layer:fg-tracker-extension:<versão>` : 'arn:aws:lambda:<região>:<conta>:layer:fg-tracker-extension:<versão>';
|
|
391
|
+
return text(await buildInstrumentPlan(root, { endpoint: client.baseUrl, serviceName: project.slug, layerArn }));
|
|
392
|
+
}));
|
|
393
|
+
/* 7 ---------------- validação (notas) ---------------- */
|
|
394
|
+
server.registerTool('infra_validate', {
|
|
395
|
+
title: 'Notas dos quatro pilares',
|
|
396
|
+
description: 'Relatório compacto por pilar (performance, segurança, escalabilidade, confiabilidade): GET .../infra/scores + findings abertos, com as principais recomendações.',
|
|
397
|
+
inputSchema: { projectId: projectIdSchema },
|
|
398
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
399
|
+
}, async ({ projectId }) => guard(async () => {
|
|
400
|
+
const scores = await client.get(`${base(projectId)}/scores`).catch(wrapInfra);
|
|
401
|
+
const findings = listOf(await client.get(`${base(projectId)}/findings`, { status: 'open' }));
|
|
402
|
+
let names = new Map();
|
|
403
|
+
try {
|
|
404
|
+
const graph = await client.get(`${base(projectId)}/graph`);
|
|
405
|
+
names = new Map((graph.nodes ?? []).map((n) => [n.id, n.name]));
|
|
406
|
+
}
|
|
407
|
+
catch {
|
|
408
|
+
/* nomes opcionais */
|
|
409
|
+
}
|
|
410
|
+
const lines = [`Nota geral: ${Math.round(scores.overall)}/100 · ${findings.length} findings abertos.`];
|
|
411
|
+
for (const [pillar, p] of Object.entries(scores.pillars ?? {})) {
|
|
412
|
+
lines.push('', `## ${PILLAR_LABEL[pillar] ?? pillar}: ${Math.round(p.score)}/100 (${p.open} abertos, ${p.critical} críticos)`);
|
|
413
|
+
const top = findings.filter((f) => f.pillar === pillar).sort((a, b) => (SEVERITY_RANK[a.severity] ?? 9) - (SEVERITY_RANK[b.severity] ?? 9)).slice(0, 5);
|
|
414
|
+
for (const f of top)
|
|
415
|
+
lines.push(`- [${f.severity}] ${f.title}${f.nodeId ? ` — ${names.get(f.nodeId) ?? f.nodeId}` : ''}${f.recommendation ? `\n → ${f.recommendation}` : ''}`);
|
|
416
|
+
if (!top.length)
|
|
417
|
+
lines.push('- sem findings abertos');
|
|
418
|
+
}
|
|
419
|
+
lines.push('', 'Correções de infra são feitas pelo FG Tracker (diagrama → mudança → aprovação), nunca direto na AWS pelo agente. Ajustes de código (timeouts, pools, retries) podem ir no PR.');
|
|
420
|
+
return text(lines.join('\n'));
|
|
421
|
+
}));
|
|
422
|
+
/* 8 ---------------- diagrama ---------------- */
|
|
423
|
+
server.registerTool('infra_get_diagram', {
|
|
424
|
+
title: 'Diagrama da infraestrutura (texto)',
|
|
425
|
+
description: 'GET .../infra/graph em texto compacto: grupos (conta/região/VPC/AZ/subnet) → nós com estado, réplicas e custo; arestas; gargalos (nós em warning/critical); deploys ativos; notas e custo.',
|
|
426
|
+
inputSchema: { projectId: projectIdSchema, maxNodes: z.number().int().min(10).max(500).default(150).describe('Máximo de nós listados') },
|
|
427
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
428
|
+
}, async ({ projectId, maxNodes }) => guard(async () => {
|
|
429
|
+
const g = await client.get(`${base(projectId)}/graph`).catch(wrapInfra);
|
|
430
|
+
const nodes = g.nodes ?? [];
|
|
431
|
+
const groups = g.groups ?? [];
|
|
432
|
+
const nameOf = new Map(nodes.map((n) => [n.id, n.name]));
|
|
433
|
+
const lines = [
|
|
434
|
+
`Conexões: ${(g.connections ?? []).map((c) => `${c.name} (${c.mode}, ${c.status}${c.accountId ? `, ${c.accountId}` : ''})`).join('; ') || 'nenhuma'}`,
|
|
435
|
+
`Nota ${g.scores ? Math.round(g.scores.overall) : '—'}/100 · custo agora ${usd(g.costs?.nowPerHour)}/h · hoje ${usd(g.costs?.today)} · mês ${usd(g.costs?.monthToDate)} (previsão ${usd(g.costs?.forecastMonth)}) · ${nodes.length} nós · ${(g.edges ?? []).length} arestas${g.live?.active ? ' · tempo real ativo' : ''}`,
|
|
436
|
+
'',
|
|
437
|
+
];
|
|
438
|
+
const children = new Map();
|
|
439
|
+
for (const gr of groups)
|
|
440
|
+
children.set(gr.parentId, [...(children.get(gr.parentId) ?? []), gr]);
|
|
441
|
+
const nodesByGroup = new Map();
|
|
442
|
+
for (const n of nodes)
|
|
443
|
+
nodesByGroup.set(n.groupId ?? null, [...(nodesByGroup.get(n.groupId ?? null) ?? []), n]);
|
|
444
|
+
let shown = 0;
|
|
445
|
+
const nodeLine = (n, indent) => {
|
|
446
|
+
const f = n.openFindings;
|
|
447
|
+
const fl = f && f.critical + f.high + f.medium + f.low ? ` · findings ${f.critical}c/${f.high}h/${f.medium}m/${f.low}l` : '';
|
|
448
|
+
const rep = n.replicas ? ` · réplicas ${n.replicas.current}/${n.replicas.desired ?? '—'}${n.replicas.max ? ` (máx ${n.replicas.max})` : ''}` : '';
|
|
449
|
+
return `${indent}- ${n.name} (${n.service}) ${n.state}${n.stateReason ? `: ${n.stateReason}` : ''}${rep}${n.costPerHour ? ` · ${usd(n.costPerHour)}/h` : ''}${n.instrumented ? ' · instrumentado' : ''}${n.owner === 'fg-tracker' ? ' · FG Tracker' : ''}${n.shared ? ' · compartilhado' : ''}${fl}`;
|
|
450
|
+
};
|
|
451
|
+
const visit = (parent, depth) => {
|
|
452
|
+
for (const gr of children.get(parent) ?? []) {
|
|
453
|
+
const indent = ' '.repeat(depth);
|
|
454
|
+
lines.push(`${indent}[${gr.kind}] ${gr.label}`);
|
|
455
|
+
for (const n of nodesByGroup.get(gr.id) ?? []) {
|
|
456
|
+
if (shown++ < maxNodes)
|
|
457
|
+
lines.push(nodeLine(n, indent + ' '));
|
|
458
|
+
}
|
|
459
|
+
visit(gr.id, depth + 1);
|
|
460
|
+
}
|
|
461
|
+
};
|
|
462
|
+
visit(null, 0);
|
|
463
|
+
const orphan = nodes.filter((n) => !n.groupId || !groups.some((gr) => gr.id === n.groupId));
|
|
464
|
+
if (orphan.length) {
|
|
465
|
+
lines.push('[sem grupo]');
|
|
466
|
+
for (const n of orphan)
|
|
467
|
+
if (shown++ < maxNodes)
|
|
468
|
+
lines.push(nodeLine(n, ' '));
|
|
469
|
+
}
|
|
470
|
+
if (shown > maxNodes)
|
|
471
|
+
lines.push(`… +${shown - maxNodes} nós (aumente maxNodes)`);
|
|
472
|
+
const edges = g.edges ?? [];
|
|
473
|
+
if (edges.length) {
|
|
474
|
+
lines.push('', `Arestas (${edges.length}):`);
|
|
475
|
+
for (const e of [...edges].sort((a, b) => b.volumePerMin - a.volumePerMin).slice(0, 80))
|
|
476
|
+
lines.push(`- ${nameOf.get(e.source) ?? e.source} → ${nameOf.get(e.target) ?? e.target} (${e.relation}, ${e.kind}${e.volumePerMin ? `, ${Math.round(e.volumePerMin)}/min` : ''})`);
|
|
477
|
+
if (edges.length > 80)
|
|
478
|
+
lines.push(`- … +${edges.length - 80} arestas`);
|
|
479
|
+
}
|
|
480
|
+
const hot = nodes.filter((n) => n.state === 'critical' || n.state === 'warning');
|
|
481
|
+
lines.push('', `Gargalos/alertas (${hot.length}):`, ...(hot.length ? hot.slice(0, 20).map((n) => `- [${n.state}] ${n.name}${n.stateReason ? `: ${n.stateReason}` : ''}`) : ['- nenhum']));
|
|
482
|
+
const deploys = g.activeDeployments ?? [];
|
|
483
|
+
if (deploys.length)
|
|
484
|
+
lines.push('', 'Deploys ativos:', ...deploys.slice(0, 10).map((d) => `- ${d.name} ${d.status}${d.stage ? ` (${d.stage})` : ''}${d.release ? ` · ${d.release}` : d.commitSha ? ` · ${d.commitSha.slice(0, 7)}` : ''}`));
|
|
485
|
+
return text(lines.join('\n'));
|
|
486
|
+
}));
|
|
487
|
+
/* 9 / 10 ---------------- handover ---------------- */
|
|
488
|
+
const handoverInputs = {
|
|
489
|
+
projectId: projectIdSchema,
|
|
490
|
+
stackNames: z.array(z.string().min(1)).min(1).max(50).describe('Nomes dos stacks CloudFormation a transferir'),
|
|
491
|
+
path: z.string().optional().describe('Caminho do repositório (default: diretório atual)'),
|
|
492
|
+
};
|
|
493
|
+
async function prepareHandover(projectId, stackNames, path) {
|
|
494
|
+
const root = await resolveRoot(path);
|
|
495
|
+
const analysis = await analyzeIac(root);
|
|
496
|
+
let apiPlan = null;
|
|
497
|
+
let apiPlanError = null;
|
|
498
|
+
try {
|
|
499
|
+
apiPlan = await client.post(`${base(projectId)}/handover/plan`, { stackNames });
|
|
500
|
+
}
|
|
501
|
+
catch (err) {
|
|
502
|
+
apiPlanError = infraRouteMissing(err) ? 'rota POST /infra/handover/plan indisponível nesta API' : err instanceof TrackApiError ? err.describe() : String(err);
|
|
503
|
+
}
|
|
504
|
+
const edits = await buildHandoverEdits(root, analysis, stackNames);
|
|
505
|
+
return { root, analysis, apiPlan, apiPlanError, edits };
|
|
506
|
+
}
|
|
507
|
+
function feasibilityLines(edits) {
|
|
508
|
+
const lines = edits.matched.map((s) => `- ${s.name} (${s.tool}): ${s.handover}${s.entryFile ? ` — \`${s.entryFile}${s.entryLines ? `:${s.entryLines[0]}` : ''}\`` : ''}`);
|
|
509
|
+
for (const m of edits.missing)
|
|
510
|
+
lines.push(`- ${m}: não encontrado no IaC do repositório (stack manual ou de outro repo) — o PR só registra no lock.`);
|
|
511
|
+
return lines;
|
|
512
|
+
}
|
|
513
|
+
server.registerTool('infra_plan_handover', {
|
|
514
|
+
title: 'Planejar transferência de controle',
|
|
515
|
+
description: 'Combina POST .../infra/handover/plan com a análise local de IaC e devolve a lista exata de edições do PR: remover stacks do app CDK e do cdk deploy do CI, separar deploy de código (fg-tracker-release deploy --release <sha>), guard do CI, infra/fg-tracker.lock.json e README. Não escreve nada.',
|
|
516
|
+
inputSchema: handoverInputs,
|
|
517
|
+
annotations: { readOnlyHint: true, openWorldHint: true },
|
|
518
|
+
}, async ({ projectId, stackNames, path }) => guard(async () => {
|
|
519
|
+
const { apiPlan, apiPlanError, edits } = await prepareHandover(projectId, stackNames, path);
|
|
520
|
+
const patchLines = edits.patch.split('\n');
|
|
521
|
+
const lines = [
|
|
522
|
+
`# Plano de transferência: ${stackNames.join(', ')}`,
|
|
523
|
+
'',
|
|
524
|
+
'## Viabilidade',
|
|
525
|
+
...feasibilityLines(edits),
|
|
526
|
+
...(edits.unsupported.length ? [`Terraform não é transferível: ${edits.unsupported.map((s) => s.name).join(', ')}.`] : []),
|
|
527
|
+
'',
|
|
528
|
+
'## Plano do FG Tracker',
|
|
529
|
+
apiPlanError ? `Indisponível: ${apiPlanError}` : '```json\n' + JSON.stringify(compact(apiPlan), null, 2) + '\n```',
|
|
530
|
+
'',
|
|
531
|
+
'## Edições do PR',
|
|
532
|
+
formatChanges(edits),
|
|
533
|
+
];
|
|
534
|
+
if (edits.warnings.length)
|
|
535
|
+
lines.push('', '## Pendências', ...edits.warnings.map((w) => `- ${w}`));
|
|
536
|
+
if (edits.patch)
|
|
537
|
+
lines.push('', `## Patch (prévia${patchLines.length > 150 ? `, ${patchLines.length} linhas — completo no infra_open_handover_pr` : ''})`, '```diff', patchLines.slice(0, 150).join('\n'), '```');
|
|
538
|
+
lines.push('', `Próximo passo (após o usuário aprovar o plano): infra_open_handover_pr { projectId: "${projectId}", stackNames: ${JSON.stringify(stackNames)} }.`);
|
|
539
|
+
return text(lines.join('\n'));
|
|
540
|
+
}));
|
|
541
|
+
server.registerTool('infra_open_handover_pr', {
|
|
542
|
+
title: 'Gerar arquivos do PR de transferência',
|
|
543
|
+
description: `Escreve SÓ arquivos locais: ${LOCK_PATH}, ${GUARD_PATH} (guard do CI), ${DOC_PATH} (corpo do PR), ${HANDOVER_README} e ${PATCH_PATH} (diff que remove os stacks do app CDK/CI). Não apaga nem move código e não executa git/gh: devolve os comandos exatos (git checkout -b, git apply, git mv, git add, git commit, gh pr create --body-file) e o passo pós-merge (infra_complete_handover).`,
|
|
544
|
+
inputSchema: { ...handoverInputs, branch: z.string().regex(/^[\w./-]+$/).optional().describe('Branch do PR (default fg-tracker/handover-<stacks>)') },
|
|
545
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
546
|
+
}, async ({ projectId, stackNames, path, branch }) => guard(async () => {
|
|
547
|
+
const { root, analysis, apiPlan, apiPlanError, edits } = await prepareHandover(projectId, stackNames, path);
|
|
548
|
+
if (!edits.matched.length && !edits.missing.length)
|
|
549
|
+
throw new Error('Nenhum stack informado.');
|
|
550
|
+
if (edits.unsupported.length === edits.matched.length && edits.matched.length && !edits.missing.length)
|
|
551
|
+
throw new Error(`Stacks Terraform não são transferíveis: ${edits.unsupported.map((s) => s.name).join(', ')}.`);
|
|
552
|
+
const project = await getProject(projectId);
|
|
553
|
+
const connections = await getConnections(projectId);
|
|
554
|
+
const conn = connections.find((c) => c.accountId) ?? connections[0];
|
|
555
|
+
const stackRegions = {};
|
|
556
|
+
try {
|
|
557
|
+
const graph = await client.get(`${base(projectId)}/graph`);
|
|
558
|
+
for (const n of graph.nodes ?? [])
|
|
559
|
+
if (n.service === 'cloudformation.stack' && stackNames.includes(n.name))
|
|
560
|
+
stackRegions[n.name] = n.region ?? null;
|
|
561
|
+
}
|
|
562
|
+
catch {
|
|
563
|
+
/* regiões opcionais */
|
|
564
|
+
}
|
|
565
|
+
const ctx = {
|
|
566
|
+
projectId,
|
|
567
|
+
projectSlug: project.slug,
|
|
568
|
+
projectName: project.name,
|
|
569
|
+
endpoint: client.baseUrl,
|
|
570
|
+
accountId: conn?.accountId ?? null,
|
|
571
|
+
environment: conn?.environment ?? null,
|
|
572
|
+
regions: conn?.regions ?? [],
|
|
573
|
+
stackRegions,
|
|
574
|
+
version,
|
|
575
|
+
};
|
|
576
|
+
const slug = stackNames.join('-').toLowerCase().replace(/[^a-z0-9-]+/g, '-').replace(/-+/g, '-').slice(0, 60).replace(/^-|-$/g, '');
|
|
577
|
+
const prBranch = branch ?? `fg-tracker/handover-${slug || 'stacks'}`;
|
|
578
|
+
// Stacks sem IaC local entram no lock como registro.
|
|
579
|
+
const lockEdits = { ...edits, matched: [...edits.matched, ...edits.missing.map((name) => ({ name, constructId: null, className: null, tool: 'cloudformation', appDir: '.', entryFile: null, entryLines: null, variable: null, standalone: false, sourceFiles: [], env: null, constructs: [], assets: [], resourceTypes: [], handover: 'ok', notes: [] }))] };
|
|
580
|
+
const files = {
|
|
581
|
+
[LOCK_PATH]: lockFile(ctx, analysis, lockEdits),
|
|
582
|
+
[GUARD_PATH]: GUARD_SCRIPT,
|
|
583
|
+
[DOC_PATH]: handoverDoc(ctx, lockEdits, prBranch),
|
|
584
|
+
[HANDOVER_README]: handoverReadme(edits),
|
|
585
|
+
};
|
|
586
|
+
if (edits.patch)
|
|
587
|
+
files[PATCH_PATH] = edits.patch;
|
|
588
|
+
const written = await writeHandoverFiles(root, files);
|
|
589
|
+
const patched = [...new Set(edits.changes.filter((c) => c.kind === 'patch').map((c) => c.path))];
|
|
590
|
+
const title = `Transferir ${stackNames.join(', ')} ao FG Tracker`;
|
|
591
|
+
const commands = [
|
|
592
|
+
`cd ${shellQuote(root)}`,
|
|
593
|
+
`git checkout -b ${prBranch}`,
|
|
594
|
+
...(edits.patch ? [`git apply --check ${PATCH_PATH} && git apply ${PATCH_PATH}`] : []),
|
|
595
|
+
...edits.moves.map((m) => `mkdir -p ${shellQuote(m.to.split('/').slice(0, -1).join('/'))} && git mv ${shellQuote(m.from)} ${shellQuote(m.to)}`),
|
|
596
|
+
`node ${GUARD_PATH}`,
|
|
597
|
+
`git add ${[LOCK_PATH, GUARD_PATH, DOC_PATH, HANDOVER_DIR, ...patched].map(shellQuote).join(' ')}`,
|
|
598
|
+
`git commit -m ${shellQuote(`infra: transfere ${stackNames.join(', ')} ao FG Tracker`)}`,
|
|
599
|
+
`git push -u origin ${prBranch}`,
|
|
600
|
+
`gh pr create --title ${shellQuote(title)} --body-file ${DOC_PATH}`,
|
|
601
|
+
];
|
|
602
|
+
const lines = [
|
|
603
|
+
`Arquivos gerados em ${root}:`,
|
|
604
|
+
...written.map((w) => `- ${w.path}${w.overwritten ? ' (sobrescrito)' : ''}`),
|
|
605
|
+
'',
|
|
606
|
+
'## Edições',
|
|
607
|
+
formatChanges(edits),
|
|
608
|
+
...(edits.warnings.length ? ['', '## Pendências (resolver antes do commit)', ...edits.warnings.map((w) => `- ${w}`)] : []),
|
|
609
|
+
...(apiPlanError ? ['', `Plano do FG Tracker indisponível: ${apiPlanError}`] : apiPlan ? ['', 'Plano do FG Tracker validado (POST /infra/handover/plan).'] : []),
|
|
610
|
+
'',
|
|
611
|
+
'## Comandos (mostre ao usuário; rode só com a aprovação dele)',
|
|
612
|
+
'```bash',
|
|
613
|
+
...commands,
|
|
614
|
+
'```',
|
|
615
|
+
'',
|
|
616
|
+
'Antes do commit: revise o diff (`git diff`), rode `npx cdk ls`/`cdk synth` e os testes do repositório.',
|
|
617
|
+
`Depois do MERGE do PR: infra_complete_handover { projectId: "${projectId}", stackNames: ${JSON.stringify(stackNames)}, prUrl: "<url do PR>" }. O FG Tracker só assume os stacks nesse passo, com a verificação de change set vazio.`,
|
|
618
|
+
];
|
|
619
|
+
return text(lines.join('\n'));
|
|
620
|
+
}));
|
|
621
|
+
/* 11 ---------------- concluir handover ---------------- */
|
|
622
|
+
server.registerTool('infra_complete_handover', {
|
|
623
|
+
title: 'Concluir transferência de controle',
|
|
624
|
+
description: 'Depois do merge do PR: POST .../infra/handover { stackIds, prUrl } — o FG Tracker lê os templates, cria o estado desejado v1, marca os stacks com fg-tracker:owner=true, aplica a stack policy e verifica com change set vazio (em dry-run só simula). Aceita stackNames (resolvidos em GET .../infra/stacks).',
|
|
625
|
+
inputSchema: {
|
|
626
|
+
projectId: projectIdSchema,
|
|
627
|
+
stackIds: z.array(z.string().uuid()).optional(),
|
|
628
|
+
stackNames: z.array(z.string().min(1)).optional(),
|
|
629
|
+
prUrl: z.string().url().describe('URL do PR mergeado'),
|
|
630
|
+
},
|
|
631
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
632
|
+
}, async ({ projectId, stackIds, stackNames, prUrl }) => guard(async () => {
|
|
633
|
+
const ids = new Set(stackIds ?? []);
|
|
634
|
+
const unresolved = [];
|
|
635
|
+
if (stackNames?.length) {
|
|
636
|
+
const stacks = listOf(await client.get(`${base(projectId)}/stacks`).catch(wrapInfra));
|
|
637
|
+
for (const name of stackNames) {
|
|
638
|
+
const found = stacks.filter((s) => (s.stackName ?? s.name) === name);
|
|
639
|
+
if (!found.length)
|
|
640
|
+
unresolved.push(name);
|
|
641
|
+
for (const s of found)
|
|
642
|
+
ids.add(s.id);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
if (!ids.size)
|
|
646
|
+
throw new Error(`Nenhum stack resolvido${unresolved.length ? ` (${unresolved.join(', ')} não estão em GET /infra/stacks — rode infra_discover)` : ''}.`);
|
|
647
|
+
const result = await client.post(`${base(projectId)}/handover`, { stackIds: [...ids], prUrl }).catch(wrapInfra);
|
|
648
|
+
const r = result;
|
|
649
|
+
return json(compact(result), [
|
|
650
|
+
`Handover ${r.id ?? ''} registrado: status ${r.status ?? '?'} para ${ids.size} stack(s).`,
|
|
651
|
+
unresolved.length ? `Não resolvidos: ${unresolved.join(', ')}.` : '',
|
|
652
|
+
`Atualize "status" em ${LOCK_PATH} para "completed" num commit seguinte, se quiser manter o registro fiel. Acompanhe a verificação com infra_get_diagram (stacks com owner FG Tracker).`,
|
|
653
|
+
].filter(Boolean).join('\n'));
|
|
654
|
+
}));
|
|
655
|
+
/* ---------------- prompt ---------------- */
|
|
656
|
+
server.registerPrompt('connect-infrastructure', {
|
|
657
|
+
title: 'Conectar a infraestrutura AWS ao FG Tracker',
|
|
658
|
+
description: 'Roteiro para o agente: detectar contas/ambientes/regiões → conectar cada ambiente (com confirmação antes do stack) → verificar → descobrir → analisar IaC → confirmar recursos → instrumentar → validar → planejar e gerar o PR de transferência, com regras de segurança.',
|
|
659
|
+
argsSchema: {
|
|
660
|
+
projectId: z.string().optional().describe('Projeto no FG Tracker (default: ler da mensagem do usuário ou escolher com track_list_projects)'),
|
|
661
|
+
level: z.string().optional().describe('read | operate (default: ler da mensagem do usuário)'),
|
|
662
|
+
name: z.string().optional().describe('Nome base da conexão (opcional)'),
|
|
663
|
+
path: z.string().optional().describe('Repositório (default: diretório atual)'),
|
|
664
|
+
environment: z.string().optional().describe('Restringe a um ambiente: production | staging | development'),
|
|
665
|
+
},
|
|
666
|
+
}, ({ projectId, level, name, path, environment }) => ({
|
|
667
|
+
messages: [{ role: 'user', content: { type: 'text', text: connectInfrastructurePrompt({ projectId, level, name, path, environment }, client.baseUrl) } }],
|
|
668
|
+
}));
|
|
669
|
+
}
|
|
670
|
+
export function connectInfrastructurePrompt(args, endpoint) {
|
|
671
|
+
const p = args.projectId ? `projectId **${args.projectId}**` : 'o projeto indicado na mensagem do usuário (senão use `track_list_projects` e pergunte)';
|
|
672
|
+
const repo = args.path ? `\`${args.path}\`` : 'o diretório atual';
|
|
673
|
+
const known = [args.level ? `nível **${args.level}**` : '', args.name ? `nome da conexão **${args.name}**` : '', args.environment ? `somente o ambiente **${args.environment}**` : ''].filter(Boolean).join(', ');
|
|
674
|
+
return `Você vai integrar a infraestrutura AWS deste repositório (${repo}) ao FG Tracker (${endpoint}) para ${p}${known ? ` (${known})` : ''}. Trabalhe passo a passo, mostre o resultado de cada tool e peça confirmação nos pontos marcados. Você mesmo identifica contas, ambientes e regiões — não peça ao usuário o que dá para detectar.
|
|
675
|
+
|
|
676
|
+
## Regras de segurança (obrigatórias)
|
|
677
|
+
- **A única mudança na AWS permitida é criar o stack de conexão \`fg-tracker-<slug>\`**, com o comando devolvido por \`infra_connect_account\` e **somente depois de o usuário confirmar explicitamente** (mostre conta, perfil, região e comando). Nunca rode \`cdk deploy\`, \`sam deploy\`, \`terraform apply\` ou outros \`aws ... create/update/delete/put\`.
|
|
678
|
+
- Alterações de infraestrutura só acontecem no FG Tracker, com **aprovação humana na tela**. O merge do PR de transferência é a confirmação humana do handover.
|
|
679
|
+
- Não faça \`git push\` nem \`gh pr create\` sem o usuário aprovar os comandos. Nunca commite chaves (\`FG_TRACKER_KEY\`, credenciais AWS, External ID, \`fg-tracker-connection-*.json\`).
|
|
680
|
+
- Se \`track_status\` indicar API fora do ar ou sem \`FG_TRACKER_TOKEN\`, pare e explique o que configurar.
|
|
681
|
+
|
|
682
|
+
## Roteiro
|
|
683
|
+
1. **Ler o pedido** — extraia da mensagem do usuário o \`projectId\`, o nível (\`read\` | \`operate\`) e o nome da conexão (opcional; default: nome do projeto). Pergunte só o que faltar.
|
|
684
|
+
2. **Detectar ambientes** — \`infra_detect_environments\`. Mostre as contas/perfis, o ambiente classificado e as regiões em uso, e **confirme com o usuário** quais conectar. Pergunte o ambiente apenas quando vier desconhecido (null); se não houver credenciais válidas, peça \`aws sso login --profile <perfil>\` e repita.
|
|
685
|
+
3. **Para cada ambiente escolhido**:
|
|
686
|
+
a. \`infra_connect_account { projectId, level, name: "<nome> · <Ambiente>", environment, regions, profile }\` (Ambiente = Produção | Staging | Desenvolvimento).
|
|
687
|
+
b. Mostre o comando \`aws cloudformation deploy ... --profile <perfil> --region <região>\` e **peça confirmação**; com o "sim", rode-o e espere terminar.
|
|
688
|
+
c. \`infra_verify_connection { projectId, connectionId, profile }\` — se falhar, explique o status e corrija antes de seguir.
|
|
689
|
+
d. \`infra_discover { projectId, profile, regions }\` com as regiões da conexão.
|
|
690
|
+
4. **Analisar o IaC** — \`infra_analyze_iac { path }\`: apps CDK, stacks, assets, CI. Relacione cada stack do repositório aos stacks descobertos (por ambiente).
|
|
691
|
+
5. **Confirmar recursos** — proponha o que pertence ao projeto e o que é compartilhado (VPC, bancos de outros times, buckets comuns). **Peça confirmação** e só então \`infra_assign_resources { include, exclude }\`.
|
|
692
|
+
6. **Instrumentar** — \`infra_instrument { projectId, path }\`; aplique os patches de código (SDK, clientes AWS, pools, reporter ECS, camada nas Lambdas, \`wrapLambda\`) no repositório, em edits mínimos. Não implante.
|
|
693
|
+
7. **Validar** — \`infra_validate { projectId }\` e \`infra_get_diagram\`; resuma as notas por pilar e as 3–5 recomendações mais importantes.
|
|
694
|
+
8. **Planejar o handover** — pergunte quais stacks transferir (só CloudFormation/CDK/SAM; Terraform fica somente leitura). \`infra_plan_handover { projectId, stackNames, path }\`; explique viabilidade, separação do deploy de código (\`fg-tracker-release deploy --release <sha>\`) e pendências. **Peça aprovação**.
|
|
695
|
+
9. **Gerar o PR** — \`infra_open_handover_pr\`; mostre os arquivos gerados e os comandos git/gh. Aplique o patch e rode as verificações locais (\`cdk ls\`, guard, testes); só rode push/PR com aprovação.
|
|
696
|
+
10. **Após o merge** — \`infra_complete_handover { projectId, stackNames, prUrl }\`.
|
|
697
|
+
|
|
698
|
+
Ao final, liste: conexões criadas (ambiente, conta, regiões, status), nº de recursos por ambiente, recursos confirmados, arquivos alterados, notas por pilar e o estado do handover.`;
|
|
699
|
+
}
|
|
700
|
+
//# sourceMappingURL=tools.js.map
|