@dooer/dooer-test-env 1.9.0 → 1.11.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/lib/command/customer.js +69 -5
- package/lib/command/service.js +52 -31
- package/lib/compose/generate.js +9 -3
- package/lib/engine/seed.js +21 -6
- package/lib/runtime.js +25 -12
- package/package.json +1 -1
- package/readme.md +59 -22
package/lib/command/customer.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
const fs = require('fs')
|
|
1
2
|
const chalk = require('chalk')
|
|
2
3
|
const engine = require('../engine/seed')
|
|
3
4
|
const { purge } = require('../engine/purge')
|
|
@@ -14,8 +15,14 @@ function toEngineArgv(argv) {
|
|
|
14
15
|
flag('target', argv.target)
|
|
15
16
|
flag('name', argv.name)
|
|
16
17
|
flag('owner-user', argv.ownerUser)
|
|
17
|
-
|
|
18
|
-
|
|
18
|
+
// `local` is a first-class TARGET/SOURCE, not a k8s namespace — --source-local / --target-local keep the
|
|
19
|
+
// two ideas separate rather than overloading --*-namespace (Jimmy 2026-09-03). The engine addresses the
|
|
20
|
+
// local stack through the pseudo-namespace `local` internally.
|
|
21
|
+
if (argv.sourceNamespace === 'local' || argv.targetNamespace === 'local') {
|
|
22
|
+
throw new Error('use --source-local / --target-local for the local env, not --*-namespace local')
|
|
23
|
+
}
|
|
24
|
+
flag('source-namespace', argv.sourceLocal ? 'local' : argv.sourceNamespace)
|
|
25
|
+
flag('target-namespace', argv.targetLocal ? 'local' : argv.targetNamespace)
|
|
19
26
|
flag('email', argv.email)
|
|
20
27
|
flag('if-target-nonempty', argv.ifTargetNonempty)
|
|
21
28
|
flag('salt', argv.salt)
|
|
@@ -28,14 +35,44 @@ function toEngineArgv(argv) {
|
|
|
28
35
|
return a
|
|
29
36
|
}
|
|
30
37
|
|
|
38
|
+
// Resolve which orgs to copy: exactly one of --source / --sourcefile. A source FILE is inherently a batch,
|
|
39
|
+
// so --name and --target (which describe ONE target org) are rejected with it. (Jimmy 2026-09-03.)
|
|
40
|
+
function readSources(argv) {
|
|
41
|
+
if (argv.source && argv.sourcefile) throw new Error('pass either --source or --sourcefile, not both')
|
|
42
|
+
if (!argv.source && !argv.sourcefile) throw new Error('missing --source <uuid> or --sourcefile <path>')
|
|
43
|
+
if (!argv.sourcefile) return [argv.source]
|
|
44
|
+
if (argv.name || argv.target) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'--name and --target describe a single target org, so they cannot be combined with --sourcefile; ' +
|
|
47
|
+
'each copied org keeps its own source name'
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
const raw = fs.readFileSync(argv.sourcefile, 'utf8')
|
|
51
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
|
52
|
+
const lines = raw
|
|
53
|
+
.split('\n')
|
|
54
|
+
.map((l) => l.trim())
|
|
55
|
+
.filter((l) => l && !l.startsWith('#'))
|
|
56
|
+
const bad = lines.filter((l) => !uuid.test(l))
|
|
57
|
+
if (bad.length) throw new Error(`${argv.sourcefile}: not org uuids: ${bad.slice(0, 3).join(', ')}`)
|
|
58
|
+
if (!lines.length) throw new Error(`${argv.sourcefile} contains no org uuids`)
|
|
59
|
+
return [...new Set(lines)]
|
|
60
|
+
}
|
|
61
|
+
|
|
31
62
|
const copyOptions = (y) =>
|
|
32
63
|
y
|
|
33
|
-
.option('source', { type: 'string',
|
|
64
|
+
.option('source', { type: 'string', describe: 'org (companies_pk) to copy FROM' })
|
|
65
|
+
.option('sourcefile', {
|
|
66
|
+
type: 'string',
|
|
67
|
+
describe: 'file with one source org uuid per line — copies each in turn (no --name / --target)',
|
|
68
|
+
})
|
|
34
69
|
.option('target', { type: 'string', describe: 'existing org to copy INTO' })
|
|
35
70
|
.option('name', { type: 'string', describe: 'create a NEW target org with this name (needs --owner-user)' })
|
|
36
71
|
.option('owner-user', { type: 'string', describe: 'existing user (users_pk) to own a newly-created org' })
|
|
37
72
|
.option('source-namespace', { type: 'string', default: 'dooer-staging', describe: 'k8s namespace to read from' })
|
|
38
73
|
.option('target-namespace', { type: 'string', describe: 'k8s namespace to write to (default = source)' })
|
|
74
|
+
.option('source-local', { type: 'boolean', describe: 'read from the LOCAL env instead of a k8s namespace' })
|
|
75
|
+
.option('target-local', { type: 'boolean', describe: 'write to the LOCAL env instead of a k8s namespace' })
|
|
39
76
|
.option('email', { type: 'string', describe: 'address to scrub emails to (default testcustomer@dooer.com)' })
|
|
40
77
|
.option('if-target-nonempty', {
|
|
41
78
|
choices: ['refuse', 'insert'],
|
|
@@ -58,7 +95,33 @@ module.exports = {
|
|
|
58
95
|
command: 'copy',
|
|
59
96
|
describe: 'copy one org into another (dry-run by default; emails anonymized)',
|
|
60
97
|
builder: copyOptions,
|
|
61
|
-
handler: (argv) =>
|
|
98
|
+
handler: async (argv) => {
|
|
99
|
+
const sources = readSources(argv)
|
|
100
|
+
if (sources.length === 1) {
|
|
101
|
+
await engine.main(engine.parseArgs(toEngineArgv({ ...argv, source: sources[0] })))
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
// Batch: each org is a full independent copy (the engine scans the source per org). One bad uuid
|
|
105
|
+
// must not abandon the rest, so failures are collected and reported at the end.
|
|
106
|
+
console.log(chalk.bold(`\ncopying ${sources.length} organizations from ${argv.sourcefile}\n`))
|
|
107
|
+
const failed = []
|
|
108
|
+
for (let i = 0; i < sources.length; i++) {
|
|
109
|
+
console.log(chalk.cyan(`\n──── [${i + 1}/${sources.length}] ${sources[i]} ────`))
|
|
110
|
+
try {
|
|
111
|
+
await engine.main(engine.parseArgs(toEngineArgv({ ...argv, source: sources[i] })))
|
|
112
|
+
} catch (e) {
|
|
113
|
+
failed.push({ source: sources[i], error: e.message })
|
|
114
|
+
console.log(chalk.red(` FAILED: ${e.message}`))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
console.log(
|
|
118
|
+
chalk.bold(
|
|
119
|
+
`\n${sources.length - failed.length}/${sources.length} organizations copied` + (failed.length ? ':' : '')
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
failed.forEach((f) => console.log(chalk.red(` ${f.source}: ${f.error}`)))
|
|
123
|
+
if (failed.length) process.exitCode = 1
|
|
124
|
+
},
|
|
62
125
|
})
|
|
63
126
|
.command({
|
|
64
127
|
command: 'purge',
|
|
@@ -67,13 +130,14 @@ module.exports = {
|
|
|
67
130
|
y2
|
|
68
131
|
.option('org', { type: 'string', demandOption: true, describe: 'org (companies_pk) to delete' })
|
|
69
132
|
.option('namespace', { type: 'string', default: 'dooer-staging', describe: 'k8s namespace' })
|
|
133
|
+
.option('local', { type: 'boolean', describe: 'purge from the LOCAL env instead of a k8s namespace' })
|
|
70
134
|
.option('skip-files', { type: 'boolean', describe: 'do not delete the org’s S3 objects' })
|
|
71
135
|
.option('confirm-production', { type: 'boolean', describe: 'REQUIRED to delete from dooer-production' })
|
|
72
136
|
.option('execute', { type: 'boolean', default: false, describe: 'actually delete (default: dry-run)' }),
|
|
73
137
|
handler: (argv) =>
|
|
74
138
|
purge({
|
|
75
139
|
org: argv.org,
|
|
76
|
-
namespace: argv.namespace,
|
|
140
|
+
namespace: argv.local ? 'local' : argv.namespace,
|
|
77
141
|
skipFiles: argv.skipFiles,
|
|
78
142
|
confirmProduction: argv.confirmProduction,
|
|
79
143
|
execute: argv.execute,
|
package/lib/command/service.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
|
-
const os = require('os')
|
|
3
2
|
const path = require('path')
|
|
4
3
|
const yaml = require('js-yaml')
|
|
5
4
|
const chalk = require('chalk')
|
|
@@ -7,7 +6,7 @@ const rt = require('../runtime')
|
|
|
7
6
|
const discovery = require('../discovery/client')
|
|
8
7
|
const bankid = require('../bankid')
|
|
9
8
|
const { imageFor, listServices } = require('../compose/manifests')
|
|
10
|
-
const { serviceHostPortMap } = require('../compose/generate')
|
|
9
|
+
const { serviceHostPortMap, KAFKA_HOST_PORT } = require('../compose/generate')
|
|
11
10
|
|
|
12
11
|
// Per-service control in a running env. `local`/`unlocal`/`deploy` update the discovery router so peers
|
|
13
12
|
// pick up the change with no restarts (see ENVIRONMENT-PLAN.md §4/§6). Local host-process ports are
|
|
@@ -71,27 +70,35 @@ function localEnv(name, port) {
|
|
|
71
70
|
// the host process, but that name does not resolve ON the host (service-accounts →
|
|
72
71
|
// /api/v1/token-invalidation/all → ENOTFOUND, which broke every HQ navigation query).
|
|
73
72
|
env[`DOOER_HOST_${name.toUpperCase().replace(/-/g, '_')}`] = `localhost:${port}`
|
|
74
|
-
// @dooer/logging: `logMode = DOOER_LOG || (process.stdout.isTTY ? 'pretty' : 'none')`.
|
|
75
|
-
//
|
|
76
|
-
// DOOER_LOG=json, which the manifests don't carry
|
|
77
|
-
env.DOOER_LOG
|
|
73
|
+
// @dooer/logging: `logMode = DOOER_LOG || (process.stdout.isTTY ? 'pretty' : 'none')`. On a terminal it
|
|
74
|
+
// picks human-readable 'pretty' by itself; when the output is piped/redirected it would log NOTHING, so
|
|
75
|
+
// fall back to json there (the images bake DOOER_LOG=json, which the manifests don't carry).
|
|
76
|
+
if (!env.DOOER_LOG && !process.stdout.isTTY) env.DOOER_LOG = 'json'
|
|
77
|
+
// Kafka: containers use the INTERNAL listener (advertised as kafka:9092, unresolvable here); a host
|
|
78
|
+
// process must bootstrap against the EXTERNAL one or it reconnects to `kafka` and fails.
|
|
79
|
+
if (env.DOOER_KAFKA_BOOTSTRAP_BROKER) env.DOOER_KAFKA_BOOTSTRAP_BROKER = `localhost:${KAFKA_HOST_PORT}`
|
|
78
80
|
return env
|
|
79
81
|
}
|
|
80
82
|
|
|
81
|
-
// Where
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
function repoPath(
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
83
|
+
// Where the code runs from: an explicit path if given, otherwise the CURRENT directory — you `cd` to your
|
|
84
|
+
// checkout and run the command there. Deliberately NOT a ~/dooer/<service> convention; everyone lays their
|
|
85
|
+
// checkouts out differently (Jimmy 2026-09-03).
|
|
86
|
+
function repoPath(given) {
|
|
87
|
+
return path.resolve(given || process.cwd())
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Service name: the argument if given, else the package.json name in the checkout (scope stripped), so
|
|
91
|
+
// `cd service-ledger && … service local` just works.
|
|
92
|
+
function serviceNameFor(given, cwd) {
|
|
93
|
+
if (given) return given
|
|
88
94
|
try {
|
|
89
|
-
const pkgName = require(path.join(
|
|
90
|
-
|
|
95
|
+
const pkgName = require(path.join(cwd, 'package.json')).name || ''
|
|
96
|
+
const name = pkgName.replace(/^@[^/]+\//, '')
|
|
97
|
+
if (name) return name
|
|
91
98
|
} catch (_) {
|
|
92
|
-
/* no readable package.json
|
|
99
|
+
/* no readable package.json */
|
|
93
100
|
}
|
|
94
|
-
|
|
101
|
+
throw new Error(`cannot tell which service this is — run inside a service checkout or pass its name`)
|
|
95
102
|
}
|
|
96
103
|
|
|
97
104
|
module.exports = {
|
|
@@ -150,31 +157,45 @@ module.exports = {
|
|
|
150
157
|
},
|
|
151
158
|
})
|
|
152
159
|
.command({
|
|
153
|
-
command: 'local
|
|
154
|
-
describe: 'run
|
|
160
|
+
command: 'local [name] [repoPath]',
|
|
161
|
+
describe: 'run THIS checkout instead of the container image (run it from your checkout; Ctrl-C restores)',
|
|
155
162
|
builder: (y2) =>
|
|
156
163
|
y2
|
|
157
164
|
.option('port', { type: 'number', describe: 'host port for the local process (default: auto)' })
|
|
158
165
|
.option('script', { type: 'string', default: 'start', describe: 'package.json script to run' }),
|
|
159
166
|
handler: async (a) => {
|
|
160
|
-
const cwd = repoPath(a.
|
|
167
|
+
const cwd = repoPath(a.repoPath)
|
|
161
168
|
if (!fs.existsSync(path.join(cwd, 'package.json')))
|
|
162
|
-
throw new Error(`no package.json
|
|
169
|
+
throw new Error(`no package.json in ${cwd} — cd to the service checkout, or pass its path`)
|
|
170
|
+
const name = serviceNameFor(a.name, cwd)
|
|
163
171
|
const port = a.port || 4000 + Math.floor((Date.now() % 1000) + Math.random() * 100) // CLI-managed
|
|
164
|
-
rt.dc(['stop',
|
|
165
|
-
|
|
166
|
-
await discovery.setTarget(rt.ROUTER_URL, a.name, { address: 'host.docker.internal', port })
|
|
172
|
+
rt.dc(['stop', name]) // free the container so only this process serves
|
|
173
|
+
await discovery.setTarget(rt.ROUTER_URL, name, { address: 'host.docker.internal', port })
|
|
167
174
|
const st = rt.readState()
|
|
168
|
-
st.local[
|
|
175
|
+
st.local[name] = { pid: process.pid, port, cwd }
|
|
169
176
|
rt.writeState(st)
|
|
170
177
|
console.log(
|
|
171
|
-
chalk.green(
|
|
172
|
-
|
|
173
|
-
rt.RUN_DIR,
|
|
174
|
-
`${a.name}.local.log`
|
|
175
|
-
)}`
|
|
176
|
-
)
|
|
178
|
+
chalk.green(`${name} now serves from ${cwd} on port ${port}; router repointed.`) +
|
|
179
|
+
chalk.gray(' Ctrl-C to stop and restore the container.\n')
|
|
177
180
|
)
|
|
181
|
+
// Foreground: the service's own logs stream to THIS terminal (no log file to go hunting for).
|
|
182
|
+
rt.spawnForeground('yarn', [a.script], {
|
|
183
|
+
cwd,
|
|
184
|
+
env: localEnv(name, port),
|
|
185
|
+
onExit: (code) => {
|
|
186
|
+
const s2 = rt.readState()
|
|
187
|
+
delete s2.local[name]
|
|
188
|
+
rt.writeState(s2)
|
|
189
|
+
discovery
|
|
190
|
+
.clearTarget(rt.ROUTER_URL, name)
|
|
191
|
+
.catch(() => {})
|
|
192
|
+
.then(() => {
|
|
193
|
+
console.log(chalk.gray(`\nrestoring ${name} container…`))
|
|
194
|
+
rt.dc(['up', '-d', name])
|
|
195
|
+
process.exit(code)
|
|
196
|
+
})
|
|
197
|
+
},
|
|
198
|
+
})
|
|
178
199
|
},
|
|
179
200
|
})
|
|
180
201
|
.command({
|
package/lib/compose/generate.js
CHANGED
|
@@ -284,12 +284,15 @@ function infraServices() {
|
|
|
284
284
|
'--node-id',
|
|
285
285
|
'0',
|
|
286
286
|
'--check=false',
|
|
287
|
+
// TWO listeners. A Kafka client connects to the bootstrap address, then reconnects to whatever the
|
|
288
|
+
// broker ADVERTISES — so a single `kafka:9092` advertisement breaks host processes (`service local`
|
|
289
|
+
// hit `getaddrinfo ENOTFOUND kafka`). INTERNAL is for containers, EXTERNAL for the host.
|
|
287
290
|
'--kafka-addr',
|
|
288
|
-
'
|
|
291
|
+
'INTERNAL://0.0.0.0:9092,EXTERNAL://0.0.0.0:19092',
|
|
289
292
|
'--advertise-kafka-addr',
|
|
290
|
-
|
|
293
|
+
`INTERNAL://kafka:9092,EXTERNAL://localhost:${KAFKA_HOST_PORT}`,
|
|
291
294
|
],
|
|
292
|
-
ports: ['9092:9092'],
|
|
295
|
+
ports: ['9092:9092', `${KAFKA_HOST_PORT}:19092`],
|
|
293
296
|
volumes: ['kafka_data:/var/lib/redpanda/data'],
|
|
294
297
|
logging: LOGGING,
|
|
295
298
|
restart: 'unless-stopped',
|
|
@@ -343,6 +346,8 @@ const GRAPHQL_HOST_PORT = 4001 // service-graphql (direct access; frontends go t
|
|
|
343
346
|
// Docker Desktop doesn't route on macOS) — so without this a locally-run service can call itself but not
|
|
344
347
|
// its peers. `service local` turns this map into DOOER_HOST_<NAME> overrides. (Jimmy 2026-09-03.)
|
|
345
348
|
const SERVICE_HOST_PORT_BASE = 21000
|
|
349
|
+
// Kafka's host-facing (EXTERNAL) listener — see the kafka block for why a second listener is required.
|
|
350
|
+
const KAFKA_HOST_PORT = 19092
|
|
346
351
|
|
|
347
352
|
// {service name → published host port}, stable because listServices() is sorted by filename.
|
|
348
353
|
function serviceHostPortMap(services) {
|
|
@@ -510,6 +515,7 @@ function generateCompose({ profile, servicesDir, out, outputValidation = false }
|
|
|
510
515
|
|
|
511
516
|
module.exports = {
|
|
512
517
|
BOOKING_PROFILE_SERVICES,
|
|
518
|
+
KAFKA_HOST_PORT,
|
|
513
519
|
serviceHostPortMap,
|
|
514
520
|
VALIDATION_ENV,
|
|
515
521
|
GLOBAL_OVERRIDES,
|
package/lib/engine/seed.js
CHANGED
|
@@ -457,8 +457,12 @@ async function main(passedArgs) {
|
|
|
457
457
|
const args = passedArgs || parseArgs(process.argv.slice(2))
|
|
458
458
|
if (args.help) return void console.log(HELP)
|
|
459
459
|
if (!args.source) throw new Error('missing --source')
|
|
460
|
-
|
|
461
|
-
|
|
460
|
+
// --name is OPTIONAL: with neither --target nor --name we create a new org and inherit the SOURCE org's
|
|
461
|
+
// name (resolved in createTargetOrg, which already has the source row). (Jimmy 2026-09-03.)
|
|
462
|
+
if (!args.target && !args.ownerUser)
|
|
463
|
+
throw new Error(
|
|
464
|
+
'creating a new target org requires --owner-user (or pass --target <uuid> to copy into an existing org)'
|
|
465
|
+
)
|
|
462
466
|
if (args.targetNamespace === 'dooer-production' && args.execute && !args.confirmProduction)
|
|
463
467
|
throw new Error('refusing to WRITE to dooer-production without --confirm-production')
|
|
464
468
|
|
|
@@ -468,7 +472,11 @@ async function main(passedArgs) {
|
|
|
468
472
|
|
|
469
473
|
console.log(`\n=== seed-test-account · ${mode} ===`)
|
|
470
474
|
console.log(`source: ${args.source} @ ${args.sourceNamespace}`)
|
|
471
|
-
console.log(
|
|
475
|
+
console.log(
|
|
476
|
+
`target: ${
|
|
477
|
+
args.target || `(create ${args.name ? `"${args.name}"` : "with the source's own name"}, owner ${args.ownerUser})`
|
|
478
|
+
} @ ${args.targetNamespace}`
|
|
479
|
+
)
|
|
472
480
|
console.log(
|
|
473
481
|
`email scrub: ${args.emailScrub} · copy tables: ${copyTables.length} · if-target-nonempty: ${args.ifTargetNonempty}\n`
|
|
474
482
|
)
|
|
@@ -798,7 +806,12 @@ async function main(passedArgs) {
|
|
|
798
806
|
await tgt.client.query("SET session_replication_role = 'origin'")
|
|
799
807
|
await tgt.client.query('COMMIT')
|
|
800
808
|
console.log(`\nEXECUTE complete — ${written} rows written to ${targetOrg} @ ${args.targetNamespace}.`)
|
|
801
|
-
if (createdOrg)
|
|
809
|
+
if (createdOrg)
|
|
810
|
+
console.log(
|
|
811
|
+
`Created org ${targetOrg}${args.name ? ` ("${args.name}")` : ' (name inherited from the source)'} with owner ${
|
|
812
|
+
args.ownerUser
|
|
813
|
+
} (role Owner).`
|
|
814
|
+
)
|
|
802
815
|
// copy the actual files (S3) for every copied blob row — on by default (skip with --skip-files)
|
|
803
816
|
if (!args.skipFiles) await copyFiles(src, args, map, remapUuid)
|
|
804
817
|
else
|
|
@@ -860,15 +873,17 @@ async function createTargetOrg(src, tgt, args, targetOrg) {
|
|
|
860
873
|
)
|
|
861
874
|
).rows[0]
|
|
862
875
|
if (!row) throw new Error(`source companies row ${args.source} not found`)
|
|
876
|
+
// No --name given → keep the source org's own name (short_name still gets a unique suffix below).
|
|
877
|
+
const name = args.name || row.company_name || 'Copied organization'
|
|
863
878
|
const slug =
|
|
864
|
-
|
|
879
|
+
name
|
|
865
880
|
.toLowerCase()
|
|
866
881
|
.replace(/[^a-z0-9]+/g, '-')
|
|
867
882
|
.replace(/^-|-$/g, '')
|
|
868
883
|
.slice(0, 40) || 'org'
|
|
869
884
|
const overrides = {
|
|
870
885
|
companies_pk: targetOrg,
|
|
871
|
-
company_name:
|
|
886
|
+
company_name: name,
|
|
872
887
|
short_name: `${slug}-${newUuid().slice(0, 8)}`, // short_name is UNIQUE
|
|
873
888
|
api_uuid: newUuid(),
|
|
874
889
|
}
|
package/lib/runtime.js
CHANGED
|
@@ -60,17 +60,30 @@ function dcCapture(rest, { profile, env: extraEnv } = {}) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
// spawn a detached host process (for `service local`), return its pid. Logs to RUN_DIR/<name>.local.log.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
63
|
+
// Run a service from a checkout in the FOREGROUND, streaming its logs straight to this terminal, so it
|
|
64
|
+
// behaves like `yarn start` — Ctrl-C stops it and the caller restores the container. `onExit` fires once,
|
|
65
|
+
// however the process ends.
|
|
66
|
+
function spawnForeground(command, cmdArgs, { cwd, env, onExit }) {
|
|
67
|
+
const child = spawn(command, cmdArgs, { cwd, env: { ...process.env, ...env }, stdio: 'inherit' })
|
|
68
|
+
let finished = false
|
|
69
|
+
const finish = (code) => {
|
|
70
|
+
if (finished) return
|
|
71
|
+
finished = true
|
|
72
|
+
onExit(code)
|
|
73
|
+
}
|
|
74
|
+
child.on('exit', (code, signal) => finish(signal ? 1 : code || 0))
|
|
75
|
+
child.on('error', () => finish(1))
|
|
76
|
+
// Forward interrupts to the child; its exit then triggers our cleanup.
|
|
77
|
+
const forward = (sig) => () => {
|
|
78
|
+
try {
|
|
79
|
+
child.kill(sig)
|
|
80
|
+
} catch (_) {
|
|
81
|
+
/* already gone */
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
process.on('SIGINT', forward('SIGINT'))
|
|
85
|
+
process.on('SIGTERM', forward('SIGTERM'))
|
|
86
|
+
return child
|
|
74
87
|
}
|
|
75
88
|
|
|
76
89
|
function isAlive(pid) {
|
|
@@ -96,6 +109,6 @@ module.exports = {
|
|
|
96
109
|
writeState,
|
|
97
110
|
dc,
|
|
98
111
|
dcCapture,
|
|
99
|
-
|
|
112
|
+
spawnForeground,
|
|
100
113
|
isAlive,
|
|
101
114
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dooer/dooer-test-env",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.11.0",
|
|
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
|
@@ -54,19 +54,21 @@ Everything that writes is **dry-run by default**; add `--execute`. Run any comma
|
|
|
54
54
|
## Use cases
|
|
55
55
|
|
|
56
56
|
Everything below runs from **any directory** (`npx` resolves the published CLI). Nothing is tied to a
|
|
57
|
-
particular machine
|
|
57
|
+
particular machine — you `cd` to your own checkouts, and state lives in `~/.dooer-test-env`.
|
|
58
58
|
|
|
59
59
|
### Where things are served
|
|
60
60
|
|
|
61
61
|
| | |
|
|
62
62
|
| --- | --- |
|
|
63
|
-
|
|
|
64
|
-
|
|
|
65
|
-
|
|
|
66
|
-
|
|
|
67
|
-
|
|
|
68
|
-
|
|
|
69
|
-
|
|
|
63
|
+
| [localhost:4000](http://localhost:4000) | **public-facade** — the browser-facing entrypoint (GraphQL + REST passthrough) |
|
|
64
|
+
| [localhost:4001](http://localhost:4001) | service-graphql (direct; frontends go via the facade) |
|
|
65
|
+
| [localhost:8080](http://localhost:8080) | frontend-hq |
|
|
66
|
+
| [localhost:8082/tools/graphiql](http://localhost:8082/tools/graphiql) | frontend-back-office-neue — handy GraphiQL console |
|
|
67
|
+
| [localhost:8089](http://localhost:8089) | frontend-sumify-neue |
|
|
68
|
+
| [localhost:8090](http://localhost:8090) · [8092](http://localhost:8092) · [8081](http://localhost:8081) | frontend-tasks · frontend-xrays · frontend-ai-data-studio |
|
|
69
|
+
| [localhost:9001](http://localhost:9001) | MinIO console (`minioadmin` / `minioadmin`) |
|
|
70
|
+
| `localhost:55432` | Postgres (`dooer` / `dooer`) |
|
|
71
|
+
| `localhost:21000`+ | every backend service, one host port each (so host processes can reach them) |
|
|
70
72
|
|
|
71
73
|
### Spin up an empty, ready-to-use customer
|
|
72
74
|
|
|
@@ -91,8 +93,8 @@ WHERE fk_user_roles_at_dooer_pk = 'customer' AND inactivated_at IS NULL LIMIT 5;
|
|
|
91
93
|
|
|
92
94
|
### Copy a real customer into the local env
|
|
93
95
|
|
|
94
|
-
`--target-
|
|
95
|
-
|
|
96
|
+
`--target-local` addresses this stack (Postgres on 55432 + MinIO); the source can be any k8s namespace
|
|
97
|
+
(`--source-local` goes the other way). Reading production needs no confirmation — only *writing* to it does. Emails are always
|
|
96
98
|
anonymized. Dry-run by default; add `--execute`.
|
|
97
99
|
|
|
98
100
|
```bash
|
|
@@ -100,7 +102,7 @@ anonymized. Dry-run by default; add `--execute`.
|
|
|
100
102
|
npx @dooer/dooer-test-env@latest customer copy \
|
|
101
103
|
--source e45a3bc4-1b61-4f55-9bd6-de2705420cc2 \
|
|
102
104
|
--source-namespace dooer-production \
|
|
103
|
-
--target-
|
|
105
|
+
--target-local \
|
|
104
106
|
--name "Ghost inspector (live)" \
|
|
105
107
|
--owner-user <local-customer-users_pk> \
|
|
106
108
|
--execute
|
|
@@ -109,10 +111,36 @@ npx @dooer/dooer-test-env@latest customer copy \
|
|
|
109
111
|
npx @dooer/dooer-test-env@latest shred --execute
|
|
110
112
|
|
|
111
113
|
# undo: delete that org again (rows + its S3 objects), dry-run first
|
|
112
|
-
npx @dooer/dooer-test-env@latest customer purge --org <orgId> --
|
|
114
|
+
npx @dooer/dooer-test-env@latest customer purge --org <orgId> --local --execute
|
|
113
115
|
```
|
|
114
116
|
|
|
115
|
-
|
|
117
|
+
Naming: with **neither `--name` nor `--target`** the copy creates a new org that **keeps the source org's
|
|
118
|
+
own name** (its `short_name` still gets a unique suffix). Pass `--name` to rename it, or `--target <uuid>`
|
|
119
|
+
to copy into an org that already exists.
|
|
120
|
+
|
|
121
|
+
**Many orgs at once** — `--sourcefile` takes a file with one org uuid per line (`#` comments and blank
|
|
122
|
+
lines ignored, duplicates collapsed). Each org is copied in turn and keeps its own name, so `--name` and
|
|
123
|
+
`--target` (which describe a single target) are rejected with it:
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
cat > orgs.txt <<'IDS'
|
|
127
|
+
e45a3bc4-1b61-4f55-9bd6-de2705420cc2
|
|
128
|
+
0391c6ba-2c41-458b-95d2-a2d555117833
|
|
129
|
+
IDS
|
|
130
|
+
|
|
131
|
+
npx @dooer/dooer-test-env@latest customer copy \
|
|
132
|
+
--sourcefile orgs.txt \
|
|
133
|
+
--source-namespace dooer-production \
|
|
134
|
+
--target-local \
|
|
135
|
+
--owner-user <local-customer-users_pk> \
|
|
136
|
+
--execute
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
One failure does not abandon the batch: failures are collected and summarised at the end (and the command
|
|
140
|
+
exits non-zero).
|
|
141
|
+
|
|
142
|
+
It scans all ~336 tables in the source before writing, so expect a few minutes per org on a production
|
|
143
|
+
source.
|
|
116
144
|
|
|
117
145
|
### Run your own code — one service, or several at once
|
|
118
146
|
|
|
@@ -120,23 +148,32 @@ It scans all ~336 tables in the source before writing, so expect a few minutes o
|
|
|
120
148
|
discovery router so **containers call your process**. Your process reaches the containerized services
|
|
121
149
|
through their published host ports, so traffic flows **both ways**.
|
|
122
150
|
|
|
123
|
-
|
|
124
|
-
|
|
151
|
+
**`cd` to your checkout** — wherever you keep it — and run it there. The service name is read from that
|
|
152
|
+
repo's `package.json`, and it runs in the foreground with its **logs streaming to your terminal**; Ctrl-C
|
|
153
|
+
stops it and restores the container.
|
|
125
154
|
|
|
126
155
|
```bash
|
|
127
|
-
|
|
128
|
-
npx @dooer/dooer-test-env@latest service local
|
|
129
|
-
npx @dooer/dooer-test-env@latest service version service-ledger # LOCAL code or which image?
|
|
130
|
-
npx @dooer/dooer-test-env@latest service unlocal service-ledger # back to the container image
|
|
156
|
+
cd ~/wherever/service-ledger
|
|
157
|
+
npx @dooer/dooer-test-env@latest service local # name inferred from package.json
|
|
131
158
|
```
|
|
132
159
|
|
|
133
|
-
|
|
160
|
+
```bash
|
|
161
|
+
# or name/point it explicitly from anywhere
|
|
162
|
+
npx @dooer/dooer-test-env@latest service local service-ledger ~/wherever/service-ledger
|
|
163
|
+
npx @dooer/dooer-test-env@latest service version service-ledger # LOCAL code, or which image?
|
|
164
|
+
npx @dooer/dooer-test-env@latest service unlocal service-ledger # back to the container image
|
|
165
|
+
```
|
|
134
166
|
|
|
135
167
|
**Several services at once** — e.g. test a gateway change together with a backend change, no image builds:
|
|
136
168
|
|
|
169
|
+
Each one holds its own terminal, so use two:
|
|
170
|
+
|
|
137
171
|
```bash
|
|
138
|
-
|
|
139
|
-
npx @dooer/dooer-test-env@latest service local
|
|
172
|
+
# terminal 1 — the backend, started FIRST
|
|
173
|
+
cd ~/wherever/service-accounts && npx @dooer/dooer-test-env@latest service local
|
|
174
|
+
|
|
175
|
+
# terminal 2 — the gateway
|
|
176
|
+
cd ~/wherever/service-graphql && npx @dooer/dooer-test-env@latest service local
|
|
140
177
|
```
|
|
141
178
|
|
|
142
179
|
Order matters: a local process learns its peers' addresses at startup, so start the service **being called**
|