@wpmoo/toolkit 0.9.25 → 0.9.27
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 +24 -5
- package/dist/audit-log.js +78 -0
- package/dist/daily-actions.js +112 -67
- package/dist/databases.js +57 -0
- package/dist/doctor.js +6 -266
- package/dist/environment-policy.js +219 -0
- package/dist/migrations.js +112 -0
- package/dist/postgres-diagnostics.js +646 -0
- package/dist/templates.js +61 -0
- package/docs/generated-environment-verification.md +11 -11
- package/package.json +1 -1
package/dist/doctor.js
CHANGED
|
@@ -5,6 +5,7 @@ import { detectComposeLayout, readEnvFile, selectedComposeEnvironment } from './
|
|
|
5
5
|
import { dailyActionScripts } from './daily-actions.js';
|
|
6
6
|
import { defaultPostgresVersion } from './external-templates.js';
|
|
7
7
|
import { defaultOdooVersion, markerPath, replaceSourceRepos } from './environment.js';
|
|
8
|
+
import { POSTGRES_DIAGNOSTICS_CONTRACT_VERSION, POSTGRES_DIAGNOSTICS_QUERY, malformedPostgresDiagnosticKeys, missingPostgresDiagnosticKeys, parsePostgresDiagnostics, postgresPostgresWarnings, renderPostgresDiagnostics, structuredPostgresDiagnostics, unavailablePostgresDiagnosticsWarning, } from './postgres-diagnostics.js';
|
|
8
9
|
import { listGitmoduleSources, readSourceManifest, sourceReposFromManifest, sourceManifestPath, syncManifestFromMetadataAndGitmodules, writeSourceManifest, } from './source-manifest.js';
|
|
9
10
|
const realCommandRunner = async (command, args, options) => {
|
|
10
11
|
const result = await execa(command, args, { cwd: options.cwd });
|
|
@@ -46,128 +47,6 @@ function isMetadataError(message) {
|
|
|
46
47
|
message.startsWith('Invalid sourceRepos entry in .wpmoo/odoo.json'));
|
|
47
48
|
}
|
|
48
49
|
const incompatiblePostgres18MountTargets = ['/var/lib/postgresql/data', '/var/lib/postgresql/18/docker'];
|
|
49
|
-
const postgresDiagnosticQuery = `
|
|
50
|
-
WITH metrics(metric, value) AS (
|
|
51
|
-
SELECT 'database_count', count(*)::text
|
|
52
|
-
FROM pg_database
|
|
53
|
-
WHERE datistemplate = false
|
|
54
|
-
UNION ALL
|
|
55
|
-
SELECT 'active_connections', count(*)::text
|
|
56
|
-
FROM pg_stat_activity
|
|
57
|
-
WHERE datname IS NOT NULL
|
|
58
|
-
AND state = 'active'
|
|
59
|
-
UNION ALL
|
|
60
|
-
SELECT 'connection_count', count(*)::text
|
|
61
|
-
FROM pg_stat_activity
|
|
62
|
-
WHERE datname IS NOT NULL
|
|
63
|
-
UNION ALL
|
|
64
|
-
SELECT 'max_connections', COALESCE(
|
|
65
|
-
(SELECT setting FROM pg_settings WHERE name = 'max_connections'),
|
|
66
|
-
'unavailable'
|
|
67
|
-
)
|
|
68
|
-
UNION ALL
|
|
69
|
-
SELECT 'total_database_size_bytes', COALESCE(sum(pg_database_size(datname)), 0)::text
|
|
70
|
-
FROM pg_database
|
|
71
|
-
WHERE datistemplate = false
|
|
72
|
-
UNION ALL
|
|
73
|
-
SELECT 'largest_database_name', COALESCE(
|
|
74
|
-
(
|
|
75
|
-
SELECT datname
|
|
76
|
-
FROM pg_database
|
|
77
|
-
WHERE datistemplate = false
|
|
78
|
-
ORDER BY pg_database_size(datname) DESC, datname
|
|
79
|
-
LIMIT 1
|
|
80
|
-
),
|
|
81
|
-
'unavailable'
|
|
82
|
-
)
|
|
83
|
-
UNION ALL
|
|
84
|
-
SELECT 'largest_database_size_bytes', COALESCE(
|
|
85
|
-
(
|
|
86
|
-
SELECT pg_database_size(datname)::text
|
|
87
|
-
FROM pg_database
|
|
88
|
-
WHERE datistemplate = false
|
|
89
|
-
ORDER BY pg_database_size(datname) DESC, datname
|
|
90
|
-
LIMIT 1
|
|
91
|
-
),
|
|
92
|
-
'0'
|
|
93
|
-
)
|
|
94
|
-
UNION ALL
|
|
95
|
-
SELECT 'slow_query_logging', COALESCE(
|
|
96
|
-
(SELECT setting || unit FROM pg_settings WHERE name = 'log_min_duration_statement'),
|
|
97
|
-
'unavailable'
|
|
98
|
-
)
|
|
99
|
-
UNION ALL
|
|
100
|
-
SELECT 'pg_stat_statements',
|
|
101
|
-
CASE
|
|
102
|
-
WHEN EXISTS (SELECT 1 FROM pg_extension WHERE extname = 'pg_stat_statements') THEN 'installed'
|
|
103
|
-
WHEN EXISTS (SELECT 1 FROM pg_available_extensions WHERE name = 'pg_stat_statements') THEN 'available'
|
|
104
|
-
ELSE 'unavailable'
|
|
105
|
-
END
|
|
106
|
-
UNION ALL
|
|
107
|
-
SELECT 'pg_stat_statements_available_version', COALESCE(
|
|
108
|
-
(SELECT default_version FROM pg_available_extensions WHERE name = 'pg_stat_statements'),
|
|
109
|
-
'unavailable'
|
|
110
|
-
)
|
|
111
|
-
UNION ALL
|
|
112
|
-
SELECT 'pg_stat_statements_installed_version', COALESCE(
|
|
113
|
-
(SELECT extversion FROM pg_extension WHERE extname = 'pg_stat_statements'),
|
|
114
|
-
''
|
|
115
|
-
)
|
|
116
|
-
UNION ALL
|
|
117
|
-
SELECT 'shared_preload_libraries', COALESCE(
|
|
118
|
-
(SELECT setting FROM pg_settings WHERE name = 'shared_preload_libraries'),
|
|
119
|
-
'unavailable'
|
|
120
|
-
)
|
|
121
|
-
UNION ALL
|
|
122
|
-
SELECT 'shared_buffers', COALESCE(
|
|
123
|
-
(SELECT setting FROM pg_settings WHERE name = 'shared_buffers'),
|
|
124
|
-
'unavailable'
|
|
125
|
-
)
|
|
126
|
-
)
|
|
127
|
-
SELECT metric || '|' || value
|
|
128
|
-
FROM metrics
|
|
129
|
-
ORDER BY CASE metric
|
|
130
|
-
WHEN 'database_count' THEN 1
|
|
131
|
-
WHEN 'active_connections' THEN 2
|
|
132
|
-
WHEN 'connection_count' THEN 3
|
|
133
|
-
WHEN 'max_connections' THEN 4
|
|
134
|
-
WHEN 'total_database_size_bytes' THEN 5
|
|
135
|
-
WHEN 'largest_database_name' THEN 6
|
|
136
|
-
WHEN 'largest_database_size_bytes' THEN 7
|
|
137
|
-
WHEN 'slow_query_logging' THEN 8
|
|
138
|
-
WHEN 'pg_stat_statements' THEN 9
|
|
139
|
-
WHEN 'pg_stat_statements_available_version' THEN 10
|
|
140
|
-
WHEN 'pg_stat_statements_installed_version' THEN 11
|
|
141
|
-
WHEN 'shared_preload_libraries' THEN 12
|
|
142
|
-
WHEN 'shared_buffers' THEN 13
|
|
143
|
-
ELSE 99
|
|
144
|
-
END;
|
|
145
|
-
`.trim();
|
|
146
|
-
const postgresDiagnosticKeys = [
|
|
147
|
-
'database_count',
|
|
148
|
-
'active_connections',
|
|
149
|
-
'connection_count',
|
|
150
|
-
'max_connections',
|
|
151
|
-
'total_database_size_bytes',
|
|
152
|
-
'largest_database_name',
|
|
153
|
-
'largest_database_size_bytes',
|
|
154
|
-
'slow_query_logging',
|
|
155
|
-
'pg_stat_statements',
|
|
156
|
-
'pg_stat_statements_available_version',
|
|
157
|
-
'pg_stat_statements_installed_version',
|
|
158
|
-
'shared_preload_libraries',
|
|
159
|
-
'shared_buffers',
|
|
160
|
-
];
|
|
161
|
-
const requiredPostgresDiagnosticKeys = [
|
|
162
|
-
'database_count',
|
|
163
|
-
'active_connections',
|
|
164
|
-
'connection_count',
|
|
165
|
-
'max_connections',
|
|
166
|
-
'total_database_size_bytes',
|
|
167
|
-
'slow_query_logging',
|
|
168
|
-
'pg_stat_statements',
|
|
169
|
-
'shared_buffers',
|
|
170
|
-
];
|
|
171
50
|
function parsePostgresMajorFromValue(value) {
|
|
172
51
|
if (!value)
|
|
173
52
|
return undefined;
|
|
@@ -178,139 +57,8 @@ function parsePostgresMajorFromValue(value) {
|
|
|
178
57
|
const match = trimmed.match(/postgres:([0-9]{1,3})(?:[-._][A-Za-z0-9._-]+)?(?:@[\w:.-]+)?/i);
|
|
179
58
|
return match?.[1];
|
|
180
59
|
}
|
|
181
|
-
function parsePostgresDiagnostics(output) {
|
|
182
|
-
const diagnostics = {};
|
|
183
|
-
const allowedKeys = new Set(postgresDiagnosticKeys);
|
|
184
|
-
for (const rawLine of output.split(/\r?\n/u)) {
|
|
185
|
-
const line = rawLine.trim();
|
|
186
|
-
if (!line)
|
|
187
|
-
continue;
|
|
188
|
-
const separatorIndex = line.indexOf('|');
|
|
189
|
-
if (separatorIndex === -1)
|
|
190
|
-
continue;
|
|
191
|
-
const key = line.slice(0, separatorIndex).trim();
|
|
192
|
-
const value = line.slice(separatorIndex + 1).trim();
|
|
193
|
-
if (allowedKeys.has(key) && value) {
|
|
194
|
-
diagnostics[key] = value;
|
|
195
|
-
}
|
|
196
|
-
}
|
|
197
|
-
return diagnostics;
|
|
198
|
-
}
|
|
199
|
-
function renderPostgresDiagnostics(diagnostics) {
|
|
200
|
-
const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
|
|
201
|
-
const parts = postgresDiagnosticKeys.flatMap((key) => {
|
|
202
|
-
const value = diagnostics[key];
|
|
203
|
-
const rendered = value ? [`${key}=${value}`] : [];
|
|
204
|
-
if (key === 'max_connections' && connectionUtilizationPct !== undefined) {
|
|
205
|
-
rendered.push(`connection_utilization_pct=${connectionUtilizationPct}`);
|
|
206
|
-
}
|
|
207
|
-
return rendered;
|
|
208
|
-
});
|
|
209
|
-
return parts.length > 0 ? `OK PostgreSQL diagnostics ${parts.join(' ')}` : undefined;
|
|
210
|
-
}
|
|
211
|
-
function missingPostgresDiagnosticKeys(diagnostics) {
|
|
212
|
-
return requiredPostgresDiagnosticKeys.filter((key) => !diagnostics[key]);
|
|
213
|
-
}
|
|
214
|
-
function unavailablePostgresDiagnosticsWarning(diagnostics, missingKeys) {
|
|
215
|
-
return Object.keys(diagnostics).length === 0
|
|
216
|
-
? 'no diagnostic rows returned'
|
|
217
|
-
: `incomplete diagnostic rows: missing ${missingKeys.join(', ')}`;
|
|
218
|
-
}
|
|
219
|
-
function integerDiagnostic(value) {
|
|
220
|
-
if (!value || !/^\d+$/u.test(value)) {
|
|
221
|
-
return undefined;
|
|
222
|
-
}
|
|
223
|
-
return Number.parseInt(value, 10);
|
|
224
|
-
}
|
|
225
|
-
function malformedPostgresDiagnosticKeys(diagnostics) {
|
|
226
|
-
const numericKeys = [
|
|
227
|
-
'database_count',
|
|
228
|
-
'active_connections',
|
|
229
|
-
'connection_count',
|
|
230
|
-
'max_connections',
|
|
231
|
-
'total_database_size_bytes',
|
|
232
|
-
'largest_database_size_bytes',
|
|
233
|
-
];
|
|
234
|
-
return numericKeys.filter((key) => diagnostics[key] !== undefined && integerDiagnostic(diagnostics[key]) === undefined);
|
|
235
|
-
}
|
|
236
|
-
function postgresConnectionUtilizationPct(diagnostics) {
|
|
237
|
-
const connectionCount = integerDiagnostic(diagnostics.connection_count);
|
|
238
|
-
const maxConnections = integerDiagnostic(diagnostics.max_connections);
|
|
239
|
-
if (connectionCount === undefined || maxConnections === undefined || maxConnections <= 0) {
|
|
240
|
-
return undefined;
|
|
241
|
-
}
|
|
242
|
-
return Math.round((connectionCount / maxConnections) * 100);
|
|
243
|
-
}
|
|
244
|
-
function postgresConnectionUtilizationWarning(diagnostics) {
|
|
245
|
-
const connectionCount = integerDiagnostic(diagnostics.connection_count);
|
|
246
|
-
const maxConnections = integerDiagnostic(diagnostics.max_connections);
|
|
247
|
-
const utilizationPct = postgresConnectionUtilizationPct(diagnostics);
|
|
248
|
-
if (connectionCount === undefined ||
|
|
249
|
-
maxConnections === undefined ||
|
|
250
|
-
utilizationPct === undefined ||
|
|
251
|
-
utilizationPct < 80) {
|
|
252
|
-
return undefined;
|
|
253
|
-
}
|
|
254
|
-
return `PostgreSQL connection utilization is high: ${utilizationPct}% of max_connections used (${connectionCount}/${maxConnections}).`;
|
|
255
|
-
}
|
|
256
|
-
function postgresSlowQueryLoggingWarning(diagnostics) {
|
|
257
|
-
const slowQueryLogging = diagnostics.slow_query_logging?.trim();
|
|
258
|
-
if (!slowQueryLogging || (!/^-1\s*(?:ms)?$/iu.test(slowQueryLogging) && !/^off$/iu.test(slowQueryLogging))) {
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
|
-
return `PostgreSQL slow-query logging is disabled (log_min_duration_statement=${slowQueryLogging}). Enable it before performance triage.`;
|
|
262
|
-
}
|
|
263
|
-
function postgresExtensionVisibilityWarning(diagnostics) {
|
|
264
|
-
if (diagnostics.pg_stat_statements === 'available' &&
|
|
265
|
-
diagnostics.pg_stat_statements_available_version &&
|
|
266
|
-
!diagnostics.pg_stat_statements_installed_version) {
|
|
267
|
-
return 'PostgreSQL pg_stat_statements is available but not installed. Install it before query-level performance triage.';
|
|
268
|
-
}
|
|
269
|
-
return undefined;
|
|
270
|
-
}
|
|
271
|
-
function structuredPostgresDiagnostics(diagnostics) {
|
|
272
|
-
const structured = {};
|
|
273
|
-
const databaseCount = integerDiagnostic(diagnostics.database_count);
|
|
274
|
-
const activeConnections = integerDiagnostic(diagnostics.active_connections);
|
|
275
|
-
const connectionCount = integerDiagnostic(diagnostics.connection_count);
|
|
276
|
-
const maxConnections = integerDiagnostic(diagnostics.max_connections);
|
|
277
|
-
const connectionUtilizationPct = postgresConnectionUtilizationPct(diagnostics);
|
|
278
|
-
const totalDatabaseSizeBytes = integerDiagnostic(diagnostics.total_database_size_bytes);
|
|
279
|
-
const largestDatabaseSizeBytes = integerDiagnostic(diagnostics.largest_database_size_bytes);
|
|
280
|
-
if (databaseCount !== undefined)
|
|
281
|
-
structured.databaseCount = databaseCount;
|
|
282
|
-
if (activeConnections !== undefined)
|
|
283
|
-
structured.activeConnections = activeConnections;
|
|
284
|
-
if (connectionCount !== undefined)
|
|
285
|
-
structured.connectionCount = connectionCount;
|
|
286
|
-
if (maxConnections !== undefined)
|
|
287
|
-
structured.maxConnections = maxConnections;
|
|
288
|
-
if (connectionUtilizationPct !== undefined)
|
|
289
|
-
structured.connectionUtilizationPct = connectionUtilizationPct;
|
|
290
|
-
if (totalDatabaseSizeBytes !== undefined)
|
|
291
|
-
structured.totalDatabaseSizeBytes = totalDatabaseSizeBytes;
|
|
292
|
-
if (diagnostics.largest_database_name)
|
|
293
|
-
structured.largestDatabaseName = diagnostics.largest_database_name;
|
|
294
|
-
if (largestDatabaseSizeBytes !== undefined)
|
|
295
|
-
structured.largestDatabaseSizeBytes = largestDatabaseSizeBytes;
|
|
296
|
-
if (diagnostics.slow_query_logging)
|
|
297
|
-
structured.slowQueryLogging = diagnostics.slow_query_logging;
|
|
298
|
-
if (diagnostics.pg_stat_statements)
|
|
299
|
-
structured.pgStatStatements = diagnostics.pg_stat_statements;
|
|
300
|
-
if (diagnostics.pg_stat_statements_available_version) {
|
|
301
|
-
structured.pgStatStatementsAvailableVersion = diagnostics.pg_stat_statements_available_version;
|
|
302
|
-
}
|
|
303
|
-
if (diagnostics.pg_stat_statements_installed_version) {
|
|
304
|
-
structured.pgStatStatementsInstalledVersion = diagnostics.pg_stat_statements_installed_version;
|
|
305
|
-
}
|
|
306
|
-
if (diagnostics.shared_preload_libraries)
|
|
307
|
-
structured.sharedPreloadLibraries = diagnostics.shared_preload_libraries;
|
|
308
|
-
if (diagnostics.shared_buffers)
|
|
309
|
-
structured.sharedBuffers = diagnostics.shared_buffers;
|
|
310
|
-
return structured;
|
|
311
|
-
}
|
|
312
60
|
async function readPostgresDiagnostics(target, runner) {
|
|
313
|
-
const queryLiteral = JSON.stringify(
|
|
61
|
+
const queryLiteral = JSON.stringify(POSTGRES_DIAGNOSTICS_QUERY);
|
|
314
62
|
const command = [
|
|
315
63
|
`query=${queryLiteral}`,
|
|
316
64
|
'. ./scripts/lib.sh >/dev/null',
|
|
@@ -716,21 +464,11 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
716
464
|
}
|
|
717
465
|
report.postgres = {
|
|
718
466
|
requested: true,
|
|
467
|
+
contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
|
|
719
468
|
available: true,
|
|
720
469
|
diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
|
|
721
470
|
};
|
|
722
|
-
|
|
723
|
-
if (connectionUtilizationWarning) {
|
|
724
|
-
warnings.push(connectionUtilizationWarning);
|
|
725
|
-
}
|
|
726
|
-
const slowQueryLoggingWarning = postgresSlowQueryLoggingWarning(postgresDiagnostics);
|
|
727
|
-
if (slowQueryLoggingWarning) {
|
|
728
|
-
warnings.push(slowQueryLoggingWarning);
|
|
729
|
-
}
|
|
730
|
-
const extensionVisibilityWarning = postgresExtensionVisibilityWarning(postgresDiagnostics);
|
|
731
|
-
if (extensionVisibilityWarning) {
|
|
732
|
-
warnings.push(extensionVisibilityWarning);
|
|
733
|
-
}
|
|
471
|
+
warnings.push(...postgresPostgresWarnings(postgresDiagnostics));
|
|
734
472
|
}
|
|
735
473
|
else {
|
|
736
474
|
const warning = malformedKeys.length > 0
|
|
@@ -739,6 +477,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
739
477
|
warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
|
|
740
478
|
report.postgres = {
|
|
741
479
|
requested: true,
|
|
480
|
+
contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
|
|
742
481
|
available: false,
|
|
743
482
|
diagnostics: structuredPostgresDiagnostics(postgresDiagnostics),
|
|
744
483
|
warning,
|
|
@@ -750,6 +489,7 @@ export async function getDoctorReport(target = process.cwd(), runnerOrOptions =
|
|
|
750
489
|
warnings.push(`PostgreSQL diagnostics unavailable: ${warning}`);
|
|
751
490
|
report.postgres = {
|
|
752
491
|
requested: true,
|
|
492
|
+
contractVersion: POSTGRES_DIAGNOSTICS_CONTRACT_VERSION,
|
|
753
493
|
available: false,
|
|
754
494
|
diagnostics: {},
|
|
755
495
|
warning,
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
export const dailyActionPolicyCommands = [
|
|
2
|
+
'start',
|
|
3
|
+
'stop',
|
|
4
|
+
'logs',
|
|
5
|
+
'restart',
|
|
6
|
+
'shell',
|
|
7
|
+
'psql',
|
|
8
|
+
'install',
|
|
9
|
+
'update',
|
|
10
|
+
'test',
|
|
11
|
+
'resetdb',
|
|
12
|
+
'snapshot',
|
|
13
|
+
'restore-snapshot',
|
|
14
|
+
'lint',
|
|
15
|
+
'pot',
|
|
16
|
+
];
|
|
17
|
+
function normalizeFlag(value) {
|
|
18
|
+
return value?.trim() ?? '';
|
|
19
|
+
}
|
|
20
|
+
function parseFlag(value) {
|
|
21
|
+
return normalizeFlag(value) === '1';
|
|
22
|
+
}
|
|
23
|
+
export const dailyActionPolicyTable = {
|
|
24
|
+
start: {
|
|
25
|
+
isDestructive: () => false,
|
|
26
|
+
isDryRunAllowed: false,
|
|
27
|
+
requiresStageLifecycleApproval: false,
|
|
28
|
+
requiresProdLifecycleApproval: false,
|
|
29
|
+
isAuditWorthy: () => false,
|
|
30
|
+
},
|
|
31
|
+
stop: {
|
|
32
|
+
isDestructive: () => false,
|
|
33
|
+
isDryRunAllowed: false,
|
|
34
|
+
requiresStageLifecycleApproval: false,
|
|
35
|
+
requiresProdLifecycleApproval: false,
|
|
36
|
+
isAuditWorthy: () => false,
|
|
37
|
+
},
|
|
38
|
+
logs: {
|
|
39
|
+
isDestructive: () => false,
|
|
40
|
+
isDryRunAllowed: false,
|
|
41
|
+
requiresStageLifecycleApproval: false,
|
|
42
|
+
requiresProdLifecycleApproval: false,
|
|
43
|
+
isAuditWorthy: () => false,
|
|
44
|
+
},
|
|
45
|
+
restart: {
|
|
46
|
+
isDestructive: () => false,
|
|
47
|
+
isDryRunAllowed: false,
|
|
48
|
+
requiresStageLifecycleApproval: false,
|
|
49
|
+
requiresProdLifecycleApproval: false,
|
|
50
|
+
isAuditWorthy: () => false,
|
|
51
|
+
},
|
|
52
|
+
shell: {
|
|
53
|
+
isDestructive: () => false,
|
|
54
|
+
isDryRunAllowed: false,
|
|
55
|
+
requiresStageLifecycleApproval: false,
|
|
56
|
+
requiresProdLifecycleApproval: false,
|
|
57
|
+
isAuditWorthy: () => false,
|
|
58
|
+
},
|
|
59
|
+
psql: {
|
|
60
|
+
isDestructive: () => false,
|
|
61
|
+
isDryRunAllowed: false,
|
|
62
|
+
requiresStageLifecycleApproval: false,
|
|
63
|
+
requiresProdLifecycleApproval: false,
|
|
64
|
+
isAuditWorthy: () => false,
|
|
65
|
+
},
|
|
66
|
+
install: {
|
|
67
|
+
isDestructive: () => false,
|
|
68
|
+
isDryRunAllowed: false,
|
|
69
|
+
requiresStageLifecycleApproval: true,
|
|
70
|
+
requiresProdLifecycleApproval: true,
|
|
71
|
+
isAuditWorthy: () => true,
|
|
72
|
+
},
|
|
73
|
+
update: {
|
|
74
|
+
isDestructive: () => false,
|
|
75
|
+
isDryRunAllowed: false,
|
|
76
|
+
requiresStageLifecycleApproval: true,
|
|
77
|
+
requiresProdLifecycleApproval: true,
|
|
78
|
+
isAuditWorthy: () => true,
|
|
79
|
+
},
|
|
80
|
+
test: {
|
|
81
|
+
isDestructive: () => false,
|
|
82
|
+
isDryRunAllowed: false,
|
|
83
|
+
requiresStageLifecycleApproval: false,
|
|
84
|
+
requiresProdLifecycleApproval: true,
|
|
85
|
+
isAuditWorthy: () => true,
|
|
86
|
+
},
|
|
87
|
+
resetdb: {
|
|
88
|
+
isDestructive: () => true,
|
|
89
|
+
isDryRunAllowed: false,
|
|
90
|
+
requiresStageLifecycleApproval: false,
|
|
91
|
+
requiresProdLifecycleApproval: false,
|
|
92
|
+
isAuditWorthy: () => true,
|
|
93
|
+
},
|
|
94
|
+
snapshot: {
|
|
95
|
+
isDestructive: () => false,
|
|
96
|
+
isDryRunAllowed: false,
|
|
97
|
+
requiresStageLifecycleApproval: false,
|
|
98
|
+
requiresProdLifecycleApproval: false,
|
|
99
|
+
isAuditWorthy: () => false,
|
|
100
|
+
},
|
|
101
|
+
'restore-snapshot': {
|
|
102
|
+
isDestructive: (args) => args[0] !== '--dry-run',
|
|
103
|
+
isDryRunAllowed: true,
|
|
104
|
+
requiresStageLifecycleApproval: false,
|
|
105
|
+
requiresProdLifecycleApproval: false,
|
|
106
|
+
isAuditWorthy: (args) => args[0] !== '--dry-run',
|
|
107
|
+
},
|
|
108
|
+
lint: {
|
|
109
|
+
isDestructive: () => false,
|
|
110
|
+
isDryRunAllowed: false,
|
|
111
|
+
requiresStageLifecycleApproval: false,
|
|
112
|
+
requiresProdLifecycleApproval: false,
|
|
113
|
+
isAuditWorthy: () => false,
|
|
114
|
+
},
|
|
115
|
+
pot: {
|
|
116
|
+
isDestructive: () => false,
|
|
117
|
+
isDryRunAllowed: false,
|
|
118
|
+
requiresStageLifecycleApproval: false,
|
|
119
|
+
requiresProdLifecycleApproval: false,
|
|
120
|
+
isAuditWorthy: () => false,
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
export function parseEnvironmentKind(rawEnvName) {
|
|
124
|
+
const normalized = rawEnvName?.trim().toLowerCase();
|
|
125
|
+
if (normalized === 'stage' || normalized === 'prod') {
|
|
126
|
+
return normalized;
|
|
127
|
+
}
|
|
128
|
+
return 'dev';
|
|
129
|
+
}
|
|
130
|
+
export function isDailyActionPolicyCommand(value) {
|
|
131
|
+
return dailyActionPolicyCommands.includes(value);
|
|
132
|
+
}
|
|
133
|
+
export function isRestoreSnapshotDryRun(args) {
|
|
134
|
+
return args[0] === '--dry-run';
|
|
135
|
+
}
|
|
136
|
+
function denyMessage(kind, command, env) {
|
|
137
|
+
if (kind === 'destructive') {
|
|
138
|
+
return `Refusing destructive command '${command}' in WPMOO_ENV=${env}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
|
|
139
|
+
}
|
|
140
|
+
if (kind === 'stage-lifecycle') {
|
|
141
|
+
return `Refusing stage lifecycle command '${command}' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally.`;
|
|
142
|
+
}
|
|
143
|
+
return `Refusing production lifecycle command '${command}' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally.`;
|
|
144
|
+
}
|
|
145
|
+
export function renderPolicyDenyMessage(deny) {
|
|
146
|
+
return denyMessage(deny.kind, deny.command, deny.env);
|
|
147
|
+
}
|
|
148
|
+
export function evaluateDailyActionPolicy(command, args, flags) {
|
|
149
|
+
const env = parseEnvironmentKind(flags.envName);
|
|
150
|
+
const policy = dailyActionPolicyTable[command];
|
|
151
|
+
const isDestructive = policy.isDestructive(args);
|
|
152
|
+
const isDryRunPreview = command === 'restore-snapshot' && isRestoreSnapshotDryRun(args);
|
|
153
|
+
const isAuditWorthy = policy.isAuditWorthy(args);
|
|
154
|
+
if (policy.requiresStageLifecycleApproval && env === 'stage' && !parseFlag(flags.allowStageLifecycle)) {
|
|
155
|
+
const deny = {
|
|
156
|
+
kind: 'stage-lifecycle',
|
|
157
|
+
command,
|
|
158
|
+
env,
|
|
159
|
+
requiredFlag: 'WPMOO_ALLOW_STAGE_LIFECYCLE',
|
|
160
|
+
requiredValue: '1',
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
allowed: false,
|
|
164
|
+
command,
|
|
165
|
+
env,
|
|
166
|
+
isDestructive,
|
|
167
|
+
isDryRunPreview,
|
|
168
|
+
isAuditWorthy,
|
|
169
|
+
deny,
|
|
170
|
+
message: renderPolicyDenyMessage(deny),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (policy.requiresProdLifecycleApproval && env === 'prod' && !parseFlag(flags.allowProdLifecycle)) {
|
|
174
|
+
const deny = {
|
|
175
|
+
kind: 'prod-lifecycle',
|
|
176
|
+
command,
|
|
177
|
+
env,
|
|
178
|
+
requiredFlag: 'WPMOO_ALLOW_PROD_LIFECYCLE',
|
|
179
|
+
requiredValue: '1',
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
allowed: false,
|
|
183
|
+
command,
|
|
184
|
+
env,
|
|
185
|
+
isDestructive,
|
|
186
|
+
isDryRunPreview,
|
|
187
|
+
isAuditWorthy,
|
|
188
|
+
deny,
|
|
189
|
+
message: renderPolicyDenyMessage(deny),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
if (isDestructive && (env === 'stage' || env === 'prod') && !parseFlag(flags.allowDestructive)) {
|
|
193
|
+
const deny = {
|
|
194
|
+
kind: 'destructive',
|
|
195
|
+
command,
|
|
196
|
+
env,
|
|
197
|
+
requiredFlag: 'WPMOO_ALLOW_DESTRUCTIVE',
|
|
198
|
+
requiredValue: '1',
|
|
199
|
+
};
|
|
200
|
+
return {
|
|
201
|
+
allowed: false,
|
|
202
|
+
command,
|
|
203
|
+
env,
|
|
204
|
+
isDestructive,
|
|
205
|
+
isDryRunPreview,
|
|
206
|
+
isAuditWorthy,
|
|
207
|
+
deny,
|
|
208
|
+
message: renderPolicyDenyMessage(deny),
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
return {
|
|
212
|
+
allowed: true,
|
|
213
|
+
command,
|
|
214
|
+
env,
|
|
215
|
+
isDestructive,
|
|
216
|
+
isDryRunPreview,
|
|
217
|
+
isAuditWorthy,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { lstat, readdir } from 'node:fs/promises';
|
|
2
|
+
import { resolve, join } from 'node:path';
|
|
3
|
+
const sourceRepoTypes = ['private', 'oca', 'external'];
|
|
4
|
+
const sourceRepoBase = ['odoo', 'custom', 'src'];
|
|
5
|
+
const migrationFolders = ['migrations', 'migration'];
|
|
6
|
+
const versionedMigrationFiles = ['pre-migration.py', 'post-migration.py', 'end-migration.py'];
|
|
7
|
+
const scriptMigrationFiles = ['migrate.py', 'migration.py'];
|
|
8
|
+
async function isDirectory(path) {
|
|
9
|
+
try {
|
|
10
|
+
const entry = await lstat(path);
|
|
11
|
+
return entry.isDirectory() && !entry.isSymbolicLink();
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
throw error;
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
async function isFile(path) {
|
|
21
|
+
try {
|
|
22
|
+
const entry = await lstat(path);
|
|
23
|
+
return entry.isFile() && !entry.isSymbolicLink();
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function listDirectoryNames(path) {
|
|
33
|
+
try {
|
|
34
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
35
|
+
const filtered = entries.filter((entry) => entry.isDirectory() && !entry.isSymbolicLink() && !entry.name.startsWith('.'));
|
|
36
|
+
return filtered.map((entry) => entry.name).sort();
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') {
|
|
40
|
+
return [];
|
|
41
|
+
}
|
|
42
|
+
throw error;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async function listMigrationFiles(modulePath, migrationFolder) {
|
|
46
|
+
const found = [];
|
|
47
|
+
const versionRoot = join(modulePath, migrationFolder);
|
|
48
|
+
if (!(await isDirectory(versionRoot))) {
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
const versions = await listDirectoryNames(versionRoot);
|
|
52
|
+
for (const version of versions) {
|
|
53
|
+
const versionPath = join(versionRoot, version);
|
|
54
|
+
if (!(await isDirectory(versionPath))) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
for (const file of versionedMigrationFiles) {
|
|
58
|
+
const path = join(versionPath, file);
|
|
59
|
+
if (await isFile(path)) {
|
|
60
|
+
found.push(path);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return found;
|
|
65
|
+
}
|
|
66
|
+
async function scanModule(modulePath) {
|
|
67
|
+
const found = [];
|
|
68
|
+
for (const folder of migrationFolders) {
|
|
69
|
+
found.push(...(await listMigrationFiles(modulePath, folder)));
|
|
70
|
+
}
|
|
71
|
+
const scriptsPath = join(modulePath, 'scripts');
|
|
72
|
+
if (!(await isDirectory(scriptsPath))) {
|
|
73
|
+
return found.sort();
|
|
74
|
+
}
|
|
75
|
+
for (const migrationScript of scriptMigrationFiles) {
|
|
76
|
+
const candidate = join(scriptsPath, migrationScript);
|
|
77
|
+
if (await isFile(candidate)) {
|
|
78
|
+
found.push(candidate);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return found;
|
|
82
|
+
}
|
|
83
|
+
export async function scanMigrationRisks(target) {
|
|
84
|
+
const root = resolve(target);
|
|
85
|
+
const foundPaths = [];
|
|
86
|
+
const srcRoot = join(root, ...sourceRepoBase);
|
|
87
|
+
for (const sourceType of sourceRepoTypes) {
|
|
88
|
+
const typeRoot = join(srcRoot, sourceType);
|
|
89
|
+
if (!(await isDirectory(typeRoot))) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
const repoNames = await listDirectoryNames(typeRoot);
|
|
93
|
+
for (const repoName of repoNames) {
|
|
94
|
+
const repoPath = join(typeRoot, repoName);
|
|
95
|
+
if (!(await isDirectory(repoPath))) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
const moduleNames = await listDirectoryNames(repoPath);
|
|
99
|
+
for (const moduleName of moduleNames) {
|
|
100
|
+
const modulePath = join(repoPath, moduleName);
|
|
101
|
+
const modulePaths = await scanModule(modulePath);
|
|
102
|
+
foundPaths.push(...modulePaths);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
foundPaths.sort();
|
|
107
|
+
return {
|
|
108
|
+
foundPaths,
|
|
109
|
+
count: foundPaths.length,
|
|
110
|
+
risk: foundPaths.length > 0,
|
|
111
|
+
};
|
|
112
|
+
}
|