@meru454545/nexus-modscript-composer 2.0.12
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/README.md +179 -0
- package/dist/composer.d.ts +83 -0
- package/dist/composer.d.ts.map +1 -0
- package/dist/composer.js +367 -0
- package/dist/composer.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +10 -0
- package/dist/index.js.map +1 -0
- package/dist/l0-parser.d.ts +22 -0
- package/dist/l0-parser.d.ts.map +1 -0
- package/dist/l0-parser.js +209 -0
- package/dist/l0-parser.js.map +1 -0
- package/dist/logger.d.ts +9 -0
- package/dist/logger.d.ts.map +1 -0
- package/dist/logger.js +54 -0
- package/dist/logger.js.map +1 -0
- package/dist/orchestrator.d.ts +41 -0
- package/dist/orchestrator.d.ts.map +1 -0
- package/dist/orchestrator.js +198 -0
- package/dist/orchestrator.js.map +1 -0
- package/dist/script-validator.d.ts +22 -0
- package/dist/script-validator.d.ts.map +1 -0
- package/dist/script-validator.js +1029 -0
- package/dist/script-validator.js.map +1 -0
- package/dist/shellcheck-bin.d.ts +2 -0
- package/dist/shellcheck-bin.d.ts.map +1 -0
- package/dist/shellcheck-bin.js +42 -0
- package/dist/shellcheck-bin.js.map +1 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/package.json +58 -0
|
@@ -0,0 +1,1029 @@
|
|
|
1
|
+
import { execSync } from "node:child_process";
|
|
2
|
+
import { writeFileSync, unlinkSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { getShellcheckPath } from "./shellcheck-bin.js";
|
|
6
|
+
import { validateHereStringTerminators } from "./composer.js";
|
|
7
|
+
import { createLogger } from "./logger.js";
|
|
8
|
+
const log = createLogger("validator");
|
|
9
|
+
// The env vars that L2 writes into the env file and L1 reads at startup.
|
|
10
|
+
// This is the contract between the two layers.
|
|
11
|
+
const NEXUS_ENV_VARS = [
|
|
12
|
+
"NEXUS_ICEBERG_ENDPOINT",
|
|
13
|
+
"NEXUS_AGENT_ID",
|
|
14
|
+
"NEXUS_AUTH_TOKEN",
|
|
15
|
+
"NEXUS_DRY_RUN",
|
|
16
|
+
"NEXUS_MAX_PARALLEL",
|
|
17
|
+
];
|
|
18
|
+
function createResult() {
|
|
19
|
+
return { valid: true, errors: [], warnings: [] };
|
|
20
|
+
}
|
|
21
|
+
function addError(result, msg) {
|
|
22
|
+
result.errors.push(msg);
|
|
23
|
+
result.valid = false;
|
|
24
|
+
}
|
|
25
|
+
function addWarning(result, msg) {
|
|
26
|
+
result.warnings.push(msg);
|
|
27
|
+
}
|
|
28
|
+
function mergeResults(...results) {
|
|
29
|
+
const merged = createResult();
|
|
30
|
+
for (const r of results) {
|
|
31
|
+
merged.errors.push(...r.errors);
|
|
32
|
+
merged.warnings.push(...r.warnings);
|
|
33
|
+
}
|
|
34
|
+
merged.valid = merged.errors.length === 0;
|
|
35
|
+
return merged;
|
|
36
|
+
}
|
|
37
|
+
// ── Shared checks ──────────────────────────────────────────────────────────
|
|
38
|
+
function checkUnreplacedPlaceholders(script, label) {
|
|
39
|
+
const result = createResult();
|
|
40
|
+
const matches = script.match(/\{\{[A-Z_]+\}\}/g);
|
|
41
|
+
if (matches) {
|
|
42
|
+
const unique = [...new Set(matches)];
|
|
43
|
+
addError(result, `${label} contains unreplaced template placeholders: ${unique.join(", ")}`);
|
|
44
|
+
}
|
|
45
|
+
return result;
|
|
46
|
+
}
|
|
47
|
+
// ── L1 Validation ──────────────────────────────────────────────────────────
|
|
48
|
+
function validateL1Linux(script, moduleNames) {
|
|
49
|
+
const result = createResult();
|
|
50
|
+
// Shebang
|
|
51
|
+
if (!script.startsWith("#!/bin/bash")) {
|
|
52
|
+
addError(result, "L1 Linux: missing #!/bin/bash shebang");
|
|
53
|
+
}
|
|
54
|
+
// Shell safety flags
|
|
55
|
+
if (!script.includes("set -uo pipefail")) {
|
|
56
|
+
addError(result, "L1 Linux: missing 'set -uo pipefail' safety flags");
|
|
57
|
+
}
|
|
58
|
+
// Env var reads — L1 must consume every var the L2 env file provides
|
|
59
|
+
for (const envVar of NEXUS_ENV_VARS) {
|
|
60
|
+
if (!script.includes(envVar)) {
|
|
61
|
+
addError(result, `L1 Linux: does not read env var ${envVar}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// Required guard — L1 must reject missing critical env vars
|
|
65
|
+
if (!script.includes('-z "$ICEBERG_ENDPOINT"')) {
|
|
66
|
+
addWarning(result, "L1 Linux: missing empty-check guard for ICEBERG_ENDPOINT");
|
|
67
|
+
}
|
|
68
|
+
if (!script.includes('-z "$AGENT_ID"')) {
|
|
69
|
+
addWarning(result, "L1 Linux: missing empty-check guard for AGENT_ID");
|
|
70
|
+
}
|
|
71
|
+
if (!script.includes('-z "$AUTH_TOKEN"')) {
|
|
72
|
+
addWarning(result, "L1 Linux: missing empty-check guard for AUTH_TOKEN");
|
|
73
|
+
}
|
|
74
|
+
// L0 function definitions — each module must have its collector function
|
|
75
|
+
for (const mod of moduleNames) {
|
|
76
|
+
const fnName = `nexus_collect_${mod}`;
|
|
77
|
+
if (!script.includes(fnName)) {
|
|
78
|
+
addError(result, `L1 Linux: missing L0 function definition '${fnName}' for module '${mod}'`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
// Module array — the orchestration loop must list all modules
|
|
82
|
+
for (const mod of moduleNames) {
|
|
83
|
+
if (!script.includes(`"${mod}"`)) {
|
|
84
|
+
addError(result, `L1 Linux: module '${mod}' not found in modules array`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
// Core functions
|
|
88
|
+
if (!script.includes("run_all_modules")) {
|
|
89
|
+
addError(result, "L1 Linux: missing 'run_all_modules' entry point");
|
|
90
|
+
}
|
|
91
|
+
if (!script.includes("_push_to_iceberg")) {
|
|
92
|
+
addError(result, "L1 Linux: missing '_push_to_iceberg' function");
|
|
93
|
+
}
|
|
94
|
+
if (!script.includes("_run_module")) {
|
|
95
|
+
addError(result, "L1 Linux: missing '_run_module' function");
|
|
96
|
+
}
|
|
97
|
+
if (!script.includes("_build_envelope")) {
|
|
98
|
+
addError(result, "L1 Linux: missing '_build_envelope' function — L0 log/result records would never be folded into a payload");
|
|
99
|
+
}
|
|
100
|
+
if (!script.includes("_json_validate")) {
|
|
101
|
+
addWarning(result, "L1 Linux: missing '_json_validate' — output won't be checked before push");
|
|
102
|
+
}
|
|
103
|
+
if (!script.includes(".ndjson")) {
|
|
104
|
+
addWarning(result, "L1 Linux: no '.ndjson' capture file — L0 record streams may not be captured line-wise");
|
|
105
|
+
}
|
|
106
|
+
if (!script.includes("_interrupt_and_flush") || !/trap\s+_interrupt_and_flush\s+TERM/.test(script)) {
|
|
107
|
+
addError(result, "L1 Linux: missing TERM timeout flush handler — partial L0 records would be lost when systemd times out");
|
|
108
|
+
}
|
|
109
|
+
// L1 must self-redirect output now that the L2 unit no longer uses
|
|
110
|
+
// StandardOutput=append: (which was removed for systemd 228 compatibility).
|
|
111
|
+
// Without this, log lines from L1 vanish into the journal on old systemd.
|
|
112
|
+
if (!/exec\s+>>.*NEXUS_SERVICE_NAME/.test(script)) {
|
|
113
|
+
addError(result, "L1 Linux: missing 'exec >> ...${NEXUS_SERVICE_NAME}...' redirect — L1 logs will be lost on systemd < 240");
|
|
114
|
+
}
|
|
115
|
+
return result;
|
|
116
|
+
}
|
|
117
|
+
function validateL1Aix(script, moduleNames) {
|
|
118
|
+
const result = createResult();
|
|
119
|
+
// Shebang — AIX default ksh (ksh88)
|
|
120
|
+
if (!script.startsWith("#!/bin/ksh")) {
|
|
121
|
+
addError(result, "L1 AIX: missing #!/bin/ksh shebang");
|
|
122
|
+
}
|
|
123
|
+
// Shell safety flags — ksh88 has no `pipefail`, so only `set -u` is required.
|
|
124
|
+
if (!/^set -u\b/m.test(script)) {
|
|
125
|
+
addError(result, "L1 AIX: missing 'set -u' safety flag");
|
|
126
|
+
}
|
|
127
|
+
// Env var reads — L1 must consume every var the L2 env file provides
|
|
128
|
+
for (const envVar of NEXUS_ENV_VARS) {
|
|
129
|
+
if (!script.includes(envVar)) {
|
|
130
|
+
addError(result, `L1 AIX: does not read env var ${envVar}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
// Required guard — L1 must reject missing critical env vars
|
|
134
|
+
if (!script.includes('-z "$ICEBERG_ENDPOINT"')) {
|
|
135
|
+
addWarning(result, "L1 AIX: missing empty-check guard for ICEBERG_ENDPOINT");
|
|
136
|
+
}
|
|
137
|
+
if (!script.includes('-z "$AGENT_ID"')) {
|
|
138
|
+
addWarning(result, "L1 AIX: missing empty-check guard for AGENT_ID");
|
|
139
|
+
}
|
|
140
|
+
if (!script.includes('-z "$AUTH_TOKEN"')) {
|
|
141
|
+
addWarning(result, "L1 AIX: missing empty-check guard for AUTH_TOKEN");
|
|
142
|
+
}
|
|
143
|
+
// L0 function definitions — each module must have its collector function
|
|
144
|
+
for (const mod of moduleNames) {
|
|
145
|
+
const fnName = `nexus_collect_${mod}`;
|
|
146
|
+
if (!script.includes(fnName)) {
|
|
147
|
+
addError(result, `L1 AIX: missing L0 function definition '${fnName}' for module '${mod}'`);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
// Module array — the orchestration loop must list all modules
|
|
151
|
+
for (const mod of moduleNames) {
|
|
152
|
+
if (!script.includes(`"${mod}"`)) {
|
|
153
|
+
addError(result, `L1 AIX: module '${mod}' not found in modules array`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
// ksh88 array population must use `set -A` (no bash `arr=(...)` form)
|
|
157
|
+
if (!/\bset -A\b/.test(script)) {
|
|
158
|
+
addError(result, "L1 AIX: missing 'set -A' module array — ksh88 does not support bash array assignment");
|
|
159
|
+
}
|
|
160
|
+
// Core functions
|
|
161
|
+
if (!script.includes("run_all_modules")) {
|
|
162
|
+
addError(result, "L1 AIX: missing 'run_all_modules' entry point");
|
|
163
|
+
}
|
|
164
|
+
if (!script.includes("_push_to_iceberg")) {
|
|
165
|
+
addError(result, "L1 AIX: missing '_push_to_iceberg' function");
|
|
166
|
+
}
|
|
167
|
+
if (!script.includes("_run_module")) {
|
|
168
|
+
addError(result, "L1 AIX: missing '_run_module' function");
|
|
169
|
+
}
|
|
170
|
+
if (!script.includes("_build_envelope")) {
|
|
171
|
+
addError(result, "L1 AIX: missing '_build_envelope' function — L0 log/result records would never be folded into a payload");
|
|
172
|
+
}
|
|
173
|
+
if (!script.includes("_json_validate")) {
|
|
174
|
+
addWarning(result, "L1 AIX: missing '_json_validate' — output won't be checked before push");
|
|
175
|
+
}
|
|
176
|
+
if (!script.includes(".ndjson")) {
|
|
177
|
+
addWarning(result, "L1 AIX: no '.ndjson' capture file — L0 record streams may not be captured line-wise");
|
|
178
|
+
}
|
|
179
|
+
if (!script.includes("_interrupt_and_flush") || !/trap\s+_interrupt_and_flush\s+TERM/.test(script)) {
|
|
180
|
+
addError(result, "L1 AIX: missing TERM timeout flush handler — partial L0 records would be lost when the wrapper watchdog fires");
|
|
181
|
+
}
|
|
182
|
+
// ksh88 incompatibilities that would silently break on AIX /bin/ksh.
|
|
183
|
+
// These are why dev-box `ksh -n` (ksh93) is not enough — ksh93 accepts them.
|
|
184
|
+
for (const err of detectKsh88Incompatibilities(script, "L1 AIX")) {
|
|
185
|
+
addError(result, err);
|
|
186
|
+
}
|
|
187
|
+
// L1 self-redirects its own logs (kept for manual debug runs; the SRC
|
|
188
|
+
// wrapper daemon also redirects, so this is belt-and-suspenders).
|
|
189
|
+
if (!/exec\s+>>.*NEXUS_SERVICE_NAME/.test(script)) {
|
|
190
|
+
addError(result, "L1 AIX: missing 'exec >> ...${NEXUS_SERVICE_NAME}...' redirect — L1 logs will be lost");
|
|
191
|
+
}
|
|
192
|
+
return result;
|
|
193
|
+
}
|
|
194
|
+
function validateL1Windows(script, moduleNames) {
|
|
195
|
+
const result = createResult();
|
|
196
|
+
// Error preference
|
|
197
|
+
if (!script.includes("$ErrorActionPreference")) {
|
|
198
|
+
addError(result, "L1 Windows: missing $ErrorActionPreference setting");
|
|
199
|
+
}
|
|
200
|
+
// Env var reads
|
|
201
|
+
for (const envVar of NEXUS_ENV_VARS) {
|
|
202
|
+
if (!script.includes(`$env:${envVar}`)) {
|
|
203
|
+
addError(result, `L1 Windows: does not read env var $env:${envVar}`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Required guard
|
|
207
|
+
if (!script.includes("-not $IcebergEndpoint") ||
|
|
208
|
+
!script.includes("-not $AgentId") ||
|
|
209
|
+
!script.includes("-not $AuthToken")) {
|
|
210
|
+
addWarning(result, "L1 Windows: missing empty-check guard for required parameters");
|
|
211
|
+
}
|
|
212
|
+
// Module array — each module must appear in the @() array
|
|
213
|
+
for (const mod of moduleNames) {
|
|
214
|
+
if (!script.includes(`"${mod}"`)) {
|
|
215
|
+
addError(result, `L1 Windows: module '${mod}' not found in modules array`);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// Core functions
|
|
219
|
+
if (!script.includes("Invoke-AllModules")) {
|
|
220
|
+
addError(result, "L1 Windows: missing 'Invoke-AllModules' entry point");
|
|
221
|
+
}
|
|
222
|
+
if (!script.includes("Push-ToIceberg")) {
|
|
223
|
+
addError(result, "L1 Windows: missing 'Push-ToIceberg' function");
|
|
224
|
+
}
|
|
225
|
+
if (!script.includes("Build-Envelope")) {
|
|
226
|
+
addError(result, "L1 Windows: missing 'Build-Envelope' function — L0 log/result records would never be folded into a payload");
|
|
227
|
+
}
|
|
228
|
+
if (!script.includes("$env:NEXUS_TIMEOUT_SECONDS") ||
|
|
229
|
+
!script.includes("$job.Shell.Stop()") ||
|
|
230
|
+
!script.includes("$collected = @($job.Output)")) {
|
|
231
|
+
addError(result, "L1 Windows: missing runspace timeout/partial-output handling — records emitted before timeout would be lost");
|
|
232
|
+
}
|
|
233
|
+
// Runspace pool — the parallelism mechanism
|
|
234
|
+
if (!script.includes("RunspacePool")) {
|
|
235
|
+
addWarning(result, "L1 Windows: missing RunspacePool — modules may not run in parallel");
|
|
236
|
+
}
|
|
237
|
+
// Here-string terminators — must conform to PowerShell 5.1 rules
|
|
238
|
+
const terminatorErrors = validateHereStringTerminators(script);
|
|
239
|
+
for (const err of terminatorErrors) {
|
|
240
|
+
addError(result, `L1 Windows: ${err}`);
|
|
241
|
+
}
|
|
242
|
+
// Mixed line endings — PowerShell 5.1 can fail with mixed \r\n and \n
|
|
243
|
+
const hasCRLF = script.includes("\r\n");
|
|
244
|
+
const bareLF = (script.match(/(?<!\r)\n/g) || []).length;
|
|
245
|
+
if (hasCRLF && bareLF > 0) {
|
|
246
|
+
addError(result, `L1 Windows: mixed line endings — ${bareLF} bare LF lines mixed with CRLF`);
|
|
247
|
+
}
|
|
248
|
+
return result;
|
|
249
|
+
}
|
|
250
|
+
export function validateL1(script, osFamily, moduleNames) {
|
|
251
|
+
log.info("Validating L1 script", { osFamily, moduleCount: moduleNames.length });
|
|
252
|
+
const placeholders = checkUnreplacedPlaceholders(script, "L1");
|
|
253
|
+
const structural = osFamily === "linux"
|
|
254
|
+
? validateL1Linux(script, moduleNames)
|
|
255
|
+
: osFamily === "aix"
|
|
256
|
+
? validateL1Aix(script, moduleNames)
|
|
257
|
+
: validateL1Windows(script, moduleNames);
|
|
258
|
+
const result = mergeResults(placeholders, structural);
|
|
259
|
+
if (result.valid) {
|
|
260
|
+
log.info("L1 validation passed", { warnings: result.warnings.length });
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
log.error("L1 validation failed", {
|
|
264
|
+
errorCount: result.errors.length,
|
|
265
|
+
errors: result.errors,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
for (const w of result.warnings) {
|
|
269
|
+
log.debug("L1 validation warning", { warning: w });
|
|
270
|
+
}
|
|
271
|
+
return result;
|
|
272
|
+
}
|
|
273
|
+
// ── L2 Validation ──────────────────────────────────────────────────────────
|
|
274
|
+
function validateL2Linux(script) {
|
|
275
|
+
const result = createResult();
|
|
276
|
+
// Shebang
|
|
277
|
+
if (!script.startsWith("#!/bin/bash")) {
|
|
278
|
+
addError(result, "L2 Linux: missing #!/bin/bash shebang");
|
|
279
|
+
}
|
|
280
|
+
// Shell safety flags
|
|
281
|
+
if (!script.includes("set -euo pipefail")) {
|
|
282
|
+
addError(result, "L2 Linux: missing 'set -euo pipefail' safety flags");
|
|
283
|
+
}
|
|
284
|
+
// Named flag argument parser
|
|
285
|
+
if (!script.includes("--service-name")) {
|
|
286
|
+
addError(result, "L2 Linux: missing --service-name flag in argument parser");
|
|
287
|
+
}
|
|
288
|
+
// Composition default variables
|
|
289
|
+
const requiredVars = [
|
|
290
|
+
"BUFFER_TIME",
|
|
291
|
+
"ICEBERG_ENDPOINT",
|
|
292
|
+
"AGENT_ID",
|
|
293
|
+
"AUTH_TOKEN",
|
|
294
|
+
"ORG_ID",
|
|
295
|
+
"MAX_PARALLEL",
|
|
296
|
+
"TIMEOUT",
|
|
297
|
+
];
|
|
298
|
+
for (const v of requiredVars) {
|
|
299
|
+
if (!script.includes(`${v}=`)) {
|
|
300
|
+
addError(result, `L2 Linux: missing variable ${v}`);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
// L1 body embedding
|
|
304
|
+
if (!script.includes("NEXUS_L1_SCRIPT_BOUNDARY")) {
|
|
305
|
+
addError(result, "L2 Linux: missing NEXUS_L1_SCRIPT_BOUNDARY heredoc — L1 not embedded");
|
|
306
|
+
}
|
|
307
|
+
// Env file — must write all NEXUS_* vars so L1 can read them at runtime
|
|
308
|
+
for (const envVar of NEXUS_ENV_VARS) {
|
|
309
|
+
if (!script.includes(`${envVar}=`)) {
|
|
310
|
+
addError(result, `L2 Linux: env file does not set ${envVar} — L1 will fail to read it`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
// Systemd units
|
|
314
|
+
if (!script.includes(".service")) {
|
|
315
|
+
addError(result, "L2 Linux: missing systemd service unit creation");
|
|
316
|
+
}
|
|
317
|
+
if (!script.includes(".timer")) {
|
|
318
|
+
addError(result, "L2 Linux: missing systemd timer unit creation");
|
|
319
|
+
}
|
|
320
|
+
if (!script.includes("EnvironmentFile=")) {
|
|
321
|
+
addError(result, "L2 Linux: systemd service missing EnvironmentFile= directive — env vars won't reach L1");
|
|
322
|
+
}
|
|
323
|
+
if (!script.includes("systemctl daemon-reload")) {
|
|
324
|
+
addError(result, "L2 Linux: missing 'systemctl daemon-reload'");
|
|
325
|
+
}
|
|
326
|
+
if (!script.includes("systemctl enable")) {
|
|
327
|
+
addWarning(result, "L2 Linux: missing 'systemctl enable' — timer won't survive reboot");
|
|
328
|
+
}
|
|
329
|
+
if (!script.includes("systemctl start")) {
|
|
330
|
+
addWarning(result, "L2 Linux: missing 'systemctl start' — timer won't begin immediately");
|
|
331
|
+
}
|
|
332
|
+
// ── systemd 228 compatibility (SLES 12 SP5) ────────────────────────────────
|
|
333
|
+
// Each of these directives breaks on systemd < a particular version. Checks
|
|
334
|
+
// are anchored at start-of-line so explanatory comments inside heredocs
|
|
335
|
+
// (which mention the broken directive names by design) don't false-positive.
|
|
336
|
+
// Type=oneshot + Restart= → forbidden until systemd 244
|
|
337
|
+
// StandardOutput=append: → introduced in systemd 240
|
|
338
|
+
// StartLimitIntervalSec= → renamed (was StartLimitInterval=) in 230
|
|
339
|
+
// RandomizedDelaySec= → introduced in systemd 229 (warn-only)
|
|
340
|
+
const hasOneshotDirective = /^Type=oneshot\s*$/m.test(script);
|
|
341
|
+
const hasRealRestart = /^Restart=(?!no\b)/m.test(script);
|
|
342
|
+
if (hasOneshotDirective && hasRealRestart) {
|
|
343
|
+
addError(result, "L2 Linux: Type=oneshot combined with Restart= is rejected by systemd < 244 (SLES 12). Use Type=simple.");
|
|
344
|
+
}
|
|
345
|
+
if (!/^Type=simple\s*$/m.test(script)) {
|
|
346
|
+
addError(result, "L2 Linux: missing 'Type=simple' — required for systemd 228 + Restart= compatibility");
|
|
347
|
+
}
|
|
348
|
+
if (/^StandardOutput=append:/m.test(script) ||
|
|
349
|
+
/^StandardError=append:/m.test(script)) {
|
|
350
|
+
addError(result, "L2 Linux: 'append:' output specifier requires systemd >= 240 (breaks SLES 12). Use in-script 'exec >>' redirect in L1 instead.");
|
|
351
|
+
}
|
|
352
|
+
if (/^StartLimitIntervalSec=/m.test(script)) {
|
|
353
|
+
addError(result, "L2 Linux: 'StartLimitIntervalSec=' requires systemd >= 230. Use 'StartLimitInterval=' (in [Service]) for systemd 228 compatibility.");
|
|
354
|
+
}
|
|
355
|
+
if (!/^StartLimitInterval=/m.test(script)) {
|
|
356
|
+
addError(result, "L2 Linux: missing rate-limit directive 'StartLimitInterval=' — required to throttle restart loops");
|
|
357
|
+
}
|
|
358
|
+
if (/^RandomizedDelaySec=/m.test(script)) {
|
|
359
|
+
addWarning(result, "L2 Linux: 'RandomizedDelaySec=' requires systemd >= 229 and is ignored on 228. Drop it unless cross-host trigger jitter is genuinely needed.");
|
|
360
|
+
}
|
|
361
|
+
if (!script.includes("NEXUS_SERVICE_NAME=")) {
|
|
362
|
+
addError(result, "L2 Linux: env file does not set NEXUS_SERVICE_NAME — L1 cannot redirect its own logs");
|
|
363
|
+
}
|
|
364
|
+
if (!/^TimeoutStopSec=\S+/m.test(script)) {
|
|
365
|
+
addError(result, "L2 Linux: missing TimeoutStopSec grace period — L1 may be killed before partial timeout records are posted");
|
|
366
|
+
}
|
|
367
|
+
return result;
|
|
368
|
+
}
|
|
369
|
+
function validateL2Aix(script) {
|
|
370
|
+
const result = createResult();
|
|
371
|
+
// Shebang
|
|
372
|
+
if (!script.startsWith("#!/bin/ksh")) {
|
|
373
|
+
addError(result, "L2 AIX: missing #!/bin/ksh shebang");
|
|
374
|
+
}
|
|
375
|
+
// Shell safety flags — ksh88 has no `pipefail`.
|
|
376
|
+
if (!/^set -u\b/m.test(script)) {
|
|
377
|
+
addError(result, "L2 AIX: missing 'set -u' safety flag");
|
|
378
|
+
}
|
|
379
|
+
// Named flag argument parser
|
|
380
|
+
if (!script.includes("--service-name")) {
|
|
381
|
+
addError(result, "L2 AIX: missing --service-name flag in argument parser");
|
|
382
|
+
}
|
|
383
|
+
// Composition default variables — the SRC wrapper uses integer seconds.
|
|
384
|
+
const requiredVars = [
|
|
385
|
+
"BUFFER_TIME_SECONDS",
|
|
386
|
+
"TIMEOUT_SECONDS",
|
|
387
|
+
"ICEBERG_ENDPOINT",
|
|
388
|
+
"AGENT_ID",
|
|
389
|
+
"AUTH_TOKEN",
|
|
390
|
+
"ORG_ID",
|
|
391
|
+
"MAX_PARALLEL",
|
|
392
|
+
];
|
|
393
|
+
for (const v of requiredVars) {
|
|
394
|
+
if (!script.includes(`${v}=`)) {
|
|
395
|
+
addError(result, `L2 AIX: missing variable ${v}`);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
// L1 body embedding — same heredoc marker as Linux
|
|
399
|
+
if (!script.includes("NEXUS_L1_SCRIPT_BOUNDARY")) {
|
|
400
|
+
addError(result, "L2 AIX: missing NEXUS_L1_SCRIPT_BOUNDARY heredoc — L1 not embedded");
|
|
401
|
+
}
|
|
402
|
+
// Env file — must write all NEXUS_* vars so L1 can read them at runtime
|
|
403
|
+
for (const envVar of NEXUS_ENV_VARS) {
|
|
404
|
+
if (!script.includes(`${envVar}=`)) {
|
|
405
|
+
addError(result, `L2 AIX: env file does not set ${envVar} — L1 will fail to read it`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
if (!script.includes("NEXUS_SERVICE_NAME=")) {
|
|
409
|
+
addError(result, "L2 AIX: env file does not set NEXUS_SERVICE_NAME — L1 cannot redirect its own logs");
|
|
410
|
+
}
|
|
411
|
+
// ── SRC subsystem registration ─────────────────────────────────────────
|
|
412
|
+
// SRC wants a daemon that never exits; L2 registers the ksh wrapper as a
|
|
413
|
+
// subsystem and boots it from inittab.
|
|
414
|
+
if (!script.includes("mkssys")) {
|
|
415
|
+
addError(result, "L2 AIX: missing 'mkssys' — SRC subsystem is never registered");
|
|
416
|
+
}
|
|
417
|
+
if (!script.includes("mkitab")) {
|
|
418
|
+
addError(result, "L2 AIX: missing 'mkitab' — subsystem won't start at boot");
|
|
419
|
+
}
|
|
420
|
+
if (!script.includes("startsrc")) {
|
|
421
|
+
addWarning(result, "L2 AIX: missing 'startsrc' — subsystem won't begin immediately");
|
|
422
|
+
}
|
|
423
|
+
// ── Teardown of previous / same-name deployments ────────────────────────
|
|
424
|
+
// Without these the old wrapper daemon keeps running and spawning L1 in
|
|
425
|
+
// parallel with the new deployment (the AIX analog of the Windows
|
|
426
|
+
// Stop-NexusModScript teardown).
|
|
427
|
+
if (!script.includes("stopsrc")) {
|
|
428
|
+
addError(result, "L2 AIX: missing 'stopsrc' — previous subsystem won't be stopped on redeploy");
|
|
429
|
+
}
|
|
430
|
+
if (!script.includes("rmssys")) {
|
|
431
|
+
addError(result, "L2 AIX: missing 'rmssys' — previous SRC subsystem definition will leak in the ODM");
|
|
432
|
+
}
|
|
433
|
+
if (!script.includes("rmitab")) {
|
|
434
|
+
addError(result, "L2 AIX: missing 'rmitab' — previous inittab boot entry will leak");
|
|
435
|
+
}
|
|
436
|
+
// ── Persistent wrapper daemon (the actual SRC-managed process) ──────────
|
|
437
|
+
// It must never exit (while loop), trap SIGTERM for a clean stopsrc, and
|
|
438
|
+
// enforce the per-run timeout by killing the L1 child (watchdog).
|
|
439
|
+
if (!/\bwhile\s+(true|:)\b/.test(script)) {
|
|
440
|
+
addError(result, "L2 AIX: wrapper daemon missing 'while true' loop — script would exit and SRC would mark it inoperative");
|
|
441
|
+
}
|
|
442
|
+
if (!script.includes("trap")) {
|
|
443
|
+
addError(result, "L2 AIX: wrapper daemon missing 'trap' — stopsrc would wait the full -w window before SIGKILL");
|
|
444
|
+
}
|
|
445
|
+
if (!/\bkill\b/.test(script)) {
|
|
446
|
+
addError(result, "L2 AIX: wrapper daemon missing 'kill' — the per-run timeout watchdog cannot force-stop a hung L1");
|
|
447
|
+
}
|
|
448
|
+
// ksh88 incompatibilities
|
|
449
|
+
for (const err of detectKsh88Incompatibilities(script, "L2 AIX")) {
|
|
450
|
+
addError(result, err);
|
|
451
|
+
}
|
|
452
|
+
return result;
|
|
453
|
+
}
|
|
454
|
+
/**
|
|
455
|
+
* Detect bash/ksh93-only constructs that AIX's default /bin/ksh (ksh88) rejects.
|
|
456
|
+
* This catches the class of bugs that `ksh -n` on a dev box misses, because dev
|
|
457
|
+
* `ksh` is almost always ksh93 (and macOS ships ksh93), which happily parses
|
|
458
|
+
* here-strings, process substitution, indirect/substring expansion, etc.
|
|
459
|
+
*
|
|
460
|
+
* Comments are stripped first so explanatory prose mentioning a construct
|
|
461
|
+
* doesn't false-positive. The checks scan code only.
|
|
462
|
+
*/
|
|
463
|
+
function detectKsh88Incompatibilities(script, label) {
|
|
464
|
+
const errors = [];
|
|
465
|
+
// Strip full-line and trailing comments (heuristic — good enough for token
|
|
466
|
+
// presence detection; avoids the obvious comment false-positives).
|
|
467
|
+
const code = script
|
|
468
|
+
.split("\n")
|
|
469
|
+
.map((line) => line.replace(/(^|\s)#.*$/, "$1"))
|
|
470
|
+
.join("\n");
|
|
471
|
+
const checks = [
|
|
472
|
+
{ re: /<<</, msg: "here-strings ('<<<') — use 'print -r -- \"$x\" | cmd'" },
|
|
473
|
+
{ re: /[<>]\(/, msg: "process substitution ('<(...)'/'>(...)') — stage to a temp file and redirect" },
|
|
474
|
+
{ re: /\$\{!/, msg: "indirect expansion ('${!var}') — use eval to read the named variable" },
|
|
475
|
+
// Offset form is `${var:N}`, `${var:N:M}` or `${var: -N}` (space before the
|
|
476
|
+
// minus). NOT `${var:-default}` (no space) — that's a valid ksh88 default.
|
|
477
|
+
{ re: /\$\{[A-Za-z_][A-Za-z0-9_]*:( +-?[0-9]+|[0-9]+)(:-?[0-9]+)?\}/, msg: "substring offset ('${var:n:m}'/'${var: -n}') — use prefix/suffix removal or sed" },
|
|
478
|
+
{ re: /\bwait -n\b/, msg: "'wait -n' — use a batch concurrency gate" },
|
|
479
|
+
{ re: /(^|;)\s*local\s+\w/m, msg: "'local' — use 'typeset' for function-local vars" },
|
|
480
|
+
{ re: /&>/, msg: "'&>' redirect — use '>file 2>&1'" },
|
|
481
|
+
{ re: /\bmapfile\b|\breadarray\b/, msg: "'mapfile'/'readarray' — read with a while loop" },
|
|
482
|
+
{ re: /\bprintf\s+-v\b/, msg: "'printf -v' — capture with command substitution" },
|
|
483
|
+
// These are the nastiest failures of the lot: ksh88 simply doesn't define
|
|
484
|
+
// them, so under the `set -u` L1 inherits, the *first* reference aborts the
|
|
485
|
+
// collector mid-run. It emits no result record and the module silently
|
|
486
|
+
// shows up as "No output from nexus_collect_<name>".
|
|
487
|
+
{ re: /\$\{?E?UID\b/, msg: "'$EUID'/'$UID' — undefined in ksh88; under 'set -u' the first reference aborts the collector. Use '$(id -u)'" },
|
|
488
|
+
{ re: /\$\{?BASH_[A-Z_]+/, msg: "a '$BASH_*' variable — undefined in ksh88; under 'set -u' the first reference aborts the collector" },
|
|
489
|
+
];
|
|
490
|
+
for (const { re, msg } of checks) {
|
|
491
|
+
if (re.test(code)) {
|
|
492
|
+
errors.push(`${label}: uses ${msg} — unsupported in ksh88 (AIX /bin/ksh).`);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
return errors;
|
|
496
|
+
}
|
|
497
|
+
function validateL2Windows(script) {
|
|
498
|
+
const result = createResult();
|
|
499
|
+
// Param block
|
|
500
|
+
if (!script.includes("param(")) {
|
|
501
|
+
addError(result, "L2 Windows: missing param() block");
|
|
502
|
+
}
|
|
503
|
+
// ServiceName is the only required runtime param() parameter
|
|
504
|
+
// All other config values are baked as variable assignments at composition time
|
|
505
|
+
const requiredVars = [
|
|
506
|
+
"ServiceName",
|
|
507
|
+
"BufferTimeSeconds",
|
|
508
|
+
"TimeoutSeconds",
|
|
509
|
+
"IcebergEndpoint",
|
|
510
|
+
"AgentId",
|
|
511
|
+
"AuthToken",
|
|
512
|
+
"OrgId",
|
|
513
|
+
];
|
|
514
|
+
for (const v of requiredVars) {
|
|
515
|
+
if (!script.includes(`$${v}`)) {
|
|
516
|
+
addError(result, `L2 Windows: missing variable $${v}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
// L1 body embedding — PowerShell here-string
|
|
520
|
+
if (!script.includes("@'") || !script.includes("'@")) {
|
|
521
|
+
addError(result, "L2 Windows: missing here-string markers — L1 not embedded");
|
|
522
|
+
}
|
|
523
|
+
// Here-string terminators — must conform to PowerShell 5.1 rules
|
|
524
|
+
const terminatorErrors = validateHereStringTerminators(script);
|
|
525
|
+
for (const err of terminatorErrors) {
|
|
526
|
+
addError(result, `L2 Windows: ${err}`);
|
|
527
|
+
}
|
|
528
|
+
// Mixed line endings — PowerShell 5.1 can fail with mixed \r\n and \n
|
|
529
|
+
const hasCRLF = script.includes("\r\n");
|
|
530
|
+
const bareLF = (script.match(/(?<!\r)\n/g) || []).length;
|
|
531
|
+
if (hasCRLF && bareLF > 0) {
|
|
532
|
+
addError(result, `L2 Windows: mixed line endings — ${bareLF} bare LF lines mixed with CRLF`);
|
|
533
|
+
}
|
|
534
|
+
// Env file — must write all NEXUS_* vars
|
|
535
|
+
for (const envVar of NEXUS_ENV_VARS) {
|
|
536
|
+
if (!script.includes(`${envVar}=`)) {
|
|
537
|
+
addError(result, `L2 Windows: env.conf does not set ${envVar} — L1 will fail to read it`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
if (!script.includes("NEXUS_TIMEOUT_SECONDS=")) {
|
|
541
|
+
addError(result, "L2 Windows: env.conf does not set NEXUS_TIMEOUT_SECONDS — L1 cannot stop runspaces gracefully before the wrapper hard timeout");
|
|
542
|
+
}
|
|
543
|
+
// Wrapper script — loads env and runs L1
|
|
544
|
+
if (!script.includes("wrapper.ps1")) {
|
|
545
|
+
addError(result, "L2 Windows: missing wrapper.ps1 creation — env vars won't be loaded before L1 runs");
|
|
546
|
+
}
|
|
547
|
+
// SetEnvironmentVariable in wrapper — the mechanism that feeds env vars to L1
|
|
548
|
+
if (!script.includes("SetEnvironmentVariable")) {
|
|
549
|
+
addError(result, "L2 Windows: wrapper missing SetEnvironmentVariable — env vars won't propagate to L1 process");
|
|
550
|
+
}
|
|
551
|
+
// Scheduled task
|
|
552
|
+
if (!script.includes("Register-ScheduledTask")) {
|
|
553
|
+
addError(result, "L2 Windows: missing Register-ScheduledTask");
|
|
554
|
+
}
|
|
555
|
+
// Teardown of previous / same-name deployments — must kill the running
|
|
556
|
+
// wrapper.ps1 + l1-script.ps1 processes, not just unregister the task.
|
|
557
|
+
// Without these markers the L2 only does a soft unregister and orphans the
|
|
558
|
+
// previous wrapper's `while ($true)` loop, which keeps spawning L1 in
|
|
559
|
+
// parallel with the new deployment.
|
|
560
|
+
if (!script.includes("Stop-NexusModScript")) {
|
|
561
|
+
addError(result, "L2 Windows: missing Stop-NexusModScript helper — teardown of previous deployment is incomplete");
|
|
562
|
+
}
|
|
563
|
+
// Helper must accept both bare service names and the prefixed task name
|
|
564
|
+
// returned in the L2 output JSON's 'service_id' field. Without this
|
|
565
|
+
// normalisation an orchestrator round-tripping service_id back into
|
|
566
|
+
// -PreviousServiceId silently doubles the prefix and matches nothing.
|
|
567
|
+
if (!script.includes('StartsWith("NexusModScript-")')) {
|
|
568
|
+
addError(result, "L2 Windows: Stop-NexusModScript missing prefix normalisation — callers passing the JSON 'service_id' value will silently miss every cleanup target");
|
|
569
|
+
}
|
|
570
|
+
if (!script.includes("Stop-ScheduledTask")) {
|
|
571
|
+
addError(result, "L2 Windows: missing Stop-ScheduledTask — previous task instances will keep running after Unregister-ScheduledTask");
|
|
572
|
+
}
|
|
573
|
+
if (!script.includes("Win32_Process")) {
|
|
574
|
+
addError(result, "L2 Windows: missing Win32_Process lookup — previous wrapper.ps1 / l1-script.ps1 processes will not be killed on redeploy");
|
|
575
|
+
}
|
|
576
|
+
// Error handling
|
|
577
|
+
if (!script.includes('$ErrorActionPreference = "Stop"')) {
|
|
578
|
+
addWarning(result, "L2 Windows: ErrorActionPreference not set to Stop — errors may be silently ignored");
|
|
579
|
+
}
|
|
580
|
+
return result;
|
|
581
|
+
}
|
|
582
|
+
export function validateL2(script, osFamily) {
|
|
583
|
+
log.info("Validating L2 script", { osFamily });
|
|
584
|
+
const placeholders = checkUnreplacedPlaceholders(script, "L2");
|
|
585
|
+
const structural = osFamily === "linux"
|
|
586
|
+
? validateL2Linux(script)
|
|
587
|
+
: osFamily === "aix"
|
|
588
|
+
? validateL2Aix(script)
|
|
589
|
+
: validateL2Windows(script);
|
|
590
|
+
const result = mergeResults(placeholders, structural);
|
|
591
|
+
if (result.valid) {
|
|
592
|
+
log.info("L2 validation passed", { warnings: result.warnings.length });
|
|
593
|
+
}
|
|
594
|
+
else {
|
|
595
|
+
log.error("L2 validation failed", {
|
|
596
|
+
errorCount: result.errors.length,
|
|
597
|
+
errors: result.errors,
|
|
598
|
+
});
|
|
599
|
+
}
|
|
600
|
+
for (const w of result.warnings) {
|
|
601
|
+
log.debug("L2 validation warning", { warning: w });
|
|
602
|
+
}
|
|
603
|
+
return result;
|
|
604
|
+
}
|
|
605
|
+
// ── Parameter flow: L2 → env file → L1 ────────────────────────────────────
|
|
606
|
+
export function validateParameterFlow(l1Script, l2Script, osFamily, orgId) {
|
|
607
|
+
log.info("Validating parameter flow L2 → L1", { osFamily });
|
|
608
|
+
const result = createResult();
|
|
609
|
+
// 1. Every env var that L2 writes must be consumed by L1
|
|
610
|
+
for (const envVar of NEXUS_ENV_VARS) {
|
|
611
|
+
const l2Writes = l2Script.includes(`${envVar}=`);
|
|
612
|
+
const l1Reads = osFamily === "windows"
|
|
613
|
+
? l1Script.includes(`$env:${envVar}`)
|
|
614
|
+
: l1Script.includes(envVar);
|
|
615
|
+
if (l2Writes && !l1Reads) {
|
|
616
|
+
addWarning(result, `Parameter flow: L2 sets ${envVar} but L1 never reads it`);
|
|
617
|
+
}
|
|
618
|
+
if (!l2Writes && l1Reads) {
|
|
619
|
+
addError(result, `Parameter flow: L1 reads ${envVar} but L2 never writes it to env file — will be empty at runtime`);
|
|
620
|
+
}
|
|
621
|
+
if (!l2Writes && !l1Reads) {
|
|
622
|
+
addError(result, `Parameter flow: ${envVar} is neither written by L2 nor read by L1 — broken contract`);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
// 2. ORG_ID is baked directly into L1 (not via env), verify it was substituted
|
|
626
|
+
if (osFamily === "linux" || osFamily === "aix") {
|
|
627
|
+
// In L1 Linux/AIX, ORG_ID appears as a literal string in the
|
|
628
|
+
// _push_to_iceberg envelope. Check it's not still a placeholder and not empty.
|
|
629
|
+
if (l1Script.includes("{{ORG_ID}}")) {
|
|
630
|
+
addError(result, "Parameter flow: ORG_ID was not substituted in L1 — still a placeholder");
|
|
631
|
+
}
|
|
632
|
+
// Verify actual orgId value appears in the script
|
|
633
|
+
if (!l1Script.includes(`'org_id': '${orgId}'`) &&
|
|
634
|
+
!l1Script.includes(`"org_id":"${orgId}"`) &&
|
|
635
|
+
!l1Script.includes(`--arg cid "${orgId}"`)) {
|
|
636
|
+
addWarning(result, `Parameter flow: ORG_ID value '${orgId}' not found baked into L1 ${osFamily} envelope`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
else {
|
|
640
|
+
if (l1Script.includes("{{ORG_ID}}")) {
|
|
641
|
+
addError(result, "Parameter flow: ORG_ID was not substituted in L1 — still a placeholder");
|
|
642
|
+
}
|
|
643
|
+
if (!l1Script.includes(`$OrgId = "${orgId}"`)) {
|
|
644
|
+
addWarning(result, `Parameter flow: ORG_ID value '${orgId}' not found baked into L1 Windows $OrgId variable`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
// 3. L2 must embed the L1 body — without this the whole chain is broken.
|
|
648
|
+
// Linux & AIX use the NEXUS_L1_SCRIPT_BOUNDARY heredoc; Windows a here-string.
|
|
649
|
+
if (osFamily === "windows") {
|
|
650
|
+
if (!l2Script.includes("$l1Content = @'")) {
|
|
651
|
+
addError(result, "Parameter flow: L2 does not embed L1 script — deployment will have no collection logic");
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
else {
|
|
655
|
+
if (!l2Script.includes("NEXUS_L1_SCRIPT_BOUNDARY")) {
|
|
656
|
+
addError(result, "Parameter flow: L2 does not embed L1 script — deployment will have no collection logic");
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
// 4. L2 env file must be loaded before L1 runs.
|
|
660
|
+
// Linux: systemd EnvironmentFile=. Windows: wrapper SetEnvironmentVariable.
|
|
661
|
+
// AIX: the SRC wrapper daemon sources the env file (`. <file>`) before exec'ing L1.
|
|
662
|
+
if (osFamily === "linux") {
|
|
663
|
+
if (!l2Script.includes("EnvironmentFile=")) {
|
|
664
|
+
addError(result, "Parameter flow: systemd service has no EnvironmentFile — L1 will run without env vars");
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
else if (osFamily === "aix") {
|
|
668
|
+
// Match a POSIX `.`/`source` of the env file in the wrapper daemon.
|
|
669
|
+
// The wrapper is written via an unquoted heredoc, so its runtime `$`
|
|
670
|
+
// appears escaped (`\$ENV_FILE`) in the composed L2 — allow the backslash.
|
|
671
|
+
if (!/(^|\s)(\.|source)\s+["']?\\?\$?\{?(INSTALL_DIR|ENV_FILE)/m.test(l2Script)) {
|
|
672
|
+
addError(result, "Parameter flow: AIX wrapper daemon doesn't source the env file — L1 will run without env vars");
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
if (!l2Script.includes("SetEnvironmentVariable")) {
|
|
677
|
+
addError(result, "Parameter flow: Windows wrapper doesn't call SetEnvironmentVariable — L1 will run without env vars");
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
if (result.valid) {
|
|
681
|
+
log.info("Parameter flow validation passed", {
|
|
682
|
+
warnings: result.warnings.length,
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
log.error("Parameter flow validation failed", {
|
|
687
|
+
errorCount: result.errors.length,
|
|
688
|
+
errors: result.errors,
|
|
689
|
+
});
|
|
690
|
+
}
|
|
691
|
+
for (const w of result.warnings) {
|
|
692
|
+
log.debug("Parameter flow warning", { warning: w });
|
|
693
|
+
}
|
|
694
|
+
return result;
|
|
695
|
+
}
|
|
696
|
+
// ── Shell syntax checks ────────────────────────────────────────────────────
|
|
697
|
+
function shellAvailable(cmd) {
|
|
698
|
+
try {
|
|
699
|
+
execSync(`which ${cmd}`, { stdio: "ignore" });
|
|
700
|
+
return true;
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
return false;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function writeTempFile(content, ext) {
|
|
707
|
+
const name = `nexus-validate-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
|
|
708
|
+
const path = join(tmpdir(), name);
|
|
709
|
+
writeFileSync(path, content, "utf-8");
|
|
710
|
+
return path;
|
|
711
|
+
}
|
|
712
|
+
function removeTempFile(path) {
|
|
713
|
+
try {
|
|
714
|
+
unlinkSync(path);
|
|
715
|
+
}
|
|
716
|
+
catch {
|
|
717
|
+
// best-effort cleanup
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
export async function syntaxCheckBash(script, label) {
|
|
721
|
+
return syntaxCheckShellLike(script, label, "bash");
|
|
722
|
+
}
|
|
723
|
+
export async function syntaxCheckKsh(script, label) {
|
|
724
|
+
// ⚠️ Dev/CI `ksh` is typically ksh93, which is more lenient than the AIX
|
|
725
|
+
// default ksh88. `ksh -n` catches gross syntax errors but won't flag every
|
|
726
|
+
// ksh88-only regression — real ksh88 safety comes from the hand-written
|
|
727
|
+
// templates and the structural ksh88 guards in validateL1Aix/validateL2Aix.
|
|
728
|
+
return syntaxCheckShellLike(script, label, "ksh");
|
|
729
|
+
}
|
|
730
|
+
async function syntaxCheckShellLike(script, label, interpreter) {
|
|
731
|
+
const result = createResult();
|
|
732
|
+
const tmpPath = writeTempFile(script, "sh");
|
|
733
|
+
try {
|
|
734
|
+
// Phase 1: `<interpreter> -n` for basic syntax validation
|
|
735
|
+
if (shellAvailable(interpreter)) {
|
|
736
|
+
log.debug(`Running ${interpreter} -n syntax check`, { label, tmpPath });
|
|
737
|
+
try {
|
|
738
|
+
execSync(`${interpreter} -n "${tmpPath}"`, {
|
|
739
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
740
|
+
timeout: 15_000,
|
|
741
|
+
});
|
|
742
|
+
log.info(`${interpreter} -n syntax check passed`, { label });
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
const stderr = err instanceof Error && "stderr" in err
|
|
746
|
+
? String(err.stderr)
|
|
747
|
+
: String(err);
|
|
748
|
+
const cleanErr = stderr.replace(new RegExp(tmpPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), label);
|
|
749
|
+
addError(result, `${label}: ${interpreter} syntax error:\n${cleanErr.trim()}`);
|
|
750
|
+
log.error(`${interpreter} -n syntax check failed`, { label, error: cleanErr.trim() });
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
else {
|
|
754
|
+
addWarning(result, `${label}: ${interpreter} not found — skipping ${interpreter} -n check`);
|
|
755
|
+
}
|
|
756
|
+
// Phase 2: shellcheck for deeper lint (SC errors = errors, SC warnings = warnings)
|
|
757
|
+
// Uses bundled platform-specific binary, falls back to system binary
|
|
758
|
+
log.debug("Running shellcheck", { label, tmpPath });
|
|
759
|
+
let shellcheckOutput = null;
|
|
760
|
+
const shellcheckBin = getShellcheckPath();
|
|
761
|
+
if (shellcheckBin !== null) {
|
|
762
|
+
try {
|
|
763
|
+
const buf = execSync(`"${shellcheckBin}" -f json -S error "${tmpPath}"`, {
|
|
764
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
765
|
+
timeout: 30_000,
|
|
766
|
+
});
|
|
767
|
+
shellcheckOutput = buf.toString("utf8");
|
|
768
|
+
}
|
|
769
|
+
catch (scErr) {
|
|
770
|
+
// shellcheck exits non-zero when it finds issues — stdout still has JSON
|
|
771
|
+
if (scErr instanceof Error && "stdout" in scErr) {
|
|
772
|
+
shellcheckOutput = String(scErr.stdout);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (shellcheckOutput !== null) {
|
|
777
|
+
let findings = [];
|
|
778
|
+
try {
|
|
779
|
+
findings = JSON.parse(shellcheckOutput);
|
|
780
|
+
}
|
|
781
|
+
catch {
|
|
782
|
+
addWarning(result, `${label}: shellcheck returned non-JSON output`);
|
|
783
|
+
}
|
|
784
|
+
const errors = findings.filter((f) => f.level === "error");
|
|
785
|
+
const warnings = findings.filter((f) => f.level === "warning");
|
|
786
|
+
for (const f of errors) {
|
|
787
|
+
addError(result, `${label}: shellcheck SC${f.code} (line ${f.line}:${f.column}): ${f.message}`);
|
|
788
|
+
}
|
|
789
|
+
for (const f of warnings) {
|
|
790
|
+
addWarning(result, `${label}: shellcheck SC${f.code} (line ${f.line}:${f.column}): ${f.message}`);
|
|
791
|
+
}
|
|
792
|
+
if (errors.length === 0) {
|
|
793
|
+
log.info("shellcheck passed", { label, warnings: warnings.length });
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
log.error("shellcheck found errors", { label, errorCount: errors.length, warningCount: warnings.length });
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
addWarning(result, `${label}: shellcheck not available — skipping deep lint`);
|
|
801
|
+
log.info("shellcheck not available, skipping", { label });
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
finally {
|
|
805
|
+
removeTempFile(tmpPath);
|
|
806
|
+
}
|
|
807
|
+
return result;
|
|
808
|
+
}
|
|
809
|
+
export async function syntaxCheckPowerShell(script, label) {
|
|
810
|
+
const result = createResult();
|
|
811
|
+
if (!shellAvailable("pwsh")) {
|
|
812
|
+
addWarning(result, `${label}: pwsh not found — skipping PowerShell syntax check`);
|
|
813
|
+
log.info("PowerShell syntax check skipped — pwsh not available", { label });
|
|
814
|
+
return result;
|
|
815
|
+
}
|
|
816
|
+
const tmpPath = writeTempFile(script, "ps1");
|
|
817
|
+
// Write a separate validator script so shell doesn't eat PowerShell's $ variables
|
|
818
|
+
const validatorScript = [
|
|
819
|
+
`$null = [System.Management.Automation.Language.Parser]::ParseFile('${tmpPath}', [ref]$null, [ref]$errors)`,
|
|
820
|
+
`if ($errors.Count -gt 0) { $errors | ForEach-Object { Write-Error $_.ToString() }; exit 1 }`,
|
|
821
|
+
].join("\n");
|
|
822
|
+
const validatorPath = writeTempFile(validatorScript, "ps1");
|
|
823
|
+
try {
|
|
824
|
+
log.debug("Running pwsh syntax check", { label, tmpPath });
|
|
825
|
+
execSync(`pwsh -NoProfile -NonInteractive -File "${validatorPath}"`, {
|
|
826
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
827
|
+
timeout: 30_000,
|
|
828
|
+
});
|
|
829
|
+
log.info("PowerShell syntax check passed", { label });
|
|
830
|
+
}
|
|
831
|
+
catch (err) {
|
|
832
|
+
const stderr = err instanceof Error && "stderr" in err
|
|
833
|
+
? String(err.stderr)
|
|
834
|
+
: String(err);
|
|
835
|
+
const cleanErr = stderr
|
|
836
|
+
.replace(new RegExp(tmpPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), label)
|
|
837
|
+
.replace(new RegExp(validatorPath.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "(validator)");
|
|
838
|
+
addError(result, `${label}: PowerShell syntax error:\n${cleanErr.trim()}`);
|
|
839
|
+
log.error("PowerShell syntax check failed", { label, error: cleanErr.trim() });
|
|
840
|
+
}
|
|
841
|
+
finally {
|
|
842
|
+
removeTempFile(tmpPath);
|
|
843
|
+
removeTempFile(validatorPath);
|
|
844
|
+
}
|
|
845
|
+
return result;
|
|
846
|
+
}
|
|
847
|
+
async function syntaxCheck(script, osFamily, label) {
|
|
848
|
+
if (osFamily === "linux") {
|
|
849
|
+
return syntaxCheckBash(script, label);
|
|
850
|
+
}
|
|
851
|
+
else if (osFamily === "aix") {
|
|
852
|
+
return syntaxCheckKsh(script, label);
|
|
853
|
+
}
|
|
854
|
+
else {
|
|
855
|
+
return syntaxCheckPowerShell(script, label);
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
// ── L2 structural dry-run ──────────────────────────────────────────────────
|
|
859
|
+
function extractL1FromL2Linux(l2Script) {
|
|
860
|
+
// L1 is embedded between heredoc markers:
|
|
861
|
+
// cat > "$SCRIPT_PATH" << 'NEXUS_L1_SCRIPT_BOUNDARY'
|
|
862
|
+
// ...l1 content...
|
|
863
|
+
// NEXUS_L1_SCRIPT_BOUNDARY
|
|
864
|
+
const startMarker = "NEXUS_L1_SCRIPT_BOUNDARY";
|
|
865
|
+
const startIdx = l2Script.indexOf(startMarker);
|
|
866
|
+
if (startIdx === -1)
|
|
867
|
+
return null;
|
|
868
|
+
// Find the first newline after the opening marker line
|
|
869
|
+
const contentStart = l2Script.indexOf("\n", startIdx) + 1;
|
|
870
|
+
if (contentStart === 0)
|
|
871
|
+
return null;
|
|
872
|
+
// Find the closing marker (standalone on its own line)
|
|
873
|
+
const closingPattern = new RegExp(`^${startMarker}$`, "m");
|
|
874
|
+
const remaining = l2Script.slice(contentStart);
|
|
875
|
+
const closingMatch = closingPattern.exec(remaining);
|
|
876
|
+
if (!closingMatch)
|
|
877
|
+
return null;
|
|
878
|
+
return remaining.slice(0, closingMatch.index);
|
|
879
|
+
}
|
|
880
|
+
function extractL1FromL2Windows(l2Script) {
|
|
881
|
+
// L1 is embedded in a PowerShell here-string:
|
|
882
|
+
// $l1Content = @'
|
|
883
|
+
// ...l1 content...
|
|
884
|
+
// '@
|
|
885
|
+
const startMarker = "$l1Content = @'";
|
|
886
|
+
const startIdx = l2Script.indexOf(startMarker);
|
|
887
|
+
if (startIdx === -1)
|
|
888
|
+
return null;
|
|
889
|
+
const contentStart = l2Script.indexOf("\n", startIdx) + 1;
|
|
890
|
+
if (contentStart === 0)
|
|
891
|
+
return null;
|
|
892
|
+
// Closing here-string: '@ at the start of a line
|
|
893
|
+
const remaining = l2Script.slice(contentStart);
|
|
894
|
+
const closingMatch = /^'@/m.exec(remaining);
|
|
895
|
+
if (!closingMatch)
|
|
896
|
+
return null;
|
|
897
|
+
return remaining.slice(0, closingMatch.index);
|
|
898
|
+
}
|
|
899
|
+
export async function validateL2StructuralDryRun(l1Script, l2Script, osFamily, moduleNames) {
|
|
900
|
+
log.info("Running L2 structural dry-run", { osFamily });
|
|
901
|
+
const result = createResult();
|
|
902
|
+
// 1. Extract the L1 body embedded inside L2.
|
|
903
|
+
// AIX uses the same NEXUS_L1_SCRIPT_BOUNDARY heredoc as Linux.
|
|
904
|
+
const extractedL1 = osFamily === "windows"
|
|
905
|
+
? extractL1FromL2Windows(l2Script)
|
|
906
|
+
: extractL1FromL2Linux(l2Script);
|
|
907
|
+
if (extractedL1 === null) {
|
|
908
|
+
addError(result, "L2 dry-run: could not extract embedded L1 body from L2 script — " +
|
|
909
|
+
(osFamily === "windows"
|
|
910
|
+
? "here-string @'...'@ block not found"
|
|
911
|
+
: "NEXUS_L1_SCRIPT_BOUNDARY heredoc not found"));
|
|
912
|
+
log.error("L2 dry-run: L1 extraction failed", { osFamily });
|
|
913
|
+
return result;
|
|
914
|
+
}
|
|
915
|
+
log.debug("Extracted L1 from L2", {
|
|
916
|
+
extractedLength: extractedL1.length,
|
|
917
|
+
standaloneLength: l1Script.length,
|
|
918
|
+
});
|
|
919
|
+
// 2. Verify extracted L1 matches the standalone L1 we composed
|
|
920
|
+
const extractedTrimmed = extractedL1.trim();
|
|
921
|
+
const standaloneTrimmed = l1Script.trim();
|
|
922
|
+
if (extractedTrimmed !== standaloneTrimmed) {
|
|
923
|
+
// Find where they diverge for a useful error
|
|
924
|
+
let divergeIdx = 0;
|
|
925
|
+
const minLen = Math.min(extractedTrimmed.length, standaloneTrimmed.length);
|
|
926
|
+
while (divergeIdx < minLen && extractedTrimmed[divergeIdx] === standaloneTrimmed[divergeIdx]) {
|
|
927
|
+
divergeIdx++;
|
|
928
|
+
}
|
|
929
|
+
const contextStart = Math.max(0, divergeIdx - 40);
|
|
930
|
+
const extractedSnippet = extractedTrimmed.slice(contextStart, divergeIdx + 40);
|
|
931
|
+
const standaloneSnippet = standaloneTrimmed.slice(contextStart, divergeIdx + 40);
|
|
932
|
+
addError(result, `L2 dry-run: embedded L1 does not match standalone L1 — ` +
|
|
933
|
+
`diverges at byte ${divergeIdx}. ` +
|
|
934
|
+
`Embedded: "...${extractedSnippet}..." vs Standalone: "...${standaloneSnippet}..."`);
|
|
935
|
+
log.error("L2 dry-run: L1 content mismatch", {
|
|
936
|
+
divergeIdx,
|
|
937
|
+
extractedLength: extractedTrimmed.length,
|
|
938
|
+
standaloneLength: standaloneTrimmed.length,
|
|
939
|
+
});
|
|
940
|
+
}
|
|
941
|
+
else {
|
|
942
|
+
log.info("L2 dry-run: embedded L1 matches standalone L1");
|
|
943
|
+
}
|
|
944
|
+
// 3. Syntax-check the extracted L1 (proves the embedding didn't corrupt it)
|
|
945
|
+
const extractedSyntax = await syntaxCheck(extractedTrimmed, osFamily, "L2-embedded-L1");
|
|
946
|
+
for (const e of extractedSyntax.errors) {
|
|
947
|
+
addError(result, `L2 dry-run: ${e}`);
|
|
948
|
+
}
|
|
949
|
+
for (const w of extractedSyntax.warnings) {
|
|
950
|
+
addWarning(result, `L2 dry-run: ${w}`);
|
|
951
|
+
}
|
|
952
|
+
// 4. Validate the extracted L1 has the expected structure
|
|
953
|
+
// (catches cases where the heredoc boundary sliced wrong)
|
|
954
|
+
if (osFamily === "linux" || osFamily === "aix") {
|
|
955
|
+
const expectedShebang = osFamily === "aix" ? "#!/bin/ksh" : "#!/bin/bash";
|
|
956
|
+
if (!extractedTrimmed.startsWith(expectedShebang)) {
|
|
957
|
+
addError(result, `L2 dry-run: extracted L1 does not start with ${expectedShebang} shebang — heredoc boundary likely wrong`);
|
|
958
|
+
}
|
|
959
|
+
if (!extractedTrimmed.includes("run_all_modules")) {
|
|
960
|
+
addError(result, "L2 dry-run: extracted L1 missing run_all_modules entry point");
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
else {
|
|
964
|
+
if (!extractedTrimmed.includes("$ErrorActionPreference")) {
|
|
965
|
+
addError(result, "L2 dry-run: extracted L1 missing $ErrorActionPreference");
|
|
966
|
+
}
|
|
967
|
+
if (!extractedTrimmed.includes("Invoke-AllModules")) {
|
|
968
|
+
addError(result, "L2 dry-run: extracted L1 missing Invoke-AllModules entry point");
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
// 5. Verify each module's collector function survived embedding.
|
|
972
|
+
// The Windows name is derived the same way the L1 template derives it:
|
|
973
|
+
// module 'tomcat' -> 'Invoke-NexusCollect_Tomcat'. Checking the bare
|
|
974
|
+
// 'Invoke-NexusCollect_' prefix would let a single collector satisfy every
|
|
975
|
+
// module in the list.
|
|
976
|
+
for (const mod of moduleNames) {
|
|
977
|
+
const fnName = osFamily === "windows"
|
|
978
|
+
? `Invoke-NexusCollect_${mod.charAt(0).toUpperCase()}${mod.slice(1)}`
|
|
979
|
+
: `nexus_collect_${mod}`;
|
|
980
|
+
if (!extractedTrimmed.includes(fnName)) {
|
|
981
|
+
addError(result, `L2 dry-run: extracted L1 missing function '${fnName}' for module '${mod}'`);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
// 6. Verify L2 env file vars will match what the extracted L1 expects
|
|
985
|
+
for (const envVar of NEXUS_ENV_VARS) {
|
|
986
|
+
const l2Writes = l2Script.includes(`${envVar}=`);
|
|
987
|
+
const l1Reads = osFamily === "windows"
|
|
988
|
+
? extractedTrimmed.includes(`$env:${envVar}`)
|
|
989
|
+
: extractedTrimmed.includes(envVar);
|
|
990
|
+
if (!l2Writes && l1Reads) {
|
|
991
|
+
addError(result, `L2 dry-run: extracted L1 reads ${envVar} but L2 env file never writes it`);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
if (result.valid) {
|
|
995
|
+
log.info("L2 structural dry-run passed", { warnings: result.warnings.length });
|
|
996
|
+
}
|
|
997
|
+
else {
|
|
998
|
+
log.error("L2 structural dry-run failed", {
|
|
999
|
+
errorCount: result.errors.length,
|
|
1000
|
+
errors: result.errors,
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
return result;
|
|
1004
|
+
}
|
|
1005
|
+
export async function validateComposedScripts(params) {
|
|
1006
|
+
log.info("Running composed script validation", {
|
|
1007
|
+
osFamily: params.osFamily,
|
|
1008
|
+
modules: params.moduleNames,
|
|
1009
|
+
});
|
|
1010
|
+
// Phase 1: structural validation (synchronous)
|
|
1011
|
+
const l1Result = validateL1(params.l1Script, params.osFamily, params.moduleNames);
|
|
1012
|
+
const l2Result = validateL2(params.l2Script, params.osFamily);
|
|
1013
|
+
const flowResult = validateParameterFlow(params.l1Script, params.l2Script, params.osFamily, params.orgId);
|
|
1014
|
+
// Phase 2: shell syntax checks — bash -n + shellcheck / pwsh parse
|
|
1015
|
+
const [l1Syntax, l2Syntax] = await Promise.all([
|
|
1016
|
+
syntaxCheck(params.l1Script, params.osFamily, "L1"),
|
|
1017
|
+
syntaxCheck(params.l2Script, params.osFamily, "L2"),
|
|
1018
|
+
]);
|
|
1019
|
+
// Phase 3: L2 structural dry-run — extract L1 from L2, verify integrity
|
|
1020
|
+
const dryRunResult = await validateL2StructuralDryRun(params.l1Script, params.l2Script, params.osFamily, params.moduleNames);
|
|
1021
|
+
const combined = mergeResults(l1Result, l2Result, flowResult, l1Syntax, l2Syntax, dryRunResult);
|
|
1022
|
+
log.info("Composed script validation complete", {
|
|
1023
|
+
valid: combined.valid,
|
|
1024
|
+
errors: combined.errors.length,
|
|
1025
|
+
warnings: combined.warnings.length,
|
|
1026
|
+
});
|
|
1027
|
+
return combined;
|
|
1028
|
+
}
|
|
1029
|
+
//# sourceMappingURL=script-validator.js.map
|