@dooer/dooer-test-env 1.8.2 → 1.10.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/discovery-router/server.js +85 -18
- package/lib/command/customer.js +12 -3
- package/lib/command/env.js +17 -0
- package/lib/command/service.js +72 -25
- package/lib/compose/generate.js +43 -5
- package/lib/runtime.js +25 -12
- package/package.json +1 -1
- package/readme.md +149 -7
|
@@ -79,7 +79,53 @@ function consulNode(name, { address, port }) {
|
|
|
79
79
|
// we grant every session and every lock acquire. This is the difference between a static Consul stub and
|
|
80
80
|
// a working local env (without it, all scheduled/background handlers stay dormant).
|
|
81
81
|
const sessions = new Set()
|
|
82
|
-
const kv = new Map() // key -> { value, session }
|
|
82
|
+
const kv = new Map() // key -> { value, session, index }
|
|
83
|
+
// Consul BLOCKING QUERIES. doode's leader election long-polls `GET /v1/kv/<lock>?index=N&wait=180s` from
|
|
84
|
+
// every service. Consul holds that request open until the key's ModifyIndex exceeds N (or `wait` elapses);
|
|
85
|
+
// our first shim answered instantly with a constant ModifyIndex, so ~90 services re-polled in a tight
|
|
86
|
+
// loop — which produced a 177 GB container log and burned CPU. Track a real index and park waiters.
|
|
87
|
+
let kvIndex = 1
|
|
88
|
+
const kvWaiters = new Map() // key -> Set<{ resolve }>
|
|
89
|
+
|
|
90
|
+
function kvBump(key) {
|
|
91
|
+
kvIndex += 1
|
|
92
|
+
const entry = kv.get(key)
|
|
93
|
+
if (entry) entry.index = kvIndex
|
|
94
|
+
const waiters = kvWaiters.get(key)
|
|
95
|
+
if (waiters) {
|
|
96
|
+
for (const w of waiters) w.resolve()
|
|
97
|
+
waiters.clear()
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Wait until `key` changes or `waitMs` elapses. Resolves either way (the caller re-reads current state).
|
|
102
|
+
function kvWait(key, waitMs) {
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
let done = false
|
|
105
|
+
const finish = () => {
|
|
106
|
+
if (done) return
|
|
107
|
+
done = true
|
|
108
|
+
clearTimeout(timer)
|
|
109
|
+
const set = kvWaiters.get(key)
|
|
110
|
+
if (set) set.delete(waiter)
|
|
111
|
+
resolve()
|
|
112
|
+
}
|
|
113
|
+
const waiter = { resolve: finish }
|
|
114
|
+
const timer = setTimeout(finish, waitMs)
|
|
115
|
+
if (!kvWaiters.has(key)) kvWaiters.set(key, new Set())
|
|
116
|
+
kvWaiters.get(key).add(waiter)
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Consul durations: "180s", "5m", "1000ms". Capped so a client cannot park a socket forever.
|
|
121
|
+
function parseWait(raw) {
|
|
122
|
+
if (!raw) return 0
|
|
123
|
+
const m = String(raw).match(/^(\d+)(ms|s|m)?$/)
|
|
124
|
+
if (!m) return 0
|
|
125
|
+
const n = Number(m[1])
|
|
126
|
+
const ms = m[2] === 'ms' ? n : m[2] === 'm' ? n * 60000 : n * 1000
|
|
127
|
+
return Math.min(ms, 600000)
|
|
128
|
+
}
|
|
83
129
|
|
|
84
130
|
function readRaw(req) {
|
|
85
131
|
return new Promise((resolve, reject) => {
|
|
@@ -121,36 +167,52 @@ async function handleConsulLock(req, res, url) {
|
|
|
121
167
|
const release = searchParams.get('release')
|
|
122
168
|
if (release) {
|
|
123
169
|
const cur = kv.get(key)
|
|
124
|
-
if (cur && cur.session === release) kv.set(key, { value: cur.value, session: null })
|
|
170
|
+
if (cur && cur.session === release) kv.set(key, { value: cur.value, session: null, index: kvIndex })
|
|
171
|
+
kvBump(key)
|
|
125
172
|
sendJson(res, 200, true)
|
|
126
173
|
} else {
|
|
127
174
|
// single-node: always grant the lock (or plain write)
|
|
128
|
-
kv.set(key, { value, session: acquire || null })
|
|
175
|
+
kv.set(key, { value, session: acquire || null, index: kvIndex })
|
|
176
|
+
kvBump(key)
|
|
129
177
|
sendJson(res, 200, true)
|
|
130
178
|
}
|
|
131
179
|
return true
|
|
132
180
|
}
|
|
133
181
|
if (method === 'GET') {
|
|
182
|
+
const askedIndex = Number(searchParams.get('index') || 0)
|
|
183
|
+
const waitMs = parseWait(searchParams.get('wait'))
|
|
184
|
+
// Blocking query: hold the request while the caller's index is current (this is what stops the
|
|
185
|
+
// leader-election long-poll from becoming a tight loop).
|
|
186
|
+
if (askedIndex > 0 && waitMs > 0) {
|
|
187
|
+
const seen = kv.get(key)
|
|
188
|
+
if ((seen ? seen.index : kvIndex) <= askedIndex) await kvWait(key, waitMs)
|
|
189
|
+
}
|
|
134
190
|
const cur = kv.get(key)
|
|
191
|
+
const idx = cur ? cur.index || kvIndex : kvIndex
|
|
135
192
|
if (!cur) {
|
|
136
|
-
|
|
193
|
+
res.writeHead(404, { 'content-type': 'application/json', 'x-consul-index': String(idx) })
|
|
194
|
+
res.end('[]')
|
|
137
195
|
return true
|
|
138
196
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
197
|
+
res.writeHead(200, { 'content-type': 'application/json', 'x-consul-index': String(idx) })
|
|
198
|
+
res.end(
|
|
199
|
+
JSON.stringify([
|
|
200
|
+
{
|
|
201
|
+
Key: key,
|
|
202
|
+
Value: Buffer.from(cur.value || '').toString('base64'),
|
|
203
|
+
Session: cur.session || undefined,
|
|
204
|
+
CreateIndex: 1,
|
|
205
|
+
ModifyIndex: idx,
|
|
206
|
+
LockIndex: 1,
|
|
207
|
+
Flags: 0,
|
|
208
|
+
},
|
|
209
|
+
])
|
|
210
|
+
)
|
|
150
211
|
return true
|
|
151
212
|
}
|
|
152
213
|
if (method === 'DELETE') {
|
|
153
214
|
kv.delete(key)
|
|
215
|
+
kvBump(key)
|
|
154
216
|
sendJson(res, 200, true)
|
|
155
217
|
return true
|
|
156
218
|
}
|
|
@@ -257,9 +319,14 @@ async function handle(req, res) {
|
|
|
257
319
|
|
|
258
320
|
const server = http.createServer((req, res) => {
|
|
259
321
|
const start = Date.now()
|
|
260
|
-
res.on('finish', () =>
|
|
261
|
-
|
|
262
|
-
|
|
322
|
+
res.on('finish', () => {
|
|
323
|
+
// Skip the leader-election/discovery poll traffic: ~90 services poll continuously, and logging every
|
|
324
|
+
// hit is what grew this container's log to 177 GB. Errors and everything else still log.
|
|
325
|
+
const noisy = /^\/v1\/(kv|session|health)\//.test(req.url || '')
|
|
326
|
+
if (!noisy || res.statusCode >= 400) {
|
|
327
|
+
log('request', { method: req.method, url: req.url, status: res.statusCode, ms: Date.now() - start })
|
|
328
|
+
}
|
|
329
|
+
})
|
|
263
330
|
handle(req, res).catch((error) => {
|
|
264
331
|
log('handler-error', { url: req.url, error: error.message })
|
|
265
332
|
if (!res.headersSent) sendJson(res, 500, { error: 'internal error' })
|
package/lib/command/customer.js
CHANGED
|
@@ -14,8 +14,14 @@ function toEngineArgv(argv) {
|
|
|
14
14
|
flag('target', argv.target)
|
|
15
15
|
flag('name', argv.name)
|
|
16
16
|
flag('owner-user', argv.ownerUser)
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
// `local` is a first-class TARGET/SOURCE, not a k8s namespace — --source-local / --target-local keep the
|
|
18
|
+
// two ideas separate rather than overloading --*-namespace (Jimmy 2026-09-03). The engine addresses the
|
|
19
|
+
// local stack through the pseudo-namespace `local` internally.
|
|
20
|
+
if (argv.sourceNamespace === 'local' || argv.targetNamespace === 'local') {
|
|
21
|
+
throw new Error('use --source-local / --target-local for the local env, not --*-namespace local')
|
|
22
|
+
}
|
|
23
|
+
flag('source-namespace', argv.sourceLocal ? 'local' : argv.sourceNamespace)
|
|
24
|
+
flag('target-namespace', argv.targetLocal ? 'local' : argv.targetNamespace)
|
|
19
25
|
flag('email', argv.email)
|
|
20
26
|
flag('if-target-nonempty', argv.ifTargetNonempty)
|
|
21
27
|
flag('salt', argv.salt)
|
|
@@ -36,6 +42,8 @@ const copyOptions = (y) =>
|
|
|
36
42
|
.option('owner-user', { type: 'string', describe: 'existing user (users_pk) to own a newly-created org' })
|
|
37
43
|
.option('source-namespace', { type: 'string', default: 'dooer-staging', describe: 'k8s namespace to read from' })
|
|
38
44
|
.option('target-namespace', { type: 'string', describe: 'k8s namespace to write to (default = source)' })
|
|
45
|
+
.option('source-local', { type: 'boolean', describe: 'read from the LOCAL env instead of a k8s namespace' })
|
|
46
|
+
.option('target-local', { type: 'boolean', describe: 'write to the LOCAL env instead of a k8s namespace' })
|
|
39
47
|
.option('email', { type: 'string', describe: 'address to scrub emails to (default testcustomer@dooer.com)' })
|
|
40
48
|
.option('if-target-nonempty', {
|
|
41
49
|
choices: ['refuse', 'insert'],
|
|
@@ -67,13 +75,14 @@ module.exports = {
|
|
|
67
75
|
y2
|
|
68
76
|
.option('org', { type: 'string', demandOption: true, describe: 'org (companies_pk) to delete' })
|
|
69
77
|
.option('namespace', { type: 'string', default: 'dooer-staging', describe: 'k8s namespace' })
|
|
78
|
+
.option('local', { type: 'boolean', describe: 'purge from the LOCAL env instead of a k8s namespace' })
|
|
70
79
|
.option('skip-files', { type: 'boolean', describe: 'do not delete the org’s S3 objects' })
|
|
71
80
|
.option('confirm-production', { type: 'boolean', describe: 'REQUIRED to delete from dooer-production' })
|
|
72
81
|
.option('execute', { type: 'boolean', default: false, describe: 'actually delete (default: dry-run)' }),
|
|
73
82
|
handler: (argv) =>
|
|
74
83
|
purge({
|
|
75
84
|
org: argv.org,
|
|
76
|
-
namespace: argv.namespace,
|
|
85
|
+
namespace: argv.local ? 'local' : argv.namespace,
|
|
77
86
|
skipFiles: argv.skipFiles,
|
|
78
87
|
confirmProduction: argv.confirmProduction,
|
|
79
88
|
execute: argv.execute,
|
package/lib/command/env.js
CHANGED
|
@@ -63,6 +63,23 @@ module.exports = [
|
|
|
63
63
|
if (bankidEnv.SECRET_DOOER_BANK_ID_PFX)
|
|
64
64
|
console.log(chalk.gray('bankid: injecting production certs from keychain'))
|
|
65
65
|
const code = rt.dc(['up', '-d'], { profile: argv.profile, env: bankidEnv })
|
|
66
|
+
// `up` starts EVERY service in the profile, including ones currently served by a `service local`
|
|
67
|
+
// host process — the resurrected container then competes with (and for peers, wins over) the local
|
|
68
|
+
// code. Stop those again so local mode survives an `up`.
|
|
69
|
+
const localEntries = Object.entries(rt.readState().local || {}).filter(([, info]) => rt.isAlive(info.pid))
|
|
70
|
+
const localNames = localEntries.map(([name]) => name)
|
|
71
|
+
if (localNames.length) {
|
|
72
|
+
rt.dc(['stop', ...localNames])
|
|
73
|
+
// The router keeps overrides in MEMORY, so a docker (or router) restart loses them and discovery
|
|
74
|
+
// falls back to the container name — which is stopped for a local service, making every call to it
|
|
75
|
+
// fail. Re-apply them here so `up` heals that instead of leaving a silently broken stack.
|
|
76
|
+
for (const [name, info] of localEntries) {
|
|
77
|
+
await discovery
|
|
78
|
+
.setTarget(rt.ROUTER_URL, name, { address: 'host.docker.internal', port: info.port })
|
|
79
|
+
.catch(() => {})
|
|
80
|
+
}
|
|
81
|
+
console.log(chalk.gray(`kept local: ${localNames.join(', ')} (containers stopped, router repointed)`))
|
|
82
|
+
}
|
|
66
83
|
if (code === 0) {
|
|
67
84
|
console.log(chalk.green(`\nenv up (profile: ${argv.profile}).`))
|
|
68
85
|
console.log('Next: `dooer-test-env db pull` to load the customer-free base DB, then `dooer-test-env status`.')
|
package/lib/command/service.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
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')
|
|
6
5
|
const rt = require('../runtime')
|
|
7
6
|
const discovery = require('../discovery/client')
|
|
8
7
|
const bankid = require('../bankid')
|
|
9
|
-
const { imageFor } = require('../compose/manifests')
|
|
8
|
+
const { imageFor, listServices } = require('../compose/manifests')
|
|
9
|
+
const { serviceHostPortMap, KAFKA_HOST_PORT } = require('../compose/generate')
|
|
10
10
|
|
|
11
11
|
// Per-service control in a running env. `local`/`unlocal`/`deploy` update the discovery router so peers
|
|
12
12
|
// pick up the change with no restarts (see ENVIRONMENT-PLAN.md §4/§6). Local host-process ports are
|
|
@@ -52,20 +52,53 @@ function localEnv(name, port) {
|
|
|
52
52
|
env.DOOER_SERVICE_NAME = env.DOOER_SERVICE_NAME || name
|
|
53
53
|
env.DOOER_ENVIRONMENT_NAME = env.DOOER_ENVIRONMENT_NAME || 'local'
|
|
54
54
|
env.NODE_ENV = env.NODE_ENV || 'development'
|
|
55
|
+
// PEER calls: a host process cannot resolve container names (`service-core-objects:3000`) — nor container
|
|
56
|
+
// IPs, which Docker Desktop doesn't route on macOS. Every service is published on a deterministic host
|
|
57
|
+
// port (see generate.js serviceHostPortMap), so point @dooer/service at those, and at THIS service's own
|
|
58
|
+
// local port for self-calls. Without this a locally-run service can only talk to itself.
|
|
59
|
+
const hostPorts = serviceHostPortMap(listServices(rt.SERVICES_DIR))
|
|
60
|
+
const localPeers = rt.readState().local || {}
|
|
61
|
+
for (const [svcName, hostPort] of Object.entries(hostPorts)) {
|
|
62
|
+
// A peer that is ALSO running locally must be reached on ITS host process port, not the port its
|
|
63
|
+
// (stopped) container would publish — otherwise a local service-graphql calls the container copy of
|
|
64
|
+
// service-accounts and never sees your local changes.
|
|
65
|
+
const peer = localPeers[svcName]
|
|
66
|
+
const target = peer && rt.isAlive(peer.pid) ? peer.port : hostPort
|
|
67
|
+
env[`DOOER_HOST_${svcName.toUpperCase().replace(/-/g, '_')}`] = `localhost:${target}`
|
|
68
|
+
}
|
|
55
69
|
// SELF-calls: the router advertises this service as host.docker.internal:<port> so CONTAINERS can reach
|
|
56
|
-
// the host process
|
|
57
|
-
//
|
|
58
|
-
// @dooer/service checks DOOER_HOST_<NAME> before Consul, so point the service at its own localhost port.
|
|
70
|
+
// the host process, but that name does not resolve ON the host (service-accounts →
|
|
71
|
+
// /api/v1/token-invalidation/all → ENOTFOUND, which broke every HQ navigation query).
|
|
59
72
|
env[`DOOER_HOST_${name.toUpperCase().replace(/-/g, '_')}`] = `localhost:${port}`
|
|
60
|
-
// @dooer/logging: `logMode = DOOER_LOG || (process.stdout.isTTY ? 'pretty' : 'none')`.
|
|
61
|
-
//
|
|
62
|
-
// DOOER_LOG=json, which the manifests don't carry
|
|
63
|
-
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}`
|
|
64
80
|
return env
|
|
65
81
|
}
|
|
66
82
|
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
94
|
+
try {
|
|
95
|
+
const pkgName = require(path.join(cwd, 'package.json')).name || ''
|
|
96
|
+
const name = pkgName.replace(/^@[^/]+\//, '')
|
|
97
|
+
if (name) return name
|
|
98
|
+
} catch (_) {
|
|
99
|
+
/* no readable package.json */
|
|
100
|
+
}
|
|
101
|
+
throw new Error(`cannot tell which service this is — run inside a service checkout or pass its name`)
|
|
69
102
|
}
|
|
70
103
|
|
|
71
104
|
module.exports = {
|
|
@@ -124,31 +157,45 @@ module.exports = {
|
|
|
124
157
|
},
|
|
125
158
|
})
|
|
126
159
|
.command({
|
|
127
|
-
command: 'local
|
|
128
|
-
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)',
|
|
129
162
|
builder: (y2) =>
|
|
130
163
|
y2
|
|
131
164
|
.option('port', { type: 'number', describe: 'host port for the local process (default: auto)' })
|
|
132
165
|
.option('script', { type: 'string', default: 'start', describe: 'package.json script to run' }),
|
|
133
166
|
handler: async (a) => {
|
|
134
|
-
const cwd = repoPath(a.
|
|
167
|
+
const cwd = repoPath(a.repoPath)
|
|
135
168
|
if (!fs.existsSync(path.join(cwd, 'package.json')))
|
|
136
|
-
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)
|
|
137
171
|
const port = a.port || 4000 + Math.floor((Date.now() % 1000) + Math.random() * 100) // CLI-managed
|
|
138
|
-
rt.dc(['stop',
|
|
139
|
-
|
|
140
|
-
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 })
|
|
141
174
|
const st = rt.readState()
|
|
142
|
-
st.local[
|
|
175
|
+
st.local[name] = { pid: process.pid, port, cwd }
|
|
143
176
|
rt.writeState(st)
|
|
144
177
|
console.log(
|
|
145
|
-
chalk.green(
|
|
146
|
-
|
|
147
|
-
rt.RUN_DIR,
|
|
148
|
-
`${a.name}.local.log`
|
|
149
|
-
)}`
|
|
150
|
-
)
|
|
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')
|
|
151
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
|
+
})
|
|
152
199
|
},
|
|
153
200
|
})
|
|
154
201
|
.command({
|
package/lib/compose/generate.js
CHANGED
|
@@ -126,6 +126,11 @@ const SERVICE_ENV_OVERRIDES = {
|
|
|
126
126
|
},
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
// Cap EVERY container's log. The json-file driver is unbounded by default: the discovery-router alone grew
|
|
130
|
+
// a 177 GB log file and filled the host disk (Jimmy 2026-09-03). 10 MB x 3 files per container bounds the
|
|
131
|
+
// whole stack to a few GB worst case, and `logs`/`docker logs` still have plenty of history.
|
|
132
|
+
const LOGGING = { driver: 'json-file', options: { 'max-size': '10m', 'max-file': '3' } }
|
|
133
|
+
|
|
129
134
|
// Marker: this secret-backed env var must NOT be emitted at all (see below).
|
|
130
135
|
const OMIT = Symbol('omit-env')
|
|
131
136
|
|
|
@@ -249,6 +254,7 @@ function infraServices() {
|
|
|
249
254
|
timeout: '5s',
|
|
250
255
|
retries: 20,
|
|
251
256
|
},
|
|
257
|
+
logging: LOGGING,
|
|
252
258
|
restart: 'unless-stopped',
|
|
253
259
|
},
|
|
254
260
|
redis: {
|
|
@@ -256,6 +262,7 @@ function infraServices() {
|
|
|
256
262
|
// password-protected like staging — see DEV.redisPassword for why it matters (GraphQL subscriptions).
|
|
257
263
|
command: ['redis-server', '--requirepass', DEV.redisPassword],
|
|
258
264
|
ports: ['6379:6379'],
|
|
265
|
+
logging: LOGGING,
|
|
259
266
|
restart: 'unless-stopped',
|
|
260
267
|
},
|
|
261
268
|
// Single-node Redpanda: Kafka-API compatible, no ZooKeeper needed — much
|
|
@@ -277,13 +284,17 @@ function infraServices() {
|
|
|
277
284
|
'--node-id',
|
|
278
285
|
'0',
|
|
279
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.
|
|
280
290
|
'--kafka-addr',
|
|
281
|
-
'
|
|
291
|
+
'INTERNAL://0.0.0.0:9092,EXTERNAL://0.0.0.0:19092',
|
|
282
292
|
'--advertise-kafka-addr',
|
|
283
|
-
|
|
293
|
+
`INTERNAL://kafka:9092,EXTERNAL://localhost:${KAFKA_HOST_PORT}`,
|
|
284
294
|
],
|
|
285
|
-
ports: ['9092:9092'],
|
|
295
|
+
ports: ['9092:9092', `${KAFKA_HOST_PORT}:19092`],
|
|
286
296
|
volumes: ['kafka_data:/var/lib/redpanda/data'],
|
|
297
|
+
logging: LOGGING,
|
|
287
298
|
restart: 'unless-stopped',
|
|
288
299
|
},
|
|
289
300
|
minio: {
|
|
@@ -296,6 +307,7 @@ function infraServices() {
|
|
|
296
307
|
},
|
|
297
308
|
ports: ['9000:9000', '9001:9001'],
|
|
298
309
|
volumes: ['minio_data:/data'],
|
|
310
|
+
logging: LOGGING,
|
|
299
311
|
restart: 'unless-stopped',
|
|
300
312
|
},
|
|
301
313
|
// One-shot: wait for MinIO, then create the buckets the services expect (idempotent). Runs on every `up`.
|
|
@@ -317,6 +329,7 @@ function infraServices() {
|
|
|
317
329
|
'discovery-router': {
|
|
318
330
|
build: path.resolve(__dirname, '..', '..', 'discovery-router'),
|
|
319
331
|
ports: ['8500:8500'],
|
|
332
|
+
logging: LOGGING,
|
|
320
333
|
restart: 'unless-stopped',
|
|
321
334
|
},
|
|
322
335
|
}
|
|
@@ -328,6 +341,27 @@ function infraServices() {
|
|
|
328
341
|
// an internal backend it fronts — NOT the facade — so it gets its own port for direct dev access.
|
|
329
342
|
const FACADE_HOST_PORT = 4000 // public-facade → DOOER_PUBLIC_FACADE_HOST / DOOER_GRAPHQL_HOST
|
|
330
343
|
const GRAPHQL_HOST_PORT = 4001 // service-graphql (direct access; frontends go through the facade)
|
|
344
|
+
// EVERY backend service is also published on a deterministic host port. Containers reach each other by
|
|
345
|
+
// container name, but a `service local` HOST process cannot resolve those names (nor container IPs, which
|
|
346
|
+
// Docker Desktop doesn't route on macOS) — so without this a locally-run service can call itself but not
|
|
347
|
+
// its peers. `service local` turns this map into DOOER_HOST_<NAME> overrides. (Jimmy 2026-09-03.)
|
|
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
|
|
351
|
+
|
|
352
|
+
// {service name → published host port}, stable because listServices() is sorted by filename.
|
|
353
|
+
function serviceHostPortMap(services) {
|
|
354
|
+
const map = {}
|
|
355
|
+
;(services || []).forEach((svc, i) => {
|
|
356
|
+
map[svc.name] =
|
|
357
|
+
svc.name === 'public-facade'
|
|
358
|
+
? FACADE_HOST_PORT
|
|
359
|
+
: svc.name === 'service-graphql'
|
|
360
|
+
? GRAPHQL_HOST_PORT
|
|
361
|
+
: SERVICE_HOST_PORT_BASE + i
|
|
362
|
+
})
|
|
363
|
+
return map
|
|
364
|
+
}
|
|
331
365
|
const HQ_HOST_PORT = 8080 // frontend-hq
|
|
332
366
|
|
|
333
367
|
// host port for a frontend: HQ is pinned to 8080; the rest get 8081+ in sorted order.
|
|
@@ -382,6 +416,7 @@ const SERVICE_ALIASES = {
|
|
|
382
416
|
function buildCompose(services, frontends, { profile, outputValidation = false } = {}) {
|
|
383
417
|
const wantedSvc = profile ? services.filter((s) => profilesFor(s.name).includes(profile)) : services
|
|
384
418
|
const portMap = frontendPortMap(frontends)
|
|
419
|
+
const svcPortMap = serviceHostPortMap(services)
|
|
385
420
|
|
|
386
421
|
const composeServices = infraServices()
|
|
387
422
|
wantedSvc.forEach((svc) => {
|
|
@@ -398,13 +433,13 @@ function buildCompose(services, frontends, { profile, outputValidation = false }
|
|
|
398
433
|
portMap
|
|
399
434
|
),
|
|
400
435
|
// publish the browser-facing facade (4000) and service-graphql (4001) on the host
|
|
401
|
-
|
|
402
|
-
...(svc.name === 'service-graphql' ? { ports: [`${GRAPHQL_HOST_PORT}:3000`] } : {}),
|
|
436
|
+
ports: [`${svcPortMap[svc.name]}:3000`],
|
|
403
437
|
// legacy-name DNS aliases (e.g. service-periods → service-closing), so callers resolving the old name
|
|
404
438
|
// reach this container on the compose network.
|
|
405
439
|
...(SERVICE_ALIASES[svc.name] ? { networks: { default: { aliases: SERVICE_ALIASES[svc.name] } } } : {}),
|
|
406
440
|
depends_on: ['postgres', 'redis', 'discovery-router'],
|
|
407
441
|
profiles: profilesFor(svc.name),
|
|
442
|
+
logging: LOGGING,
|
|
408
443
|
restart: 'unless-stopped',
|
|
409
444
|
}
|
|
410
445
|
})
|
|
@@ -433,6 +468,7 @@ function buildCompose(services, frontends, { profile, outputValidation = false }
|
|
|
433
468
|
ports: [`${hostPort}:3000`],
|
|
434
469
|
depends_on: ['discovery-router'],
|
|
435
470
|
profiles: ['full', 'frontend', ...(fe.name === 'frontend-hq' ? ['hq'] : [])],
|
|
471
|
+
logging: LOGGING,
|
|
436
472
|
restart: 'unless-stopped',
|
|
437
473
|
}
|
|
438
474
|
})
|
|
@@ -479,6 +515,8 @@ function generateCompose({ profile, servicesDir, out, outputValidation = false }
|
|
|
479
515
|
|
|
480
516
|
module.exports = {
|
|
481
517
|
BOOKING_PROFILE_SERVICES,
|
|
518
|
+
KAFKA_HOST_PORT,
|
|
519
|
+
serviceHostPortMap,
|
|
482
520
|
VALIDATION_ENV,
|
|
483
521
|
GLOBAL_OVERRIDES,
|
|
484
522
|
secretDefault,
|
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.10.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
|
@@ -51,19 +51,158 @@ service <start|stop|restart|deploy|local|unlocal|version> <name> per-service c
|
|
|
51
51
|
|
|
52
52
|
Everything that writes is **dry-run by default**; add `--execute`. Run any command with `--help`.
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
## Use cases
|
|
55
|
+
|
|
56
|
+
Everything below runs from **any directory** (`npx` resolves the published CLI). Nothing is tied to a
|
|
57
|
+
particular machine — you `cd` to your own checkouts, and state lives in `~/.dooer-test-env`.
|
|
58
|
+
|
|
59
|
+
### Where things are served
|
|
60
|
+
|
|
61
|
+
| | |
|
|
62
|
+
| --- | --- |
|
|
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) |
|
|
72
|
+
|
|
73
|
+
### Spin up an empty, ready-to-use customer
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
npx @dooer/dooer-test-env@latest customer new "Nordvik Handel AB" \
|
|
77
|
+
--owner <users_pk-of-a-CUSTOMER-user> --execute
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Provisions through the product APIs (so `company-created` / `fiscal-year-created` fire and service-closing
|
|
81
|
+
generates the accounting periods): company + Owner + address, the Ghost-Inspector subscription set, partner
|
|
82
|
+
`dooer`, Terms of Service pre-accepted, the current-year fiscal year, and the temporal keys the platform
|
|
83
|
+
reads (`fiscalYear`, `hasVatRegistration`, `hasCompanyTax`, `hasEmployeeRegistration`, `bookkeepingMethod`,
|
|
84
|
+
`vatPeriod`/`vatDue`). `--no-fiscal-year` leaves all of that blank.
|
|
85
|
+
|
|
86
|
+
The owner must be a **`customer`** user — a `hi`/admin owner cannot accept Terms of Service and the command
|
|
87
|
+
warns you. Find one with:
|
|
88
|
+
|
|
89
|
+
```sql
|
|
90
|
+
SELECT users_pk, email FROM service_accounts.users
|
|
91
|
+
WHERE fk_user_roles_at_dooer_pk = 'customer' AND inactivated_at IS NULL LIMIT 5;
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Copy a real customer into the local env
|
|
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
|
|
98
|
+
anonymized. Dry-run by default; add `--execute`.
|
|
55
99
|
|
|
56
100
|
```bash
|
|
57
|
-
#
|
|
58
|
-
npx @dooer/dooer-test-env customer
|
|
101
|
+
# copy Ghost Inspector out of LIVE into the local env, as a new org owned by a local user
|
|
102
|
+
npx @dooer/dooer-test-env@latest customer copy \
|
|
103
|
+
--source e45a3bc4-1b61-4f55-9bd6-de2705420cc2 \
|
|
104
|
+
--source-namespace dooer-production \
|
|
105
|
+
--target-local \
|
|
106
|
+
--name "Ghost inspector (live)" \
|
|
107
|
+
--owner-user <local-customer-users_pk> \
|
|
108
|
+
--execute
|
|
109
|
+
|
|
110
|
+
# optional: shred the rest of the PII afterwards (localhost only)
|
|
111
|
+
npx @dooer/dooer-test-env@latest shred --execute
|
|
112
|
+
|
|
113
|
+
# undo: delete that org again (rows + its S3 objects), dry-run first
|
|
114
|
+
npx @dooer/dooer-test-env@latest customer purge --org <orgId> --local --execute
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
It scans all ~336 tables in the source before writing, so expect a few minutes on a production source.
|
|
118
|
+
|
|
119
|
+
### Run your own code — one service, or several at once
|
|
120
|
+
|
|
121
|
+
`service local` stops that service's container, starts `yarn start` from your checkout, and repoints the
|
|
122
|
+
discovery router so **containers call your process**. Your process reaches the containerized services
|
|
123
|
+
through their published host ports, so traffic flows **both ways**.
|
|
59
124
|
|
|
60
|
-
|
|
61
|
-
|
|
125
|
+
**`cd` to your checkout** — wherever you keep it — and run it there. The service name is read from that
|
|
126
|
+
repo's `package.json`, and it runs in the foreground with its **logs streaming to your terminal**; Ctrl-C
|
|
127
|
+
stops it and restores the container.
|
|
62
128
|
|
|
63
|
-
|
|
64
|
-
|
|
129
|
+
```bash
|
|
130
|
+
cd ~/wherever/service-ledger
|
|
131
|
+
npx @dooer/dooer-test-env@latest service local # name inferred from package.json
|
|
65
132
|
```
|
|
66
133
|
|
|
134
|
+
```bash
|
|
135
|
+
# or name/point it explicitly from anywhere
|
|
136
|
+
npx @dooer/dooer-test-env@latest service local service-ledger ~/wherever/service-ledger
|
|
137
|
+
npx @dooer/dooer-test-env@latest service version service-ledger # LOCAL code, or which image?
|
|
138
|
+
npx @dooer/dooer-test-env@latest service unlocal service-ledger # back to the container image
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
**Several services at once** — e.g. test a gateway change together with a backend change, no image builds:
|
|
142
|
+
|
|
143
|
+
Each one holds its own terminal, so use two:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
# terminal 1 — the backend, started FIRST
|
|
147
|
+
cd ~/wherever/service-accounts && npx @dooer/dooer-test-env@latest service local
|
|
148
|
+
|
|
149
|
+
# terminal 2 — the gateway
|
|
150
|
+
cd ~/wherever/service-graphql && npx @dooer/dooer-test-env@latest service local
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Order matters: a local process learns its peers' addresses at startup, so start the service **being called**
|
|
154
|
+
before the one calling it (or restart the caller afterwards).
|
|
155
|
+
|
|
156
|
+
Now a single query through the facade spans everything — browser → public-facade (Docker) → service-graphql
|
|
157
|
+
(your code) → service-accounts (your code) **and** service-core-objects (Docker):
|
|
158
|
+
|
|
159
|
+
```graphql
|
|
160
|
+
query VerifyLocalCode {
|
|
161
|
+
organization(id: "<orgId>") {
|
|
162
|
+
id
|
|
163
|
+
documents(first: 1) { totalCount } # service-core-objects, in Docker
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
Try it in back-office's GraphiQL at `http://localhost:8082/tools/graphiql`, or:
|
|
169
|
+
|
|
170
|
+
```bash
|
|
171
|
+
curl -s -X POST http://localhost:4000/graphql -H 'Content-Type: application/json' \
|
|
172
|
+
-H "Authorization: Bearer <token>" \
|
|
173
|
+
--data '{"query":"query { organization(id:\"<orgId>\") { id documents(first:1){ totalCount } } }"}'
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
### Test a published build before it ships
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npx @dooer/dooer-test-env@latest service deploy service-graphql 66.34.2 # pin an image tag
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
Pins it in `docker-compose.override.yml` and keeps `DOOER_SERVICE_VERSION` in sync so logs report the
|
|
183
|
+
version actually running.
|
|
184
|
+
|
|
185
|
+
### Find out what broke
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
npx @dooer/dooer-test-env@latest logs <transactionId> # across ALL services at once
|
|
189
|
+
npx @dooer/dooer-test-env@latest logs "error" --service ledger --since 30m -C 2
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
### Output-schema validation (off by default)
|
|
193
|
+
|
|
194
|
+
Off means the local env behaves like staging/live, where these checks are silent. Turn it **on** to hunt
|
|
195
|
+
stale service definitions — a consumer whose bundled definition predates a producer's added fields returns
|
|
196
|
+
500 on perfectly valid data:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
npx @dooer/dooer-test-env@latest validation on # then reproduce; drift shows up as 500s
|
|
200
|
+
npx @dooer/dooer-test-env@latest validation status
|
|
201
|
+
npx @dooer/dooer-test-env@latest validation off
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
Inbound *input* validation always runs — this only affects response validation.
|
|
205
|
+
|
|
67
206
|
## How it works
|
|
68
207
|
|
|
69
208
|
- **Compose generator** (`lib/compose/`): translates the s-e032 k8s manifests → a docker-compose spec —
|
|
@@ -82,6 +221,9 @@ npx @dooer/dooer-test-env service local service-ledger
|
|
|
82
221
|
PII-audits, and uploads the artifact to an OBC bucket; devs only `db pull`. The CronJob + OBC manifests
|
|
83
222
|
live with the other staging yaml in `new-infrastructure/kubernetes/environments/s-e032-onprem/`.
|
|
84
223
|
|
|
224
|
+
- **Log caps**: every container is capped at `10m x 3` json-file logs. Unbounded logging once grew the
|
|
225
|
+
discovery-router's log to 179 GB and filled the host disk.
|
|
226
|
+
|
|
85
227
|
## Safety
|
|
86
228
|
|
|
87
229
|
- `shred` from a dev machine is **localhost-only**.
|