@blogic-cz/agent-tools 0.14.9 → 0.14.11
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/package.json
CHANGED
package/src/db-tool/schema.ts
CHANGED
|
@@ -5,18 +5,39 @@
|
|
|
5
5
|
* This is a known limitation — callers must validate the table name
|
|
6
6
|
* via `isValidTableName()` before calling `getColumns()`.
|
|
7
7
|
*/
|
|
8
|
+
export const SYSTEM_SCHEMAS_SQL = "'pg_catalog', 'information_schema'";
|
|
9
|
+
|
|
10
|
+
export function parseTableReference(tableName: string): { schemaName?: string; tableName: string } {
|
|
11
|
+
const [schemaName, name, ...extra] = tableName.split(".");
|
|
12
|
+
|
|
13
|
+
if (name && extra.length === 0) {
|
|
14
|
+
return { schemaName, tableName: name };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return { tableName };
|
|
18
|
+
}
|
|
19
|
+
|
|
8
20
|
export const SCHEMA_QUERIES = {
|
|
9
21
|
tables: `
|
|
10
|
-
SELECT
|
|
22
|
+
SELECT
|
|
23
|
+
schemaname as schema,
|
|
24
|
+
tablename as name,
|
|
25
|
+
schemaname || '.' || tablename as qualified_name
|
|
11
26
|
FROM pg_tables
|
|
12
|
-
WHERE schemaname
|
|
13
|
-
ORDER BY tablename
|
|
27
|
+
WHERE schemaname NOT IN (${SYSTEM_SCHEMAS_SQL})
|
|
28
|
+
ORDER BY schemaname, tablename
|
|
14
29
|
`,
|
|
15
30
|
columns: (tableName: string) => {
|
|
16
|
-
const
|
|
31
|
+
const tableReference = parseTableReference(tableName);
|
|
32
|
+
const escapedTableName = tableReference.tableName.replaceAll("'", "''");
|
|
33
|
+
const schemaFilter = tableReference.schemaName
|
|
34
|
+
? `AND c.table_schema = '${tableReference.schemaName.replaceAll("'", "''")}'`
|
|
35
|
+
: `AND c.table_schema NOT IN (${SYSTEM_SCHEMAS_SQL})`;
|
|
17
36
|
|
|
18
37
|
return `
|
|
19
38
|
SELECT
|
|
39
|
+
c.table_schema as schema,
|
|
40
|
+
c.table_name as table,
|
|
20
41
|
c.column_name as name,
|
|
21
42
|
c.data_type as type,
|
|
22
43
|
c.is_nullable = 'YES' as nullable,
|
|
@@ -32,14 +53,16 @@ export const SCHEMA_QUERIES = {
|
|
|
32
53
|
) as is_primary_key
|
|
33
54
|
FROM information_schema.columns c
|
|
34
55
|
WHERE c.table_name = '${escapedTableName}'
|
|
35
|
-
|
|
36
|
-
ORDER BY c.ordinal_position
|
|
56
|
+
${schemaFilter}
|
|
57
|
+
ORDER BY c.table_schema, c.ordinal_position
|
|
37
58
|
`;
|
|
38
59
|
},
|
|
39
60
|
relationships: `
|
|
40
61
|
SELECT
|
|
62
|
+
tc.table_schema as from_schema,
|
|
41
63
|
tc.table_name as from_table,
|
|
42
64
|
kcu.column_name as from_column,
|
|
65
|
+
ccu.table_schema as to_schema,
|
|
43
66
|
ccu.table_name as to_table,
|
|
44
67
|
ccu.column_name as to_column,
|
|
45
68
|
tc.constraint_name
|
|
@@ -49,10 +72,10 @@ export const SCHEMA_QUERIES = {
|
|
|
49
72
|
AND tc.table_schema = kcu.table_schema
|
|
50
73
|
JOIN information_schema.constraint_column_usage ccu
|
|
51
74
|
ON ccu.constraint_name = tc.constraint_name
|
|
52
|
-
AND ccu.
|
|
75
|
+
AND ccu.constraint_schema = tc.constraint_schema
|
|
53
76
|
WHERE tc.constraint_type = 'FOREIGN KEY'
|
|
54
|
-
AND tc.table_schema
|
|
55
|
-
ORDER BY tc.table_name, kcu.column_name
|
|
77
|
+
AND tc.table_schema NOT IN (${SYSTEM_SCHEMAS_SQL})
|
|
78
|
+
ORDER BY tc.table_schema, tc.table_name, kcu.column_name
|
|
56
79
|
`,
|
|
57
80
|
};
|
|
58
81
|
|
package/src/db-tool/security.ts
CHANGED
|
@@ -10,7 +10,7 @@ const MUTATION_PATTERNS = [
|
|
|
10
10
|
/^\s*CREATE\s+/i,
|
|
11
11
|
];
|
|
12
12
|
|
|
13
|
-
const TABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]
|
|
13
|
+
const TABLE_NAME_PATTERN = /^[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)?$/;
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Strip SQL comments (block and line) while preserving string literals.
|
package/src/db-tool/service.ts
CHANGED
|
@@ -15,7 +15,13 @@ import {
|
|
|
15
15
|
DbTunnelError,
|
|
16
16
|
type DbError,
|
|
17
17
|
} from "./errors";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
getColumns,
|
|
20
|
+
getRelationships,
|
|
21
|
+
getTableNames,
|
|
22
|
+
parseTableReference,
|
|
23
|
+
SYSTEM_SCHEMAS_SQL,
|
|
24
|
+
} from "./schema";
|
|
19
25
|
import { detectSchemaError, isValidTableName, isMutationQuery } from "./security";
|
|
20
26
|
import { transformQueryResult } from "./transformers";
|
|
21
27
|
|
|
@@ -324,7 +330,7 @@ export class DbService extends Context.Service<
|
|
|
324
330
|
) {
|
|
325
331
|
const command = buildPsqlCommand(
|
|
326
332
|
config,
|
|
327
|
-
|
|
333
|
+
`SELECT schemaname || '.' || tablename FROM pg_tables WHERE schemaname NOT IN (${SYSTEM_SCHEMAS_SQL}) ORDER BY schemaname, tablename;`,
|
|
328
334
|
password,
|
|
329
335
|
true,
|
|
330
336
|
);
|
|
@@ -356,11 +362,16 @@ export class DbService extends Context.Service<
|
|
|
356
362
|
return [] as string[];
|
|
357
363
|
}
|
|
358
364
|
|
|
359
|
-
const
|
|
365
|
+
const tableReference = parseTableReference(tableName);
|
|
366
|
+
const escapedSchemaName = tableReference.schemaName?.replaceAll("'", "''");
|
|
367
|
+
const escapedTableName = tableReference.tableName.replaceAll("'", "''");
|
|
368
|
+
const schemaFilter = escapedSchemaName
|
|
369
|
+
? `AND table_schema = '${escapedSchemaName}'`
|
|
370
|
+
: `AND table_schema NOT IN (${SYSTEM_SCHEMAS_SQL})`;
|
|
360
371
|
|
|
361
372
|
const command = buildPsqlCommand(
|
|
362
373
|
config,
|
|
363
|
-
`SELECT column_name FROM information_schema.columns WHERE table_name = '${escapedTableName}'
|
|
374
|
+
`SELECT column_name FROM information_schema.columns WHERE table_name = '${escapedTableName}' ${schemaFilter} ORDER BY table_schema, ordinal_position;`,
|
|
364
375
|
password,
|
|
365
376
|
true,
|
|
366
377
|
);
|
|
@@ -516,21 +527,25 @@ export class DbService extends Context.Service<
|
|
|
516
527
|
}
|
|
517
528
|
|
|
518
529
|
const tables = tablesResult.data as {
|
|
530
|
+
schema?: string;
|
|
519
531
|
name: string;
|
|
532
|
+
qualified_name?: string;
|
|
520
533
|
}[];
|
|
521
534
|
const fullSchema: Record<string, unknown>[] = [];
|
|
522
535
|
|
|
523
536
|
for (const table of tables) {
|
|
524
537
|
const columnsResult = yield* executeSelectQuery(
|
|
525
538
|
config,
|
|
526
|
-
getColumns(table.name),
|
|
539
|
+
getColumns(table.qualified_name ?? table.name),
|
|
527
540
|
password,
|
|
528
541
|
startTimeMs,
|
|
529
542
|
).pipe(Effect.catch(() => Effect.succeed(null)));
|
|
530
543
|
|
|
531
544
|
if (columnsResult && columnsResult.success && columnsResult.data) {
|
|
532
545
|
fullSchema.push({
|
|
546
|
+
schema: table.schema,
|
|
533
547
|
table: table.name,
|
|
548
|
+
qualified_name: table.qualified_name ?? table.name,
|
|
534
549
|
columns: columnsResult.data,
|
|
535
550
|
});
|
|
536
551
|
}
|
|
@@ -668,7 +683,7 @@ export class DbService extends Context.Service<
|
|
|
668
683
|
return {
|
|
669
684
|
success: false,
|
|
670
685
|
error:
|
|
671
|
-
"Invalid table name. Use only letters, numbers, and
|
|
686
|
+
"Invalid table name. Use only letters, numbers, underscores, and an optional schema prefix, and start each identifier with a letter or underscore.",
|
|
672
687
|
executionTimeMs: Number(endTime) - Number(startTimeMs),
|
|
673
688
|
};
|
|
674
689
|
}
|
|
@@ -10,7 +10,6 @@ import { AuditServiceLayer, withAudit } from "#shared/audit";
|
|
|
10
10
|
import { VERSION } from "#shared";
|
|
11
11
|
|
|
12
12
|
import { metricsCommand } from "./metrics";
|
|
13
|
-
import { logsCommand } from "./logs";
|
|
14
13
|
import { traceCommand } from "./trace";
|
|
15
14
|
|
|
16
15
|
const renderCauseToStderr = (cause: Cause.Cause<unknown>) => Console.error(cause.toString());
|
|
@@ -19,7 +18,7 @@ const mainCommand = Command.make("observability-tool", {}).pipe(
|
|
|
19
18
|
Command.withDescription(
|
|
20
19
|
"LGTM observability queries — Tempo traces, Loki logs, Prometheus metrics",
|
|
21
20
|
),
|
|
22
|
-
Command.withSubcommands([traceCommand, metricsCommand
|
|
21
|
+
Command.withSubcommands([traceCommand, metricsCommand]),
|
|
23
22
|
);
|
|
24
23
|
|
|
25
24
|
const cli = Command.run(mainCommand, { version: VERSION });
|
|
@@ -13,7 +13,6 @@ import {
|
|
|
13
13
|
profileOption,
|
|
14
14
|
resolveConfig,
|
|
15
15
|
} from "./shared";
|
|
16
|
-
import { extractLogsFromDsQuery } from "./logs";
|
|
17
16
|
import type {
|
|
18
17
|
FlattenedSpan,
|
|
19
18
|
ObservabilityEnvConfig,
|
|
@@ -222,6 +221,18 @@ function summarizeTrace(traceId: string, spans: readonly FlattenedSpan[]): Trace
|
|
|
222
221
|
};
|
|
223
222
|
}
|
|
224
223
|
|
|
224
|
+
function parseLabel(value: string | Record<string, string>): Record<string, string> {
|
|
225
|
+
if (typeof value === "object" && value !== null) {
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
return JSON.parse(value) as Record<string, string>;
|
|
231
|
+
} catch {
|
|
232
|
+
return {};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
225
236
|
type ResolvedTrace = {
|
|
226
237
|
readonly resolution: SpanResolution;
|
|
227
238
|
readonly spans: FlattenedSpan[];
|
|
@@ -471,7 +482,34 @@ const logsCommand = Command.make(
|
|
|
471
482
|
});
|
|
472
483
|
}
|
|
473
484
|
|
|
474
|
-
const logs =
|
|
485
|
+
const logs: Array<{ timestamp: string; line: string; labels: Record<string, string> }> = [];
|
|
486
|
+
for (const frame of response.results.A.frames ?? []) {
|
|
487
|
+
const fields = frame.schema.fields;
|
|
488
|
+
const values = frame.data.values;
|
|
489
|
+
const timeIndex = fields.findIndex(
|
|
490
|
+
(field) => field.name === "timestamp" || field.type === "time",
|
|
491
|
+
);
|
|
492
|
+
const lineIndex = fields.findIndex(
|
|
493
|
+
(field) => field.name === "body" || field.name === "Line" || field.name === "line",
|
|
494
|
+
);
|
|
495
|
+
const labelsIndex = fields.findIndex(
|
|
496
|
+
(field) => field.name === "labels" || field.name === "labelTypes",
|
|
497
|
+
);
|
|
498
|
+
|
|
499
|
+
const timestamps = (timeIndex >= 0 ? values[timeIndex] : []) as Array<string | number>;
|
|
500
|
+
const lines = (lineIndex >= 0 ? values[lineIndex] : []) as string[];
|
|
501
|
+
const labelValues = (labelsIndex >= 0 ? values[labelsIndex] : []) as Array<
|
|
502
|
+
string | Record<string, string>
|
|
503
|
+
>;
|
|
504
|
+
|
|
505
|
+
for (const [index, line] of lines.entries()) {
|
|
506
|
+
logs.push({
|
|
507
|
+
timestamp: String(timestamps[index] ?? ""),
|
|
508
|
+
line,
|
|
509
|
+
labels: labelValues[index] ? parseLabel(labelValues[index]) : {},
|
|
510
|
+
});
|
|
511
|
+
}
|
|
512
|
+
}
|
|
475
513
|
|
|
476
514
|
const result = {
|
|
477
515
|
success: true,
|
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
import { Console, Effect } from "effect";
|
|
2
|
-
import { Argument, Command, Flag } from "effect/unstable/cli";
|
|
3
|
-
|
|
4
|
-
import { formatOption, formatOutput } from "#shared";
|
|
5
|
-
|
|
6
|
-
import { ObservabilityToolError } from "./errors";
|
|
7
|
-
import {
|
|
8
|
-
envOption,
|
|
9
|
-
formatObservabilityError,
|
|
10
|
-
observabilityDsQuery,
|
|
11
|
-
profileOption,
|
|
12
|
-
resolveConfig,
|
|
13
|
-
} from "./shared";
|
|
14
|
-
|
|
15
|
-
type LogLine = {
|
|
16
|
-
readonly timestamp: string;
|
|
17
|
-
readonly line: string;
|
|
18
|
-
readonly labels: Record<string, string>;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
|
-
function parseLabel(value: string | Record<string, string>): Record<string, string> {
|
|
22
|
-
if (typeof value === "object" && value !== null) {
|
|
23
|
-
return value;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
try {
|
|
27
|
-
return JSON.parse(value) as Record<string, string>;
|
|
28
|
-
} catch {
|
|
29
|
-
return {};
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function extractLogsFromDsQuery(response: {
|
|
34
|
-
results: {
|
|
35
|
-
A: {
|
|
36
|
-
frames?: Array<{
|
|
37
|
-
schema: { fields: Array<{ name: string; type?: string }> };
|
|
38
|
-
data: { values: unknown[][] };
|
|
39
|
-
}>;
|
|
40
|
-
};
|
|
41
|
-
};
|
|
42
|
-
}): LogLine[] {
|
|
43
|
-
const logs: LogLine[] = [];
|
|
44
|
-
|
|
45
|
-
for (const frame of response.results.A.frames ?? []) {
|
|
46
|
-
const fields = frame.schema.fields;
|
|
47
|
-
const values = frame.data.values;
|
|
48
|
-
const timeIndex = fields.findIndex(
|
|
49
|
-
(field) => field.name === "timestamp" || field.name === "Time" || field.type === "time",
|
|
50
|
-
);
|
|
51
|
-
const lineIndex = fields.findIndex(
|
|
52
|
-
(field) => field.name === "body" || field.name === "Line" || field.name === "line",
|
|
53
|
-
);
|
|
54
|
-
const labelsIndex = fields.findIndex(
|
|
55
|
-
(field) => field.name === "labels" || field.name === "labelTypes",
|
|
56
|
-
);
|
|
57
|
-
|
|
58
|
-
const timestamps = (timeIndex >= 0 ? values[timeIndex] : []) as Array<string | number>;
|
|
59
|
-
const lines = (lineIndex >= 0 ? values[lineIndex] : []) as string[];
|
|
60
|
-
const labelValues = (labelsIndex >= 0 ? values[labelsIndex] : []) as Array<
|
|
61
|
-
string | Record<string, string>
|
|
62
|
-
>;
|
|
63
|
-
|
|
64
|
-
for (const [index, line] of lines.entries()) {
|
|
65
|
-
logs.push({
|
|
66
|
-
timestamp: String(timestamps[index] ?? ""),
|
|
67
|
-
line,
|
|
68
|
-
labels: labelValues[index] ? parseLabel(labelValues[index]) : {},
|
|
69
|
-
});
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
return logs;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const queryCommand = Command.make(
|
|
77
|
-
"query",
|
|
78
|
-
{
|
|
79
|
-
logql: Argument.string("logql"),
|
|
80
|
-
format: formatOption,
|
|
81
|
-
env: envOption,
|
|
82
|
-
profile: profileOption,
|
|
83
|
-
start: Flag.string("start").pipe(
|
|
84
|
-
Flag.withDescription("Start time (default: now-1h)"),
|
|
85
|
-
Flag.withDefault("now-1h"),
|
|
86
|
-
),
|
|
87
|
-
end: Flag.string("end").pipe(
|
|
88
|
-
Flag.withDescription("End time (default: now)"),
|
|
89
|
-
Flag.withDefault("now"),
|
|
90
|
-
),
|
|
91
|
-
limit: Flag.integer("limit").pipe(
|
|
92
|
-
Flag.withDescription("Max log lines (default: 100)"),
|
|
93
|
-
Flag.withDefault(100),
|
|
94
|
-
),
|
|
95
|
-
},
|
|
96
|
-
({ logql, format, env, profile, start, end, limit }) => {
|
|
97
|
-
const startedAt = Date.now();
|
|
98
|
-
|
|
99
|
-
return Effect.gen(function* () {
|
|
100
|
-
const config = yield* resolveConfig(env, profile);
|
|
101
|
-
const response = yield* observabilityDsQuery(config, config.lokiUid, "loki", logql, {
|
|
102
|
-
from: start,
|
|
103
|
-
to: end,
|
|
104
|
-
maxLines: limit,
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
if (response.results.A.error) {
|
|
108
|
-
return yield* new ObservabilityToolError({ cause: new Error(response.results.A.error) });
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
const logs = extractLogsFromDsQuery(response).toSorted((left, right) =>
|
|
112
|
-
right.timestamp.localeCompare(left.timestamp),
|
|
113
|
-
);
|
|
114
|
-
|
|
115
|
-
const result = {
|
|
116
|
-
success: true,
|
|
117
|
-
message: `Found ${logs.length} Loki log line(s)`,
|
|
118
|
-
data: {
|
|
119
|
-
environment: env,
|
|
120
|
-
grafanaUrl: config.url,
|
|
121
|
-
lokiDatasourceUid: config.lokiUid,
|
|
122
|
-
query: logql,
|
|
123
|
-
start,
|
|
124
|
-
end,
|
|
125
|
-
limit,
|
|
126
|
-
logCount: logs.length,
|
|
127
|
-
logs,
|
|
128
|
-
},
|
|
129
|
-
executionTimeMs: Date.now() - startedAt,
|
|
130
|
-
};
|
|
131
|
-
|
|
132
|
-
yield* Console.log(formatOutput(result, format));
|
|
133
|
-
}).pipe(
|
|
134
|
-
Effect.catch((error) =>
|
|
135
|
-
Effect.gen(function* () {
|
|
136
|
-
const result = {
|
|
137
|
-
success: false,
|
|
138
|
-
message: "Failed to execute LogQL query",
|
|
139
|
-
error: formatObservabilityError(error),
|
|
140
|
-
hint: "Check LogQL syntax and Grafana/Loki connectivity",
|
|
141
|
-
executionTimeMs: Date.now() - startedAt,
|
|
142
|
-
};
|
|
143
|
-
yield* Console.log(formatOutput(result, format));
|
|
144
|
-
}),
|
|
145
|
-
),
|
|
146
|
-
);
|
|
147
|
-
},
|
|
148
|
-
).pipe(Command.withDescription("Execute LogQL range query via Grafana/Loki"));
|
|
149
|
-
|
|
150
|
-
export const logsCommand = Command.make("logs", {}).pipe(
|
|
151
|
-
Command.withDescription("Loki log operations via Grafana"),
|
|
152
|
-
Command.withSubcommands([queryCommand]),
|
|
153
|
-
);
|