@blogic-cz/agent-tools 0.12.1 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -0
- package/package.json +1 -1
- package/schemas/agent-tools.schema.json +173 -2
- package/src/config/loader.ts +57 -0
- package/src/config/types.ts +57 -2
- package/src/k8s-tool/index.ts +1 -1
- package/src/k8s-tool/service.ts +59 -35
- package/src/logs-tool/service.ts +16 -10
- package/src/observability-tool/trace.ts +247 -61
- package/src/observability-tool/types.ts +48 -0
- package/src/shared/prerequisites/config.ts +38 -0
- package/src/shared/prerequisites/types.ts +21 -0
- package/src/shared/prerequisites/vpn.ts +119 -0
package/README.md
CHANGED
|
@@ -173,10 +173,20 @@ bun run agent-tools/example-tool/index.ts ping
|
|
|
173
173
|
retentionDays: 90,
|
|
174
174
|
dbPath: "~/.agent-tools/audit.sqlite",
|
|
175
175
|
},
|
|
176
|
+
vpns: {
|
|
177
|
+
blogic: {
|
|
178
|
+
// auto defaults to true:
|
|
179
|
+
// darwin -> macos-scutil, linux -> linux-nmcli, win32 -> windows-rasdial
|
|
180
|
+
name: "BLVPN",
|
|
181
|
+
},
|
|
182
|
+
},
|
|
176
183
|
kubernetes: {
|
|
177
184
|
default: {
|
|
178
185
|
clusterId: "your-cluster-id",
|
|
179
186
|
namespaces: { test: "your-ns-test", prod: "your-ns-prod" },
|
|
187
|
+
prerequisites: [{ type: "vpn", key: "blogic" }],
|
|
188
|
+
// Prerequisites are currently decoded and validated as config metadata;
|
|
189
|
+
// automatic VPN connect/disconnect execution is planned for a follow-up release.
|
|
180
190
|
},
|
|
181
191
|
},
|
|
182
192
|
logs: {
|
package/package.json
CHANGED
|
@@ -72,6 +72,13 @@
|
|
|
72
72
|
"additionalProperties": {
|
|
73
73
|
"$ref": "#/definitions/GitHubRepoConfig"
|
|
74
74
|
}
|
|
75
|
+
},
|
|
76
|
+
"vpns": {
|
|
77
|
+
"description": "Named VPN definitions referenced by profile prerequisites.",
|
|
78
|
+
"type": "object",
|
|
79
|
+
"additionalProperties": {
|
|
80
|
+
"$ref": "#/definitions/VpnConfig"
|
|
81
|
+
}
|
|
75
82
|
}
|
|
76
83
|
},
|
|
77
84
|
"definitions": {
|
|
@@ -81,8 +88,14 @@
|
|
|
81
88
|
"additionalProperties": false,
|
|
82
89
|
"required": ["owner", "repo"],
|
|
83
90
|
"properties": {
|
|
84
|
-
"owner": {
|
|
85
|
-
|
|
91
|
+
"owner": {
|
|
92
|
+
"type": "string",
|
|
93
|
+
"description": "GitHub organization or user name."
|
|
94
|
+
},
|
|
95
|
+
"repo": {
|
|
96
|
+
"type": "string",
|
|
97
|
+
"description": "Repository name."
|
|
98
|
+
}
|
|
86
99
|
}
|
|
87
100
|
},
|
|
88
101
|
"AzureConfig": {
|
|
@@ -125,6 +138,17 @@
|
|
|
125
138
|
"timeoutMs": {
|
|
126
139
|
"description": "Optional command timeout in milliseconds.",
|
|
127
140
|
"type": "number"
|
|
141
|
+
},
|
|
142
|
+
"prerequisites": {
|
|
143
|
+
"description": "Ordered prerequisite references required before remote tool execution.",
|
|
144
|
+
"type": "array",
|
|
145
|
+
"items": {
|
|
146
|
+
"$ref": "#/definitions/Prerequisite"
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
"vpn": {
|
|
150
|
+
"type": "string",
|
|
151
|
+
"description": "Convenience sugar for a VPN prerequisite key. Runtime normalizes this to prerequisites."
|
|
128
152
|
}
|
|
129
153
|
},
|
|
130
154
|
"required": ["clusterId", "namespaces"]
|
|
@@ -251,6 +275,21 @@
|
|
|
251
275
|
"remotePath": {
|
|
252
276
|
"description": "Remote logs path used in container or host environments.",
|
|
253
277
|
"type": "string"
|
|
278
|
+
},
|
|
279
|
+
"prerequisites": {
|
|
280
|
+
"description": "Ordered prerequisite references required before remote tool execution.",
|
|
281
|
+
"type": "array",
|
|
282
|
+
"items": {
|
|
283
|
+
"$ref": "#/definitions/Prerequisite"
|
|
284
|
+
}
|
|
285
|
+
},
|
|
286
|
+
"vpn": {
|
|
287
|
+
"type": "string",
|
|
288
|
+
"description": "Convenience sugar for a VPN prerequisite key. Runtime normalizes this to prerequisites."
|
|
289
|
+
},
|
|
290
|
+
"kubernetesProfile": {
|
|
291
|
+
"type": "string",
|
|
292
|
+
"description": "Kubernetes profile used for remote logs."
|
|
254
293
|
}
|
|
255
294
|
},
|
|
256
295
|
"required": ["localDir", "remotePath"]
|
|
@@ -336,6 +375,138 @@
|
|
|
336
375
|
}
|
|
337
376
|
}
|
|
338
377
|
}
|
|
378
|
+
},
|
|
379
|
+
"VpnPrerequisite": {
|
|
380
|
+
"description": "VPN prerequisite reference used by tool profiles.",
|
|
381
|
+
"type": "object",
|
|
382
|
+
"additionalProperties": false,
|
|
383
|
+
"properties": {
|
|
384
|
+
"type": {
|
|
385
|
+
"const": "vpn",
|
|
386
|
+
"description": "Prerequisite type."
|
|
387
|
+
},
|
|
388
|
+
"key": {
|
|
389
|
+
"type": "string",
|
|
390
|
+
"description": "Key from the top-level vpns registry."
|
|
391
|
+
},
|
|
392
|
+
"cleanup": {
|
|
393
|
+
"description": "VPN cleanup policy.",
|
|
394
|
+
"type": "string",
|
|
395
|
+
"enum": ["leave-running", "stop-if-started"]
|
|
396
|
+
}
|
|
397
|
+
},
|
|
398
|
+
"required": ["type", "key"]
|
|
399
|
+
},
|
|
400
|
+
"Prerequisite": {
|
|
401
|
+
"oneOf": [
|
|
402
|
+
{
|
|
403
|
+
"$ref": "#/definitions/VpnPrerequisite"
|
|
404
|
+
}
|
|
405
|
+
]
|
|
406
|
+
},
|
|
407
|
+
"MacosScutilVpnDriverConfig": {
|
|
408
|
+
"type": "object",
|
|
409
|
+
"additionalProperties": false,
|
|
410
|
+
"properties": {
|
|
411
|
+
"type": {
|
|
412
|
+
"const": "macos-scutil"
|
|
413
|
+
},
|
|
414
|
+
"serviceName": {
|
|
415
|
+
"type": "string"
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
"required": ["type"]
|
|
419
|
+
},
|
|
420
|
+
"LinuxNmcliVpnDriverConfig": {
|
|
421
|
+
"type": "object",
|
|
422
|
+
"additionalProperties": false,
|
|
423
|
+
"properties": {
|
|
424
|
+
"type": {
|
|
425
|
+
"const": "linux-nmcli"
|
|
426
|
+
},
|
|
427
|
+
"connectionName": {
|
|
428
|
+
"type": "string"
|
|
429
|
+
}
|
|
430
|
+
},
|
|
431
|
+
"required": ["type"]
|
|
432
|
+
},
|
|
433
|
+
"WindowsRasdialVpnDriverConfig": {
|
|
434
|
+
"type": "object",
|
|
435
|
+
"additionalProperties": false,
|
|
436
|
+
"properties": {
|
|
437
|
+
"type": {
|
|
438
|
+
"const": "windows-rasdial"
|
|
439
|
+
},
|
|
440
|
+
"entryName": {
|
|
441
|
+
"type": "string"
|
|
442
|
+
}
|
|
443
|
+
},
|
|
444
|
+
"required": ["type"]
|
|
445
|
+
},
|
|
446
|
+
"VpnDriverConfig": {
|
|
447
|
+
"oneOf": [
|
|
448
|
+
{
|
|
449
|
+
"$ref": "#/definitions/MacosScutilVpnDriverConfig"
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
"$ref": "#/definitions/LinuxNmcliVpnDriverConfig"
|
|
453
|
+
},
|
|
454
|
+
{
|
|
455
|
+
"$ref": "#/definitions/WindowsRasdialVpnDriverConfig"
|
|
456
|
+
}
|
|
457
|
+
]
|
|
458
|
+
},
|
|
459
|
+
"VpnConfig": {
|
|
460
|
+
"description": "VPN configuration. auto defaults to true and maps name to a deterministic OS-specific driver.",
|
|
461
|
+
"type": "object",
|
|
462
|
+
"additionalProperties": false,
|
|
463
|
+
"properties": {
|
|
464
|
+
"name": {
|
|
465
|
+
"type": "string",
|
|
466
|
+
"description": "VPN connection/service name used by auto driver mapping."
|
|
467
|
+
},
|
|
468
|
+
"auto": {
|
|
469
|
+
"type": "boolean",
|
|
470
|
+
"default": true,
|
|
471
|
+
"description": "Defaults to true. Maps by process.platform without heuristic discovery."
|
|
472
|
+
},
|
|
473
|
+
"defaultCleanup": {
|
|
474
|
+
"description": "VPN cleanup policy.",
|
|
475
|
+
"type": "string",
|
|
476
|
+
"enum": ["leave-running", "stop-if-started"]
|
|
477
|
+
},
|
|
478
|
+
"connectTimeoutMs": {
|
|
479
|
+
"type": "number"
|
|
480
|
+
},
|
|
481
|
+
"disconnectTimeoutMs": {
|
|
482
|
+
"type": "number"
|
|
483
|
+
},
|
|
484
|
+
"cooldownMs": {
|
|
485
|
+
"type": "number"
|
|
486
|
+
},
|
|
487
|
+
"leaseTtlMs": {
|
|
488
|
+
"type": "number"
|
|
489
|
+
},
|
|
490
|
+
"drivers": {
|
|
491
|
+
"type": "object",
|
|
492
|
+
"additionalProperties": false,
|
|
493
|
+
"properties": {
|
|
494
|
+
"darwin": {
|
|
495
|
+
"$ref": "#/definitions/MacosScutilVpnDriverConfig"
|
|
496
|
+
},
|
|
497
|
+
"linux": {
|
|
498
|
+
"$ref": "#/definitions/LinuxNmcliVpnDriverConfig"
|
|
499
|
+
},
|
|
500
|
+
"win32": {
|
|
501
|
+
"$ref": "#/definitions/WindowsRasdialVpnDriverConfig"
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
"driver": {
|
|
506
|
+
"$ref": "#/definitions/VpnDriverConfig"
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
"required": ["name"]
|
|
339
510
|
}
|
|
340
511
|
},
|
|
341
512
|
"examples": [
|
package/src/config/loader.ts
CHANGED
|
@@ -16,6 +16,56 @@ const CredentialGuardConfigSchema = Schema.Struct({
|
|
|
16
16
|
additionalDangerousBashPatterns: Schema.optionalKey(Schema.Array(Schema.String)),
|
|
17
17
|
});
|
|
18
18
|
|
|
19
|
+
const CleanupPolicySchema = Schema.Literals(["leave-running", "stop-if-started"]);
|
|
20
|
+
|
|
21
|
+
const VpnPrerequisiteSchema = Schema.Struct({
|
|
22
|
+
type: Schema.Literal("vpn"),
|
|
23
|
+
key: Schema.String,
|
|
24
|
+
cleanup: Schema.optionalKey(CleanupPolicySchema),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const PrerequisiteSchema = VpnPrerequisiteSchema;
|
|
28
|
+
const PrerequisitesSchema = Schema.Array(PrerequisiteSchema);
|
|
29
|
+
|
|
30
|
+
const MacosScutilVpnDriverConfigSchema = Schema.Struct({
|
|
31
|
+
type: Schema.Literal("macos-scutil"),
|
|
32
|
+
serviceName: Schema.optionalKey(Schema.String),
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const LinuxNmcliVpnDriverConfigSchema = Schema.Struct({
|
|
36
|
+
type: Schema.Literal("linux-nmcli"),
|
|
37
|
+
connectionName: Schema.optionalKey(Schema.String),
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
const WindowsRasdialVpnDriverConfigSchema = Schema.Struct({
|
|
41
|
+
type: Schema.Literal("windows-rasdial"),
|
|
42
|
+
entryName: Schema.optionalKey(Schema.String),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const VpnDriverConfigSchema = Schema.Union([
|
|
46
|
+
MacosScutilVpnDriverConfigSchema,
|
|
47
|
+
LinuxNmcliVpnDriverConfigSchema,
|
|
48
|
+
WindowsRasdialVpnDriverConfigSchema,
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const VpnConfigSchema = Schema.Struct({
|
|
52
|
+
name: Schema.String,
|
|
53
|
+
auto: Schema.optionalKey(Schema.Boolean),
|
|
54
|
+
defaultCleanup: Schema.optionalKey(CleanupPolicySchema),
|
|
55
|
+
connectTimeoutMs: Schema.optionalKey(Schema.Number),
|
|
56
|
+
disconnectTimeoutMs: Schema.optionalKey(Schema.Number),
|
|
57
|
+
cooldownMs: Schema.optionalKey(Schema.Number),
|
|
58
|
+
leaseTtlMs: Schema.optionalKey(Schema.Number),
|
|
59
|
+
drivers: Schema.optionalKey(
|
|
60
|
+
Schema.Struct({
|
|
61
|
+
darwin: Schema.optionalKey(MacosScutilVpnDriverConfigSchema),
|
|
62
|
+
linux: Schema.optionalKey(LinuxNmcliVpnDriverConfigSchema),
|
|
63
|
+
win32: Schema.optionalKey(WindowsRasdialVpnDriverConfigSchema),
|
|
64
|
+
}),
|
|
65
|
+
),
|
|
66
|
+
driver: Schema.optionalKey(VpnDriverConfigSchema),
|
|
67
|
+
});
|
|
68
|
+
|
|
19
69
|
const AzureConfigSchema = Schema.Struct({
|
|
20
70
|
organization: Schema.String,
|
|
21
71
|
defaultProject: Schema.String,
|
|
@@ -26,6 +76,8 @@ const K8sConfigSchema = Schema.Struct({
|
|
|
26
76
|
clusterId: Schema.String,
|
|
27
77
|
namespaces: Schema.Record(Schema.String, Schema.String),
|
|
28
78
|
timeoutMs: Schema.optionalKey(Schema.Number),
|
|
79
|
+
prerequisites: Schema.optionalKey(PrerequisitesSchema),
|
|
80
|
+
vpn: Schema.optionalKey(Schema.String),
|
|
29
81
|
});
|
|
30
82
|
|
|
31
83
|
const DbEnvConfigSchema = Schema.Struct({
|
|
@@ -52,6 +104,9 @@ const DatabaseConfigSchema = Schema.Struct({
|
|
|
52
104
|
const LogsConfigSchema = Schema.Struct({
|
|
53
105
|
localDir: Schema.String,
|
|
54
106
|
remotePath: Schema.String,
|
|
107
|
+
kubernetesProfile: Schema.optionalKey(Schema.String),
|
|
108
|
+
prerequisites: Schema.optionalKey(PrerequisitesSchema),
|
|
109
|
+
vpn: Schema.optionalKey(Schema.String),
|
|
55
110
|
});
|
|
56
111
|
|
|
57
112
|
const ObservabilityEnvTargetSchema = Schema.Struct({
|
|
@@ -78,6 +133,7 @@ const GitHubRepoConfigSchema = Schema.Struct({
|
|
|
78
133
|
const KNOWN_TOP_LEVEL_KEYS = new Set([
|
|
79
134
|
"$schema",
|
|
80
135
|
"azure",
|
|
136
|
+
"vpns",
|
|
81
137
|
"kubernetes",
|
|
82
138
|
"database",
|
|
83
139
|
"observability",
|
|
@@ -92,6 +148,7 @@ const KNOWN_TOP_LEVEL_KEYS = new Set([
|
|
|
92
148
|
const AgentToolsConfigSchema = Schema.Struct({
|
|
93
149
|
$schema: Schema.optionalKey(Schema.String),
|
|
94
150
|
azure: Schema.optionalKey(Schema.Record(Schema.String, AzureConfigSchema)),
|
|
151
|
+
vpns: Schema.optionalKey(Schema.Record(Schema.String, VpnConfigSchema)),
|
|
95
152
|
kubernetes: Schema.optionalKey(Schema.Record(Schema.String, K8sConfigSchema)),
|
|
96
153
|
database: Schema.optionalKey(Schema.Record(Schema.String, DatabaseConfigSchema)),
|
|
97
154
|
observability: Schema.optionalKey(Schema.Record(Schema.String, ObservabilityConfigSchema)),
|
package/src/config/types.ts
CHANGED
|
@@ -5,8 +5,60 @@ export type AzureConfig = {
|
|
|
5
5
|
timeoutMs?: number;
|
|
6
6
|
};
|
|
7
7
|
|
|
8
|
+
export type CleanupPolicy = "leave-running" | "stop-if-started";
|
|
9
|
+
|
|
10
|
+
export type VpnPrerequisite = {
|
|
11
|
+
type: "vpn";
|
|
12
|
+
key: string;
|
|
13
|
+
cleanup?: CleanupPolicy;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type MacosScutilVpnDriverConfig = {
|
|
17
|
+
type: "macos-scutil";
|
|
18
|
+
serviceName?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type LinuxNmcliVpnDriverConfig = {
|
|
22
|
+
type: "linux-nmcli";
|
|
23
|
+
connectionName?: string;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type WindowsRasdialVpnDriverConfig = {
|
|
27
|
+
type: "windows-rasdial";
|
|
28
|
+
entryName?: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type VpnDriverConfig =
|
|
32
|
+
| MacosScutilVpnDriverConfig
|
|
33
|
+
| LinuxNmcliVpnDriverConfig
|
|
34
|
+
| WindowsRasdialVpnDriverConfig;
|
|
35
|
+
|
|
36
|
+
export type VpnConfig = {
|
|
37
|
+
name: string;
|
|
38
|
+
/** Defaults to true. Auto maps name to the current OS driver deterministically. */
|
|
39
|
+
auto?: boolean;
|
|
40
|
+
defaultCleanup?: CleanupPolicy;
|
|
41
|
+
connectTimeoutMs?: number;
|
|
42
|
+
disconnectTimeoutMs?: number;
|
|
43
|
+
cooldownMs?: number;
|
|
44
|
+
leaseTtlMs?: number;
|
|
45
|
+
drivers?: {
|
|
46
|
+
darwin?: MacosScutilVpnDriverConfig;
|
|
47
|
+
linux?: LinuxNmcliVpnDriverConfig;
|
|
48
|
+
win32?: WindowsRasdialVpnDriverConfig;
|
|
49
|
+
};
|
|
50
|
+
/** Explicit current-platform driver, required when auto is false and no per-OS driver is available. */
|
|
51
|
+
driver?: VpnDriverConfig;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type ProfilePrerequisites = {
|
|
55
|
+
prerequisites?: readonly VpnPrerequisite[];
|
|
56
|
+
/** Convenience input sugar; normalize to prerequisites before execution. */
|
|
57
|
+
vpn?: string;
|
|
58
|
+
};
|
|
59
|
+
|
|
8
60
|
/** Kubernetes cluster profile configuration */
|
|
9
|
-
export type K8sConfig = {
|
|
61
|
+
export type K8sConfig = ProfilePrerequisites & {
|
|
10
62
|
clusterId: string;
|
|
11
63
|
/** Named namespaces, e.g. { test: "my-app-test", prod: "my-app-prod" } */
|
|
12
64
|
namespaces: Record<string, string>;
|
|
@@ -38,9 +90,10 @@ export type DatabaseConfig = {
|
|
|
38
90
|
};
|
|
39
91
|
|
|
40
92
|
/** Logs profile configuration */
|
|
41
|
-
export type LogsConfig = {
|
|
93
|
+
export type LogsConfig = ProfilePrerequisites & {
|
|
42
94
|
localDir: string;
|
|
43
95
|
remotePath: string;
|
|
96
|
+
kubernetesProfile?: string;
|
|
44
97
|
};
|
|
45
98
|
|
|
46
99
|
/** Single observability environment connection details */
|
|
@@ -93,6 +146,8 @@ export type AgentToolsConfig = {
|
|
|
93
146
|
$schema?: string;
|
|
94
147
|
/** Named Azure DevOps profiles. e.g. { default: { organization: "...", defaultProject: "..." } } */
|
|
95
148
|
azure?: Record<string, AzureConfig>;
|
|
149
|
+
/** Named VPN definitions referenced by profile prerequisites. */
|
|
150
|
+
vpns?: Record<string, VpnConfig>;
|
|
96
151
|
/** Named Kubernetes cluster profiles. e.g. { default: {...}, staging: {...} } */
|
|
97
152
|
kubernetes?: Record<string, K8sConfig>;
|
|
98
153
|
/** Named database profiles. e.g. { default: {...}, analytics: {...} } */
|
package/src/k8s-tool/index.ts
CHANGED
|
@@ -82,7 +82,7 @@ const executeK8sCommand = (command: string, options: CommonK8sCommandOptions) =>
|
|
|
82
82
|
const resolvedEnv = yield* resolveEnv(options.env, config);
|
|
83
83
|
|
|
84
84
|
const k8sService = yield* K8sService;
|
|
85
|
-
const result = yield* k8sService.runKubectl(command, options.dryRun).pipe(
|
|
85
|
+
const result = yield* k8sService.runKubectl(command, options.dryRun, profileName).pipe(
|
|
86
86
|
Effect.catchTags({
|
|
87
87
|
K8sContextError: (error) => {
|
|
88
88
|
const errorResult: CommandResult = {
|
package/src/k8s-tool/service.ts
CHANGED
|
@@ -19,6 +19,7 @@ export class K8sService extends Context.Service<
|
|
|
19
19
|
readonly runCommand: (
|
|
20
20
|
cmd: string,
|
|
21
21
|
env: Environment,
|
|
22
|
+
profile?: string,
|
|
22
23
|
) => Effect.Effect<
|
|
23
24
|
string,
|
|
24
25
|
K8sContextError | K8sCommandError | K8sTimeoutError | K8sDangerousCommandError
|
|
@@ -26,6 +27,7 @@ export class K8sService extends Context.Service<
|
|
|
26
27
|
readonly runKubectl: (
|
|
27
28
|
cmd: string,
|
|
28
29
|
dryRun: boolean,
|
|
30
|
+
profile?: string,
|
|
29
31
|
) => Effect.Effect<
|
|
30
32
|
CommandResult,
|
|
31
33
|
K8sContextError | K8sCommandError | K8sTimeoutError | K8sDangerousCommandError
|
|
@@ -39,24 +41,27 @@ export class K8sService extends Context.Service<
|
|
|
39
41
|
const executor = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
40
42
|
|
|
41
43
|
const config = yield* ConfigService;
|
|
42
|
-
const k8sConfig = getToolConfig<K8sConfig>(config, "kubernetes");
|
|
43
44
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
45
|
+
const getK8sConfig = (profile?: string) =>
|
|
46
|
+
getToolConfig<K8sConfig>(config, "kubernetes", profile);
|
|
47
|
+
|
|
48
|
+
const requireK8sConfig = (profile?: string) =>
|
|
49
|
+
Effect.gen(function* () {
|
|
50
|
+
const k8sConfig = getK8sConfig(profile);
|
|
51
|
+
if (!k8sConfig) {
|
|
52
|
+
return yield* new K8sContextError({
|
|
53
|
+
message: profile
|
|
54
|
+
? `No Kubernetes configuration found for profile: ${profile}.`
|
|
55
|
+
: "No Kubernetes configuration found. Add a 'kubernetes' section to agent-tools.json5.",
|
|
56
|
+
clusterId: profile ?? "unknown",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
55
59
|
|
|
56
|
-
|
|
60
|
+
return k8sConfig;
|
|
61
|
+
});
|
|
57
62
|
|
|
58
|
-
//
|
|
59
|
-
const contextRef = yield* Ref.make<string
|
|
63
|
+
// Cache context by selected profile/cluster instead of a single default profile.
|
|
64
|
+
const contextRef = yield* Ref.make<Record<string, string>>({});
|
|
60
65
|
|
|
61
66
|
// Helper that uses executor.spawn() to avoid ChildProcessSpawner requirement in return type
|
|
62
67
|
const runShellCommand = (commandStr: string, timeoutMs: number) =>
|
|
@@ -97,22 +102,27 @@ export class K8sService extends Context.Service<
|
|
|
97
102
|
),
|
|
98
103
|
);
|
|
99
104
|
|
|
100
|
-
const resolveContext = Effect.fn("K8sService.resolveContext")(function* (
|
|
101
|
-
|
|
105
|
+
const resolveContext = Effect.fn("K8sService.resolveContext")(function* (
|
|
106
|
+
profile: string | undefined,
|
|
107
|
+
k8sConfig: K8sConfig,
|
|
108
|
+
) {
|
|
109
|
+
const timeoutMs = k8sConfig.timeoutMs ?? 60000;
|
|
110
|
+
const cacheKey = profile ?? `cluster:${k8sConfig.clusterId}`;
|
|
102
111
|
const cached = yield* Ref.get(contextRef);
|
|
103
|
-
|
|
104
|
-
|
|
112
|
+
const cachedContext = cached[cacheKey];
|
|
113
|
+
if (cachedContext !== undefined) {
|
|
114
|
+
return cachedContext;
|
|
105
115
|
}
|
|
106
116
|
|
|
107
117
|
const jqCommand = `kubectl config view -o json | jq -r '.contexts[] | select(.context.cluster == "${k8sConfig.clusterId}") | .name' | head -1`;
|
|
108
118
|
|
|
109
|
-
const contextResultOption = yield* runShellCommand(jqCommand,
|
|
119
|
+
const contextResultOption = yield* runShellCommand(jqCommand, timeoutMs);
|
|
110
120
|
|
|
111
121
|
if (Option.isNone(contextResultOption)) {
|
|
112
122
|
return yield* new K8sTimeoutError({
|
|
113
|
-
message: `Context resolution timed out after ${
|
|
123
|
+
message: `Context resolution timed out after ${timeoutMs}ms`,
|
|
114
124
|
command: jqCommand,
|
|
115
|
-
timeoutMs
|
|
125
|
+
timeoutMs,
|
|
116
126
|
});
|
|
117
127
|
}
|
|
118
128
|
|
|
@@ -120,19 +130,22 @@ export class K8sService extends Context.Service<
|
|
|
120
130
|
|
|
121
131
|
if (contextResult.exitCode === 0 && contextResult.stdout.trim()) {
|
|
122
132
|
const resolvedContextValue = contextResult.stdout.trim();
|
|
123
|
-
yield* Ref.
|
|
133
|
+
yield* Ref.update(contextRef, (contexts) => ({
|
|
134
|
+
...contexts,
|
|
135
|
+
[cacheKey]: resolvedContextValue,
|
|
136
|
+
}));
|
|
124
137
|
return resolvedContextValue;
|
|
125
138
|
}
|
|
126
139
|
|
|
127
140
|
const fallbackCommand = `kubectl config view -o json | jq -r '.contexts[] as $ctx | .clusters[] | select(.name == $ctx.context.cluster and (.cluster.server | contains("${k8sConfig.clusterId}"))) | $ctx.name' | head -1`;
|
|
128
141
|
|
|
129
|
-
const fallbackResultOption = yield* runShellCommand(fallbackCommand,
|
|
142
|
+
const fallbackResultOption = yield* runShellCommand(fallbackCommand, timeoutMs);
|
|
130
143
|
|
|
131
144
|
if (Option.isNone(fallbackResultOption)) {
|
|
132
145
|
return yield* new K8sTimeoutError({
|
|
133
|
-
message: `Context resolution timed out after ${
|
|
146
|
+
message: `Context resolution timed out after ${timeoutMs}ms`,
|
|
134
147
|
command: fallbackCommand,
|
|
135
|
-
timeoutMs
|
|
148
|
+
timeoutMs,
|
|
136
149
|
});
|
|
137
150
|
}
|
|
138
151
|
|
|
@@ -140,7 +153,10 @@ export class K8sService extends Context.Service<
|
|
|
140
153
|
|
|
141
154
|
if (fallbackResult.exitCode === 0 && fallbackResult.stdout.trim()) {
|
|
142
155
|
const resolvedContextValue = fallbackResult.stdout.trim();
|
|
143
|
-
yield* Ref.
|
|
156
|
+
yield* Ref.update(contextRef, (contexts) => ({
|
|
157
|
+
...contexts,
|
|
158
|
+
[cacheKey]: resolvedContextValue,
|
|
159
|
+
}));
|
|
144
160
|
return resolvedContextValue;
|
|
145
161
|
}
|
|
146
162
|
|
|
@@ -150,17 +166,22 @@ export class K8sService extends Context.Service<
|
|
|
150
166
|
});
|
|
151
167
|
});
|
|
152
168
|
|
|
153
|
-
const executeCommand = Effect.fn("K8sService.executeCommand")(function* (
|
|
154
|
-
|
|
169
|
+
const executeCommand = Effect.fn("K8sService.executeCommand")(function* (
|
|
170
|
+
cmd: string,
|
|
171
|
+
profile?: string,
|
|
172
|
+
) {
|
|
173
|
+
const k8sConfig = yield* requireK8sConfig(profile);
|
|
174
|
+
const timeoutMs = k8sConfig.timeoutMs ?? 60000;
|
|
175
|
+
const context = yield* resolveContext(profile, k8sConfig);
|
|
155
176
|
const fullCommand = `kubectl --context ${context} ${cmd}`;
|
|
156
177
|
|
|
157
|
-
const resultOption = yield* runShellCommand(fullCommand,
|
|
178
|
+
const resultOption = yield* runShellCommand(fullCommand, timeoutMs);
|
|
158
179
|
|
|
159
180
|
if (Option.isNone(resultOption)) {
|
|
160
181
|
return yield* new K8sTimeoutError({
|
|
161
|
-
message: `Command timed out after ${
|
|
182
|
+
message: `Command timed out after ${timeoutMs}ms`,
|
|
162
183
|
command: fullCommand,
|
|
163
|
-
timeoutMs
|
|
184
|
+
timeoutMs,
|
|
164
185
|
});
|
|
165
186
|
}
|
|
166
187
|
|
|
@@ -177,6 +198,7 @@ export class K8sService extends Context.Service<
|
|
|
177
198
|
const runCommand = Effect.fn("K8sService.runCommand")(function* (
|
|
178
199
|
cmd: string,
|
|
179
200
|
_env: Environment,
|
|
201
|
+
profile?: string,
|
|
180
202
|
) {
|
|
181
203
|
// Security: block dangerous commands before execution
|
|
182
204
|
const securityCheck = isKubectlCommandAllowed(cmd);
|
|
@@ -189,7 +211,7 @@ export class K8sService extends Context.Service<
|
|
|
189
211
|
});
|
|
190
212
|
}
|
|
191
213
|
|
|
192
|
-
const result = yield* executeCommand(cmd);
|
|
214
|
+
const result = yield* executeCommand(cmd, profile);
|
|
193
215
|
if (result.exitCode !== 0) {
|
|
194
216
|
return yield* new K8sCommandError({
|
|
195
217
|
message: result.stderr ?? `kubectl exited with code ${result.exitCode}`,
|
|
@@ -205,6 +227,7 @@ export class K8sService extends Context.Service<
|
|
|
205
227
|
const runKubectl = Effect.fn("K8sService.runKubectl")(function* (
|
|
206
228
|
cmd: string,
|
|
207
229
|
dryRun: boolean,
|
|
230
|
+
profile?: string,
|
|
208
231
|
) {
|
|
209
232
|
// Security: block dangerous commands before execution (even dry-run)
|
|
210
233
|
const securityCheck = isKubectlCommandAllowed(cmd);
|
|
@@ -219,7 +242,8 @@ export class K8sService extends Context.Service<
|
|
|
219
242
|
|
|
220
243
|
const startTime = Date.now();
|
|
221
244
|
if (dryRun) {
|
|
222
|
-
const
|
|
245
|
+
const k8sConfig = yield* requireK8sConfig(profile);
|
|
246
|
+
const context = yield* resolveContext(profile, k8sConfig);
|
|
223
247
|
const fullCommand = `kubectl --context ${context} ${cmd}`;
|
|
224
248
|
return {
|
|
225
249
|
success: true,
|
|
@@ -229,7 +253,7 @@ export class K8sService extends Context.Service<
|
|
|
229
253
|
};
|
|
230
254
|
}
|
|
231
255
|
|
|
232
|
-
const result = yield* executeCommand(cmd);
|
|
256
|
+
const result = yield* executeCommand(cmd, profile);
|
|
233
257
|
|
|
234
258
|
if (result.exitCode !== 0) {
|
|
235
259
|
return yield* new K8sCommandError({
|
package/src/logs-tool/service.ts
CHANGED
|
@@ -125,11 +125,13 @@ export class LogsService extends Context.Service<
|
|
|
125
125
|
logsConfig: LogsConfig,
|
|
126
126
|
) {
|
|
127
127
|
const remotePath = logsConfig.remotePath;
|
|
128
|
+
const kubernetesProfile = logsConfig.kubernetesProfile;
|
|
128
129
|
|
|
129
130
|
const podResult = yield* k8s
|
|
130
131
|
.runKubectl(
|
|
131
132
|
`get pods --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}'`,
|
|
132
133
|
false,
|
|
134
|
+
kubernetesProfile,
|
|
133
135
|
)
|
|
134
136
|
.pipe(
|
|
135
137
|
Effect.mapError(
|
|
@@ -143,15 +145,17 @@ export class LogsService extends Context.Service<
|
|
|
143
145
|
|
|
144
146
|
const pod = readCommandOutput(podResult.output).replace(/'/g, "");
|
|
145
147
|
|
|
146
|
-
const listResult = yield* k8s
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
148
|
+
const listResult = yield* k8s
|
|
149
|
+
.runKubectl(`exec ${pod} -- ls -la ${remotePath}`, false, kubernetesProfile)
|
|
150
|
+
.pipe(
|
|
151
|
+
Effect.mapError(
|
|
152
|
+
(error) =>
|
|
153
|
+
new LogsReadError({
|
|
154
|
+
message: error instanceof Error ? error.message : "Failed to list remote logs",
|
|
155
|
+
source: `${pod}:${remotePath}`,
|
|
156
|
+
}),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
155
159
|
|
|
156
160
|
const files = parseLogFiles(readCommandOutput(listResult.output));
|
|
157
161
|
if (files.length === 0) {
|
|
@@ -226,11 +230,13 @@ export class LogsService extends Context.Service<
|
|
|
226
230
|
logsConfig: LogsConfig,
|
|
227
231
|
) {
|
|
228
232
|
const remotePath = logsConfig.remotePath;
|
|
233
|
+
const kubernetesProfile = logsConfig.kubernetesProfile;
|
|
229
234
|
|
|
230
235
|
const podResult = yield* k8s
|
|
231
236
|
.runKubectl(
|
|
232
237
|
`get pods --field-selector=status.phase=Running -o jsonpath='{.items[0].metadata.name}'`,
|
|
233
238
|
false,
|
|
239
|
+
kubernetesProfile,
|
|
234
240
|
)
|
|
235
241
|
.pipe(
|
|
236
242
|
Effect.mapError(
|
|
@@ -252,7 +258,7 @@ export class LogsService extends Context.Service<
|
|
|
252
258
|
}
|
|
253
259
|
|
|
254
260
|
const execResult = yield* k8s
|
|
255
|
-
.runKubectl(`exec ${pod} -- sh -c "${command}"`, false)
|
|
261
|
+
.runKubectl(`exec ${pod} -- sh -c "${command}"`, false, kubernetesProfile)
|
|
256
262
|
.pipe(Effect.result);
|
|
257
263
|
|
|
258
264
|
return yield* Result.match(execResult, {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import { Console, Effect } from "effect";
|
|
1
|
+
import { Console, Effect, type Option } from "effect";
|
|
2
2
|
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
3
3
|
|
|
4
4
|
import { formatOption, formatOutput } from "#shared";
|
|
5
|
+
import type { OutputFormat } from "#shared";
|
|
5
6
|
|
|
6
7
|
import { ObservabilityToolError } from "./errors";
|
|
7
8
|
import {
|
|
@@ -14,14 +15,31 @@ import {
|
|
|
14
15
|
} from "./shared";
|
|
15
16
|
import type {
|
|
16
17
|
FlattenedSpan,
|
|
18
|
+
ObservabilityEnvConfig,
|
|
17
19
|
OtlpAnyValue,
|
|
18
20
|
OtlpAttribute,
|
|
21
|
+
ParsedId,
|
|
22
|
+
SearchWindow,
|
|
23
|
+
SpanResolution,
|
|
24
|
+
TempoSearchResponse,
|
|
19
25
|
TempoTraceResponse,
|
|
20
26
|
TraceSummary,
|
|
21
27
|
} from "./types";
|
|
22
28
|
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
const SPAN_SEARCH_WINDOWS: SearchWindow[] = [
|
|
30
|
+
{ start: "now-1h", end: "now" },
|
|
31
|
+
{ start: "now-24h", end: "now" },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function parseId(value: string): ParsedId | undefined {
|
|
35
|
+
const trimmed = value.trim().toLowerCase();
|
|
36
|
+
if (/^[\da-f]{32}$/.test(trimmed)) {
|
|
37
|
+
return { rawId: value, normalizedId: trimmed, kind: "trace_id" };
|
|
38
|
+
}
|
|
39
|
+
if (/^[\da-f]{16}$/.test(trimmed)) {
|
|
40
|
+
return { rawId: value, normalizedId: trimmed, kind: "span_id" };
|
|
41
|
+
}
|
|
42
|
+
return undefined;
|
|
25
43
|
}
|
|
26
44
|
|
|
27
45
|
function getStringAttribute(
|
|
@@ -111,6 +129,16 @@ function computeDurationMs(start?: string, end?: string): number | undefined {
|
|
|
111
129
|
return Number(endNano - startNano) / 1_000_000;
|
|
112
130
|
}
|
|
113
131
|
|
|
132
|
+
function relativeToEpoch(value: string, nowEpoch: number): number {
|
|
133
|
+
const match = /^now-(\d+)([smhd])$/.exec(value.trim());
|
|
134
|
+
if (!match) return nowEpoch;
|
|
135
|
+
|
|
136
|
+
const amount = Number(match[1]);
|
|
137
|
+
const unit = match[2];
|
|
138
|
+
const multipliers: Record<string, number> = { s: 1, m: 60, h: 3600, d: 86400 };
|
|
139
|
+
return nowEpoch - amount * (multipliers[unit] ?? 1);
|
|
140
|
+
}
|
|
141
|
+
|
|
114
142
|
function isErrorStatus(code?: string | number): boolean {
|
|
115
143
|
if (code === undefined) return false;
|
|
116
144
|
if (typeof code === "number") return code === 2;
|
|
@@ -205,72 +233,207 @@ function parseLabel(value: string | Record<string, string>): Record<string, stri
|
|
|
205
233
|
}
|
|
206
234
|
}
|
|
207
235
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
236
|
+
type ResolvedTrace = {
|
|
237
|
+
readonly resolution: SpanResolution;
|
|
238
|
+
readonly spans: FlattenedSpan[];
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
function searchTempoBySpanId(
|
|
242
|
+
config: ObservabilityEnvConfig,
|
|
243
|
+
spanId: string,
|
|
244
|
+
window: SearchWindow,
|
|
245
|
+
): Effect.Effect<TempoSearchResponse, ObservabilityToolError> {
|
|
246
|
+
const now = Math.floor(Date.now() / 1000);
|
|
247
|
+
const startEpoch = relativeToEpoch(window.start, now);
|
|
248
|
+
const endEpoch = relativeToEpoch(window.end, now);
|
|
249
|
+
const traceql = encodeURIComponent(`{ span:id = "${spanId}" }`);
|
|
250
|
+
const searchUrl =
|
|
251
|
+
`/api/datasources/proxy/uid/${config.tempoUid}/api/search` +
|
|
252
|
+
`?q=${traceql}&start=${startEpoch}&end=${endEpoch}&limit=5`;
|
|
253
|
+
|
|
254
|
+
return observabilityFetch<TempoSearchResponse>(config, searchUrl);
|
|
255
|
+
}
|
|
218
256
|
|
|
219
|
-
|
|
220
|
-
|
|
257
|
+
function fetchFullTrace(
|
|
258
|
+
config: ObservabilityEnvConfig,
|
|
259
|
+
traceId: string,
|
|
260
|
+
): Effect.Effect<FlattenedSpan[], ObservabilityToolError> {
|
|
261
|
+
return Effect.gen(function* () {
|
|
262
|
+
const raw = yield* observabilityFetch<TempoTraceResponse>(
|
|
263
|
+
config,
|
|
264
|
+
`/api/datasources/proxy/uid/${config.tempoUid}/api/traces/${traceId}`,
|
|
265
|
+
);
|
|
266
|
+
return flattenTrace(raw);
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function resolveTraceFromId(
|
|
271
|
+
config: ObservabilityEnvConfig,
|
|
272
|
+
parsed: ParsedId,
|
|
273
|
+
explicitWindows?: { start: string; end: string },
|
|
274
|
+
): Effect.Effect<ResolvedTrace, ObservabilityToolError> {
|
|
275
|
+
return Effect.gen(function* () {
|
|
276
|
+
if (parsed.kind === "trace_id") {
|
|
277
|
+
const spans = yield* fetchFullTrace(config, parsed.normalizedId);
|
|
278
|
+
if (spans.length === 0) {
|
|
221
279
|
return yield* new ObservabilityToolError({
|
|
222
|
-
cause: new Error(
|
|
280
|
+
cause: new Error(`Trace ${parsed.normalizedId} returned zero spans`),
|
|
223
281
|
});
|
|
224
282
|
}
|
|
283
|
+
return {
|
|
284
|
+
resolution: {
|
|
285
|
+
via: "direct_trace_id" as const,
|
|
286
|
+
resolvedTraceId: parsed.normalizedId,
|
|
287
|
+
},
|
|
288
|
+
spans,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
225
291
|
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
`/api/datasources/proxy/uid/${config.tempoUid}/api/traces/${traceId.toLowerCase()}`,
|
|
230
|
-
);
|
|
231
|
-
const spans = flattenTrace(raw);
|
|
292
|
+
const windows = explicitWindows
|
|
293
|
+
? [{ start: explicitWindows.start, end: explicitWindows.end }]
|
|
294
|
+
: SPAN_SEARCH_WINDOWS;
|
|
232
295
|
|
|
233
|
-
|
|
296
|
+
const attemptedWindows: SearchWindow[] = [];
|
|
297
|
+
let usedWindow: SearchWindow | undefined;
|
|
298
|
+
let uniqueTraceIds: string[] = [];
|
|
299
|
+
|
|
300
|
+
for (const window of windows) {
|
|
301
|
+
attemptedWindows.push(window);
|
|
302
|
+
const searchResult = yield* searchTempoBySpanId(config, parsed.normalizedId, window);
|
|
303
|
+
const traces = searchResult.traces ?? [];
|
|
304
|
+
|
|
305
|
+
if (traces.length === 0) continue;
|
|
306
|
+
|
|
307
|
+
const candidateTraceIds = traces
|
|
308
|
+
.map((trace) => trace.traceID?.toLowerCase())
|
|
309
|
+
.filter((id): id is string => id !== undefined);
|
|
310
|
+
|
|
311
|
+
uniqueTraceIds = [...new Set(candidateTraceIds)];
|
|
312
|
+
|
|
313
|
+
if (uniqueTraceIds.length > 1) {
|
|
234
314
|
return yield* new ObservabilityToolError({
|
|
235
|
-
cause:
|
|
315
|
+
cause: {
|
|
316
|
+
message: `Ambiguous span ID ${parsed.normalizedId} — found in ${uniqueTraceIds.length} traces`,
|
|
317
|
+
code: "AMBIGUOUS_SPAN_ID",
|
|
318
|
+
retryable: true,
|
|
319
|
+
details: { candidateTraceIds: uniqueTraceIds },
|
|
320
|
+
},
|
|
236
321
|
});
|
|
237
322
|
}
|
|
238
323
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
324
|
+
usedWindow = window;
|
|
325
|
+
break;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (uniqueTraceIds.length === 0 || !usedWindow) {
|
|
329
|
+
const windowDesc = attemptedWindows
|
|
330
|
+
.map((window) => `${window.start} → ${window.end}`)
|
|
331
|
+
.join(", ");
|
|
332
|
+
return yield* new ObservabilityToolError({
|
|
333
|
+
cause: new Error(
|
|
334
|
+
`No trace found containing span ${parsed.normalizedId} (searched windows: ${windowDesc})`,
|
|
335
|
+
),
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const traceId = uniqueTraceIds[0];
|
|
340
|
+
const spans = yield* fetchFullTrace(config, traceId);
|
|
341
|
+
|
|
342
|
+
if (spans.length === 0) {
|
|
343
|
+
return yield* new ObservabilityToolError({
|
|
344
|
+
cause: new Error(`Trace ${traceId} returned zero spans`),
|
|
345
|
+
});
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const focusSpan = spans.find((span) => span.spanId === parsed.normalizedId);
|
|
349
|
+
|
|
350
|
+
return {
|
|
351
|
+
resolution: {
|
|
352
|
+
via: "span_search" as const,
|
|
353
|
+
resolvedTraceId: traceId,
|
|
354
|
+
searchedSpanId: parsed.normalizedId,
|
|
355
|
+
focusSpan,
|
|
356
|
+
attemptedWindows,
|
|
357
|
+
usedWindow,
|
|
358
|
+
},
|
|
359
|
+
spans,
|
|
360
|
+
};
|
|
361
|
+
});
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function handleTraceGet(
|
|
365
|
+
id: string,
|
|
366
|
+
format: OutputFormat,
|
|
367
|
+
env: string,
|
|
368
|
+
profile: Option.Option<string>,
|
|
369
|
+
) {
|
|
370
|
+
const startedAt = Date.now();
|
|
371
|
+
|
|
372
|
+
return Effect.gen(function* () {
|
|
373
|
+
const parsed = parseId(id);
|
|
374
|
+
if (!parsed) {
|
|
375
|
+
return yield* new ObservabilityToolError({
|
|
376
|
+
cause: {
|
|
377
|
+
message: `Invalid ID format: expected 32-char trace ID or 16-char span ID, got ${id.length} characters`,
|
|
378
|
+
code: "INVALID_ID_FORMAT",
|
|
379
|
+
retryable: false,
|
|
248
380
|
},
|
|
249
|
-
|
|
250
|
-
|
|
381
|
+
});
|
|
382
|
+
}
|
|
251
383
|
|
|
252
|
-
|
|
253
|
-
})
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
384
|
+
const config = yield* resolveConfig(env, profile);
|
|
385
|
+
const { resolution, spans } = yield* resolveTraceFromId(config, parsed);
|
|
386
|
+
|
|
387
|
+
const result = {
|
|
388
|
+
success: true,
|
|
389
|
+
message:
|
|
390
|
+
parsed.kind === "span_id"
|
|
391
|
+
? `Found trace ${resolution.resolvedTraceId} via span ${parsed.normalizedId} with ${spans.length} span(s)`
|
|
392
|
+
: `Resolved trace ${resolution.resolvedTraceId} with ${spans.length} span(s)`,
|
|
393
|
+
data: {
|
|
394
|
+
environment: env,
|
|
395
|
+
grafanaUrl: config.url,
|
|
396
|
+
tempoDatasourceUid: config.tempoUid,
|
|
397
|
+
input: parsed,
|
|
398
|
+
resolution,
|
|
399
|
+
summary: summarizeTrace(resolution.resolvedTraceId, spans),
|
|
400
|
+
spans,
|
|
401
|
+
},
|
|
402
|
+
executionTimeMs: Date.now() - startedAt,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
yield* Console.log(formatOutput(result, format));
|
|
406
|
+
}).pipe(
|
|
407
|
+
Effect.catch((error) =>
|
|
408
|
+
Effect.gen(function* () {
|
|
409
|
+
const result = {
|
|
410
|
+
success: false,
|
|
411
|
+
message: "Failed to resolve trace from Tempo",
|
|
412
|
+
error: formatObservabilityError(error),
|
|
413
|
+
hint: "Accepts 32-char trace ID or 16-char span ID. Check format and Grafana/Tempo connectivity",
|
|
414
|
+
executionTimeMs: Date.now() - startedAt,
|
|
415
|
+
};
|
|
416
|
+
yield* Console.log(formatOutput(result, format));
|
|
417
|
+
}),
|
|
418
|
+
),
|
|
419
|
+
);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const getCommand = Command.make(
|
|
423
|
+
"get",
|
|
424
|
+
{
|
|
425
|
+
id: Argument.string("id"),
|
|
426
|
+
format: formatOption,
|
|
427
|
+
env: envOption,
|
|
428
|
+
profile: profileOption,
|
|
267
429
|
},
|
|
268
|
-
|
|
430
|
+
({ id, format, env, profile }) => handleTraceGet(id, format, env, profile),
|
|
431
|
+
).pipe(Command.withDescription("Resolve a trace by trace ID or span ID via Grafana/Tempo"));
|
|
269
432
|
|
|
270
433
|
const logsCommand = Command.make(
|
|
271
434
|
"logs",
|
|
272
435
|
{
|
|
273
|
-
|
|
436
|
+
id: Argument.string("id"),
|
|
274
437
|
format: formatOption,
|
|
275
438
|
env: envOption,
|
|
276
439
|
profile: profileOption,
|
|
@@ -287,19 +450,26 @@ const logsCommand = Command.make(
|
|
|
287
450
|
Flag.withDefault("now"),
|
|
288
451
|
),
|
|
289
452
|
},
|
|
290
|
-
({
|
|
453
|
+
({ id, format, env, profile, limit, start, end }) => {
|
|
291
454
|
const startedAt = Date.now();
|
|
292
455
|
|
|
293
456
|
return Effect.gen(function* () {
|
|
294
|
-
|
|
457
|
+
const parsed = parseId(id);
|
|
458
|
+
if (!parsed) {
|
|
295
459
|
return yield* new ObservabilityToolError({
|
|
296
|
-
cause:
|
|
460
|
+
cause: {
|
|
461
|
+
message: `Invalid ID format: expected 32-char trace ID or 16-char span ID, got ${id.length} characters`,
|
|
462
|
+
code: "INVALID_ID_FORMAT",
|
|
463
|
+
retryable: false,
|
|
464
|
+
},
|
|
297
465
|
});
|
|
298
466
|
}
|
|
299
467
|
|
|
300
468
|
const config = yield* resolveConfig(env, profile);
|
|
301
|
-
const
|
|
302
|
-
const
|
|
469
|
+
const { resolution } = yield* resolveTraceFromId(config, parsed, { start, end });
|
|
470
|
+
const resolvedTraceId = resolution.resolvedTraceId;
|
|
471
|
+
|
|
472
|
+
const logql = `{job=~".+"} |= "${resolvedTraceId}"`;
|
|
303
473
|
const response = yield* observabilityDsQuery(config, config.lokiUid, "loki", logql, {
|
|
304
474
|
from: start,
|
|
305
475
|
to: end,
|
|
@@ -343,11 +513,16 @@ const logsCommand = Command.make(
|
|
|
343
513
|
|
|
344
514
|
const result = {
|
|
345
515
|
success: true,
|
|
346
|
-
message:
|
|
516
|
+
message:
|
|
517
|
+
parsed.kind === "span_id"
|
|
518
|
+
? `Found ${logs.length} log line(s) for trace ${resolvedTraceId} (resolved from span ${parsed.normalizedId})`
|
|
519
|
+
: `Found ${logs.length} log line(s) mentioning trace ${resolvedTraceId}`,
|
|
347
520
|
data: {
|
|
348
521
|
environment: env,
|
|
349
522
|
grafanaUrl: config.url,
|
|
350
523
|
lokiDatasourceUid: config.lokiUid,
|
|
524
|
+
input: parsed,
|
|
525
|
+
resolution,
|
|
351
526
|
query: logql,
|
|
352
527
|
logCount: logs.length,
|
|
353
528
|
logs: logs.toSorted((left, right) => right.timestamp.localeCompare(left.timestamp)),
|
|
@@ -363,7 +538,7 @@ const logsCommand = Command.make(
|
|
|
363
538
|
success: false,
|
|
364
539
|
message: "Failed to execute trace log lookup",
|
|
365
540
|
error: formatObservabilityError(error),
|
|
366
|
-
hint: "
|
|
541
|
+
hint: "Accepts 32-char trace ID or 16-char span ID. Check format and Grafana/Loki connectivity",
|
|
367
542
|
executionTimeMs: Date.now() - startedAt,
|
|
368
543
|
};
|
|
369
544
|
yield* Console.log(formatOutput(result, format));
|
|
@@ -371,9 +546,20 @@ const logsCommand = Command.make(
|
|
|
371
546
|
),
|
|
372
547
|
);
|
|
373
548
|
},
|
|
374
|
-
).pipe(Command.withDescription("Find Loki logs mentioning a trace ID"));
|
|
549
|
+
).pipe(Command.withDescription("Find Loki logs mentioning a trace (accepts trace ID or span ID)"));
|
|
550
|
+
|
|
551
|
+
const findCommand = Command.make(
|
|
552
|
+
"find",
|
|
553
|
+
{
|
|
554
|
+
id: Argument.string("id"),
|
|
555
|
+
format: formatOption,
|
|
556
|
+
env: envOption,
|
|
557
|
+
profile: profileOption,
|
|
558
|
+
},
|
|
559
|
+
({ id, format, env, profile }) => handleTraceGet(id, format, env, profile),
|
|
560
|
+
).pipe(Command.withDescription("Alias for 'trace get' — resolve a trace by trace ID or span ID"));
|
|
375
561
|
|
|
376
562
|
export const traceCommand = Command.make("trace", {}).pipe(
|
|
377
563
|
Command.withDescription("Tempo trace operations"),
|
|
378
|
-
Command.withSubcommands([getCommand, logsCommand]),
|
|
564
|
+
Command.withSubcommands([getCommand, logsCommand, findCommand]),
|
|
379
565
|
);
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
1
3
|
export type ObservabilityEnvConfig = {
|
|
2
4
|
url: string;
|
|
3
5
|
token?: string;
|
|
@@ -89,6 +91,52 @@ export type TraceSummary = {
|
|
|
89
91
|
readonly endedAtUnixNano?: string;
|
|
90
92
|
};
|
|
91
93
|
|
|
94
|
+
export type TempoSearchResponse = {
|
|
95
|
+
readonly traces?: ReadonlyArray<{
|
|
96
|
+
readonly traceID?: string;
|
|
97
|
+
readonly rootServiceName?: string;
|
|
98
|
+
readonly rootTraceName?: string;
|
|
99
|
+
readonly startTimeUnixNano?: string;
|
|
100
|
+
readonly durationMs?: number;
|
|
101
|
+
readonly spanSets?: ReadonlyArray<{
|
|
102
|
+
readonly spans?: ReadonlyArray<{
|
|
103
|
+
readonly spanID?: string;
|
|
104
|
+
readonly startTimeUnixNano?: string;
|
|
105
|
+
readonly durationNanos?: string;
|
|
106
|
+
readonly attributes?: ReadonlyArray<OtlpAttribute>;
|
|
107
|
+
}>;
|
|
108
|
+
readonly matched?: number;
|
|
109
|
+
}>;
|
|
110
|
+
}>;
|
|
111
|
+
readonly metrics?: Record<string, unknown>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export const IdKind = Schema.Literals(["trace_id", "span_id"]);
|
|
115
|
+
export type IdKind = typeof IdKind.Type;
|
|
116
|
+
|
|
117
|
+
export const ResolutionVia = Schema.Literals(["direct_trace_id", "span_search"]);
|
|
118
|
+
export type ResolutionVia = typeof ResolutionVia.Type;
|
|
119
|
+
|
|
120
|
+
export type ParsedId = {
|
|
121
|
+
readonly rawId: string;
|
|
122
|
+
readonly normalizedId: string;
|
|
123
|
+
readonly kind: IdKind;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export type SearchWindow = {
|
|
127
|
+
readonly start: string;
|
|
128
|
+
readonly end: string;
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export type SpanResolution = {
|
|
132
|
+
readonly via: ResolutionVia;
|
|
133
|
+
readonly resolvedTraceId: string;
|
|
134
|
+
readonly searchedSpanId?: string;
|
|
135
|
+
readonly focusSpan?: FlattenedSpan;
|
|
136
|
+
readonly attemptedWindows?: ReadonlyArray<SearchWindow>;
|
|
137
|
+
readonly usedWindow?: SearchWindow;
|
|
138
|
+
};
|
|
139
|
+
|
|
92
140
|
export type DsQueryOpts = {
|
|
93
141
|
instant?: boolean;
|
|
94
142
|
from?: string;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { AgentToolsConfig, VpnPrerequisite, ProfilePrerequisites } from "#config/types";
|
|
2
|
+
import type { PrerequisiteResolution } from "#shared/prerequisites/types";
|
|
3
|
+
|
|
4
|
+
export function normalizeProfilePrerequisites(
|
|
5
|
+
profile: ProfilePrerequisites,
|
|
6
|
+
): readonly VpnPrerequisite[] {
|
|
7
|
+
const prerequisites = [...(profile.prerequisites ?? [])];
|
|
8
|
+
|
|
9
|
+
if (
|
|
10
|
+
profile.vpn &&
|
|
11
|
+
!prerequisites.some(
|
|
12
|
+
(prerequisite) => prerequisite.type === "vpn" && prerequisite.key === profile.vpn,
|
|
13
|
+
)
|
|
14
|
+
) {
|
|
15
|
+
prerequisites.push({ type: "vpn", key: profile.vpn });
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
return prerequisites;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function resolveProfilePrerequisites(
|
|
22
|
+
config: AgentToolsConfig,
|
|
23
|
+
profile: ProfilePrerequisites,
|
|
24
|
+
): PrerequisiteResolution {
|
|
25
|
+
const prerequisites = normalizeProfilePrerequisites(profile);
|
|
26
|
+
|
|
27
|
+
for (const prerequisite of prerequisites) {
|
|
28
|
+
if (prerequisite.type === "vpn" && !config.vpns?.[prerequisite.key]) {
|
|
29
|
+
return {
|
|
30
|
+
success: false,
|
|
31
|
+
error: `VPN prerequisite "${prerequisite.key}" is not defined.`,
|
|
32
|
+
hint: `Add vpns.${prerequisite.key} to agent-tools.json5 or remove the prerequisite.`,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { success: true, prerequisites };
|
|
38
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
LinuxNmcliVpnDriverConfig,
|
|
3
|
+
MacosScutilVpnDriverConfig,
|
|
4
|
+
VpnPrerequisite,
|
|
5
|
+
WindowsRasdialVpnDriverConfig,
|
|
6
|
+
} from "#config/types";
|
|
7
|
+
|
|
8
|
+
export type PrerequisiteResolution =
|
|
9
|
+
| { success: true; prerequisites: readonly VpnPrerequisite[] }
|
|
10
|
+
| { success: false; error: string; hint: string };
|
|
11
|
+
|
|
12
|
+
export type SupportedPlatform = "darwin" | "linux" | "win32";
|
|
13
|
+
|
|
14
|
+
export type ResolvedVpnDriver =
|
|
15
|
+
| (Required<MacosScutilVpnDriverConfig> & { platform: "darwin" })
|
|
16
|
+
| (Required<LinuxNmcliVpnDriverConfig> & { platform: "linux" })
|
|
17
|
+
| (Required<WindowsRasdialVpnDriverConfig> & { platform: "win32" });
|
|
18
|
+
|
|
19
|
+
export type VpnDriverResolution =
|
|
20
|
+
| { success: true; driver: ResolvedVpnDriver }
|
|
21
|
+
| { success: false; error: string; hint: string };
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import type { VpnConfig, VpnDriverConfig } from "#config/types";
|
|
2
|
+
import type {
|
|
3
|
+
ResolvedVpnDriver,
|
|
4
|
+
SupportedPlatform,
|
|
5
|
+
VpnDriverResolution,
|
|
6
|
+
} from "#shared/prerequisites/types";
|
|
7
|
+
|
|
8
|
+
const isSupportedPlatform = (platform: typeof process.platform): platform is SupportedPlatform =>
|
|
9
|
+
platform === "darwin" || platform === "linux" || platform === "win32";
|
|
10
|
+
|
|
11
|
+
const resolveExplicitDriver = (
|
|
12
|
+
config: VpnConfig,
|
|
13
|
+
platform: SupportedPlatform,
|
|
14
|
+
driver: VpnDriverConfig,
|
|
15
|
+
): VpnDriverResolution => {
|
|
16
|
+
if (driver.type === "macos-scutil") {
|
|
17
|
+
if (platform !== "darwin") {
|
|
18
|
+
return {
|
|
19
|
+
success: false,
|
|
20
|
+
error: `VPN driver "${driver.type}" is not supported on ${platform}.`,
|
|
21
|
+
hint: "Configure a driver for the current OS or enable auto detection.",
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
success: true,
|
|
27
|
+
driver: { platform, type: driver.type, serviceName: driver.serviceName ?? config.name },
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (driver.type === "linux-nmcli") {
|
|
32
|
+
if (platform !== "linux") {
|
|
33
|
+
return {
|
|
34
|
+
success: false,
|
|
35
|
+
error: `VPN driver "${driver.type}" is not supported on ${platform}.`,
|
|
36
|
+
hint: "Configure a driver for the current OS or enable auto detection.",
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
success: true,
|
|
42
|
+
driver: { platform, type: driver.type, connectionName: driver.connectionName ?? config.name },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (driver.type === "windows-rasdial") {
|
|
47
|
+
if (platform !== "win32") {
|
|
48
|
+
return {
|
|
49
|
+
success: false,
|
|
50
|
+
error: `VPN driver "${driver.type}" is not supported on ${platform}.`,
|
|
51
|
+
hint: "Configure a driver for the current OS or enable auto detection.",
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
success: true,
|
|
57
|
+
driver: { platform, type: driver.type, entryName: driver.entryName ?? config.name },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const exhaustive: never = driver;
|
|
62
|
+
return exhaustive;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export function resolveVpnDriverConfig(
|
|
66
|
+
config: VpnConfig,
|
|
67
|
+
platform: typeof process.platform = process.platform,
|
|
68
|
+
): VpnDriverResolution {
|
|
69
|
+
if (!isSupportedPlatform(platform)) {
|
|
70
|
+
return {
|
|
71
|
+
success: false,
|
|
72
|
+
error: `VPN auto detection is not supported on ${platform}.`,
|
|
73
|
+
hint: "Configure an explicit supported VPN driver for this platform.",
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const platformDriver = config.drivers?.[platform];
|
|
78
|
+
if (platformDriver) {
|
|
79
|
+
return resolveExplicitDriver(config, platform, platformDriver);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (config.driver) {
|
|
83
|
+
return resolveExplicitDriver(config, platform, config.driver);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const auto = config.auto ?? true;
|
|
87
|
+
if (!auto) {
|
|
88
|
+
return {
|
|
89
|
+
success: false,
|
|
90
|
+
error: "VPN auto detection is disabled, but no driver is configured for this platform.",
|
|
91
|
+
hint: `Add vpns.<key>.drivers.${platform} or enable auto detection.`,
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (platform === "darwin") {
|
|
96
|
+
return { success: true, driver: { platform, type: "macos-scutil", serviceName: config.name } };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (platform === "linux") {
|
|
100
|
+
return {
|
|
101
|
+
success: true,
|
|
102
|
+
driver: { platform, type: "linux-nmcli", connectionName: config.name },
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { success: true, driver: { platform, type: "windows-rasdial", entryName: config.name } };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function missingVpnToolHint(driver: ResolvedVpnDriver): string {
|
|
110
|
+
if (driver.type === "macos-scutil") {
|
|
111
|
+
return "scutil was not found or is unavailable. Ensure macOS system tools are available and the VPN service name matches the Network service name.";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (driver.type === "linux-nmcli") {
|
|
115
|
+
return "nmcli was not found. Install or enable NetworkManager CLI, or configure an explicit supported VPN driver.";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return "rasdial was not found or is unavailable. Ensure Windows RAS tooling is available and the VPN entry name matches the configured connection.";
|
|
119
|
+
}
|