@blogic-cz/agent-tools 0.14.61 → 0.15.1
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 +12 -3
- package/package.json +1 -1
- package/schemas/agent-tools.schema.json +17 -8
- package/src/config/loader.ts +38 -4
- package/src/config/types.ts +3 -2
- package/src/k8s-tool/index.ts +12 -11
- package/src/k8s-tool/security.ts +265 -49
- package/src/k8s-tool/service.ts +136 -16
- package/src/logs-tool/index.ts +1 -1
- package/src/logs-tool/service.ts +94 -47
- package/src/shared/prerequisites/driver-commands.ts +88 -0
- package/src/shared/prerequisites/guardian-entry.ts +49 -0
- package/src/shared/prerequisites/guardian.ts +157 -0
- package/src/shared/prerequisites/runtime.ts +703 -531
- package/src/shared/prerequisites/store.ts +715 -0
- package/src/shared/prerequisites/types.ts +0 -25
package/src/k8s-tool/service.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
|
|
1
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
2
4
|
import { Context, Effect, Layer, Option, Ref, Stream } from "effect";
|
|
3
5
|
|
|
@@ -16,7 +18,7 @@ import { resolveEnvTemplate } from "#shared/env-template";
|
|
|
16
18
|
import { isPrerequisiteRunError } from "#shared/prerequisites/errors";
|
|
17
19
|
import { runWithProfilePrerequisites } from "#shared/prerequisites/runtime";
|
|
18
20
|
import { buildApiProbeArgs } from "#shared/k8s-probe";
|
|
19
|
-
import { isKubectlCommandAllowed } from "./security";
|
|
21
|
+
import { isKubectlCommandAllowed, isSafeLogPath } from "./security";
|
|
20
22
|
|
|
21
23
|
export class K8sService extends Context.Service<
|
|
22
24
|
K8sService,
|
|
@@ -37,6 +39,16 @@ export class K8sService extends Context.Service<
|
|
|
37
39
|
CommandResult,
|
|
38
40
|
K8sContextError | K8sCommandError | K8sTimeoutError | K8sDangerousCommandError
|
|
39
41
|
>;
|
|
42
|
+
readonly runLogTail: (
|
|
43
|
+
pod: string,
|
|
44
|
+
basePath: string,
|
|
45
|
+
path: string,
|
|
46
|
+
lines: number,
|
|
47
|
+
profile?: string,
|
|
48
|
+
) => Effect.Effect<
|
|
49
|
+
CommandResult,
|
|
50
|
+
K8sContextError | K8sCommandError | K8sTimeoutError | K8sDangerousCommandError
|
|
51
|
+
>;
|
|
40
52
|
}
|
|
41
53
|
>()("@agent-tools/K8sService") {
|
|
42
54
|
static readonly layer = Layer.effect(
|
|
@@ -86,6 +98,10 @@ export class K8sService extends Context.Service<
|
|
|
86
98
|
|
|
87
99
|
const withKubeconfig = (command: string, kubeconfig: string | undefined) =>
|
|
88
100
|
kubeconfig ? `KUBECONFIG=${quoteShellArg(kubeconfig)} ${command}` : command;
|
|
101
|
+
const renderArg = (arg: string) =>
|
|
102
|
+
/^[A-Za-z0-9_./:=,@+-]+$/.test(arg) ? arg : quoteShellArg(arg);
|
|
103
|
+
const renderKubectlCommand = (context: string, argv: readonly string[]) =>
|
|
104
|
+
["kubectl", "--context", context, ...argv].map(renderArg).join(" ");
|
|
89
105
|
|
|
90
106
|
// Cache context by selected profile/cluster instead of a single default profile.
|
|
91
107
|
const contextRef = yield* Ref.make<Record<string, string>>({});
|
|
@@ -253,7 +269,7 @@ export class K8sService extends Context.Service<
|
|
|
253
269
|
});
|
|
254
270
|
|
|
255
271
|
const executeCommand = Effect.fn("K8sService.executeCommand")(function* (
|
|
256
|
-
|
|
272
|
+
argv: readonly string[],
|
|
257
273
|
profile?: string,
|
|
258
274
|
) {
|
|
259
275
|
const k8sConfig = yield* requireK8sConfig(profile);
|
|
@@ -275,9 +291,29 @@ export class K8sService extends Context.Service<
|
|
|
275
291
|
});
|
|
276
292
|
}
|
|
277
293
|
|
|
278
|
-
const fullCommand =
|
|
279
|
-
|
|
280
|
-
|
|
294
|
+
const fullCommand = renderKubectlCommand(context, argv);
|
|
295
|
+
const command = ChildProcess.make("kubectl", ["--context", context, ...argv], {
|
|
296
|
+
stdout: "pipe",
|
|
297
|
+
stderr: "pipe",
|
|
298
|
+
...(kubeconfig ? { env: { KUBECONFIG: kubeconfig }, extendEnv: true } : {}),
|
|
299
|
+
});
|
|
300
|
+
const resultOption = yield* Effect.scoped(
|
|
301
|
+
Effect.gen(function* () {
|
|
302
|
+
const process = yield* executor.spawn(command);
|
|
303
|
+
return yield* collectProcessOutput(process);
|
|
304
|
+
}),
|
|
305
|
+
).pipe(
|
|
306
|
+
Effect.timeoutOption(timeoutMs),
|
|
307
|
+
Effect.mapError(
|
|
308
|
+
(platformError) =>
|
|
309
|
+
new K8sCommandError({
|
|
310
|
+
message: `Command execution failed: ${String(platformError)}`,
|
|
311
|
+
command: fullCommand,
|
|
312
|
+
exitCode: -1,
|
|
313
|
+
stderr: undefined,
|
|
314
|
+
}),
|
|
315
|
+
),
|
|
316
|
+
);
|
|
281
317
|
|
|
282
318
|
if (Option.isNone(resultOption)) {
|
|
283
319
|
return yield* new K8sTimeoutError({
|
|
@@ -316,16 +352,18 @@ export class K8sService extends Context.Service<
|
|
|
316
352
|
) {
|
|
317
353
|
// Security: block dangerous commands before execution
|
|
318
354
|
const securityCheck = isKubectlCommandAllowed(cmd);
|
|
319
|
-
if (!securityCheck.allowed) {
|
|
355
|
+
if (!securityCheck.allowed || !securityCheck.argv) {
|
|
320
356
|
return yield* new K8sDangerousCommandError({
|
|
321
357
|
message: securityCheck.reason ?? "Command not allowed",
|
|
322
358
|
command: cmd,
|
|
323
|
-
verb: securityCheck.verb,
|
|
324
|
-
hint:
|
|
359
|
+
...(securityCheck.verb ? { verb: securityCheck.verb } : {}),
|
|
360
|
+
hint:
|
|
361
|
+
securityCheck.hint ??
|
|
362
|
+
"AI agents can only run read-only kubectl commands. For mutating operations, use kubectl directly or ask a human operator.",
|
|
325
363
|
});
|
|
326
364
|
}
|
|
327
365
|
|
|
328
|
-
const result = yield* executeCommand(
|
|
366
|
+
const result = yield* executeCommand(securityCheck.argv, profile);
|
|
329
367
|
if (result.exitCode !== 0) {
|
|
330
368
|
return yield* new K8sCommandError({
|
|
331
369
|
message: result.stderr ?? `kubectl exited with code ${result.exitCode}`,
|
|
@@ -345,20 +383,22 @@ export class K8sService extends Context.Service<
|
|
|
345
383
|
) {
|
|
346
384
|
// Security: block dangerous commands before execution (even dry-run)
|
|
347
385
|
const securityCheck = isKubectlCommandAllowed(cmd);
|
|
348
|
-
if (!securityCheck.allowed) {
|
|
386
|
+
if (!securityCheck.allowed || !securityCheck.argv) {
|
|
349
387
|
return yield* new K8sDangerousCommandError({
|
|
350
388
|
message: securityCheck.reason ?? "Command not allowed",
|
|
351
389
|
command: cmd,
|
|
352
|
-
verb: securityCheck.verb,
|
|
353
|
-
hint:
|
|
390
|
+
...(securityCheck.verb ? { verb: securityCheck.verb } : {}),
|
|
391
|
+
hint:
|
|
392
|
+
securityCheck.hint ??
|
|
393
|
+
"AI agents can only run read-only kubectl commands. For mutating operations, use kubectl directly or ask a human operator.",
|
|
354
394
|
});
|
|
355
395
|
}
|
|
356
396
|
|
|
357
397
|
const startTime = Date.now();
|
|
358
398
|
if (dryRun) {
|
|
359
399
|
const k8sConfig = yield* requireK8sConfig(profile);
|
|
360
|
-
const { context
|
|
361
|
-
const fullCommand =
|
|
400
|
+
const { context } = yield* resolveContext(profile, k8sConfig);
|
|
401
|
+
const fullCommand = renderKubectlCommand(context, securityCheck.argv);
|
|
362
402
|
return {
|
|
363
403
|
success: true,
|
|
364
404
|
command: fullCommand,
|
|
@@ -367,8 +407,88 @@ export class K8sService extends Context.Service<
|
|
|
367
407
|
};
|
|
368
408
|
}
|
|
369
409
|
|
|
370
|
-
const result = yield* executeCommand(
|
|
410
|
+
const result = yield* executeCommand(securityCheck.argv, profile);
|
|
411
|
+
|
|
412
|
+
if (result.exitCode !== 0) {
|
|
413
|
+
return yield* new K8sCommandError({
|
|
414
|
+
message: result.stderr ?? `kubectl exited with code ${result.exitCode}`,
|
|
415
|
+
command: result.command,
|
|
416
|
+
exitCode: result.exitCode,
|
|
417
|
+
stderr: result.stderr ?? undefined,
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
return {
|
|
422
|
+
success: true,
|
|
423
|
+
output: result.stdout.trim(),
|
|
424
|
+
command: result.command,
|
|
425
|
+
executionTimeMs: Date.now() - startTime,
|
|
426
|
+
};
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const runLogTail = Effect.fn("K8sService.runLogTail")(function* (
|
|
430
|
+
pod: string,
|
|
431
|
+
basePath: string,
|
|
432
|
+
path: string,
|
|
433
|
+
lines: number,
|
|
434
|
+
profile?: string,
|
|
435
|
+
) {
|
|
436
|
+
const normalizedBase = posix.resolve(basePath);
|
|
437
|
+
const normalizedPath = posix.resolve(path);
|
|
438
|
+
const lexicalRelative = posix.relative(normalizedBase, normalizedPath);
|
|
439
|
+
if (
|
|
440
|
+
!/^[a-z0-9](?:[-a-z0-9.]*[a-z0-9])?$/.test(pod) ||
|
|
441
|
+
!Number.isInteger(lines) ||
|
|
442
|
+
lines < 1 ||
|
|
443
|
+
lexicalRelative === ".." ||
|
|
444
|
+
lexicalRelative.startsWith("../") ||
|
|
445
|
+
posix.isAbsolute(lexicalRelative) ||
|
|
446
|
+
!isSafeLogPath(normalizedPath)
|
|
447
|
+
) {
|
|
448
|
+
return yield* new K8sDangerousCommandError({
|
|
449
|
+
message: "Invalid internal log-tail request.",
|
|
450
|
+
command: "exec tail",
|
|
451
|
+
verb: "exec",
|
|
452
|
+
hint: "Use logs-tool with a log file inside its configured remote directory.",
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const realpathArgv = ["exec", pod, "--", "realpath", normalizedBase, normalizedPath];
|
|
457
|
+
const realpathResult = yield* executeCommand(realpathArgv, profile);
|
|
458
|
+
if (realpathResult.exitCode !== 0) {
|
|
459
|
+
return yield* new K8sCommandError({
|
|
460
|
+
message:
|
|
461
|
+
realpathResult.stderr ||
|
|
462
|
+
`Remote realpath exited with code ${realpathResult.exitCode}`,
|
|
463
|
+
command: realpathResult.command,
|
|
464
|
+
exitCode: realpathResult.exitCode,
|
|
465
|
+
stderr: realpathResult.stderr || undefined,
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
const [canonicalBase, canonicalPath] = realpathResult.stdout.trim().split("\n");
|
|
469
|
+
const canonicalRelative =
|
|
470
|
+
canonicalBase === undefined || canonicalPath === undefined
|
|
471
|
+
? ".."
|
|
472
|
+
: posix.relative(canonicalBase, canonicalPath);
|
|
473
|
+
if (
|
|
474
|
+
canonicalBase === undefined ||
|
|
475
|
+
canonicalPath === undefined ||
|
|
476
|
+
canonicalRelative === ".." ||
|
|
477
|
+
canonicalRelative.startsWith("../") ||
|
|
478
|
+
posix.isAbsolute(canonicalRelative) ||
|
|
479
|
+
!isSafeLogPath(canonicalPath)
|
|
480
|
+
) {
|
|
481
|
+
return yield* new K8sDangerousCommandError({
|
|
482
|
+
message: "Canonical log path escapes the configured remote directory.",
|
|
483
|
+
command: realpathResult.command,
|
|
484
|
+
verb: "exec",
|
|
485
|
+
hint: "Remove symlinks that point outside the configured remote log directory.",
|
|
486
|
+
});
|
|
487
|
+
}
|
|
371
488
|
|
|
489
|
+
const argv = ["exec", pod, "--", "tail", "-n", String(lines), canonicalPath];
|
|
490
|
+
const startTime = Date.now();
|
|
491
|
+
const result = yield* executeCommand(argv, profile);
|
|
372
492
|
if (result.exitCode !== 0) {
|
|
373
493
|
return yield* new K8sCommandError({
|
|
374
494
|
message: result.stderr ?? `kubectl exited with code ${result.exitCode}`,
|
|
@@ -386,7 +506,7 @@ export class K8sService extends Context.Service<
|
|
|
386
506
|
};
|
|
387
507
|
});
|
|
388
508
|
|
|
389
|
-
return { runCommand, runKubectl };
|
|
509
|
+
return { runCommand, runKubectl, runLogTail };
|
|
390
510
|
}),
|
|
391
511
|
),
|
|
392
512
|
);
|
package/src/logs-tool/index.ts
CHANGED
|
@@ -158,7 +158,7 @@ const readCommand = Command.make(
|
|
|
158
158
|
),
|
|
159
159
|
format: formatOption,
|
|
160
160
|
grep: Flag.string("grep").pipe(
|
|
161
|
-
Flag.withDescription("Filter lines containing
|
|
161
|
+
Flag.withDescription("Filter lines containing case-insensitive literal text"),
|
|
162
162
|
Flag.optional,
|
|
163
163
|
),
|
|
164
164
|
pretty: Flag.boolean("pretty").pipe(
|
package/src/logs-tool/service.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import { isAbsolute, posix, relative, resolve } from "node:path";
|
|
2
|
+
|
|
1
3
|
import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
|
|
2
|
-
import { Context, Effect, Layer, Result
|
|
4
|
+
import { Context, Effect, Layer, Result } from "effect";
|
|
3
5
|
|
|
4
6
|
import type { Environment, LogFile, ReadOptions } from "./types";
|
|
5
7
|
|
|
6
|
-
import { K8sCommandError } from "#k8s/errors";
|
|
7
8
|
import { K8sService, K8sServiceLayer } from "#k8s/service";
|
|
8
9
|
import { ConfigService, ConfigServiceLayer, getToolConfig } from "#config/loader";
|
|
9
10
|
import type { LogsConfig } from "#config/types";
|
|
10
11
|
import { LogsNotFoundError, LogsReadError, type LogsError } from "./errors";
|
|
12
|
+
import { collectProcessOutput } from "#shared/exec";
|
|
11
13
|
import { transformLogOutput } from "./transformers";
|
|
12
14
|
|
|
13
15
|
export const parseLogFiles = (output: string): LogFile[] => {
|
|
@@ -46,6 +48,33 @@ export const formatPrettyOutput = (output: string): string => {
|
|
|
46
48
|
export const sanitizeShellArg = (input: string): string => `'${input.replace(/'/g, "'\\''")}'`;
|
|
47
49
|
|
|
48
50
|
const readCommandOutput = (output: unknown): string => (typeof output === "string" ? output : "");
|
|
51
|
+
const filterLogLines = (output: string, grep: string | undefined): string => {
|
|
52
|
+
if (!grep) return output;
|
|
53
|
+
const needle = grep.toLowerCase();
|
|
54
|
+
return output
|
|
55
|
+
.split("\n")
|
|
56
|
+
.filter((line) => line.toLowerCase().includes(needle))
|
|
57
|
+
.join("\n");
|
|
58
|
+
};
|
|
59
|
+
const resolveLocalLogPath = (base: string, file: string): string | undefined => {
|
|
60
|
+
const resolvedBase = resolve(base);
|
|
61
|
+
const resolvedPath = resolve(resolvedBase, file);
|
|
62
|
+
const relativePath = relative(resolvedBase, resolvedPath);
|
|
63
|
+
return relativePath === ".." ||
|
|
64
|
+
relativePath.startsWith(`..${pathSeparator}`) ||
|
|
65
|
+
isAbsolute(relativePath)
|
|
66
|
+
? undefined
|
|
67
|
+
: resolvedPath;
|
|
68
|
+
};
|
|
69
|
+
const pathSeparator = process.platform === "win32" ? "\\" : "/";
|
|
70
|
+
const resolveRemoteLogPath = (base: string, file: string): string | undefined => {
|
|
71
|
+
const resolvedBase = posix.resolve(base);
|
|
72
|
+
const resolvedPath = posix.resolve(resolvedBase, file);
|
|
73
|
+
const relativePath = posix.relative(resolvedBase, resolvedPath);
|
|
74
|
+
return relativePath === ".." || relativePath.startsWith("../") || posix.isAbsolute(relativePath)
|
|
75
|
+
? undefined
|
|
76
|
+
: resolvedPath;
|
|
77
|
+
};
|
|
49
78
|
|
|
50
79
|
export class LogsService extends Context.Service<
|
|
51
80
|
LogsService,
|
|
@@ -73,15 +102,7 @@ export class LogsService extends Context.Service<
|
|
|
73
102
|
stderr: "pipe",
|
|
74
103
|
});
|
|
75
104
|
const process = yield* executor.spawn(command);
|
|
76
|
-
|
|
77
|
-
const stdoutChunk = yield* process.stdout.pipe(Stream.decodeText(), Stream.runCollect);
|
|
78
|
-
const stderrChunk = yield* process.stderr.pipe(Stream.decodeText(), Stream.runCollect);
|
|
79
|
-
|
|
80
|
-
const stdout = stdoutChunk.join("");
|
|
81
|
-
const stderr = stderrChunk.join("");
|
|
82
|
-
const exitCode = yield* process.exitCode;
|
|
83
|
-
|
|
84
|
-
return { stdout, stderr, exitCode };
|
|
105
|
+
return yield* collectProcessOutput(process);
|
|
85
106
|
}),
|
|
86
107
|
).pipe(
|
|
87
108
|
Effect.catch((platformError) =>
|
|
@@ -93,6 +114,22 @@ export class LogsService extends Context.Service<
|
|
|
93
114
|
),
|
|
94
115
|
);
|
|
95
116
|
|
|
117
|
+
const runDirectCommand = (executable: string, args: readonly string[]) =>
|
|
118
|
+
Effect.scoped(
|
|
119
|
+
Effect.gen(function* () {
|
|
120
|
+
const command = ChildProcess.make(executable, args, {
|
|
121
|
+
stdout: "pipe",
|
|
122
|
+
stderr: "pipe",
|
|
123
|
+
});
|
|
124
|
+
const process = yield* executor.spawn(command);
|
|
125
|
+
return yield* collectProcessOutput(process);
|
|
126
|
+
}),
|
|
127
|
+
).pipe(
|
|
128
|
+
Effect.catch((platformError) =>
|
|
129
|
+
Effect.succeed({ stdout: "", stderr: String(platformError), exitCode: -1 }),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
|
|
96
133
|
const getLogsConfig = (profile?: string): LogsConfig | undefined =>
|
|
97
134
|
getToolConfig<LogsConfig>(config, "logs", profile);
|
|
98
135
|
|
|
@@ -196,27 +233,45 @@ export class LogsService extends Context.Service<
|
|
|
196
233
|
logFile = latestPath.split("/").pop() ?? latestPath;
|
|
197
234
|
}
|
|
198
235
|
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
236
|
+
const lexicalPath = resolveLocalLogPath(localDir, logFile);
|
|
237
|
+
if (lexicalPath === undefined) {
|
|
238
|
+
return yield* new LogsReadError({
|
|
239
|
+
message: "Log file must stay within the configured local log directory.",
|
|
240
|
+
source: localDir,
|
|
241
|
+
});
|
|
204
242
|
}
|
|
205
|
-
|
|
243
|
+
const realpathResult = yield* runDirectCommand("realpath", [localDir, lexicalPath]);
|
|
244
|
+
if (realpathResult.exitCode !== 0) {
|
|
245
|
+
return yield* new LogsReadError({
|
|
246
|
+
message:
|
|
247
|
+
realpathResult.stderr.trim() ||
|
|
248
|
+
`realpath failed with exit code ${realpathResult.exitCode}`,
|
|
249
|
+
source: lexicalPath,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
const [canonicalBase, canonicalPath] = realpathResult.stdout.trim().split("\n");
|
|
253
|
+
if (
|
|
254
|
+
canonicalBase === undefined ||
|
|
255
|
+
canonicalPath === undefined ||
|
|
256
|
+
resolveLocalLogPath(canonicalBase, canonicalPath) !== canonicalPath ||
|
|
257
|
+
!canonicalPath.endsWith(".log")
|
|
258
|
+
) {
|
|
259
|
+
return yield* new LogsReadError({
|
|
260
|
+
message: "Canonical log path escapes the configured local log directory.",
|
|
261
|
+
source: localDir,
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
const command = `tail -${options.tail} ${sanitizeShellArg(canonicalPath)}`;
|
|
206
265
|
const result = yield* runShellCommand(command);
|
|
207
266
|
|
|
208
|
-
if (result.exitCode !== 0
|
|
267
|
+
if (result.exitCode !== 0) {
|
|
209
268
|
return yield* new LogsReadError({
|
|
210
269
|
message: result.stderr.trim() || `Command failed with exit code ${result.exitCode}`,
|
|
211
|
-
source:
|
|
270
|
+
source: canonicalPath,
|
|
212
271
|
});
|
|
213
272
|
}
|
|
214
273
|
|
|
215
|
-
|
|
216
|
-
return "(no matching lines)";
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
const output = result.stdout.trim();
|
|
274
|
+
const output = filterLogLines(result.stdout, options.grep).trim();
|
|
220
275
|
if (!output) {
|
|
221
276
|
return "(no matching lines)";
|
|
222
277
|
}
|
|
@@ -231,6 +286,14 @@ export class LogsService extends Context.Service<
|
|
|
231
286
|
) {
|
|
232
287
|
const remotePath = logsConfig.remotePath;
|
|
233
288
|
const kubernetesProfile = logsConfig.kubernetesProfile;
|
|
289
|
+
const logFile = options.file ?? "app.log";
|
|
290
|
+
const logPath = resolveRemoteLogPath(remotePath, logFile);
|
|
291
|
+
if (logPath === undefined) {
|
|
292
|
+
return yield* new LogsReadError({
|
|
293
|
+
message: "Log file must stay within the configured remote log directory.",
|
|
294
|
+
source: remotePath,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
234
297
|
|
|
235
298
|
const podResult = yield* k8s
|
|
236
299
|
.runKubectl(
|
|
@@ -249,38 +312,22 @@ export class LogsService extends Context.Service<
|
|
|
249
312
|
);
|
|
250
313
|
|
|
251
314
|
const pod = readCommandOutput(podResult.output).replace(/'/g, "");
|
|
252
|
-
const logFile = options.file ?? "app.log";
|
|
253
|
-
const logPath = `${remotePath}/${logFile}`;
|
|
254
|
-
let command = `tail -${options.tail} ${sanitizeShellArg(logPath)}`;
|
|
255
|
-
|
|
256
|
-
if (options.grep) {
|
|
257
|
-
command += ` | grep -i ${sanitizeShellArg(options.grep)}`;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
315
|
const execResult = yield* k8s
|
|
261
|
-
.
|
|
316
|
+
.runLogTail(pod, remotePath, logPath, options.tail, kubernetesProfile)
|
|
262
317
|
.pipe(Effect.result);
|
|
263
318
|
|
|
264
319
|
return yield* Result.match(execResult, {
|
|
265
|
-
onFailure: (error) =>
|
|
266
|
-
|
|
267
|
-
return Effect.succeed("(no matching lines)");
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
return Effect.fail(
|
|
320
|
+
onFailure: (error) =>
|
|
321
|
+
Effect.fail(
|
|
271
322
|
new LogsReadError({
|
|
272
323
|
message: error instanceof Error ? error.message : "Failed to read remote logs",
|
|
273
324
|
source: `${pod}:${logPath}`,
|
|
274
325
|
}),
|
|
275
|
-
)
|
|
276
|
-
},
|
|
326
|
+
),
|
|
277
327
|
onSuccess: (result) => {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
return Effect.succeed(transformLogOutput(trimmed));
|
|
328
|
+
const output = readCommandOutput(result.output);
|
|
329
|
+
const trimmed = filterLogLines(output, options.grep).trim();
|
|
330
|
+
return Effect.succeed(trimmed ? transformLogOutput(trimmed) : "(no matching lines)");
|
|
284
331
|
},
|
|
285
332
|
});
|
|
286
333
|
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { ChildProcess } from "effect/unstable/process";
|
|
2
|
+
|
|
3
|
+
import type { ResolvedVpnDriver } from "#shared/prerequisites/types";
|
|
4
|
+
import type { SanitizedVpnDriver } from "#shared/prerequisites/store";
|
|
5
|
+
|
|
6
|
+
export type VpnDriverAction = "status" | "start" | "stop";
|
|
7
|
+
export type VpnCommandSpec = { readonly executable: string; readonly args: readonly string[] };
|
|
8
|
+
|
|
9
|
+
export const sanitizeVpnDriver = (driver: ResolvedVpnDriver): SanitizedVpnDriver => {
|
|
10
|
+
if (driver.type === "macos-scutil") {
|
|
11
|
+
return { type: driver.type, platform: driver.platform, serviceName: driver.serviceName };
|
|
12
|
+
}
|
|
13
|
+
if (driver.type === "linux-nmcli") {
|
|
14
|
+
return { type: driver.type, platform: driver.platform, connectionName: driver.connectionName };
|
|
15
|
+
}
|
|
16
|
+
return { type: driver.type, platform: driver.platform, entryName: driver.entryName };
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export const vpnCommandSpec = (
|
|
20
|
+
driver: SanitizedVpnDriver,
|
|
21
|
+
action: VpnDriverAction,
|
|
22
|
+
): VpnCommandSpec => {
|
|
23
|
+
if (driver.type === "macos-scutil") {
|
|
24
|
+
return {
|
|
25
|
+
executable: "scutil",
|
|
26
|
+
args:
|
|
27
|
+
action === "status"
|
|
28
|
+
? ["--nc", "status", driver.serviceName]
|
|
29
|
+
: ["--nc", action, driver.serviceName],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (driver.type === "linux-nmcli") {
|
|
33
|
+
return {
|
|
34
|
+
executable: "nmcli",
|
|
35
|
+
args:
|
|
36
|
+
action === "status"
|
|
37
|
+
? ["-t", "-e", "no", "-f", "NAME", "connection", "show", "--active"]
|
|
38
|
+
: ["connection", action === "start" ? "up" : "down", driver.connectionName],
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
return {
|
|
42
|
+
executable: "rasdial",
|
|
43
|
+
args:
|
|
44
|
+
action === "status"
|
|
45
|
+
? []
|
|
46
|
+
: action === "start"
|
|
47
|
+
? [driver.entryName]
|
|
48
|
+
: [driver.entryName, "/disconnect"],
|
|
49
|
+
};
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const makeParentVpnCommand = (
|
|
53
|
+
driver: ResolvedVpnDriver,
|
|
54
|
+
action: VpnDriverAction,
|
|
55
|
+
secret?: string,
|
|
56
|
+
) => {
|
|
57
|
+
const spec = vpnCommandSpec(sanitizeVpnDriver(driver), action);
|
|
58
|
+
const secretArgs = action === "start" && secret ? ["--secret", secret] : [];
|
|
59
|
+
const args = [...spec.args, ...secretArgs];
|
|
60
|
+
const labelArgs = [...spec.args, ...(secretArgs.length > 0 ? ["--secret", "<redacted>"] : [])];
|
|
61
|
+
return {
|
|
62
|
+
command: ChildProcess.make(spec.executable, args, { stdout: "pipe", stderr: "pipe" }),
|
|
63
|
+
label: [spec.executable, ...labelArgs].join(" "),
|
|
64
|
+
};
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export const parseVpnStatus = (
|
|
68
|
+
driver: SanitizedVpnDriver,
|
|
69
|
+
result: { readonly stdout: string; readonly exitCode: number },
|
|
70
|
+
): boolean | undefined => {
|
|
71
|
+
if (result.exitCode !== 0) return undefined;
|
|
72
|
+
const lines = result.stdout.split(/\r?\n/);
|
|
73
|
+
if (driver.type === "macos-scutil") {
|
|
74
|
+
if (lines.includes("Connected")) return true;
|
|
75
|
+
if (lines.includes("Disconnected")) return false;
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
if (driver.type === "linux-nmcli") {
|
|
79
|
+
return lines.some((line) => line === driver.connectionName);
|
|
80
|
+
}
|
|
81
|
+
const records = lines.map((line) => line.trim()).filter((line) => line.length > 0);
|
|
82
|
+
const successFooter = "Command completed successfully.";
|
|
83
|
+
if (records.at(-1) !== successFooter) return undefined;
|
|
84
|
+
const body = records.slice(0, -1);
|
|
85
|
+
if (body.length === 1 && body[0] === "No connections") return false;
|
|
86
|
+
if (body[0] !== "Connected to" || body.length === 1) return undefined;
|
|
87
|
+
return body.slice(1).includes(driver.entryName);
|
|
88
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
GuardianInboundMessage,
|
|
3
|
+
GuardianOutboundMessage,
|
|
4
|
+
} from "#shared/prerequisites/guardian";
|
|
5
|
+
import { runGuardian } from "#shared/prerequisites/guardian";
|
|
6
|
+
|
|
7
|
+
let release: (() => Promise<void>) | undefined;
|
|
8
|
+
let initialized = false;
|
|
9
|
+
let initializedLeaseId: string | undefined;
|
|
10
|
+
let requestedLeaseId: string | undefined;
|
|
11
|
+
let disconnected = false;
|
|
12
|
+
let releaseStarted = false;
|
|
13
|
+
|
|
14
|
+
const send = (message: GuardianOutboundMessage) => process.send?.(message);
|
|
15
|
+
const fail = (error: unknown) => {
|
|
16
|
+
send({ type: "ERROR", message: error instanceof Error ? error.message : String(error) });
|
|
17
|
+
process.exitCode = 1;
|
|
18
|
+
};
|
|
19
|
+
const releaseIfRequested = () => {
|
|
20
|
+
if (releaseStarted || !release || (!disconnected && requestedLeaseId !== initializedLeaseId)) {
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
releaseStarted = true;
|
|
24
|
+
void release().catch(fail);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
process.on("message", (message: GuardianInboundMessage) => {
|
|
28
|
+
if (message.type === "INIT" && !initialized) {
|
|
29
|
+
initialized = true;
|
|
30
|
+
initializedLeaseId = message.leaseId;
|
|
31
|
+
void runGuardian(message, send)
|
|
32
|
+
.then((guardian) => {
|
|
33
|
+
release = guardian.release;
|
|
34
|
+
send({ type: "READY", leaseId: message.leaseId, guardianId: message.guardianId });
|
|
35
|
+
return releaseIfRequested();
|
|
36
|
+
})
|
|
37
|
+
.catch(fail);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (message.type === "RELEASE") {
|
|
41
|
+
requestedLeaseId = message.leaseId;
|
|
42
|
+
releaseIfRequested();
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
process.on("disconnect", () => {
|
|
47
|
+
disconnected = true;
|
|
48
|
+
releaseIfRequested();
|
|
49
|
+
});
|