@dooer/dooer-test-env 1.17.2 → 1.18.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/command/customer.js +35 -0
- package/lib/command/env.js +20 -2
- package/lib/compose/generate.js +6 -0
- package/lib/copy.js +14 -0
- package/package.json +1 -1
- package/readme.md +19 -1
package/lib/command/customer.js
CHANGED
|
@@ -2,6 +2,28 @@ const fs = require('fs')
|
|
|
2
2
|
const chalk = require('chalk')
|
|
3
3
|
const remote = require('../remote')
|
|
4
4
|
const serviceClient = require('../service-client')
|
|
5
|
+
|
|
6
|
+
// Run the documented shredder over the local database. Emails are already anonymized by the copy itself;
|
|
7
|
+
// this is the rest of the PII.
|
|
8
|
+
async function shredLocal() {
|
|
9
|
+
console.log(chalk.bold('\nshredding the local database…'))
|
|
10
|
+
const { shred } = require('../shred')
|
|
11
|
+
const { Client } = require('pg')
|
|
12
|
+
const client = new Client({ host: 'localhost', port: 55432, user: 'dooer', password: 'dooer', database: 'dooer' })
|
|
13
|
+
await client.connect()
|
|
14
|
+
try {
|
|
15
|
+
const result = await shred(client, { execute: true })
|
|
16
|
+
const failed = (result && result.failed) || []
|
|
17
|
+
if (failed.length) {
|
|
18
|
+
// Say so rather than letting a partial shred read as a clean one.
|
|
19
|
+
console.log(chalk.yellow(` ${failed.length} shred script(s) failed — local PII is NOT fully anonymized\n`))
|
|
20
|
+
} else {
|
|
21
|
+
console.log(chalk.green(` shredded ${((result && result.scripts) || []).length} script(s)\n`))
|
|
22
|
+
}
|
|
23
|
+
} finally {
|
|
24
|
+
await client.end().catch(() => {})
|
|
25
|
+
}
|
|
26
|
+
}
|
|
5
27
|
const { createAccount, SUBSCRIPTION_TYPES } = require('../account')
|
|
6
28
|
|
|
7
29
|
// `copy` now runs through service-dooer-test-env (export in the source, import in the target) instead of
|
|
@@ -65,6 +87,10 @@ const copyOptions = (y) =>
|
|
|
65
87
|
.option('skip-users', { type: 'boolean', describe: 'skip copying referenced users missing from the target' })
|
|
66
88
|
.option('confirm-production', { type: 'boolean', describe: 'REQUIRED to write to dooer-production' })
|
|
67
89
|
.option('reason', { type: 'string', describe: 'recorded in the audit trail at both ends' })
|
|
90
|
+
.option('shred', {
|
|
91
|
+
type: 'boolean',
|
|
92
|
+
describe: 'after a LOCAL copy, run the shredder over the local database (localhost only)',
|
|
93
|
+
})
|
|
68
94
|
.option('execute', { type: 'boolean', default: false, describe: 'actually write (default: dry-run)' })
|
|
69
95
|
|
|
70
96
|
module.exports = {
|
|
@@ -82,6 +108,12 @@ module.exports = {
|
|
|
82
108
|
const targetEnv = envFromFlags(argv.targetLocal, argv.targetNamespace, sourceEnv)
|
|
83
109
|
const { copyOrganization } = require('../copy')
|
|
84
110
|
|
|
111
|
+
// Shredding is whole-database and localhost-only by design (see command/shred.js), so it can
|
|
112
|
+
// only follow a copy INTO local, and only once — not per org in a batch.
|
|
113
|
+
if (argv.shred && targetEnv !== 'local') {
|
|
114
|
+
throw new Error('--shred only applies to a copy into the local env (the shredder is localhost-only)')
|
|
115
|
+
}
|
|
116
|
+
|
|
85
117
|
if (sources.length === 1) {
|
|
86
118
|
await copyOrganization({
|
|
87
119
|
organizationId: sources[0],
|
|
@@ -90,6 +122,7 @@ module.exports = {
|
|
|
90
122
|
reason: argv.reason,
|
|
91
123
|
dryRun: !argv.execute,
|
|
92
124
|
})
|
|
125
|
+
if (argv.shred && argv.execute) await shredLocal()
|
|
93
126
|
return
|
|
94
127
|
}
|
|
95
128
|
// Batch: each org is a full independent copy (the engine scans the source per org). One bad uuid
|
|
@@ -117,6 +150,8 @@ module.exports = {
|
|
|
117
150
|
)
|
|
118
151
|
)
|
|
119
152
|
failed.forEach((f) => console.log(chalk.red(` ${f.source}: ${f.error}`)))
|
|
153
|
+
// Once, after the whole batch — the shredder covers the entire database.
|
|
154
|
+
if (argv.shred && argv.execute && failed.length < sources.length) await shredLocal()
|
|
120
155
|
if (failed.length) process.exitCode = 1
|
|
121
156
|
},
|
|
122
157
|
})
|
package/lib/command/env.js
CHANGED
|
@@ -15,15 +15,33 @@ const PROFILES = ['full', 'frontend', 'hq', 'booking']
|
|
|
15
15
|
// `full` (everything staging runs) — compose profiles are additive, so we must name it explicitly.
|
|
16
16
|
// See ENVIRONMENT-PLAN.md §6.
|
|
17
17
|
|
|
18
|
+
// Regenerate when the generator is newer than the file it produced. It used to generate only when the
|
|
19
|
+
// file was ABSENT, which meant a CLI upgrade never reached an existing environment: every generator fix
|
|
20
|
+
// (a container flag, a new service, an image bump) silently applied to new installs only, and the way you
|
|
21
|
+
// found out was a bug that was already fixed. An npm install rewrites lib/, so its mtime moves ahead of a
|
|
22
|
+
// compose file generated by the previous version, and a local edit to the generator does the same.
|
|
23
|
+
// The compose file is a build artifact — anything hand-edited there was already lost on the next `db pull`.
|
|
24
|
+
function composeIsStale() {
|
|
25
|
+
if (!fs.existsSync(rt.COMPOSE_FILE)) return true
|
|
26
|
+
try {
|
|
27
|
+
const generator = require.resolve('../compose/generate')
|
|
28
|
+
return fs.statSync(generator).mtimeMs > fs.statSync(rt.COMPOSE_FILE).mtimeMs
|
|
29
|
+
} catch (_) {
|
|
30
|
+
return false // never block `up` on a stat that failed
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
18
34
|
function ensureCompose() {
|
|
19
35
|
rt.ensureRunDir()
|
|
20
|
-
if (
|
|
36
|
+
if (composeIsStale()) {
|
|
37
|
+
const existed = fs.existsSync(rt.COMPOSE_FILE)
|
|
21
38
|
const r = generateCompose({
|
|
22
39
|
servicesDir: rt.SERVICES_DIR,
|
|
23
40
|
out: rt.COMPOSE_FILE,
|
|
24
41
|
outputValidation: !!rt.readState().outputValidation,
|
|
25
42
|
})
|
|
26
|
-
|
|
43
|
+
const what = existed ? 'regenerated (CLI is newer)' : 'generated'
|
|
44
|
+
console.log(chalk.gray(`${what} ${rt.COMPOSE_FILE} (${(r && r.serviceCount) || 'n'} services)`))
|
|
27
45
|
}
|
|
28
46
|
return rt.COMPOSE_FILE
|
|
29
47
|
}
|
package/lib/compose/generate.js
CHANGED
|
@@ -242,6 +242,12 @@ function infraServices() {
|
|
|
242
242
|
'ssl_cert_file=/etc/postgresql/server.crt',
|
|
243
243
|
'-c',
|
|
244
244
|
'ssl_key_file=/etc/postgresql/server.key',
|
|
245
|
+
// A full Dooer DB is ~163 schemas / 25k tables+sequences, and `db snapshot` runs pg_dump, which
|
|
246
|
+
// takes an ACCESS SHARE lock on every one of them inside a single transaction. The default 64
|
|
247
|
+
// gives ~64×(max_connections+prepared) lock slots and pg_dump dies partway with "out of shared
|
|
248
|
+
// memory / You might need to increase max_locks_per_transaction" — reliably, on a real base DB.
|
|
249
|
+
'-c',
|
|
250
|
+
'max_locks_per_transaction=1024',
|
|
245
251
|
],
|
|
246
252
|
environment: {
|
|
247
253
|
POSTGRES_DB: 'dooer',
|
package/lib/copy.js
CHANGED
|
@@ -93,6 +93,20 @@ async function copyOrganization({ organizationId, sourceEnv, targetEnv, reason,
|
|
|
93
93
|
` ${detail.rows} rows across ${detail.tables} tables`
|
|
94
94
|
)
|
|
95
95
|
console.log(` new organization id: ${chalk.bold(detail.organizationId)}`)
|
|
96
|
+
|
|
97
|
+
// Rows whose foreign key points at a parent that did not come with them. The load runs with FK
|
|
98
|
+
// enforcement off (no ordering of 336 tables satisfies every constraint), and Postgres never re-checks,
|
|
99
|
+
// so these commit silently — they have to be told, or the copy looks clean when it is not.
|
|
100
|
+
if (detail.danglingRows) {
|
|
101
|
+
console.log(chalk.yellow(`\n ${detail.danglingRows} row(s) reference a parent that was not copied:`))
|
|
102
|
+
for (const d of detail.dangling || []) {
|
|
103
|
+
console.log(` ${String(d.rows).padStart(6)} ${d.from} → ${d.to} (${d.cause})`)
|
|
104
|
+
}
|
|
105
|
+
console.log(
|
|
106
|
+
' The copy is complete and usable; these references dangle. `parent table not in schema map`\n' +
|
|
107
|
+
' means the map does not carry that parent — it will recur on every copy until the map changes.'
|
|
108
|
+
)
|
|
109
|
+
}
|
|
96
110
|
console.log(` audit: export ${exported.id} (${source.name}), import ${imported.id} (${target.name})\n`)
|
|
97
111
|
|
|
98
112
|
return { organizationId: detail.organizationId, rows: detail.rows, tables: detail.tables, skipped }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dooer/dooer-test-env",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.18.1",
|
|
4
4
|
"description": "Run the whole Dooer backend locally (staging DB minus customers), copy/purge customers between environments, and shred — one CLI.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"repository": "Dooer/cli-dooer-test-env",
|
package/readme.md
CHANGED
|
@@ -254,10 +254,28 @@ npx @dooer/dooer-test-env@latest customer copy \
|
|
|
254
254
|
# optional: shred the rest of the PII afterwards (localhost only)
|
|
255
255
|
npx @dooer/dooer-test-env@latest shred --execute
|
|
256
256
|
|
|
257
|
-
# undo: delete that org
|
|
257
|
+
# undo: delete that org's rows again — dry-run first (omit --execute)
|
|
258
258
|
npx @dooer/dooer-test-env@latest customer purge --org <orgId> --local --execute
|
|
259
259
|
```
|
|
260
260
|
|
|
261
|
+
**What the purge does not do:** it deletes rows, **not S3 objects**. A purged org leaves its uploaded
|
|
262
|
+
documents in the bucket; the command says so in its own output. Removing them needs bucket credentials the
|
|
263
|
+
service does not hold today.
|
|
264
|
+
|
|
265
|
+
**Dangling references.** The load runs with foreign-key enforcement off — no ordering of 336 tables
|
|
266
|
+
satisfies every constraint on the way in — and Postgres never re-checks afterwards, so a copied row whose
|
|
267
|
+
parent is missing would otherwise commit silently. The import checks the rows it wrote and reports any
|
|
268
|
+
that dangle:
|
|
269
|
+
|
|
270
|
+
```
|
|
271
|
+
44 row(s) reference a parent that was not copied:
|
|
272
|
+
42 service_workflow.run.definitionId → service_workflow.definition (parent table not in schema map)
|
|
273
|
+
2 service_accounts.systemMessageRecipient.systemMessageId → service_accounts.systemMessage
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`parent table not in schema map` means the map does not carry that parent at all, so it recurs on every
|
|
277
|
+
copy until the map changes. The copy is complete and usable; those references dangle.
|
|
278
|
+
|
|
261
279
|
Naming: with **neither `--name` nor `--target`** the copy creates a new org that **keeps the source org's
|
|
262
280
|
own name** (its `short_name` still gets a unique suffix). Pass `--name` to rename it, or `--target <uuid>`
|
|
263
281
|
to copy into an org that already exists.
|