mcp-aws-manager 0.3.4 → 0.3.6
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/AWS_SSO_SETUP_GUIDE.md +133 -0
- package/AWS_SSO_SETUP_GUIDE_KO.md +70 -0
- package/IMPLEMENTATION_INTEGRATIONS.md +38 -4
- package/MCP_CLIENT_SETUP.md +3 -1
- package/MCP_CLIENT_SETUP_KO.md +107 -0
- package/README.md +248 -30
- package/README_KO.md +115 -0
- package/bin/mcp-aws-manager-mcp.js +1097 -649
- package/bin/mcp-aws-manager.js +970 -27
- package/package.json +6 -2
- package/AGENT_GUIDANCE_LOOP_TEMPLATE_KO.md +0 -68
package/bin/mcp-aws-manager.js
CHANGED
|
@@ -46,6 +46,147 @@ function parseCsv(raw) {
|
|
|
46
46
|
return list.length ? list : null;
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
function normalizeKey(value) {
|
|
50
|
+
return String(value == null ? "" : value)
|
|
51
|
+
.trim()
|
|
52
|
+
.toLowerCase()
|
|
53
|
+
.replace(/[^a-z0-9]/g, "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function normalizedLookup(raw) {
|
|
57
|
+
const out = {};
|
|
58
|
+
if (!raw || typeof raw !== "object") return out;
|
|
59
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
60
|
+
const original = String(key || "");
|
|
61
|
+
out[original] = value;
|
|
62
|
+
const normalized = normalizeKey(original);
|
|
63
|
+
if (normalized && !Object.prototype.hasOwnProperty.call(out, normalized)) {
|
|
64
|
+
out[normalized] = value;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return out;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function pickField(raw, keys) {
|
|
71
|
+
const lookup = normalizedLookup(raw);
|
|
72
|
+
for (const key of keys) {
|
|
73
|
+
const normalized = normalizeKey(key);
|
|
74
|
+
if (!normalized) continue;
|
|
75
|
+
if (!Object.prototype.hasOwnProperty.call(lookup, normalized)) continue;
|
|
76
|
+
const value = lookup[normalized];
|
|
77
|
+
if (value == null) continue;
|
|
78
|
+
if (typeof value === "string" && value.trim() === "") continue;
|
|
79
|
+
return value;
|
|
80
|
+
}
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function asText(value) {
|
|
85
|
+
if (value == null) return null;
|
|
86
|
+
const text = String(value).trim();
|
|
87
|
+
return text || null;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function asBool(value) {
|
|
91
|
+
if (value == null || value === "") return null;
|
|
92
|
+
if (typeof value === "boolean") return value;
|
|
93
|
+
if (typeof value === "number") return value !== 0;
|
|
94
|
+
const text = String(value).trim().toLowerCase();
|
|
95
|
+
if (!text) return null;
|
|
96
|
+
if (["1", "true", "yes", "y", "on"].includes(text)) return true;
|
|
97
|
+
if (["0", "false", "no", "n", "off"].includes(text)) return false;
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function asInt(value) {
|
|
102
|
+
if (value == null || value === "") return null;
|
|
103
|
+
const num = Number(value);
|
|
104
|
+
if (!Number.isFinite(num)) return null;
|
|
105
|
+
return Math.round(num);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function parseCsvLine(line) {
|
|
109
|
+
const fields = [];
|
|
110
|
+
let current = "";
|
|
111
|
+
let inQuotes = false;
|
|
112
|
+
const text = String(line || "");
|
|
113
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
114
|
+
const ch = text[i];
|
|
115
|
+
if (ch === "\"") {
|
|
116
|
+
if (inQuotes && text[i + 1] === "\"") {
|
|
117
|
+
current += "\"";
|
|
118
|
+
i += 1;
|
|
119
|
+
} else {
|
|
120
|
+
inQuotes = !inQuotes;
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (ch === "," && !inQuotes) {
|
|
125
|
+
fields.push(current.trim());
|
|
126
|
+
current = "";
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
current += ch;
|
|
130
|
+
}
|
|
131
|
+
fields.push(current.trim());
|
|
132
|
+
return fields;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function parseCsvObjects(text) {
|
|
136
|
+
const lines = String(text || "")
|
|
137
|
+
.replace(/^\uFEFF/, "")
|
|
138
|
+
.split(/\r?\n/)
|
|
139
|
+
.map((line) => line.trim())
|
|
140
|
+
.filter((line) => line && !line.startsWith("#"));
|
|
141
|
+
if (!lines.length) return [];
|
|
142
|
+
|
|
143
|
+
const headers = parseCsvLine(lines[0]).map((h, i) => h || `col_${i + 1}`);
|
|
144
|
+
const out = [];
|
|
145
|
+
for (let i = 1; i < lines.length; i += 1) {
|
|
146
|
+
const values = parseCsvLine(lines[i]);
|
|
147
|
+
if (!values.some((v) => String(v || "").trim())) continue;
|
|
148
|
+
const row = {};
|
|
149
|
+
for (let j = 0; j < headers.length; j += 1) {
|
|
150
|
+
row[headers[j]] = values[j] != null ? values[j] : "";
|
|
151
|
+
}
|
|
152
|
+
out.push(row);
|
|
153
|
+
}
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function loadManualServerEntries(filePath) {
|
|
158
|
+
const text = fs.readFileSync(filePath, "utf8").replace(/^\uFEFF/, "");
|
|
159
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
160
|
+
if (ext === ".csv") {
|
|
161
|
+
return parseCsvObjects(text);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
let parsed = null;
|
|
165
|
+
try {
|
|
166
|
+
parsed = JSON.parse(text);
|
|
167
|
+
} catch (error) {
|
|
168
|
+
if (ext === ".json") {
|
|
169
|
+
throw new Error(`Invalid JSON in manual server list: ${error.message || String(error)}`);
|
|
170
|
+
}
|
|
171
|
+
return parseCsvObjects(text);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (Array.isArray(parsed)) return parsed;
|
|
175
|
+
if (parsed && typeof parsed === "object") {
|
|
176
|
+
if (Array.isArray(parsed.servers)) return parsed.servers;
|
|
177
|
+
if (Array.isArray(parsed.instances)) return parsed.instances;
|
|
178
|
+
if (Array.isArray(parsed.items)) return parsed.items;
|
|
179
|
+
if (Array.isArray(parsed.records)) return parsed.records;
|
|
180
|
+
}
|
|
181
|
+
throw new Error("manual server list JSON must be an array or an object containing servers/instances/items/records array.");
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function resolvePathList(raw) {
|
|
185
|
+
const items = parseCsv(raw);
|
|
186
|
+
if (!items) return null;
|
|
187
|
+
return items.map((value) => path.resolve(expandHome(value)));
|
|
188
|
+
}
|
|
189
|
+
|
|
49
190
|
function toPosNum(raw, name, fallback) {
|
|
50
191
|
const value = Number(raw == null ? fallback : raw);
|
|
51
192
|
if (!Number.isFinite(value) || value <= 0) {
|
|
@@ -120,6 +261,13 @@ function usageText() {
|
|
|
120
261
|
" --snapshot-timeout <seconds> (default: 60)",
|
|
121
262
|
" --snapshot-concurrency <n> (default: 3)",
|
|
122
263
|
" --snapshot-max-kb <n> (default: 64)",
|
|
264
|
+
" --manual-server-list <path> (JSON/CSV server list for manual inventory mode)",
|
|
265
|
+
" --pem-paths <a,b,c> (optional PEM keys for SSH runtime snapshot)",
|
|
266
|
+
" --ssh-user <name> (default: ec2-user)",
|
|
267
|
+
" --ssh-port <port> (default: 22)",
|
|
268
|
+
" --ssh-connect-timeout <seconds> (default: 8)",
|
|
269
|
+
" --html-out <path> (optional interactive HTML report output path)",
|
|
270
|
+
" --open-html (try opening HTML report after write)",
|
|
123
271
|
" --auto-sso-login / --no-auto-sso-login",
|
|
124
272
|
" --format <json|csv> (default: json)",
|
|
125
273
|
" --out <path>",
|
|
@@ -135,6 +283,9 @@ function usageText() {
|
|
|
135
283
|
" MCP_AWS_ALLOW_REPLACE_PROFILE, MCP_AWS_REMEDIATION_WAIT",
|
|
136
284
|
" MCP_AWS_RUNTIME_SNAPSHOT, MCP_AWS_SNAPSHOT_TIMEOUT, MCP_AWS_SNAPSHOT_CONCURRENCY",
|
|
137
285
|
" MCP_AWS_SNAPSHOT_MAX_KB, MCP_AWS_AUTO_SSO_LOGIN",
|
|
286
|
+
" MCP_AWS_MANUAL_SERVER_LIST, MCP_AWS_PEM_PATHS, MCP_AWS_SSH_USER, MCP_AWS_SSH_PORT",
|
|
287
|
+
" MCP_AWS_SSH_CONNECT_TIMEOUT",
|
|
288
|
+
" MCP_AWS_HTML_OUT, MCP_AWS_OPEN_HTML",
|
|
138
289
|
" MCP_AWS_FORMAT, MCP_AWS_OUT, MCP_AWS_NO_PROGRESS",
|
|
139
290
|
""
|
|
140
291
|
].join("\n");
|
|
@@ -287,6 +438,13 @@ function parseDiscoverArgs(argv) {
|
|
|
287
438
|
snapshotTimeoutSec: null,
|
|
288
439
|
snapshotConcurrency: null,
|
|
289
440
|
snapshotMaxKb: null,
|
|
441
|
+
manualServerListPath: null,
|
|
442
|
+
pemPaths: null,
|
|
443
|
+
sshUser: null,
|
|
444
|
+
sshPort: null,
|
|
445
|
+
sshConnectTimeoutSec: null,
|
|
446
|
+
htmlOutPath: null,
|
|
447
|
+
openHtml: null,
|
|
290
448
|
autoSsoLogin: null,
|
|
291
449
|
format: null,
|
|
292
450
|
outPath: null,
|
|
@@ -305,6 +463,12 @@ function parseDiscoverArgs(argv) {
|
|
|
305
463
|
case "snapshot-timeout": options.snapshotTimeoutSec = value; break;
|
|
306
464
|
case "snapshot-concurrency": options.snapshotConcurrency = value; break;
|
|
307
465
|
case "snapshot-max-kb": options.snapshotMaxKb = value; break;
|
|
466
|
+
case "manual-server-list": options.manualServerListPath = value; break;
|
|
467
|
+
case "pem-paths": options.pemPaths = value; break;
|
|
468
|
+
case "ssh-user": options.sshUser = value; break;
|
|
469
|
+
case "ssh-port": options.sshPort = value; break;
|
|
470
|
+
case "ssh-connect-timeout": options.sshConnectTimeoutSec = value; break;
|
|
471
|
+
case "html-out": options.htmlOutPath = value; break;
|
|
308
472
|
case "format": options.format = value; break;
|
|
309
473
|
case "out": options.outPath = value; break;
|
|
310
474
|
default: throw new Error(`Unknown discover option --${key}`);
|
|
@@ -337,6 +501,7 @@ function parseDiscoverArgs(argv) {
|
|
|
337
501
|
if (arg === "--no-runtime-snapshot") { options.runtimeSnapshot = false; continue; }
|
|
338
502
|
if (arg === "--auto-sso-login") { options.autoSsoLogin = true; continue; }
|
|
339
503
|
if (arg === "--no-auto-sso-login") { options.autoSsoLogin = false; continue; }
|
|
504
|
+
if (arg === "--open-html") { options.openHtml = true; continue; }
|
|
340
505
|
if (arg === "--progress") { options.noProgress = false; continue; }
|
|
341
506
|
if (arg === "--no-progress") { options.noProgress = true; continue; }
|
|
342
507
|
if (!arg.startsWith("--")) throw new Error(`Unexpected argument: ${arg}`);
|
|
@@ -387,6 +552,17 @@ function parseDiscoverArgs(argv) {
|
|
|
387
552
|
snapshotTimeoutSec: Math.round(toPosNum(options.snapshotTimeoutSec || envText("MCP_AWS_SNAPSHOT_TIMEOUT") || "60", "--snapshot-timeout", 60)),
|
|
388
553
|
snapshotConcurrency: Math.max(1, Math.round(toPosNum(options.snapshotConcurrency || envText("MCP_AWS_SNAPSHOT_CONCURRENCY") || DEFAULT_SNAPSHOT_CONCURRENCY, "--snapshot-concurrency", DEFAULT_SNAPSHOT_CONCURRENCY))),
|
|
389
554
|
snapshotMaxBytes: Math.round(toPosNum(options.snapshotMaxKb || envText("MCP_AWS_SNAPSHOT_MAX_KB") || "64", "--snapshot-max-kb", 64)) * 1024,
|
|
555
|
+
manualServerListPath: options.manualServerListPath
|
|
556
|
+
? path.resolve(expandHome(options.manualServerListPath))
|
|
557
|
+
: (envText("MCP_AWS_MANUAL_SERVER_LIST") ? path.resolve(expandHome(envText("MCP_AWS_MANUAL_SERVER_LIST"))) : null),
|
|
558
|
+
pemPaths: resolvePathList(options.pemPaths) || resolvePathList(envText("MCP_AWS_PEM_PATHS")),
|
|
559
|
+
sshUser: (options.sshUser || envText("MCP_AWS_SSH_USER") || "ec2-user").trim(),
|
|
560
|
+
sshPort: Math.round(toPosNum(options.sshPort || envText("MCP_AWS_SSH_PORT") || "22", "--ssh-port", 22)),
|
|
561
|
+
sshConnectTimeoutSec: Math.round(toPosNum(options.sshConnectTimeoutSec || envText("MCP_AWS_SSH_CONNECT_TIMEOUT") || "8", "--ssh-connect-timeout", 8)),
|
|
562
|
+
htmlOutPath: options.htmlOutPath
|
|
563
|
+
? path.resolve(expandHome(options.htmlOutPath))
|
|
564
|
+
: (envText("MCP_AWS_HTML_OUT") ? path.resolve(expandHome(envText("MCP_AWS_HTML_OUT"))) : null),
|
|
565
|
+
openHtml: options.openHtml != null ? options.openHtml : envBool("MCP_AWS_OPEN_HTML"),
|
|
390
566
|
autoSsoLogin: options.autoSsoLogin != null ? options.autoSsoLogin : (envText("MCP_AWS_AUTO_SSO_LOGIN") != null ? envBool("MCP_AWS_AUTO_SSO_LOGIN") : true),
|
|
391
567
|
format,
|
|
392
568
|
outPath: options.outPath ? path.resolve(expandHome(options.outPath)) : (envText("MCP_AWS_OUT") ? path.resolve(expandHome(envText("MCP_AWS_OUT"))) : null),
|
|
@@ -398,6 +574,52 @@ function validateConfig(config, warnings, requiredActions) {
|
|
|
398
574
|
if (config.outPath) {
|
|
399
575
|
fs.mkdirSync(path.dirname(config.outPath), { recursive: true });
|
|
400
576
|
}
|
|
577
|
+
if (config.htmlOutPath) {
|
|
578
|
+
fs.mkdirSync(path.dirname(config.htmlOutPath), { recursive: true });
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (config.manualServerListPath) {
|
|
582
|
+
if (!fs.existsSync(config.manualServerListPath)) {
|
|
583
|
+
throw new Error(`--manual-server-list not found: ${config.manualServerListPath}`);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
if (!config.includeEc2) {
|
|
587
|
+
warnings.push("manual-server-list mode forces EC2 records; enabling include-ec2.");
|
|
588
|
+
config.includeEc2 = true;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (config.includeLambda || config.includeAlb || config.includeAsg || config.includeRds || config.includeElastiCache || config.includeRoute53) {
|
|
592
|
+
warnings.push("manual-server-list mode supports EC2-style server records only; non-EC2 inventory flags are ignored.");
|
|
593
|
+
config.includeLambda = false;
|
|
594
|
+
config.includeAlb = false;
|
|
595
|
+
config.includeAsg = false;
|
|
596
|
+
config.includeRds = false;
|
|
597
|
+
config.includeElastiCache = false;
|
|
598
|
+
config.includeRoute53 = false;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (config.managedOnly) {
|
|
602
|
+
warnings.push("manual-server-list mode does not use SSM managed filters; disabling --managed-only.");
|
|
603
|
+
config.managedOnly = false;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (config.autoRemediateSsm) {
|
|
607
|
+
warnings.push("manual-server-list mode does not support EC2 IAM profile remediation; disabling --auto-remediate-ssm.");
|
|
608
|
+
config.autoRemediateSsm = false;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
if (Array.isArray(config.pemPaths) && config.pemPaths.length) {
|
|
612
|
+
for (const pemPath of config.pemPaths) {
|
|
613
|
+
if (fs.existsSync(pemPath)) continue;
|
|
614
|
+
pushRequiredAction(requiredActions, {
|
|
615
|
+
code: "PEM_KEY_NOT_FOUND",
|
|
616
|
+
message: `Configured PEM key not found: ${pemPath}`,
|
|
617
|
+
hint: "Fix --pem-paths or remove missing PEM paths."
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
401
623
|
const includeAny = config.includeEc2
|
|
402
624
|
|| config.includeLambda
|
|
403
625
|
|| config.includeAlb
|
|
@@ -830,7 +1052,7 @@ async function ensureCallerIdentity(profileName, profileLabel, config, warnings,
|
|
|
830
1052
|
pushRequiredAction(requiredActions, {
|
|
831
1053
|
code: "SSO_REAUTH_REQUIRED",
|
|
832
1054
|
message: `Profile '${profileLabel}' still cannot authenticate after auto login.`,
|
|
833
|
-
hint: `Run 'aws sso login --profile ${profileName}'. Error: ${retryErr.message || String(retryErr)}`
|
|
1055
|
+
hint: `Run 'aws sso login --profile ${profileName}'. If SSO/access key cannot be used, switch to manual fallback with --manual-server-list <path>. Error: ${retryErr.message || String(retryErr)}`
|
|
834
1056
|
});
|
|
835
1057
|
return { ok: false, accountId: null };
|
|
836
1058
|
}
|
|
@@ -839,7 +1061,7 @@ async function ensureCallerIdentity(profileName, profileLabel, config, warnings,
|
|
|
839
1061
|
pushRequiredAction(requiredActions, {
|
|
840
1062
|
code: "SSO_LOGIN_NEEDED",
|
|
841
1063
|
message: `Profile '${profileLabel}' requires interactive SSO login.`,
|
|
842
|
-
hint: `Run 'aws sso login --profile ${profileName}'. CLI: ${(login.stderr || "").trim() || "failed"}`
|
|
1064
|
+
hint: `Run 'aws sso login --profile ${profileName}'. If SSO/access key cannot be used, use manual fallback with --manual-server-list <path>. CLI: ${(login.stderr || "").trim() || "failed"}`
|
|
843
1065
|
});
|
|
844
1066
|
return { ok: false, accountId: null };
|
|
845
1067
|
}
|
|
@@ -847,7 +1069,7 @@ async function ensureCallerIdentity(profileName, profileLabel, config, warnings,
|
|
|
847
1069
|
pushRequiredAction(requiredActions, {
|
|
848
1070
|
code: "AWS_CREDENTIALS_REQUIRED",
|
|
849
1071
|
message: `Profile '${profileLabel}' authentication failed.`,
|
|
850
|
-
hint: firstError
|
|
1072
|
+
hint: `Configure SSO/access key, or use manual fallback with --manual-server-list <path>. Error: ${firstError}`
|
|
851
1073
|
});
|
|
852
1074
|
return { ok: false, accountId: null };
|
|
853
1075
|
} finally {
|
|
@@ -902,6 +1124,15 @@ async function buildDiscoveryPlan(config, warnings, requiredActions) {
|
|
|
902
1124
|
}
|
|
903
1125
|
|
|
904
1126
|
function buildOperationPlan(config) {
|
|
1127
|
+
if (config.manualServerListPath) {
|
|
1128
|
+
const inventoryOps = ["inventory.manualServerList"];
|
|
1129
|
+
const runtimeOps = [];
|
|
1130
|
+
if (config.runtimeSnapshot) {
|
|
1131
|
+
runtimeOps.push("runtime.snapshot.sshPem");
|
|
1132
|
+
}
|
|
1133
|
+
return { inventoryOps, runtimeOps };
|
|
1134
|
+
}
|
|
1135
|
+
|
|
905
1136
|
const inventoryOps = [];
|
|
906
1137
|
if (config.includeEc2) {
|
|
907
1138
|
inventoryOps.push("inventory.ec2");
|
|
@@ -1123,6 +1354,13 @@ function baseInventoryRecord(plan, region, resourceType, service, resourceId, na
|
|
|
1123
1354
|
ssmPlatformName: null,
|
|
1124
1355
|
ssmPlatformVersion: null,
|
|
1125
1356
|
remediationStatus: null,
|
|
1357
|
+
manualInput: false,
|
|
1358
|
+
connectionMode: null,
|
|
1359
|
+
sshHost: null,
|
|
1360
|
+
sshUser: null,
|
|
1361
|
+
sshPort: null,
|
|
1362
|
+
sshPemPath: null,
|
|
1363
|
+
sshPemKeyName: null,
|
|
1126
1364
|
runtimeSnapshot: null,
|
|
1127
1365
|
lambdaFunctionName: null,
|
|
1128
1366
|
lambdaFunctionArn: null,
|
|
@@ -1306,6 +1544,131 @@ function addRoute53ZoneRecord(records, plan, zone, recordSetCount) {
|
|
|
1306
1544
|
records.push(record);
|
|
1307
1545
|
}
|
|
1308
1546
|
|
|
1547
|
+
function normalizeManualRecord(config, entry, index) {
|
|
1548
|
+
const profile = asText(pickField(entry, ["profile", "awsProfile", "profileName"])) || "manual";
|
|
1549
|
+
const accountId = asText(pickField(entry, ["accountId", "account", "accountNumber"])) || "manual";
|
|
1550
|
+
const region = asText(pickField(entry, ["region", "awsRegion"])) || (config.regions && config.regions.length ? config.regions[0] : baseRegion());
|
|
1551
|
+
|
|
1552
|
+
const instanceId = asText(pickField(entry, ["instanceId", "instance", "id", "serverId"]));
|
|
1553
|
+
const publicIp = asText(pickField(entry, ["publicIp", "publicIpv4", "ip", "ipAddress"]));
|
|
1554
|
+
const privateIp = asText(pickField(entry, ["privateIp", "privateIpv4", "privateAddress"]));
|
|
1555
|
+
const publicDns = asText(pickField(entry, ["publicDns", "dns", "publicHostname"]));
|
|
1556
|
+
const host = asText(pickField(entry, ["host", "hostname", "address", "target", "endpoint", "sshHost"]))
|
|
1557
|
+
|| publicIp
|
|
1558
|
+
|| publicDns
|
|
1559
|
+
|| privateIp;
|
|
1560
|
+
const name = asText(pickField(entry, ["name", "serverName", "displayName"])) || host || instanceId || `manual-${index + 1}`;
|
|
1561
|
+
const state = asText(pickField(entry, ["state", "status"])) || "unknown";
|
|
1562
|
+
const platform = asText(pickField(entry, ["platform", "os", "osType"])) || "Linux/UNIX";
|
|
1563
|
+
const iamInstanceProfileArn = asText(pickField(entry, ["iamInstanceProfileArn", "instanceProfileArn"]));
|
|
1564
|
+
const pemPathRaw = asText(pickField(entry, ["pemPath", "pemFile", "keyPath", "privateKeyPath"]));
|
|
1565
|
+
const pemKeyName = asText(pickField(entry, ["pemKeyName", "keyName"]));
|
|
1566
|
+
const sshUser = asText(pickField(entry, ["sshUser", "user", "username"])) || config.sshUser;
|
|
1567
|
+
const sshPort = asInt(pickField(entry, ["sshPort", "port"])) || config.sshPort;
|
|
1568
|
+
const ssmManaged = asBool(pickField(entry, ["ssmManaged", "isSsmManaged"]));
|
|
1569
|
+
const ssmOnline = asBool(pickField(entry, ["ssmOnline", "isSsmOnline", "online"]));
|
|
1570
|
+
const resourceId = instanceId || host || name || `manual-${index + 1}`;
|
|
1571
|
+
|
|
1572
|
+
const plan = { profileLabel: profile, accountId };
|
|
1573
|
+
const record = baseInventoryRecord(plan, region, "ec2", "ec2", resourceId, name, state, platform);
|
|
1574
|
+
record.instanceId = instanceId || resourceId;
|
|
1575
|
+
record.privateIp = privateIp;
|
|
1576
|
+
record.publicIp = publicIp;
|
|
1577
|
+
record.publicDns = publicDns;
|
|
1578
|
+
record.iamInstanceProfileArn = iamInstanceProfileArn;
|
|
1579
|
+
record.ssmManaged = ssmManaged == null ? false : ssmManaged;
|
|
1580
|
+
record.ssmOnline = ssmOnline == null ? false : ssmOnline;
|
|
1581
|
+
record.manualInput = true;
|
|
1582
|
+
record.connectionMode = "ssh-pem";
|
|
1583
|
+
record.sshHost = host;
|
|
1584
|
+
record.sshUser = sshUser;
|
|
1585
|
+
record.sshPort = sshPort;
|
|
1586
|
+
record.sshPemPath = pemPathRaw ? path.resolve(expandHome(pemPathRaw)) : null;
|
|
1587
|
+
record.sshPemKeyName = pemKeyName;
|
|
1588
|
+
return record;
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
async function collectManualInventory(config, warnings, requiredActions) {
|
|
1592
|
+
const stats = {
|
|
1593
|
+
profiles: 0,
|
|
1594
|
+
regions: 0,
|
|
1595
|
+
regionErrors: 0,
|
|
1596
|
+
instancesScanned: 0,
|
|
1597
|
+
lambdaFunctionsScanned: 0,
|
|
1598
|
+
loadBalancersScanned: 0,
|
|
1599
|
+
targetGroupsScanned: 0,
|
|
1600
|
+
autoScalingGroupsScanned: 0,
|
|
1601
|
+
rdsInstancesScanned: 0,
|
|
1602
|
+
elasticacheClustersScanned: 0,
|
|
1603
|
+
route53ZonesScanned: 0,
|
|
1604
|
+
remediationAttempts: 0,
|
|
1605
|
+
remediationChanged: 0,
|
|
1606
|
+
manualServersScanned: 0,
|
|
1607
|
+
manualServersLoaded: 0
|
|
1608
|
+
};
|
|
1609
|
+
const records = [];
|
|
1610
|
+
const rows = loadManualServerEntries(config.manualServerListPath);
|
|
1611
|
+
stats.manualServersScanned = rows.length;
|
|
1612
|
+
const profileSet = new Set();
|
|
1613
|
+
const regionSet = new Set();
|
|
1614
|
+
const instanceFilter = config.instanceIds ? new Set(config.instanceIds) : null;
|
|
1615
|
+
|
|
1616
|
+
if (!rows.length) {
|
|
1617
|
+
pushRequiredAction(requiredActions, {
|
|
1618
|
+
code: "MANUAL_SERVER_LIST_EMPTY",
|
|
1619
|
+
message: "manual-server-list file is empty.",
|
|
1620
|
+
hint: "Provide JSON/CSV rows with at least host/publicIp/privateIp and optional pemPath."
|
|
1621
|
+
});
|
|
1622
|
+
return { records, stats };
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
rows.forEach((entry, index) => {
|
|
1626
|
+
if (!entry || typeof entry !== "object") {
|
|
1627
|
+
warnings.push(`manual-server-list row ${index + 1} is not an object; skipped.`);
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
stats.instancesScanned += 1;
|
|
1632
|
+
const record = normalizeManualRecord(config, entry, index);
|
|
1633
|
+
|
|
1634
|
+
if (instanceFilter && !instanceFilter.has(record.instanceId)) {
|
|
1635
|
+
return;
|
|
1636
|
+
}
|
|
1637
|
+
if (config.publicOnly && !record.publicIp) {
|
|
1638
|
+
return;
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
if (!record.sshHost) {
|
|
1642
|
+
pushRequiredAction(requiredActions, {
|
|
1643
|
+
code: "MANUAL_SERVER_HOST_REQUIRED",
|
|
1644
|
+
message: `No SSH host could be resolved for manual record '${record.instanceId}'.`,
|
|
1645
|
+
hint: "Set host/publicIp/privateIp/publicDns in manual server list."
|
|
1646
|
+
});
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
if (record.sshPemPath && !fs.existsSync(record.sshPemPath)) {
|
|
1650
|
+
pushRequiredAction(requiredActions, {
|
|
1651
|
+
code: "PEM_KEY_NOT_FOUND",
|
|
1652
|
+
message: `Configured PEM key not found: ${record.sshPemPath}`,
|
|
1653
|
+
hint: "Fix pemPath in manual server list."
|
|
1654
|
+
});
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
profileSet.add(record.profile || "manual");
|
|
1658
|
+
regionSet.add(record.region || "unknown");
|
|
1659
|
+
records.push(record);
|
|
1660
|
+
});
|
|
1661
|
+
|
|
1662
|
+
stats.manualServersLoaded = records.length;
|
|
1663
|
+
stats.profiles = profileSet.size || 1;
|
|
1664
|
+
stats.regions = regionSet.size || 1;
|
|
1665
|
+
|
|
1666
|
+
if (!records.length) {
|
|
1667
|
+
warnings.push("manual-server-list produced zero records after filters.");
|
|
1668
|
+
}
|
|
1669
|
+
return { records, stats };
|
|
1670
|
+
}
|
|
1671
|
+
|
|
1309
1672
|
async function collectInventory(config, plans, warnings, requiredActions) {
|
|
1310
1673
|
const { ec2, ssm, lambda, elbv2, autoScaling, rds, elasticache, route53 } = loadAwsModules();
|
|
1311
1674
|
const stats = {
|
|
@@ -1321,7 +1684,9 @@ async function collectInventory(config, plans, warnings, requiredActions) {
|
|
|
1321
1684
|
elasticacheClustersScanned: 0,
|
|
1322
1685
|
route53ZonesScanned: 0,
|
|
1323
1686
|
remediationAttempts: 0,
|
|
1324
|
-
remediationChanged: 0
|
|
1687
|
+
remediationChanged: 0,
|
|
1688
|
+
manualServersScanned: 0,
|
|
1689
|
+
manualServersLoaded: 0
|
|
1325
1690
|
};
|
|
1326
1691
|
const records = [];
|
|
1327
1692
|
|
|
@@ -1696,7 +2061,233 @@ async function waitInvocation(ssmClient, commandId, instanceId, timeoutMs) {
|
|
|
1696
2061
|
}
|
|
1697
2062
|
|
|
1698
2063
|
return { ok: false, error: new Error("Timed out waiting for command invocation") };
|
|
1699
|
-
}
|
|
2064
|
+
}
|
|
2065
|
+
|
|
2066
|
+
function normalizePemName(value) {
|
|
2067
|
+
const base = path.basename(String(value || ""));
|
|
2068
|
+
return base.replace(/\.pem$/i, "").toLowerCase();
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
function resolvePemForRecord(record, config) {
|
|
2072
|
+
if (record.sshPemPath) {
|
|
2073
|
+
const explicit = path.resolve(expandHome(record.sshPemPath));
|
|
2074
|
+
if (fs.existsSync(explicit)) {
|
|
2075
|
+
return explicit;
|
|
2076
|
+
}
|
|
2077
|
+
return null;
|
|
2078
|
+
}
|
|
2079
|
+
|
|
2080
|
+
const configured = Array.isArray(config.pemPaths) ? config.pemPaths : [];
|
|
2081
|
+
const existing = configured.filter((pemPath) => fs.existsSync(pemPath));
|
|
2082
|
+
if (!existing.length) return null;
|
|
2083
|
+
if (existing.length === 1) return existing[0];
|
|
2084
|
+
|
|
2085
|
+
if (record.sshPemKeyName) {
|
|
2086
|
+
const hint = normalizePemName(record.sshPemKeyName);
|
|
2087
|
+
const exact = existing.find((pemPath) => normalizePemName(pemPath) === hint);
|
|
2088
|
+
if (exact) return exact;
|
|
2089
|
+
|
|
2090
|
+
const partial = existing.find((pemPath) => {
|
|
2091
|
+
const base = normalizePemName(pemPath);
|
|
2092
|
+
return base.includes(hint) || hint.includes(base);
|
|
2093
|
+
});
|
|
2094
|
+
if (partial) return partial;
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
return null;
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
function runSshCommand(args, timeoutMs) {
|
|
2101
|
+
return new Promise((resolve) => {
|
|
2102
|
+
const child = spawn("ssh", args, {
|
|
2103
|
+
cwd: process.cwd(),
|
|
2104
|
+
env: process.env,
|
|
2105
|
+
stdio: ["ignore", "pipe", "pipe"]
|
|
2106
|
+
});
|
|
2107
|
+
|
|
2108
|
+
let stdout = "";
|
|
2109
|
+
let stderr = "";
|
|
2110
|
+
let done = false;
|
|
2111
|
+
let timedOut = false;
|
|
2112
|
+
|
|
2113
|
+
if (child.stdout) {
|
|
2114
|
+
child.stdout.setEncoding("utf8");
|
|
2115
|
+
child.stdout.on("data", (chunk) => { stdout += chunk; });
|
|
2116
|
+
}
|
|
2117
|
+
if (child.stderr) {
|
|
2118
|
+
child.stderr.setEncoding("utf8");
|
|
2119
|
+
child.stderr.on("data", (chunk) => { stderr += chunk; });
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
const timer = setTimeout(() => {
|
|
2123
|
+
if (done) return;
|
|
2124
|
+
timedOut = true;
|
|
2125
|
+
done = true;
|
|
2126
|
+
try { child.kill("SIGTERM"); } catch {}
|
|
2127
|
+
resolve({ ok: false, code: null, stdout, stderr: stderr || "ssh timed out", timedOut, spawnError: null });
|
|
2128
|
+
}, timeoutMs);
|
|
2129
|
+
timer.unref();
|
|
2130
|
+
|
|
2131
|
+
child.on("error", (error) => {
|
|
2132
|
+
if (done) return;
|
|
2133
|
+
done = true;
|
|
2134
|
+
clearTimeout(timer);
|
|
2135
|
+
resolve({
|
|
2136
|
+
ok: false,
|
|
2137
|
+
code: null,
|
|
2138
|
+
stdout,
|
|
2139
|
+
stderr,
|
|
2140
|
+
timedOut,
|
|
2141
|
+
spawnError: error && error.message ? error.message : String(error)
|
|
2142
|
+
});
|
|
2143
|
+
});
|
|
2144
|
+
|
|
2145
|
+
child.on("close", (code) => {
|
|
2146
|
+
if (done) return;
|
|
2147
|
+
done = true;
|
|
2148
|
+
clearTimeout(timer);
|
|
2149
|
+
resolve({ ok: code === 0, code, stdout, stderr, timedOut, spawnError: null });
|
|
2150
|
+
});
|
|
2151
|
+
});
|
|
2152
|
+
}
|
|
2153
|
+
|
|
2154
|
+
function sshSnapshotCommand(record) {
|
|
2155
|
+
if (!isWindowsRecord(record)) {
|
|
2156
|
+
return linuxSnapshotCommands().join(" ; ");
|
|
2157
|
+
}
|
|
2158
|
+
|
|
2159
|
+
const script = windowsSnapshotCommands().join("; ").replace(/"/g, "\\\"");
|
|
2160
|
+
return `powershell -NoProfile -NonInteractive -Command "${script}"`;
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
async function snapshotRecordViaSsh(record, config, warnings, requiredActions, sshAvailable) {
|
|
2164
|
+
const host = asText(record.sshHost) || asText(record.publicIp) || asText(record.publicDns) || asText(record.privateIp);
|
|
2165
|
+
const user = asText(record.sshUser) || config.sshUser || "ec2-user";
|
|
2166
|
+
const port = asInt(record.sshPort) || config.sshPort || 22;
|
|
2167
|
+
const collectedAt = new Date().toISOString();
|
|
2168
|
+
|
|
2169
|
+
if (!host) {
|
|
2170
|
+
record.runtimeSnapshot = {
|
|
2171
|
+
status: "skipped-no-host",
|
|
2172
|
+
collectedAt,
|
|
2173
|
+
output: "",
|
|
2174
|
+
errorOutput: "No host/publicIp/privateIp/publicDns"
|
|
2175
|
+
};
|
|
2176
|
+
pushRequiredAction(requiredActions, {
|
|
2177
|
+
code: "MANUAL_SERVER_HOST_REQUIRED",
|
|
2178
|
+
message: `No SSH host could be resolved for manual record '${record.instanceId}'.`,
|
|
2179
|
+
hint: "Set host/publicIp/privateIp/publicDns in manual server list."
|
|
2180
|
+
});
|
|
2181
|
+
return;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
if (!sshAvailable) {
|
|
2185
|
+
record.runtimeSnapshot = {
|
|
2186
|
+
status: "failed-no-ssh-client",
|
|
2187
|
+
collectedAt,
|
|
2188
|
+
output: "",
|
|
2189
|
+
errorOutput: "ssh command not found in PATH"
|
|
2190
|
+
};
|
|
2191
|
+
pushRequiredAction(requiredActions, {
|
|
2192
|
+
code: "SSH_CLIENT_NOT_FOUND",
|
|
2193
|
+
message: "OpenSSH client is not available in PATH.",
|
|
2194
|
+
hint: "Install OpenSSH client and verify 'ssh -V' works."
|
|
2195
|
+
});
|
|
2196
|
+
return;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
let pemPath = resolvePemForRecord(record, config);
|
|
2200
|
+
let explicitPemMissing = false;
|
|
2201
|
+
if (!pemPath && record.sshPemPath) {
|
|
2202
|
+
const explicit = path.resolve(expandHome(record.sshPemPath));
|
|
2203
|
+
if (!fs.existsSync(explicit)) {
|
|
2204
|
+
explicitPemMissing = true;
|
|
2205
|
+
pushRequiredAction(requiredActions, {
|
|
2206
|
+
code: "PEM_KEY_NOT_FOUND",
|
|
2207
|
+
message: `Configured PEM key not found: ${explicit}`,
|
|
2208
|
+
hint: "Fix pemPath in manual server list or --pem-paths."
|
|
2209
|
+
});
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
if (!pemPath) {
|
|
2214
|
+
record.runtimeSnapshot = {
|
|
2215
|
+
status: "skipped-no-pem",
|
|
2216
|
+
collectedAt,
|
|
2217
|
+
output: "",
|
|
2218
|
+
errorOutput: "No PEM mapping was resolved for this server."
|
|
2219
|
+
};
|
|
2220
|
+
if (!explicitPemMissing) {
|
|
2221
|
+
pushRequiredAction(requiredActions, {
|
|
2222
|
+
code: "PEM_MAPPING_REQUIRED",
|
|
2223
|
+
message: `No PEM key mapping for manual server '${record.instanceId}' (${host}).`,
|
|
2224
|
+
hint: "Set pemPath per server or pass --pem-paths (single key or keyName-matchable list)."
|
|
2225
|
+
});
|
|
2226
|
+
}
|
|
2227
|
+
return;
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
record.sshPemPath = pemPath;
|
|
2231
|
+
const target = `${user}@${host}`;
|
|
2232
|
+
const args = [
|
|
2233
|
+
"-i", pemPath,
|
|
2234
|
+
"-o", "BatchMode=yes",
|
|
2235
|
+
"-o", "StrictHostKeyChecking=accept-new",
|
|
2236
|
+
"-o", `ConnectTimeout=${config.sshConnectTimeoutSec}`,
|
|
2237
|
+
"-p", String(port),
|
|
2238
|
+
target,
|
|
2239
|
+
sshSnapshotCommand(record)
|
|
2240
|
+
];
|
|
2241
|
+
|
|
2242
|
+
const run = await runSshCommand(args, config.snapshotTimeoutSec * 1000 + 15000);
|
|
2243
|
+
if (run.spawnError) {
|
|
2244
|
+
record.runtimeSnapshot = {
|
|
2245
|
+
status: "failed-spawn",
|
|
2246
|
+
collectedAt,
|
|
2247
|
+
output: "",
|
|
2248
|
+
errorOutput: run.spawnError
|
|
2249
|
+
};
|
|
2250
|
+
pushRequiredAction(requiredActions, {
|
|
2251
|
+
code: "SSH_CLIENT_NOT_FOUND",
|
|
2252
|
+
message: "Failed to start ssh client.",
|
|
2253
|
+
hint: run.spawnError
|
|
2254
|
+
});
|
|
2255
|
+
return;
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
if (run.ok) {
|
|
2259
|
+
record.runtimeSnapshot = {
|
|
2260
|
+
status: "Success",
|
|
2261
|
+
collectedAt,
|
|
2262
|
+
transport: "ssh-pem",
|
|
2263
|
+
host,
|
|
2264
|
+
user,
|
|
2265
|
+
port,
|
|
2266
|
+
output: truncateText(run.stdout || "", config.snapshotMaxBytes),
|
|
2267
|
+
errorOutput: truncateText(run.stderr || "", config.snapshotMaxBytes)
|
|
2268
|
+
};
|
|
2269
|
+
return;
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
const status = run.timedOut ? "failed-timeout" : "failed-ssh";
|
|
2273
|
+
const detail = truncateText((run.stderr || run.stdout || "ssh command failed").trim(), config.snapshotMaxBytes);
|
|
2274
|
+
record.runtimeSnapshot = {
|
|
2275
|
+
status,
|
|
2276
|
+
collectedAt,
|
|
2277
|
+
transport: "ssh-pem",
|
|
2278
|
+
host,
|
|
2279
|
+
user,
|
|
2280
|
+
port,
|
|
2281
|
+
output: truncateText(run.stdout || "", config.snapshotMaxBytes),
|
|
2282
|
+
errorOutput: detail
|
|
2283
|
+
};
|
|
2284
|
+
warnings.push(`[manual/${record.region || "unknown"}/${record.instanceId}] SSH snapshot failed: ${detail}`);
|
|
2285
|
+
pushRequiredAction(requiredActions, {
|
|
2286
|
+
code: "SSH_AUTH_OR_CONNECT_FAILED",
|
|
2287
|
+
message: `SSH snapshot failed for ${record.instanceId} (${target}).`,
|
|
2288
|
+
hint: detail || "Check network/security group/user/pem permissions."
|
|
2289
|
+
});
|
|
2290
|
+
}
|
|
1700
2291
|
|
|
1701
2292
|
async function snapshotRecord(record, planLookup, config, warnings, requiredActions) {
|
|
1702
2293
|
const { ssm } = loadAwsModules();
|
|
@@ -1890,31 +2481,65 @@ async function collectRemediation(config, records, plans, warnings, requiredActi
|
|
|
1890
2481
|
}
|
|
1891
2482
|
|
|
1892
2483
|
async function collectSnapshots(config, records, plans, warnings, requiredActions) {
|
|
1893
|
-
if (!config.runtimeSnapshot)
|
|
1894
|
-
|
|
2484
|
+
if (!config.runtimeSnapshot) {
|
|
2485
|
+
return {
|
|
2486
|
+
attempted: 0,
|
|
2487
|
+
succeeded: 0,
|
|
2488
|
+
ssmAttempted: 0,
|
|
2489
|
+
ssmSucceeded: 0,
|
|
2490
|
+
sshAttempted: 0,
|
|
2491
|
+
sshSucceeded: 0
|
|
2492
|
+
};
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
const ssmTargets = records.filter((r) => r.ssmOnline === true && r.manualInput !== true);
|
|
2496
|
+
const sshTargets = records.filter((r) => r.resourceType === "ec2" && r.manualInput === true);
|
|
1895
2497
|
const planLookup = new Map(plans.map((p) => [`${p.profileLabel}::${p.accountId}`, p]));
|
|
1896
2498
|
let attempted = 0;
|
|
1897
2499
|
let succeeded = 0;
|
|
2500
|
+
let ssmAttempted = 0;
|
|
2501
|
+
let ssmSucceeded = 0;
|
|
2502
|
+
let sshAttempted = 0;
|
|
2503
|
+
let sshSucceeded = 0;
|
|
1898
2504
|
|
|
1899
|
-
await mapWithConcurrency(
|
|
2505
|
+
await mapWithConcurrency(ssmTargets, config.snapshotConcurrency || DEFAULT_SNAPSHOT_CONCURRENCY, async (record) => {
|
|
1900
2506
|
attempted += 1;
|
|
2507
|
+
ssmAttempted += 1;
|
|
1901
2508
|
await snapshotRecord(record, planLookup, config, warnings, requiredActions);
|
|
1902
2509
|
if (record.runtimeSnapshot && record.runtimeSnapshot.status === "Success") {
|
|
1903
2510
|
succeeded += 1;
|
|
2511
|
+
ssmSucceeded += 1;
|
|
1904
2512
|
}
|
|
1905
2513
|
});
|
|
1906
2514
|
|
|
1907
|
-
|
|
2515
|
+
const sshAvailable = sshTargets.length ? commandExists("ssh", ["-V"]) : true;
|
|
2516
|
+
await mapWithConcurrency(sshTargets, config.snapshotConcurrency || DEFAULT_SNAPSHOT_CONCURRENCY, async (record) => {
|
|
2517
|
+
attempted += 1;
|
|
2518
|
+
sshAttempted += 1;
|
|
2519
|
+
await snapshotRecordViaSsh(record, config, warnings, requiredActions, sshAvailable);
|
|
2520
|
+
if (record.runtimeSnapshot && record.runtimeSnapshot.status === "Success") {
|
|
2521
|
+
succeeded += 1;
|
|
2522
|
+
sshSucceeded += 1;
|
|
2523
|
+
}
|
|
2524
|
+
});
|
|
2525
|
+
|
|
2526
|
+
return { attempted, succeeded, ssmAttempted, ssmSucceeded, sshAttempted, sshSucceeded };
|
|
1908
2527
|
}
|
|
1909
2528
|
|
|
1910
2529
|
async function executeRuntimeOperations(config, records, plans, warnings, requiredActions) {
|
|
1911
|
-
const remediation =
|
|
2530
|
+
const remediation = config.manualServerListPath
|
|
2531
|
+
? { attempted: 0, changed: 0 }
|
|
2532
|
+
: await collectRemediation(config, records, plans, warnings, requiredActions);
|
|
1912
2533
|
const snapshots = await collectSnapshots(config, records, plans, warnings, requiredActions);
|
|
1913
2534
|
return {
|
|
1914
2535
|
remediationAttempts: remediation.attempted,
|
|
1915
2536
|
remediationChanged: remediation.changed,
|
|
1916
2537
|
snapshotAttempted: snapshots.attempted,
|
|
1917
|
-
snapshotSucceeded: snapshots.succeeded
|
|
2538
|
+
snapshotSucceeded: snapshots.succeeded,
|
|
2539
|
+
snapshotSsmAttempted: snapshots.ssmAttempted,
|
|
2540
|
+
snapshotSsmSucceeded: snapshots.ssmSucceeded,
|
|
2541
|
+
snapshotSshAttempted: snapshots.sshAttempted,
|
|
2542
|
+
snapshotSshSucceeded: snapshots.sshSucceeded
|
|
1918
2543
|
};
|
|
1919
2544
|
}
|
|
1920
2545
|
|
|
@@ -1957,6 +2582,13 @@ function toCsvRow(record) {
|
|
|
1957
2582
|
ssmPlatformName: record.ssmPlatformName,
|
|
1958
2583
|
ssmPlatformVersion: record.ssmPlatformVersion,
|
|
1959
2584
|
remediationStatus: record.remediationStatus,
|
|
2585
|
+
manualInput: record.manualInput,
|
|
2586
|
+
connectionMode: record.connectionMode,
|
|
2587
|
+
sshHost: record.sshHost,
|
|
2588
|
+
sshUser: record.sshUser,
|
|
2589
|
+
sshPort: record.sshPort,
|
|
2590
|
+
sshPemPath: record.sshPemPath,
|
|
2591
|
+
sshPemKeyName: record.sshPemKeyName,
|
|
1960
2592
|
lambdaFunctionName: record.lambdaFunctionName,
|
|
1961
2593
|
lambdaFunctionArn: record.lambdaFunctionArn,
|
|
1962
2594
|
lambdaRuntime: record.lambdaRuntime,
|
|
@@ -2020,6 +2652,7 @@ function renderOutput(config, records) {
|
|
|
2020
2652
|
"profile", "accountId", "region", "instanceId", "name", "state", "privateIp", "publicIp", "publicDns",
|
|
2021
2653
|
"platform", "iamInstanceProfileArn", "ssmManaged", "ssmOnline", "ssmPingStatus", "ssmLastPingDateTime",
|
|
2022
2654
|
"ssmAgentVersion", "ssmPlatformName", "ssmPlatformVersion", "remediationStatus",
|
|
2655
|
+
"manualInput", "connectionMode", "sshHost", "sshUser", "sshPort", "sshPemPath", "sshPemKeyName",
|
|
2023
2656
|
"lambdaFunctionName", "lambdaFunctionArn", "lambdaRuntime", "lambdaHandler", "lambdaRole", "lambdaLastModified",
|
|
2024
2657
|
"lambdaState", "lambdaPackageType", "lambdaTimeoutSec", "lambdaMemoryMb", "lambdaVpcConfigured",
|
|
2025
2658
|
"albArn", "albType", "albScheme", "albDnsName", "albVpcId", "albListenerPorts",
|
|
@@ -2039,6 +2672,295 @@ function renderOutput(config, records) {
|
|
|
2039
2672
|
return lines.join("\n");
|
|
2040
2673
|
}
|
|
2041
2674
|
|
|
2675
|
+
function htmlSafeJson(value) {
|
|
2676
|
+
return JSON.stringify(value)
|
|
2677
|
+
.replace(/</g, "\\u003c")
|
|
2678
|
+
.replace(/>/g, "\\u003e")
|
|
2679
|
+
.replace(/&/g, "\\u0026");
|
|
2680
|
+
}
|
|
2681
|
+
|
|
2682
|
+
function renderHtmlReport(records) {
|
|
2683
|
+
const payload = htmlSafeJson(Array.isArray(records) ? records : []);
|
|
2684
|
+
return `<!doctype html>
|
|
2685
|
+
<html lang="en">
|
|
2686
|
+
<head>
|
|
2687
|
+
<meta charset="utf-8" />
|
|
2688
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2689
|
+
<title>AWS Inventory Report</title>
|
|
2690
|
+
<style>
|
|
2691
|
+
:root {
|
|
2692
|
+
--bg: #0b1220;
|
|
2693
|
+
--panel: #121a2b;
|
|
2694
|
+
--panel-2: #16233a;
|
|
2695
|
+
--text: #eef4ff;
|
|
2696
|
+
--muted: #a9bad8;
|
|
2697
|
+
--line: #27406b;
|
|
2698
|
+
--accent: #2dd4bf;
|
|
2699
|
+
--accent-2: #38bdf8;
|
|
2700
|
+
--warn: #f59e0b;
|
|
2701
|
+
}
|
|
2702
|
+
* { box-sizing: border-box; }
|
|
2703
|
+
body {
|
|
2704
|
+
margin: 0;
|
|
2705
|
+
color: var(--text);
|
|
2706
|
+
font-family: "Segoe UI", "Noto Sans KR", system-ui, sans-serif;
|
|
2707
|
+
background: radial-gradient(1200px 600px at 10% -20%, #1e3a8a 0%, transparent 70%), linear-gradient(160deg, #0b1220 0%, #07101d 100%);
|
|
2708
|
+
min-height: 100vh;
|
|
2709
|
+
}
|
|
2710
|
+
.wrap { max-width: 1280px; margin: 0 auto; padding: 24px; }
|
|
2711
|
+
.hero {
|
|
2712
|
+
background: linear-gradient(135deg, rgba(45, 212, 191, 0.15), rgba(56, 189, 248, 0.12));
|
|
2713
|
+
border: 1px solid var(--line);
|
|
2714
|
+
border-radius: 16px;
|
|
2715
|
+
padding: 20px;
|
|
2716
|
+
backdrop-filter: blur(6px);
|
|
2717
|
+
}
|
|
2718
|
+
.title { font-size: 28px; font-weight: 700; margin: 0; letter-spacing: 0.3px; }
|
|
2719
|
+
.sub { margin: 8px 0 0; color: var(--muted); font-size: 13px; }
|
|
2720
|
+
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 10px; margin-top: 14px; }
|
|
2721
|
+
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 10px 12px; }
|
|
2722
|
+
.card .k { color: var(--muted); font-size: 12px; }
|
|
2723
|
+
.card .v { margin-top: 4px; font-size: 18px; font-weight: 700; }
|
|
2724
|
+
.tools {
|
|
2725
|
+
margin-top: 16px;
|
|
2726
|
+
display: grid;
|
|
2727
|
+
grid-template-columns: 1fr 180px 120px 140px;
|
|
2728
|
+
gap: 10px;
|
|
2729
|
+
}
|
|
2730
|
+
input, select, button {
|
|
2731
|
+
width: 100%;
|
|
2732
|
+
border-radius: 10px;
|
|
2733
|
+
border: 1px solid var(--line);
|
|
2734
|
+
background: var(--panel-2);
|
|
2735
|
+
color: var(--text);
|
|
2736
|
+
padding: 10px 12px;
|
|
2737
|
+
font-size: 14px;
|
|
2738
|
+
}
|
|
2739
|
+
button {
|
|
2740
|
+
cursor: pointer;
|
|
2741
|
+
background: linear-gradient(135deg, var(--accent), var(--accent-2));
|
|
2742
|
+
color: #042b33;
|
|
2743
|
+
font-weight: 700;
|
|
2744
|
+
border: none;
|
|
2745
|
+
}
|
|
2746
|
+
.table-wrap {
|
|
2747
|
+
margin-top: 14px;
|
|
2748
|
+
background: rgba(11, 18, 32, 0.65);
|
|
2749
|
+
border: 1px solid var(--line);
|
|
2750
|
+
border-radius: 14px;
|
|
2751
|
+
overflow: auto;
|
|
2752
|
+
max-height: calc(100vh - 320px);
|
|
2753
|
+
}
|
|
2754
|
+
table { width: 100%; border-collapse: collapse; min-width: 980px; }
|
|
2755
|
+
th, td { padding: 9px 10px; border-bottom: 1px solid rgba(39, 64, 107, 0.45); text-align: left; font-size: 13px; }
|
|
2756
|
+
th { position: sticky; top: 0; background: #0d1729; color: #d9e8ff; z-index: 1; }
|
|
2757
|
+
td.muted { color: var(--muted); }
|
|
2758
|
+
.tag {
|
|
2759
|
+
display: inline-block;
|
|
2760
|
+
border-radius: 99px;
|
|
2761
|
+
padding: 2px 8px;
|
|
2762
|
+
font-size: 12px;
|
|
2763
|
+
background: #1f2f4f;
|
|
2764
|
+
border: 1px solid var(--line);
|
|
2765
|
+
}
|
|
2766
|
+
.ok { color: #86efac; }
|
|
2767
|
+
.warn { color: var(--warn); }
|
|
2768
|
+
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
|
|
2769
|
+
@media (max-width: 900px) {
|
|
2770
|
+
.tools { grid-template-columns: 1fr; }
|
|
2771
|
+
.wrap { padding: 12px; }
|
|
2772
|
+
.title { font-size: 22px; }
|
|
2773
|
+
}
|
|
2774
|
+
</style>
|
|
2775
|
+
</head>
|
|
2776
|
+
<body>
|
|
2777
|
+
<div class="wrap">
|
|
2778
|
+
<section class="hero">
|
|
2779
|
+
<h1 class="title">AWS Inventory GUI</h1>
|
|
2780
|
+
<p class="sub">Interactive local report generated by mcp-aws-manager</p>
|
|
2781
|
+
<div class="grid" id="summary"></div>
|
|
2782
|
+
<div class="tools">
|
|
2783
|
+
<input id="q" type="text" placeholder="Search instance/resource id, name, IP, profile, region..." />
|
|
2784
|
+
<select id="serviceFilter"><option value="">All services</option></select>
|
|
2785
|
+
<button id="btnReset" type="button">Reset</button>
|
|
2786
|
+
<button id="btnCsv" type="button">Download CSV</button>
|
|
2787
|
+
</div>
|
|
2788
|
+
</section>
|
|
2789
|
+
<div class="table-wrap">
|
|
2790
|
+
<table>
|
|
2791
|
+
<thead>
|
|
2792
|
+
<tr>
|
|
2793
|
+
<th>service</th>
|
|
2794
|
+
<th>resourceType</th>
|
|
2795
|
+
<th>resourceId</th>
|
|
2796
|
+
<th>name</th>
|
|
2797
|
+
<th>profile</th>
|
|
2798
|
+
<th>region</th>
|
|
2799
|
+
<th>state</th>
|
|
2800
|
+
<th>privateIp</th>
|
|
2801
|
+
<th>publicIp</th>
|
|
2802
|
+
<th>ssm</th>
|
|
2803
|
+
<th>snapshot</th>
|
|
2804
|
+
</tr>
|
|
2805
|
+
</thead>
|
|
2806
|
+
<tbody id="rows"></tbody>
|
|
2807
|
+
</table>
|
|
2808
|
+
</div>
|
|
2809
|
+
</div>
|
|
2810
|
+
<script>
|
|
2811
|
+
const records = ${payload};
|
|
2812
|
+
const qEl = document.getElementById("q");
|
|
2813
|
+
const serviceEl = document.getElementById("serviceFilter");
|
|
2814
|
+
const rowsEl = document.getElementById("rows");
|
|
2815
|
+
const summaryEl = document.getElementById("summary");
|
|
2816
|
+
const btnReset = document.getElementById("btnReset");
|
|
2817
|
+
const btnCsv = document.getElementById("btnCsv");
|
|
2818
|
+
|
|
2819
|
+
const services = Array.from(new Set(records.map(r => r.service || ""))).filter(Boolean).sort();
|
|
2820
|
+
for (const s of services) {
|
|
2821
|
+
const op = document.createElement("option");
|
|
2822
|
+
op.value = s;
|
|
2823
|
+
op.textContent = s;
|
|
2824
|
+
serviceEl.appendChild(op);
|
|
2825
|
+
}
|
|
2826
|
+
|
|
2827
|
+
function esc(v) {
|
|
2828
|
+
const span = document.createElement("span");
|
|
2829
|
+
span.textContent = v == null ? "" : String(v);
|
|
2830
|
+
return span.innerHTML;
|
|
2831
|
+
}
|
|
2832
|
+
|
|
2833
|
+
function yesNo(v) {
|
|
2834
|
+
if (v === true) return '<span class="tag ok">yes</span>';
|
|
2835
|
+
if (v === false) return '<span class="tag warn">no</span>';
|
|
2836
|
+
return '<span class="tag">n/a</span>';
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
function summarize(list) {
|
|
2840
|
+
const byService = {};
|
|
2841
|
+
for (const r of list) {
|
|
2842
|
+
const key = r.service || "unknown";
|
|
2843
|
+
byService[key] = (byService[key] || 0) + 1;
|
|
2844
|
+
}
|
|
2845
|
+
const cards = [];
|
|
2846
|
+
cards.push({ k: "Total Records", v: list.length });
|
|
2847
|
+
cards.push({ k: "EC2", v: list.filter(r => r.resourceType === "ec2").length });
|
|
2848
|
+
cards.push({ k: "Lambda", v: list.filter(r => r.resourceType === "lambda").length });
|
|
2849
|
+
cards.push({ k: "SSM Online", v: list.filter(r => r.ssmOnline === true).length });
|
|
2850
|
+
cards.push({ k: "With Public IP", v: list.filter(r => !!r.publicIp).length });
|
|
2851
|
+
for (const [k, v] of Object.entries(byService).sort((a, b) => a[0].localeCompare(b[0])).slice(0, 6)) {
|
|
2852
|
+
cards.push({ k: "svc:" + k, v });
|
|
2853
|
+
}
|
|
2854
|
+
return cards;
|
|
2855
|
+
}
|
|
2856
|
+
|
|
2857
|
+
function filterRows() {
|
|
2858
|
+
const q = qEl.value.trim().toLowerCase();
|
|
2859
|
+
const service = serviceEl.value;
|
|
2860
|
+
return records.filter((r) => {
|
|
2861
|
+
if (service && (r.service || "") !== service) return false;
|
|
2862
|
+
if (!q) return true;
|
|
2863
|
+
const bucket = [
|
|
2864
|
+
r.resourceId, r.instanceId, r.name, r.profile, r.region, r.state,
|
|
2865
|
+
r.privateIp, r.publicIp, r.publicDns, r.service, r.resourceType
|
|
2866
|
+
].map(v => (v == null ? "" : String(v).toLowerCase())).join("|");
|
|
2867
|
+
return bucket.includes(q);
|
|
2868
|
+
});
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
function render() {
|
|
2872
|
+
const list = filterRows();
|
|
2873
|
+
rowsEl.innerHTML = list.map((r) => \`
|
|
2874
|
+
<tr>
|
|
2875
|
+
<td><span class="tag">\${esc(r.service || "")}</span></td>
|
|
2876
|
+
<td>\${esc(r.resourceType || "")}</td>
|
|
2877
|
+
<td class="mono">\${esc(r.resourceId || "")}</td>
|
|
2878
|
+
<td>\${esc(r.name || "")}</td>
|
|
2879
|
+
<td>\${esc(r.profile || "")}</td>
|
|
2880
|
+
<td>\${esc(r.region || "")}</td>
|
|
2881
|
+
<td>\${esc(r.state || "")}</td>
|
|
2882
|
+
<td class="mono muted">\${esc(r.privateIp || "")}</td>
|
|
2883
|
+
<td class="mono">\${esc(r.publicIp || "")}</td>
|
|
2884
|
+
<td>\${yesNo(r.ssmOnline)}</td>
|
|
2885
|
+
<td>\${esc(r.runtimeSnapshot && r.runtimeSnapshot.status ? r.runtimeSnapshot.status : "")}</td>
|
|
2886
|
+
</tr>
|
|
2887
|
+
\`).join("");
|
|
2888
|
+
|
|
2889
|
+
const cards = summarize(list);
|
|
2890
|
+
summaryEl.innerHTML = cards.map(c => \`<div class="card"><div class="k">\${esc(c.k)}</div><div class="v">\${esc(c.v)}</div></div>\`).join("");
|
|
2891
|
+
window.__view = list;
|
|
2892
|
+
}
|
|
2893
|
+
|
|
2894
|
+
function toCsv(list) {
|
|
2895
|
+
const fields = [
|
|
2896
|
+
"resourceType","service","resourceId","instanceId","name","profile","region","state",
|
|
2897
|
+
"privateIp","publicIp","publicDns","ssmManaged","ssmOnline","runtimeSnapshot"
|
|
2898
|
+
];
|
|
2899
|
+
const escCsv = (v) => {
|
|
2900
|
+
if (v == null) return "";
|
|
2901
|
+
const text = typeof v === "object" ? JSON.stringify(v) : String(v);
|
|
2902
|
+
return /[",\\r\\n]/.test(text) ? '"' + text.replace(/"/g, '""') + '"' : text;
|
|
2903
|
+
};
|
|
2904
|
+
const lines = [fields.join(",")];
|
|
2905
|
+
for (const row of list) {
|
|
2906
|
+
lines.push(fields.map(f => escCsv(row[f])).join(","));
|
|
2907
|
+
}
|
|
2908
|
+
return lines.join("\\n");
|
|
2909
|
+
}
|
|
2910
|
+
|
|
2911
|
+
qEl.addEventListener("input", render);
|
|
2912
|
+
serviceEl.addEventListener("change", render);
|
|
2913
|
+
btnReset.addEventListener("click", () => {
|
|
2914
|
+
qEl.value = "";
|
|
2915
|
+
serviceEl.value = "";
|
|
2916
|
+
render();
|
|
2917
|
+
});
|
|
2918
|
+
btnCsv.addEventListener("click", () => {
|
|
2919
|
+
const csv = toCsv(window.__view || []);
|
|
2920
|
+
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
|
|
2921
|
+
const url = URL.createObjectURL(blob);
|
|
2922
|
+
const a = document.createElement("a");
|
|
2923
|
+
a.href = url;
|
|
2924
|
+
a.download = "inventory-view.csv";
|
|
2925
|
+
a.click();
|
|
2926
|
+
URL.revokeObjectURL(url);
|
|
2927
|
+
});
|
|
2928
|
+
render();
|
|
2929
|
+
</script>
|
|
2930
|
+
</body>
|
|
2931
|
+
</html>`;
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
function writeHtmlReport(config, records) {
|
|
2935
|
+
if (!config.htmlOutPath) return null;
|
|
2936
|
+
const html = renderHtmlReport(records);
|
|
2937
|
+
fs.writeFileSync(config.htmlOutPath, html, "utf8");
|
|
2938
|
+
eprint(`Wrote HTML report to ${config.htmlOutPath}`);
|
|
2939
|
+
return config.htmlOutPath;
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
function tryOpenFile(targetPath) {
|
|
2943
|
+
if (!targetPath) return { ok: false, detail: "empty path" };
|
|
2944
|
+
const fullPath = path.resolve(targetPath);
|
|
2945
|
+
try {
|
|
2946
|
+
if (process.platform === "win32") {
|
|
2947
|
+
const child = spawn("explorer.exe", [fullPath], { stdio: "ignore", detached: true, windowsHide: true });
|
|
2948
|
+
child.unref();
|
|
2949
|
+
return { ok: true, detail: "explorer.exe" };
|
|
2950
|
+
}
|
|
2951
|
+
if (process.platform === "darwin") {
|
|
2952
|
+
const child = spawn("open", [fullPath], { stdio: "ignore", detached: true });
|
|
2953
|
+
child.unref();
|
|
2954
|
+
return { ok: true, detail: "open" };
|
|
2955
|
+
}
|
|
2956
|
+
const child = spawn("xdg-open", [fullPath], { stdio: "ignore", detached: true });
|
|
2957
|
+
child.unref();
|
|
2958
|
+
return { ok: true, detail: "xdg-open" };
|
|
2959
|
+
} catch (error) {
|
|
2960
|
+
return { ok: false, detail: error && error.message ? error.message : String(error) };
|
|
2961
|
+
}
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2042
2964
|
function writeOutput(config, content) {
|
|
2043
2965
|
if (config.outPath) {
|
|
2044
2966
|
fs.writeFileSync(config.outPath, content, "utf8");
|
|
@@ -2054,7 +2976,7 @@ async function runWorkflow(config) {
|
|
|
2054
2976
|
const requiredActions = [];
|
|
2055
2977
|
|
|
2056
2978
|
progress(config, 1, "orchestrator: parse user request and operation scope");
|
|
2057
|
-
eprint(`Inputs: execution_mode=${INTERNAL_BACKEND_ID}, profiles=${config.profiles ? config.profiles.join(",") : "auto"}, regions=${config.regions ? config.regions.join(",") : "auto"}, include_ec2=${config.includeEc2 ? "on" : "off"}, include_lambda=${config.includeLambda ? "on" : "off"}, include_alb=${config.includeAlb ? "on" : "off"}, include_asg=${config.includeAsg ? "on" : "off"}, include_rds=${config.includeRds ? "on" : "off"}, include_elasticache=${config.includeElastiCache ? "on" : "off"}, include_route53=${config.includeRoute53 ? "on" : "off"}, public_only=${config.publicOnly ? "on" : "off"}, managed_only=${config.managedOnly ? "on" : "off"}, auto_remediate_ssm=${config.autoRemediateSsm ? "on" : "off"}, runtime_snapshot=${config.runtimeSnapshot ? "on" : "off"}, auto_sso_login=${config.autoSsoLogin ? "on" : "off"}`);
|
|
2979
|
+
eprint(`Inputs: execution_mode=${INTERNAL_BACKEND_ID}, profiles=${config.profiles ? config.profiles.join(",") : "auto"}, regions=${config.regions ? config.regions.join(",") : "auto"}, include_ec2=${config.includeEc2 ? "on" : "off"}, include_lambda=${config.includeLambda ? "on" : "off"}, include_alb=${config.includeAlb ? "on" : "off"}, include_asg=${config.includeAsg ? "on" : "off"}, include_rds=${config.includeRds ? "on" : "off"}, include_elasticache=${config.includeElastiCache ? "on" : "off"}, include_route53=${config.includeRoute53 ? "on" : "off"}, public_only=${config.publicOnly ? "on" : "off"}, managed_only=${config.managedOnly ? "on" : "off"}, auto_remediate_ssm=${config.autoRemediateSsm ? "on" : "off"}, runtime_snapshot=${config.runtimeSnapshot ? "on" : "off"}, manual_server_list=${config.manualServerListPath || "off"}, pem_paths=${config.pemPaths && config.pemPaths.length ? config.pemPaths.length : 0}, ssh_user=${config.sshUser}, ssh_port=${config.sshPort}, html_out=${config.htmlOutPath || "off"}, open_html=${config.openHtml ? "on" : "off"}, auto_sso_login=${config.autoSsoLogin ? "on" : "off"}`);
|
|
2058
2980
|
|
|
2059
2981
|
progress(config, 2, "config_validator: validate settings and output path");
|
|
2060
2982
|
validateConfig(config, warnings, requiredActions);
|
|
@@ -2066,11 +2988,16 @@ async function runWorkflow(config) {
|
|
|
2066
2988
|
const operationPlan = buildOperationPlan(config);
|
|
2067
2989
|
eprint(`Operation plan: inventory=${operationPlan.inventoryOps.length ? operationPlan.inventoryOps.join(",") : "none"}; runtime=${operationPlan.runtimeOps.length ? operationPlan.runtimeOps.join(",") : "none"}`);
|
|
2068
2990
|
|
|
2069
|
-
|
|
2070
|
-
if (
|
|
2071
|
-
eprint(
|
|
2991
|
+
let plans = [];
|
|
2992
|
+
if (config.manualServerListPath) {
|
|
2993
|
+
eprint(`Discovery plan: manual server list mode (${config.manualServerListPath})`);
|
|
2072
2994
|
} else {
|
|
2073
|
-
|
|
2995
|
+
plans = await buildDiscoveryPlan(config, warnings, requiredActions);
|
|
2996
|
+
if (plans.length) {
|
|
2997
|
+
eprint("Discovery plan: " + plans.map((p) => `${p.profileLabel}(${p.regions.length} regions)`).join(", "));
|
|
2998
|
+
} else {
|
|
2999
|
+
warnings.push("No usable AWS profiles were found after validation.");
|
|
3000
|
+
}
|
|
2074
3001
|
}
|
|
2075
3002
|
|
|
2076
3003
|
if (config.autoRemediateSsm) {
|
|
@@ -2078,23 +3005,30 @@ async function runWorkflow(config) {
|
|
|
2078
3005
|
}
|
|
2079
3006
|
|
|
2080
3007
|
progress(config, 5, "resource_inventory_collector: collect multi-service inventory");
|
|
2081
|
-
const inventoryResult =
|
|
3008
|
+
const inventoryResult = config.manualServerListPath
|
|
3009
|
+
? await collectManualInventory(config, warnings, requiredActions)
|
|
3010
|
+
: await collectInventory(config, plans, warnings, requiredActions);
|
|
2082
3011
|
const records = inventoryResult.records;
|
|
2083
3012
|
const stats = inventoryResult.stats;
|
|
2084
3013
|
eprint(`Collected inventory records: ${records.length}`);
|
|
2085
3014
|
|
|
2086
3015
|
progress(config, 6, "runtime_operation_executor: execute runtime snapshot/remediation tasks");
|
|
2087
3016
|
const ec2Records = records.filter((r) => r.resourceType === "ec2");
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
3017
|
+
if (config.manualServerListPath) {
|
|
3018
|
+
const manualTargets = ec2Records.filter((r) => r.manualInput === true).length;
|
|
3019
|
+
eprint(`Pre-runtime manual SSH state: targets=${manualTargets}`);
|
|
3020
|
+
} else {
|
|
3021
|
+
const unmanaged = ec2Records.filter((r) => !r.ssmManaged).length;
|
|
3022
|
+
const offline = ec2Records.filter((r) => r.ssmManaged && !r.ssmOnline).length;
|
|
3023
|
+
eprint(`Pre-runtime SSM state: unmanaged=${unmanaged}, managed_offline=${offline}`);
|
|
2091
3024
|
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
3025
|
+
if (unmanaged > 0 && !config.autoRemediateSsm) {
|
|
3026
|
+
pushRequiredAction(requiredActions, {
|
|
3027
|
+
code: "SSM_ROLE_OR_AGENT_REQUIRED",
|
|
3028
|
+
message: `${unmanaged} instances are not SSM managed.`,
|
|
3029
|
+
hint: "Attach instance profile with AmazonSSMManagedInstanceCore and verify SSM agent/network."
|
|
3030
|
+
});
|
|
3031
|
+
}
|
|
2098
3032
|
}
|
|
2099
3033
|
|
|
2100
3034
|
const runtimeStats = await executeRuntimeOperations(config, records, plans, warnings, requiredActions);
|
|
@@ -2104,6 +3038,15 @@ async function runWorkflow(config) {
|
|
|
2104
3038
|
|
|
2105
3039
|
progress(config, 8, "cli_output_formatter: render JSON/CSV and guidance payload");
|
|
2106
3040
|
writeOutput(config, renderOutput(config, outputRecords));
|
|
3041
|
+
const htmlPath = writeHtmlReport(config, outputRecords);
|
|
3042
|
+
if (htmlPath && config.openHtml) {
|
|
3043
|
+
const opened = tryOpenFile(htmlPath);
|
|
3044
|
+
if (!opened.ok) {
|
|
3045
|
+
warnings.push(`Failed to auto-open HTML report: ${opened.detail}`);
|
|
3046
|
+
} else {
|
|
3047
|
+
eprint(`Opened HTML report via ${opened.detail}`);
|
|
3048
|
+
}
|
|
3049
|
+
}
|
|
2107
3050
|
|
|
2108
3051
|
progress(config, 9, "END: emit execution summary and evidence metadata");
|
|
2109
3052
|
const outputEc2 = outputRecords.filter((r) => r.resourceType === "ec2");
|
|
@@ -2117,7 +3060,7 @@ async function runWorkflow(config) {
|
|
|
2117
3060
|
const ssmManaged = outputEc2.filter((r) => r.ssmManaged).length;
|
|
2118
3061
|
const ssmOnline = outputEc2.filter((r) => r.ssmOnline).length;
|
|
2119
3062
|
const publicCount = outputEc2.filter((r) => Boolean(r.publicIp)).length;
|
|
2120
|
-
eprint(`Summary: execution_mode=${INTERNAL_BACKEND_ID}, profiles=${stats.profiles}, regions_scanned=${stats.regions}, region_errors=${stats.regionErrors}, ec2_scanned=${stats.instancesScanned}, lambda_scanned=${stats.lambdaFunctionsScanned}, alb_scanned=${stats.loadBalancersScanned}, targetgroup_scanned=${stats.targetGroupsScanned}, asg_scanned=${stats.autoScalingGroupsScanned}, rds_scanned=${stats.rdsInstancesScanned}, elasticache_scanned=${stats.elasticacheClustersScanned}, route53_zone_scanned=${stats.route53ZonesScanned}, output_records=${outputRecords.length}, output_ec2=${outputEc2.length}, output_lambda=${outputLambda.length}, output_alb=${outputAlb.length}, output_target_group=${outputTargetGroups.length}, output_asg=${outputAsg.length}, output_rds=${outputRds.length}, output_elasticache=${outputElastiCache.length}, output_route53_zone=${outputRoute53.length}, ec2_public_ip_records=${publicCount}, ec2_ssm_managed=${ssmManaged}, ec2_ssm_online=${ssmOnline}, remediation_attempts=${runtimeStats.remediationAttempts}, remediation_changed=${runtimeStats.remediationChanged}, runtime_snapshot_attempted=${runtimeStats.snapshotAttempted}, runtime_snapshot_succeeded=${runtimeStats.snapshotSucceeded}, warnings=${warnings.length}, required_actions=${requiredActions.length}`);
|
|
3063
|
+
eprint(`Summary: execution_mode=${INTERNAL_BACKEND_ID}, manual_mode=${config.manualServerListPath ? "on" : "off"}, profiles=${stats.profiles}, regions_scanned=${stats.regions}, region_errors=${stats.regionErrors}, ec2_scanned=${stats.instancesScanned}, lambda_scanned=${stats.lambdaFunctionsScanned}, alb_scanned=${stats.loadBalancersScanned}, targetgroup_scanned=${stats.targetGroupsScanned}, asg_scanned=${stats.autoScalingGroupsScanned}, rds_scanned=${stats.rdsInstancesScanned}, elasticache_scanned=${stats.elasticacheClustersScanned}, route53_zone_scanned=${stats.route53ZonesScanned}, manual_servers_scanned=${stats.manualServersScanned || 0}, manual_servers_loaded=${stats.manualServersLoaded || 0}, output_records=${outputRecords.length}, output_ec2=${outputEc2.length}, output_lambda=${outputLambda.length}, output_alb=${outputAlb.length}, output_target_group=${outputTargetGroups.length}, output_asg=${outputAsg.length}, output_rds=${outputRds.length}, output_elasticache=${outputElastiCache.length}, output_route53_zone=${outputRoute53.length}, ec2_public_ip_records=${publicCount}, ec2_ssm_managed=${ssmManaged}, ec2_ssm_online=${ssmOnline}, remediation_attempts=${runtimeStats.remediationAttempts}, remediation_changed=${runtimeStats.remediationChanged}, runtime_snapshot_attempted=${runtimeStats.snapshotAttempted}, runtime_snapshot_succeeded=${runtimeStats.snapshotSucceeded}, runtime_snapshot_ssm_attempted=${runtimeStats.snapshotSsmAttempted}, runtime_snapshot_ssm_succeeded=${runtimeStats.snapshotSsmSucceeded}, runtime_snapshot_ssh_attempted=${runtimeStats.snapshotSshAttempted}, runtime_snapshot_ssh_succeeded=${runtimeStats.snapshotSshSucceeded}, warnings=${warnings.length}, required_actions=${requiredActions.length}`);
|
|
2121
3064
|
eprint(`EvidenceMeta: workflow_id=b60f0f18-cc45-4af9-a7c7-217284457759; schema=gesia.orflow.contract.v2; step_count=${TOTAL_STEPS}; execution_mode=${INTERNAL_BACKEND_ID}; inventory_ops=${operationPlan.inventoryOps.length}; runtime_ops=${operationPlan.runtimeOps.length}`);
|
|
2122
3065
|
|
|
2123
3066
|
for (const warning of warnings) eprint(`WARNING: ${warning}`);
|