@wpmoo/toolkit 0.9.25 → 0.9.26
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/dist/audit-log.js +78 -0
- package/dist/daily-actions.js +112 -67
- package/dist/databases.js +57 -0
- package/dist/environment-policy.js +219 -0
- package/dist/migrations.js +112 -0
- package/dist/templates.js +61 -0
- package/package.json +1 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { appendFile, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
const secretFlagNames = new Set([
|
|
4
|
+
'password',
|
|
5
|
+
'api-key',
|
|
6
|
+
'token',
|
|
7
|
+
'secret',
|
|
8
|
+
'api_key',
|
|
9
|
+
]);
|
|
10
|
+
function isSecretFlagToken(token) {
|
|
11
|
+
return token === '--password'
|
|
12
|
+
? '--password'
|
|
13
|
+
: token === '--api-key'
|
|
14
|
+
? '--api-key'
|
|
15
|
+
: token === '--token'
|
|
16
|
+
? '--token'
|
|
17
|
+
: token === '--secret'
|
|
18
|
+
? '--secret'
|
|
19
|
+
: undefined;
|
|
20
|
+
}
|
|
21
|
+
function isSecretKVToken(token) {
|
|
22
|
+
const firstEquals = token.indexOf('=');
|
|
23
|
+
if (firstEquals < 0) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
const [name] = [token.slice(0, firstEquals), token.slice(firstEquals + 1)];
|
|
27
|
+
return secretFlagNames.has(name.replace(/^--+/, '').toLowerCase()) ? name : undefined;
|
|
28
|
+
}
|
|
29
|
+
function normalizeFlag(flag) {
|
|
30
|
+
return flag.startsWith('--') ? flag : `--${flag}`;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Redacts values for common secret-like arguments.
|
|
34
|
+
*/
|
|
35
|
+
export function sanitizeCommandArgs(args) {
|
|
36
|
+
const sanitized = [];
|
|
37
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
38
|
+
const arg = args[index];
|
|
39
|
+
const secretKV = isSecretKVToken(arg);
|
|
40
|
+
if (secretKV) {
|
|
41
|
+
sanitized.push(`${secretKV}=***`);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const secretFlag = isSecretFlagToken(arg);
|
|
45
|
+
if (secretFlag && index + 1 < args.length && !args[index + 1].startsWith('--')) {
|
|
46
|
+
sanitized.push(arg);
|
|
47
|
+
sanitized.push('***');
|
|
48
|
+
index += 1;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
sanitized.push(arg);
|
|
52
|
+
}
|
|
53
|
+
return sanitized;
|
|
54
|
+
}
|
|
55
|
+
export function extractApprovedFlags(args, approvedFlagNames) {
|
|
56
|
+
const present = [];
|
|
57
|
+
const argsByIndex = args.map((arg) => arg.toLowerCase());
|
|
58
|
+
approvedFlagNames.forEach((name) => {
|
|
59
|
+
const normalized = normalizeFlag(name).toLowerCase();
|
|
60
|
+
if (argsByIndex.some((arg) => arg === normalized || arg.startsWith(`${normalized}=`))) {
|
|
61
|
+
present.push(name);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
return present;
|
|
65
|
+
}
|
|
66
|
+
export async function appendAuditLog(options) {
|
|
67
|
+
const logPath = join(options.environmentPath, '.wpmoo', 'audit.log');
|
|
68
|
+
const event = {
|
|
69
|
+
timestamp: (options.timestamp ?? new Date()).toISOString(),
|
|
70
|
+
command: options.command,
|
|
71
|
+
environment: options.environment,
|
|
72
|
+
dryRun: options.dryRun,
|
|
73
|
+
approvedFlags: [...(options.approvedFlags ?? extractApprovedFlags(options.args, options.approvedFlagNames))],
|
|
74
|
+
args: sanitizeCommandArgs(options.args),
|
|
75
|
+
};
|
|
76
|
+
await mkdir(dirname(logPath), { recursive: true });
|
|
77
|
+
await appendFile(logPath, `${JSON.stringify(event)}\n`, 'utf8');
|
|
78
|
+
}
|
package/dist/daily-actions.js
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { access } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { appendAuditLog } from './audit-log.js';
|
|
4
5
|
import { readEnvFile, selectedComposeEnvironment } from './compose-layout.js';
|
|
5
|
-
import { normalizeDatabaseName } from './databases.js';
|
|
6
|
+
import { defaultDatabaseSnapshotMaxAgeMs, findDatabaseSnapshots, normalizeDatabaseName } from './databases.js';
|
|
7
|
+
import { evaluateDailyActionPolicy, } from './environment-policy.js';
|
|
6
8
|
import { markerPath } from './environment.js';
|
|
9
|
+
import { scanMigrationRisks } from './migrations.js';
|
|
7
10
|
export const dailyActionCommands = [
|
|
8
11
|
'start',
|
|
9
12
|
'stop',
|
|
@@ -185,68 +188,6 @@ function scriptArgs(command, argv) {
|
|
|
185
188
|
return ensureNoArgs(command, argv);
|
|
186
189
|
return validateDatabaseArg(positionalArgs(command, argv, 1, 3), 1);
|
|
187
190
|
}
|
|
188
|
-
function isDestructiveCommand(command, args) {
|
|
189
|
-
if (command === 'resetdb')
|
|
190
|
-
return true;
|
|
191
|
-
return command === 'restore-snapshot' && args[0] !== '--dry-run';
|
|
192
|
-
}
|
|
193
|
-
function isProductionLifecycleCommand(command) {
|
|
194
|
-
return command === 'install' || command === 'update' || command === 'test';
|
|
195
|
-
}
|
|
196
|
-
function isStageLifecycleCommand(command) {
|
|
197
|
-
return command === 'install' || command === 'update';
|
|
198
|
-
}
|
|
199
|
-
function destructiveCommandError(command, envName) {
|
|
200
|
-
return `Refusing destructive command '${command}' in WPMOO_ENV=${envName}. Set WPMOO_ALLOW_DESTRUCTIVE=1 to run it intentionally.`;
|
|
201
|
-
}
|
|
202
|
-
function stageLifecycleCommandError(command) {
|
|
203
|
-
return `Refusing stage lifecycle command '${command}' in WPMOO_ENV=stage. Set WPMOO_ALLOW_STAGE_LIFECYCLE=1 to run it intentionally.`;
|
|
204
|
-
}
|
|
205
|
-
function productionLifecycleCommandError(command) {
|
|
206
|
-
return `Refusing production lifecycle command '${command}' in WPMOO_ENV=prod. Set WPMOO_ALLOW_PROD_LIFECYCLE=1 to run it intentionally.`;
|
|
207
|
-
}
|
|
208
|
-
async function assertDestructiveCommandAllowed(command, args, cwd) {
|
|
209
|
-
if (!isDestructiveCommand(command, args)) {
|
|
210
|
-
return;
|
|
211
|
-
}
|
|
212
|
-
const env = await readEnvFile(cwd);
|
|
213
|
-
const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
|
|
214
|
-
if (envName !== 'stage' && envName !== 'prod') {
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
const allowDestructive = process.env.WPMOO_ALLOW_DESTRUCTIVE?.trim() || env?.get('WPMOO_ALLOW_DESTRUCTIVE')?.trim();
|
|
218
|
-
if (allowDestructive !== '1') {
|
|
219
|
-
throw new Error(destructiveCommandError(command, envName));
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
async function assertProductionLifecycleCommandAllowed(command, cwd) {
|
|
223
|
-
if (!isProductionLifecycleCommand(command)) {
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
const env = await readEnvFile(cwd);
|
|
227
|
-
const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
|
|
228
|
-
if (envName !== 'prod') {
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
const allowProdLifecycle = process.env.WPMOO_ALLOW_PROD_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_PROD_LIFECYCLE')?.trim();
|
|
232
|
-
if (allowProdLifecycle !== '1') {
|
|
233
|
-
throw new Error(productionLifecycleCommandError(command));
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
async function assertStageLifecycleCommandAllowed(command, cwd) {
|
|
237
|
-
if (!isStageLifecycleCommand(command)) {
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
const env = await readEnvFile(cwd);
|
|
241
|
-
const envName = process.env.WPMOO_ENV?.trim() || selectedComposeEnvironment(env);
|
|
242
|
-
if (envName !== 'stage') {
|
|
243
|
-
return;
|
|
244
|
-
}
|
|
245
|
-
const allowStageLifecycle = process.env.WPMOO_ALLOW_STAGE_LIFECYCLE?.trim() || env?.get('WPMOO_ALLOW_STAGE_LIFECYCLE')?.trim();
|
|
246
|
-
if (allowStageLifecycle !== '1') {
|
|
247
|
-
throw new Error(stageLifecycleCommandError(command));
|
|
248
|
-
}
|
|
249
|
-
}
|
|
250
191
|
async function assertEnvironmentRoot(cwd) {
|
|
251
192
|
try {
|
|
252
193
|
await access(join(cwd, markerPath));
|
|
@@ -265,17 +206,121 @@ async function assertScriptExists(cwd, script) {
|
|
|
265
206
|
}
|
|
266
207
|
return scriptPath;
|
|
267
208
|
}
|
|
268
|
-
|
|
209
|
+
function envValue(env, key) {
|
|
210
|
+
return process.env[key]?.trim() || env?.get(key)?.trim();
|
|
211
|
+
}
|
|
212
|
+
function flagEnabled(env, key) {
|
|
213
|
+
return envValue(env, key) === '1';
|
|
214
|
+
}
|
|
215
|
+
function approvedFlags(env) {
|
|
216
|
+
return [
|
|
217
|
+
'WPMOO_ALLOW_DESTRUCTIVE',
|
|
218
|
+
'WPMOO_ALLOW_STAGE_LIFECYCLE',
|
|
219
|
+
'WPMOO_ALLOW_PROD_LIFECYCLE',
|
|
220
|
+
'WPMOO_ALLOW_NO_RECENT_SNAPSHOT',
|
|
221
|
+
'WPMOO_ALLOW_MIGRATIONS',
|
|
222
|
+
].filter((key) => flagEnabled(env, key));
|
|
223
|
+
}
|
|
224
|
+
function requiresMigrationApproval(command) {
|
|
225
|
+
return command === 'install' || command === 'update' || command === 'test';
|
|
226
|
+
}
|
|
227
|
+
function noRecentSnapshotMessage(command, environment) {
|
|
228
|
+
return `Refusing destructive command '${command}' in WPMOO_ENV=${environment} without a recent database snapshot. Create a snapshot first or set WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1 to run it intentionally.`;
|
|
229
|
+
}
|
|
230
|
+
function migrationRiskMessage(command, environment) {
|
|
231
|
+
return `Refusing migration-risk command '${command}' in WPMOO_ENV=${environment}. Review detected migration scripts or set WPMOO_ALLOW_MIGRATIONS=1 to run it intentionally.`;
|
|
232
|
+
}
|
|
233
|
+
async function auditDailyActionPreview(preview) {
|
|
234
|
+
if (preview.environment !== 'prod' || !preview.auditWorthy) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
await appendAuditLog({
|
|
238
|
+
environmentPath: preview.cwd,
|
|
239
|
+
command: preview.command,
|
|
240
|
+
environment: preview.environment,
|
|
241
|
+
dryRun: preview.dryRun,
|
|
242
|
+
args: preview.args,
|
|
243
|
+
approvedFlagNames: [],
|
|
244
|
+
approvedFlags: preview.approvedFlags,
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
export async function dailyActionSafetyPreview(command, argv, cwd = process.cwd()) {
|
|
269
248
|
await assertEnvironmentRoot(cwd);
|
|
270
249
|
const scriptPath = await assertScriptExists(cwd, dailyActionScripts[command]);
|
|
271
250
|
const args = scriptArgs(command, argv);
|
|
272
|
-
await
|
|
273
|
-
|
|
274
|
-
|
|
251
|
+
const env = await readEnvFile(cwd);
|
|
252
|
+
const envName = envValue(env, 'WPMOO_ENV') || selectedComposeEnvironment(env);
|
|
253
|
+
const policy = evaluateDailyActionPolicy(command, args, {
|
|
254
|
+
envName,
|
|
255
|
+
allowDestructive: envValue(env, 'WPMOO_ALLOW_DESTRUCTIVE'),
|
|
256
|
+
allowStageLifecycle: envValue(env, 'WPMOO_ALLOW_STAGE_LIFECYCLE'),
|
|
257
|
+
allowProdLifecycle: envValue(env, 'WPMOO_ALLOW_PROD_LIFECYCLE'),
|
|
258
|
+
});
|
|
259
|
+
const warnings = [];
|
|
260
|
+
const snapshotRequired = policy.isDestructive && (policy.env === 'stage' || policy.env === 'prod');
|
|
261
|
+
const snapshot = snapshotRequired ? findDatabaseSnapshots(cwd) : undefined;
|
|
262
|
+
const noRecentSnapshot = snapshotRequired &&
|
|
263
|
+
snapshot &&
|
|
264
|
+
(snapshot.newestSnapshotAgeMs === null || snapshot.newestSnapshotAgeMs > defaultDatabaseSnapshotMaxAgeMs) &&
|
|
265
|
+
!flagEnabled(env, 'WPMOO_ALLOW_NO_RECENT_SNAPSHOT');
|
|
266
|
+
if (noRecentSnapshot) {
|
|
267
|
+
warnings.push({
|
|
268
|
+
kind: 'no-recent-snapshot',
|
|
269
|
+
requiredFlag: 'WPMOO_ALLOW_NO_RECENT_SNAPSHOT',
|
|
270
|
+
blocking: true,
|
|
271
|
+
message: noRecentSnapshotMessage(command, policy.env),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
const migrations = requiresMigrationApproval(command) && (policy.env === 'stage' || policy.env === 'prod')
|
|
275
|
+
? await scanMigrationRisks(cwd)
|
|
276
|
+
: undefined;
|
|
277
|
+
if (migrations?.risk && !flagEnabled(env, 'WPMOO_ALLOW_MIGRATIONS')) {
|
|
278
|
+
warnings.push({
|
|
279
|
+
kind: 'migration-risk',
|
|
280
|
+
requiredFlag: 'WPMOO_ALLOW_MIGRATIONS',
|
|
281
|
+
blocking: true,
|
|
282
|
+
message: migrationRiskMessage(command, policy.env),
|
|
283
|
+
});
|
|
284
|
+
}
|
|
275
285
|
return {
|
|
276
286
|
cwd,
|
|
277
287
|
scriptPath,
|
|
278
288
|
args,
|
|
289
|
+
command,
|
|
290
|
+
environment: policy.env,
|
|
291
|
+
dryRun: policy.isDryRunPreview,
|
|
292
|
+
destructive: policy.isDestructive,
|
|
293
|
+
auditWorthy: policy.isAuditWorthy,
|
|
294
|
+
allowed: policy.allowed,
|
|
295
|
+
deny: policy.allowed ? undefined : { ...policy.deny, message: policy.message },
|
|
296
|
+
refusalMessage: policy.allowed ? undefined : policy.message,
|
|
297
|
+
requiredFlag: policy.allowed ? undefined : policy.deny.requiredFlag,
|
|
298
|
+
warnings,
|
|
299
|
+
snapshot: snapshot
|
|
300
|
+
? {
|
|
301
|
+
requiredRecent: true,
|
|
302
|
+
newestSnapshotAgeMs: snapshot.newestSnapshotAgeMs,
|
|
303
|
+
snapshotPaths: snapshot.snapshotPaths,
|
|
304
|
+
}
|
|
305
|
+
: undefined,
|
|
306
|
+
migrations,
|
|
307
|
+
approvedFlags: approvedFlags(env),
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
export async function dailyActionPlan(command, argv, cwd = process.cwd()) {
|
|
311
|
+
const preview = await dailyActionSafetyPreview(command, argv, cwd);
|
|
312
|
+
await auditDailyActionPreview(preview);
|
|
313
|
+
if (!preview.allowed) {
|
|
314
|
+
throw new Error(preview.refusalMessage);
|
|
315
|
+
}
|
|
316
|
+
const blockingWarning = preview.warnings.find((warning) => warning.blocking);
|
|
317
|
+
if (blockingWarning) {
|
|
318
|
+
throw new Error(blockingWarning.message);
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
cwd: preview.cwd,
|
|
322
|
+
scriptPath: preview.scriptPath,
|
|
323
|
+
args: preview.args,
|
|
279
324
|
};
|
|
280
325
|
}
|
|
281
326
|
async function spawnDailyAction(plan) {
|
package/dist/databases.js
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
|
+
import { readdirSync, statSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
1
3
|
import { spawn } from 'node:child_process';
|
|
4
|
+
export const defaultDatabaseSnapshotMaxAgeMs = 24 * 60 * 60 * 1000;
|
|
5
|
+
export const databaseSnapshotDirectoryNames = ['backups', 'backup', 'snapshots'];
|
|
6
|
+
export const databaseSnapshotExtensions = ['.dump', '.sql', '.sql.gz', '.zip', '.tar', '.tar.gz'];
|
|
7
|
+
function isDatabaseSnapshotFile(fileName, extensions) {
|
|
8
|
+
const normalized = fileName.toLowerCase();
|
|
9
|
+
return extensions.some((extension) => normalized.endsWith(extension));
|
|
10
|
+
}
|
|
2
11
|
const maintenanceDatabases = new Set(['postgres']);
|
|
3
12
|
const databaseNamePattern = /^[A-Za-z0-9_.-]+$/u;
|
|
4
13
|
export function isValidDatabaseName(value) {
|
|
@@ -46,6 +55,54 @@ export function parseDatabaseListOutput(output, options = {}) {
|
|
|
46
55
|
}
|
|
47
56
|
return databases;
|
|
48
57
|
}
|
|
58
|
+
export function findDatabaseSnapshots(targetDirectory, options = {}) {
|
|
59
|
+
const { nowMs = Date.now(), snapshotDirectories = [...databaseSnapshotDirectoryNames], snapshotExtensions = [...databaseSnapshotExtensions], } = options;
|
|
60
|
+
const snapshots = [];
|
|
61
|
+
for (const directoryName of snapshotDirectories) {
|
|
62
|
+
const directory = join(targetDirectory, directoryName);
|
|
63
|
+
let entries;
|
|
64
|
+
try {
|
|
65
|
+
entries = readdirSync(directory, { withFileTypes: true });
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
if (error.code === 'ENOENT') {
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
for (const entry of entries) {
|
|
74
|
+
if (!isDatabaseSnapshotFile(entry.name, snapshotExtensions)) {
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
const path = join(directory, entry.name);
|
|
78
|
+
let stats;
|
|
79
|
+
try {
|
|
80
|
+
stats = statSync(path);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
if (!stats.isFile()) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const mtimeMs = stats.mtimeMs;
|
|
89
|
+
const ageMs = Math.max(0, nowMs - mtimeMs);
|
|
90
|
+
snapshots.push({ path, mtimeMs, ageMs });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
snapshots.sort((a, b) => b.mtimeMs - a.mtimeMs || a.path.localeCompare(b.path));
|
|
94
|
+
const newestSnapshot = snapshots[0] ?? null;
|
|
95
|
+
return {
|
|
96
|
+
snapshots,
|
|
97
|
+
snapshotPaths: snapshots.map((snapshot) => snapshot.path),
|
|
98
|
+
newestSnapshotAgeMs: newestSnapshot ? newestSnapshot.ageMs : null,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
export function hasRecentDatabaseSnapshot(targetDirectory, options = {}) {
|
|
102
|
+
const { maxAgeMs = defaultDatabaseSnapshotMaxAgeMs, ...scanOptions } = options;
|
|
103
|
+
const result = findDatabaseSnapshots(targetDirectory, scanOptions);
|
|
104
|
+
return result.newestSnapshotAgeMs !== null && result.newestSnapshotAgeMs <= maxAgeMs;
|
|
105
|
+
}
|
|
49
106
|
export function normalizeDatabaseListResult(result) {
|
|
50
107
|
if (Array.isArray(result)) {
|
|
51
108
|
return { ok: true, databases: result };
|
|
@@ -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
|
+
}
|
package/dist/templates.js
CHANGED
|
@@ -601,6 +601,62 @@ allow_stage_lifecycle() {
|
|
|
601
601
|
[[ "$value" == "1" ]]
|
|
602
602
|
}
|
|
603
603
|
|
|
604
|
+
allow_no_recent_snapshot() {
|
|
605
|
+
local value="\${WPMOO_ALLOW_NO_RECENT_SNAPSHOT:-$(env_file_value WPMOO_ALLOW_NO_RECENT_SNAPSHOT)}"
|
|
606
|
+
[[ "$value" == "1" ]]
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
allow_migrations() {
|
|
610
|
+
local value="\${WPMOO_ALLOW_MIGRATIONS:-$(env_file_value WPMOO_ALLOW_MIGRATIONS)}"
|
|
611
|
+
[[ "$value" == "1" ]]
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
has_recent_snapshot() {
|
|
615
|
+
local dir
|
|
616
|
+
for dir in backups backup snapshots; do
|
|
617
|
+
[[ -d "$dir" ]] || continue
|
|
618
|
+
if find "$dir" -type f \\( -name "*.dump" -o -name "*.sql" -o -name "*.sql.gz" -o -name "*.zip" -o -name "*.tar" -o -name "*.tar.gz" \\) -mtime -1 -print -quit 2>/dev/null | grep -q .; then
|
|
619
|
+
return 0
|
|
620
|
+
fi
|
|
621
|
+
done
|
|
622
|
+
return 1
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
require_recent_snapshot_or_override() {
|
|
626
|
+
local command="$1"
|
|
627
|
+
local env_name
|
|
628
|
+
env_name="$(selected_env)"
|
|
629
|
+
if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
|
|
630
|
+
if ! allow_no_recent_snapshot && ! has_recent_snapshot; then
|
|
631
|
+
echo "Refusing destructive command '$command' in WPMOO_ENV=$env_name without a recent database snapshot. Create a snapshot first or set WPMOO_ALLOW_NO_RECENT_SNAPSHOT=1 to run it intentionally." >&2
|
|
632
|
+
exit 1
|
|
633
|
+
fi
|
|
634
|
+
fi
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
has_migration_risk() {
|
|
638
|
+
local base
|
|
639
|
+
for base in odoo/custom/src/private odoo/custom/src/oca odoo/custom/src/external; do
|
|
640
|
+
[[ -d "$base" ]] || continue
|
|
641
|
+
if find "$base" -type f \\( -path "*/migrations/*/pre-migration.py" -o -path "*/migrations/*/post-migration.py" -o -path "*/migrations/*/end-migration.py" -o -path "*/migration/*/pre-migration.py" -o -path "*/migration/*/post-migration.py" -o -path "*/migration/*/end-migration.py" -o -path "*/scripts/migrate.py" -o -path "*/scripts/migration.py" \\) -print -quit 2>/dev/null | grep -q .; then
|
|
642
|
+
return 0
|
|
643
|
+
fi
|
|
644
|
+
done
|
|
645
|
+
return 1
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
require_migrations_allowed() {
|
|
649
|
+
local command="$1"
|
|
650
|
+
local env_name
|
|
651
|
+
env_name="$(selected_env)"
|
|
652
|
+
if [[ "$env_name" == "stage" || "$env_name" == "prod" ]]; then
|
|
653
|
+
if ! allow_migrations && has_migration_risk; then
|
|
654
|
+
echo "Refusing migration-risk command '$command' in WPMOO_ENV=$env_name. Review detected migration scripts or set WPMOO_ALLOW_MIGRATIONS=1 to run it intentionally." >&2
|
|
655
|
+
exit 1
|
|
656
|
+
fi
|
|
657
|
+
fi
|
|
658
|
+
}
|
|
659
|
+
|
|
604
660
|
require_stage_lifecycle_allowed() {
|
|
605
661
|
local command="$1"
|
|
606
662
|
local env_name
|
|
@@ -712,6 +768,7 @@ case "$command" in
|
|
|
712
768
|
require_module_args "$command" "$@"
|
|
713
769
|
require_stage_lifecycle_allowed "$command"
|
|
714
770
|
require_prod_lifecycle_allowed "$command"
|
|
771
|
+
require_migrations_allowed "$command"
|
|
715
772
|
run_script ./scripts/install.sh "$@"
|
|
716
773
|
;;
|
|
717
774
|
"update")
|
|
@@ -719,18 +776,21 @@ case "$command" in
|
|
|
719
776
|
require_module_args "$command" "$@"
|
|
720
777
|
require_stage_lifecycle_allowed "$command"
|
|
721
778
|
require_prod_lifecycle_allowed "$command"
|
|
779
|
+
require_migrations_allowed "$command"
|
|
722
780
|
run_script ./scripts/update.sh "$@"
|
|
723
781
|
;;
|
|
724
782
|
"test")
|
|
725
783
|
shift
|
|
726
784
|
validate_test_args "$@"
|
|
727
785
|
require_prod_lifecycle_allowed "$command"
|
|
786
|
+
require_migrations_allowed "$command"
|
|
728
787
|
run_script ./scripts/test.sh "$@"
|
|
729
788
|
;;
|
|
730
789
|
"resetdb")
|
|
731
790
|
shift
|
|
732
791
|
positional_args "$command" 0 2 "$@"
|
|
733
792
|
require_destructive_allowed "$command"
|
|
793
|
+
require_recent_snapshot_or_override "$command"
|
|
734
794
|
run_script ./scripts/resetdb.sh "$@"
|
|
735
795
|
;;
|
|
736
796
|
"snapshot")
|
|
@@ -749,6 +809,7 @@ case "$command" in
|
|
|
749
809
|
restore_args+=("$@")
|
|
750
810
|
if [[ "\${restore_args[0]:-}" != "--dry-run" ]]; then
|
|
751
811
|
require_destructive_allowed "$command"
|
|
812
|
+
require_recent_snapshot_or_override "$command"
|
|
752
813
|
fi
|
|
753
814
|
run_script ./scripts/restore-snapshot.sh "\${restore_args[@]}"
|
|
754
815
|
;;
|