@dooer/dooer-test-env 1.15.0 → 1.17.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/docs/getting-access.md +35 -70
- package/docs/issuing-access.md +9 -0
- package/docs/user-management.md +246 -0
- package/lib/bankid.js +28 -1
- package/lib/command/bankid.js +2 -2
- package/lib/command/customer.js +28 -32
- package/lib/command/db.js +58 -1
- package/lib/command/env.js +15 -3
- package/lib/command/remote-environment.js +11 -0
- package/lib/command/setup.js +25 -16
- package/lib/compose/generate.js +5 -1
- package/lib/compose/manifests.js +7 -0
- package/lib/copy.js +101 -0
- package/lib/db/migrate-stale.js +60 -0
- package/lib/db/roles.js +6 -0
- package/lib/env-spec.js +65 -0
- package/lib/registry.js +28 -0
- package/lib/remote.js +11 -5
- package/lib/runtime.js +6 -3
- package/lib/service-client.js +161 -0
- package/package.json +1 -1
- package/readme.md +65 -45
package/lib/command/env.js
CHANGED
|
@@ -6,6 +6,10 @@ const bankid = require('../bankid')
|
|
|
6
6
|
const { generateCompose } = require('../compose/generate')
|
|
7
7
|
const discovery = require('../discovery/client')
|
|
8
8
|
|
|
9
|
+
// Every profile the generator emits (lib/compose/generate.js). `down` must name them all, or compose
|
|
10
|
+
// silently leaves the profiled containers running.
|
|
11
|
+
const PROFILES = ['full', 'frontend', 'hq', 'booking']
|
|
12
|
+
|
|
9
13
|
// Environment lifecycle for the whole local stack. The compose file is generated from the s-e032 k8s
|
|
10
14
|
// manifests (lib/compose) on first `up`; the discovery-router wires services together. Default profile is
|
|
11
15
|
// `full` (everything staging runs) — compose profiles are additive, so we must name it explicitly.
|
|
@@ -91,14 +95,15 @@ module.exports = [
|
|
|
91
95
|
command: 'stop',
|
|
92
96
|
describe: 'stop the running env (preserve containers/volumes)',
|
|
93
97
|
handler: () => {
|
|
94
|
-
|
|
98
|
+
// Same profile trap as `down` — without the profiles, compose stops nothing.
|
|
99
|
+
process.exitCode = rt.dc(['stop'], { profile: PROFILES.join(',') })
|
|
95
100
|
},
|
|
96
101
|
},
|
|
97
102
|
{
|
|
98
103
|
command: 'start',
|
|
99
104
|
describe: 'start a previously-stopped env (not recreate)',
|
|
100
105
|
handler: () => {
|
|
101
|
-
process.exitCode = rt.dc(['start'])
|
|
106
|
+
process.exitCode = rt.dc(['start'], { profile: PROFILES.join(',') })
|
|
102
107
|
},
|
|
103
108
|
},
|
|
104
109
|
{
|
|
@@ -106,7 +111,14 @@ module.exports = [
|
|
|
106
111
|
describe: 'stop and remove the env',
|
|
107
112
|
builder: (y) => y.option('volumes', { type: 'boolean', describe: 'also remove volumes (wipes the local DB)' }),
|
|
108
113
|
handler: (argv) => {
|
|
109
|
-
|
|
114
|
+
// Compose only targets containers whose profile is active. `up` starts everything under `full` (or
|
|
115
|
+
// whichever profile was asked for), so a bare `down` removed the volumes and the network but left
|
|
116
|
+
// every profiled container running — observed 2026-09-04: 86 survivors and "Network … is still in
|
|
117
|
+
// use". Name every profile so `down` means down. --remove-orphans catches containers left behind by
|
|
118
|
+
// an earlier compose file (e.g. after a service was renamed or dropped from the manifests).
|
|
119
|
+
process.exitCode = rt.dc(['down', '--remove-orphans', ...(argv.volumes ? ['-v'] : [])], {
|
|
120
|
+
profile: PROFILES.join(','),
|
|
121
|
+
})
|
|
110
122
|
},
|
|
111
123
|
},
|
|
112
124
|
{
|
|
@@ -204,6 +204,17 @@ module.exports = {
|
|
|
204
204
|
console.log(` Revert with: dooer-test-env remote-environment become --revert --env ${env.name}\n`)
|
|
205
205
|
},
|
|
206
206
|
})
|
|
207
|
+
.command({
|
|
208
|
+
command: 'token',
|
|
209
|
+
describe: 'print the stored token for an environment — bare, for scripts',
|
|
210
|
+
builder: envOption,
|
|
211
|
+
handler: (argv) => {
|
|
212
|
+
const env = remote.resolveEnv(argv.env)
|
|
213
|
+
// Deliberately the ONLY thing on stdout: no colour, no label, no trailing prose, so
|
|
214
|
+
// `$(… token)` and `| pbcopy` both do the obvious thing. Failures go to stderr via cli.js.
|
|
215
|
+
process.stdout.write(remote.tokenFor(env.name) + '\n')
|
|
216
|
+
},
|
|
217
|
+
})
|
|
207
218
|
.command({
|
|
208
219
|
command: 'status',
|
|
209
220
|
describe: 'who you are logged in as, and until when',
|
package/lib/command/setup.js
CHANGED
|
@@ -34,29 +34,38 @@ module.exports = {
|
|
|
34
34
|
handler: async (argv) => {
|
|
35
35
|
console.log(chalk.bold('\ndooer-test-env setup\n'))
|
|
36
36
|
|
|
37
|
+
// No kubectl. The prerequisites are now Docker, the VPN, and an ADMIN SESSION for the environment
|
|
38
|
+
// this stack is built from — service-dooer-test-env supplies everything a kubeconfig used to.
|
|
37
39
|
const dockerOk = registry.have('docker') && check('docker', ['info'])
|
|
38
40
|
console.log(`${ok(dockerOk)} docker (installed + daemon running)`)
|
|
39
|
-
const kubectlOk = registry.have('kubectl')
|
|
40
|
-
console.log(`${ok(kubectlOk)} kubectl installed`)
|
|
41
|
-
// k8s reachable + access: `get ns <ns>` needs the API server (VPN) AND RBAC, so it proves both at once.
|
|
42
|
-
const nsOk = kubectlOk && check('kubectl', ['get', 'ns', argv.namespace, '--request-timeout=10s'])
|
|
43
|
-
console.log(`${ok(nsOk)} k8s cluster reachable + access to namespace ${argv.namespace} (needs VPN)`)
|
|
44
|
-
const secretsOk = nsOk && check('kubectl', ['auth', 'can-i', 'get', 'secrets', '-n', argv.namespace])
|
|
45
|
-
console.log(`${ok(secretsOk)} can read secrets (registry pull secrets + DB creds) in ${argv.namespace}`)
|
|
46
|
-
const dbOk =
|
|
47
|
-
nsOk && check('kubectl', ['get', 'svc', 'dooer-database', '-n', argv.namespace, '--request-timeout=10s'])
|
|
48
|
-
console.log(`${ok(dbOk)} can reach the dooer-database service in ${argv.namespace}`)
|
|
49
41
|
if (!dockerOk) throw new Error('docker is required (install Docker + start the daemon).')
|
|
50
|
-
|
|
51
|
-
|
|
42
|
+
|
|
43
|
+
const remote = require('../remote')
|
|
44
|
+
const serviceClient = require('../service-client')
|
|
45
|
+
const envSpec = require('../env-spec')
|
|
46
|
+
const sourceEnv = remote.resolveEnv(argv.env || 'staging')
|
|
47
|
+
|
|
48
|
+
let describe = null
|
|
49
|
+
try {
|
|
50
|
+
describe = await serviceClient.request(sourceEnv, { path: '/v1/environment', timeoutMs: 20000 })
|
|
51
|
+
} catch (e) {
|
|
52
|
+
console.log(`${ok(false)} service-dooer-test-env in ${sourceEnv.label}`)
|
|
52
53
|
throw new Error(
|
|
53
|
-
`cannot reach
|
|
54
|
+
`cannot reach service-dooer-test-env in ${sourceEnv.label}: ${e.message}\n` +
|
|
55
|
+
` · is the VPN up? (the service is not publicly exposed)\n` +
|
|
56
|
+
` · are you logged in? \`dooer-test-env remote-environment login --env ${sourceEnv.name} --email <admin>\`\n` +
|
|
57
|
+
` · creating users needs an ADMIN session; BankID cannot reach one.`
|
|
54
58
|
)
|
|
55
|
-
|
|
59
|
+
}
|
|
60
|
+
console.log(`${ok(true)} service-dooer-test-env reachable in ${describe.environment} (needs VPN)`)
|
|
61
|
+
|
|
62
|
+
// Fetch the environment spec — replaces parsing a local new-infrastructure checkout.
|
|
63
|
+
const spec = await envSpec.refresh(sourceEnv.name)
|
|
64
|
+
console.log(`${ok(true)} environment spec: ${spec.services.length} services, ${spec.frontends.length} frontends`)
|
|
56
65
|
|
|
57
66
|
// registry login (the part that used to be manual)
|
|
58
67
|
console.log('\nLogging in to the private registries…')
|
|
59
|
-
const logins = await registry.loginAll({ namespace: argv.namespace })
|
|
68
|
+
const logins = await registry.loginAll({ namespace: argv.namespace, env: sourceEnv.name })
|
|
60
69
|
for (const l of logins) console.log(` ${ok(l.ok)} ${l.host} (${l.via})`)
|
|
61
70
|
if (logins.some((l) => !l.ok)) throw new Error('one or more registry logins failed')
|
|
62
71
|
|
|
@@ -73,7 +82,7 @@ module.exports = {
|
|
|
73
82
|
// if not already stored. Non-fatal — the env still runs (test-mode BankID) without them.
|
|
74
83
|
if (bankid.status().every((s) => !s.present)) {
|
|
75
84
|
try {
|
|
76
|
-
const stored = bankid.pullAndStore({ namespace: argv.namespace })
|
|
85
|
+
const stored = await bankid.pullAndStore({ namespace: argv.namespace })
|
|
77
86
|
console.log(`${ok(true)} BankID: pulled ${stored.length} secret(s) from staging → keychain`)
|
|
78
87
|
} catch (e) {
|
|
79
88
|
console.log(`${chalk.yellow('!')} BankID: skipped (${e.message}); local login stays in test mode`)
|
package/lib/compose/generate.js
CHANGED
|
@@ -59,7 +59,11 @@ const GLOBAL_OVERRIDES = {
|
|
|
59
59
|
// matching roles are created locally by `db roles` (run automatically after `db pull`). Only the password
|
|
60
60
|
// is forced to the shared dev secret; a service with no manifest user falls back to `dooer` (below).
|
|
61
61
|
DOOER_SQL_APPLICATION_PASSWORD: DEV.sqlPassword,
|
|
62
|
-
|
|
62
|
+
// Migrations run as the `dooer` superuser: @dooer/database's db-migrate driver issues
|
|
63
|
+
// `SET SESSION ROLE <database owner>`, which a plain service role is not permitted to do
|
|
64
|
+
// ("permission denied to set role dooer"). The driver takes the target schema from its own config
|
|
65
|
+
// (`schema: <service schema>`), so migrations still land in the SERVICE'S OWN schema, not public.
|
|
66
|
+
DOOER_SQL_MIGRATION_USER: DEV.sqlUser,
|
|
63
67
|
DOOER_SQL_MIGRATION_PASSWORD: DEV.sqlPassword,
|
|
64
68
|
DOOER_IS_ONEPLATFORMER: 'false',
|
|
65
69
|
// clear the RDS CA baked into the image, and disable node TLS verification: @dooer/database always
|
package/lib/compose/manifests.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
const fs = require('fs')
|
|
10
10
|
const path = require('path')
|
|
11
11
|
const yaml = require('js-yaml')
|
|
12
|
+
const envSpec = require('../env-spec')
|
|
12
13
|
|
|
13
14
|
// Where the s-e032 (staging) manifests live by default. Callers should pass an
|
|
14
15
|
// explicit `servicesDir`; this is only the fallback for `imageFor(name)`.
|
|
@@ -68,7 +69,12 @@ function parseManifestFile(filePath) {
|
|
|
68
69
|
|
|
69
70
|
// List every dooer service defined by a `service-*.yaml` Deployment in `dir`,
|
|
70
71
|
// sorted by filename for deterministic output.
|
|
72
|
+
// The spec now comes from service-dooer-test-env (lib/env-spec.js), read from the live cluster, instead
|
|
73
|
+
// of a local `new-infrastructure` checkout — that is the whole point of the service. The yaml reader
|
|
74
|
+
// below is kept as a fallback for anyone who still has the checkout and wants to work from it, and
|
|
75
|
+
// because it is what generated every stack before this migration.
|
|
71
76
|
function listServices(servicesDir) {
|
|
77
|
+
if (!servicesDir && envSpec.isCached()) return envSpec.services()
|
|
72
78
|
const dir = servicesDir || DEFAULT_SERVICES_DIR
|
|
73
79
|
return fs
|
|
74
80
|
.readdirSync(dir)
|
|
@@ -80,6 +86,7 @@ function listServices(servicesDir) {
|
|
|
80
86
|
|
|
81
87
|
// List every frontend defined by a `frontend-*.yaml` Deployment in `dir`.
|
|
82
88
|
function listFrontends(servicesDir) {
|
|
89
|
+
if (!servicesDir && envSpec.isCached()) return envSpec.frontends()
|
|
83
90
|
const dir = servicesDir || DEFAULT_SERVICES_DIR
|
|
84
91
|
return fs
|
|
85
92
|
.readdirSync(dir)
|
package/lib/copy.js
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// `customer copy`, orchestrated across two environments.
|
|
2
|
+
//
|
|
3
|
+
// The user-facing command does not change: one `copy`, same flags. Export-then-import is an
|
|
4
|
+
// implementation detail (Jimmy 2026-09-04). What changed underneath is that neither half needs a
|
|
5
|
+
// kubeconfig — each is an admin-authorized call to service-dooer-test-env in its own environment:
|
|
6
|
+
//
|
|
7
|
+
// POST <source>/v1/exports authorized by admin IN THE SOURCE -> artifact
|
|
8
|
+
// POST <target>/v1/imports authorized by admin IN THE TARGET <- artifact
|
|
9
|
+
//
|
|
10
|
+
// Neither service ever validates the other's token, and the operator has to genuinely be an admin at
|
|
11
|
+
// both ends — a stronger check than "can reach the cluster", which is what it replaces.
|
|
12
|
+
const fs = require('fs')
|
|
13
|
+
const os = require('os')
|
|
14
|
+
const path = require('path')
|
|
15
|
+
const chalk = require('chalk')
|
|
16
|
+
|
|
17
|
+
const remote = require('./remote')
|
|
18
|
+
const serviceClient = require('./service-client')
|
|
19
|
+
|
|
20
|
+
const seconds = (ms) => `${Math.round(ms / 1000)}s`
|
|
21
|
+
|
|
22
|
+
// Both halves are long-running and async by design, so progress has to be visible or a copy looks hung.
|
|
23
|
+
function progress(label) {
|
|
24
|
+
const started = Date.now()
|
|
25
|
+
let lastStatus = null
|
|
26
|
+
return (op) => {
|
|
27
|
+
if (op.status === lastStatus) {
|
|
28
|
+
process.stdout.write('.')
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
lastStatus = op.status
|
|
32
|
+
process.stdout.write(`\n ${label}: ${op.status} (${seconds(Date.now() - started)})`)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function copyOrganization({ organizationId, sourceEnv, targetEnv, reason, dryRun = false }) {
|
|
37
|
+
const source = remote.resolveEnv(sourceEnv)
|
|
38
|
+
const target = remote.resolveEnv(targetEnv)
|
|
39
|
+
|
|
40
|
+
console.log(chalk.bold(`\ncopying ${organizationId}`) + ` ${source.label} → ${target.label}\n`)
|
|
41
|
+
|
|
42
|
+
// ── export ────────────────────────────────────────────────────────────────
|
|
43
|
+
const exportOp = await serviceClient.request(source, {
|
|
44
|
+
method: 'POST',
|
|
45
|
+
path: '/v1/exports',
|
|
46
|
+
body: { organizationId, reason: reason || `copy to ${target.name}` },
|
|
47
|
+
})
|
|
48
|
+
const exported = await serviceClient.pollOperation(source, 'exports', exportOp.id, {
|
|
49
|
+
onTick: progress('export'),
|
|
50
|
+
})
|
|
51
|
+
if (exported.status !== 'succeeded') {
|
|
52
|
+
throw new Error(`\nexport failed: ${exported.error || 'unknown error'}`)
|
|
53
|
+
}
|
|
54
|
+
const exportDetail = serviceClient.detailOf(exported)
|
|
55
|
+
console.log(`\n export: ${(exportDetail.bytes / 1e6).toFixed(1)} MB artifact`)
|
|
56
|
+
|
|
57
|
+
const skipped = exportDetail.skippedInSource || []
|
|
58
|
+
if (skipped.length) {
|
|
59
|
+
// Never let a partial copy pass silently — the whole point of recording skips service-side.
|
|
60
|
+
console.log(chalk.yellow(` WARNING: ${skipped.length} table(s) could not be read from the source:`))
|
|
61
|
+
for (const s of skipped) console.log(chalk.yellow(` ${s.skippedTable}: ${s.error}`))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Straight to a file: an artifact this size should never sit in the CLI's heap.
|
|
65
|
+
const artifactPath = path.join(os.tmpdir(), `dooer-copy-${exported.id}.ndjson.gz`)
|
|
66
|
+
await serviceClient.downloadTo(source, `/v1/exports/${exported.id}/content`, artifactPath)
|
|
67
|
+
|
|
68
|
+
// ── import ────────────────────────────────────────────────────────────────
|
|
69
|
+
let importOp
|
|
70
|
+
try {
|
|
71
|
+
importOp = await serviceClient.uploadArtifactFile(
|
|
72
|
+
target,
|
|
73
|
+
`/v1/imports?${dryRun ? 'dryRun=true&' : ''}reason=${encodeURIComponent(reason || `copy from ${source.name}`)}`,
|
|
74
|
+
artifactPath
|
|
75
|
+
)
|
|
76
|
+
} finally {
|
|
77
|
+
try {
|
|
78
|
+
fs.unlinkSync(artifactPath)
|
|
79
|
+
} catch (_) {
|
|
80
|
+
/* already gone */
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const imported = await serviceClient.pollOperation(target, 'imports', importOp.id, {
|
|
84
|
+
onTick: progress('import'),
|
|
85
|
+
})
|
|
86
|
+
if (imported.status !== 'succeeded') {
|
|
87
|
+
throw new Error(`\nimport failed: ${imported.error || 'unknown error'}`)
|
|
88
|
+
}
|
|
89
|
+
const detail = serviceClient.detailOf(imported)
|
|
90
|
+
|
|
91
|
+
console.log(
|
|
92
|
+
(dryRun ? chalk.yellow('\n\nDRY RUN — nothing written') : chalk.green('\n\ncopied')) +
|
|
93
|
+
` ${detail.rows} rows across ${detail.tables} tables`
|
|
94
|
+
)
|
|
95
|
+
console.log(` new organization id: ${chalk.bold(detail.organizationId)}`)
|
|
96
|
+
console.log(` audit: export ${exported.id} (${source.name}), import ${imported.id} (${target.name})\n`)
|
|
97
|
+
|
|
98
|
+
return { organizationId: detail.organizationId, rows: detail.rows, tables: detail.tables, skipped }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
module.exports = { copyOrganization }
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Re-run migrations for services whose tables the base-DB restore wiped.
|
|
2
|
+
//
|
|
3
|
+
// A restored dump only contains the schemas that existed when it was BUILT, so a service deployed since
|
|
4
|
+
// then comes up healthy with no tables and fails every query with `relation "…" does not exist`.
|
|
5
|
+
// Restarting the container is supposed to fix that — the base image runs `yarn db-migrate` before
|
|
6
|
+
// starting — but that wrapper cannot be relied on here: @dooer/database's `migrate` hands the real work to
|
|
7
|
+
// a `resolveBin('db-migrate', …)` CALLBACK and does not await it, so the process frequently exits before
|
|
8
|
+
// db-migrate has done anything. It reports success either way (exit 0, no output), which is the worst
|
|
9
|
+
// combination: a container that looks migrated and is not.
|
|
10
|
+
//
|
|
11
|
+
// So we run db-migrate directly with the config @dooer/database builds. That config already carries the
|
|
12
|
+
// right `schema`, so the tables land in the service's OWN schema.
|
|
13
|
+
const { execFileSync } = require('child_process')
|
|
14
|
+
|
|
15
|
+
const CONTAINER_PREFIX = 'dooer-test-env-'
|
|
16
|
+
|
|
17
|
+
// Written inside the container: build the db-migrate config from the service's own env, then run it.
|
|
18
|
+
const SCRIPT = `
|
|
19
|
+
set -e
|
|
20
|
+
PATH="$PATH:./node_modules/.bin"
|
|
21
|
+
node -e '
|
|
22
|
+
const fs = require("fs")
|
|
23
|
+
const db = require("@dooer/database")({
|
|
24
|
+
serviceName: process.env.DOOER_SERVICE_NAME,
|
|
25
|
+
host: process.env.DOOER_SQL_HOST,
|
|
26
|
+
port: Number(process.env.DOOER_SQL_PORT || 5432),
|
|
27
|
+
database: process.env.DOOER_SQL_DATABASE || "dooer",
|
|
28
|
+
migrationUser: process.env.DOOER_SQL_MIGRATION_USER,
|
|
29
|
+
migrationPassword: process.env.DOOER_SQL_MIGRATION_PASSWORD,
|
|
30
|
+
applicationUser: process.env.DOOER_SQL_APPLICATION_USER,
|
|
31
|
+
applicationPassword: process.env.DOOER_SQL_APPLICATION_PASSWORD,
|
|
32
|
+
isOneplatformer: process.env.DOOER_IS_ONEPLATFORMER === "true",
|
|
33
|
+
})
|
|
34
|
+
fs.writeFileSync("/tmp/db-migrate.json", JSON.stringify(db._dbMigrateConfig))
|
|
35
|
+
'
|
|
36
|
+
db-migrate --config /tmp/db-migrate.json up
|
|
37
|
+
`
|
|
38
|
+
|
|
39
|
+
// Returns { service, ok, detail } per service.
|
|
40
|
+
function migrateServices(services) {
|
|
41
|
+
return services.map((service) => {
|
|
42
|
+
const container = `${CONTAINER_PREFIX}${service}-1`
|
|
43
|
+
try {
|
|
44
|
+
const out = execFileSync('docker', ['exec', container, 'sh', '-c', SCRIPT], {
|
|
45
|
+
encoding: 'utf8',
|
|
46
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
47
|
+
maxBuffer: 8 * 1024 * 1024,
|
|
48
|
+
})
|
|
49
|
+
const applied = (out.match(/Processed migration/g) || []).length
|
|
50
|
+
return { service, ok: true, detail: applied ? `${applied} migration(s)` : 'already up to date' }
|
|
51
|
+
} catch (e) {
|
|
52
|
+
// A service with no migrations directory, or one that is not running, is not a failure worth
|
|
53
|
+
// stopping a `db pull` for — report it and move on.
|
|
54
|
+
const msg = (e.stderr || e.stdout || e.message || '').toString().trim().split('\n').slice(-1)[0]
|
|
55
|
+
return { service, ok: false, detail: msg.slice(0, 120) }
|
|
56
|
+
}
|
|
57
|
+
})
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
module.exports = { migrateServices }
|
package/lib/db/roles.js
CHANGED
|
@@ -42,6 +42,12 @@ function schemaSetupSql(schema) {
|
|
|
42
42
|
BEGIN TRANSACTION;
|
|
43
43
|
CREATE SCHEMA IF NOT EXISTS ${ident(schema)};
|
|
44
44
|
CREATE SCHEMA IF NOT EXISTS ${ident(`${schema}_history`)};
|
|
45
|
+
-- Every service keeps its OWN migrations ledger in its own schema; the base DB dump carries one per
|
|
46
|
+
-- service. A service that did not exist when the dump was built has none, so @dooer/database's
|
|
47
|
+
-- migrate resolves \`migrations\` down the search_path to the owner-only \`public.migrations\` and
|
|
48
|
+
-- fails with "permission denied for table migrations" — a brand-new service then cannot migrate
|
|
49
|
+
-- locally at all. Give it an empty ledger of its own so migrations run from scratch.
|
|
50
|
+
CREATE TABLE IF NOT EXISTS ${ident(schema)}.migrations (LIKE public.migrations INCLUDING ALL);
|
|
45
51
|
DO $body$ BEGIN
|
|
46
52
|
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = ${literal(`${schema}_access`)}) THEN
|
|
47
53
|
CREATE ROLE ${ident(`${schema}_access`)};
|
package/lib/env-spec.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// The environment spec — what services and frontends exist, their images, ports and env.
|
|
2
|
+
//
|
|
3
|
+
// This used to come from a local checkout of `new-infrastructure` parsed with js-yaml, which meant every
|
|
4
|
+
// developer needed that repo and a spec that silently went stale. It now comes from
|
|
5
|
+
// service-dooer-test-env's `/v1/environment/compose-spec`, read from the LIVE Deployments in the cluster.
|
|
6
|
+
//
|
|
7
|
+
// The fetched spec is cached in the run directory so `up`, `status` and `service local` do not each make a
|
|
8
|
+
// network call, and so the stack can be brought up while the VPN is down. Refresh with
|
|
9
|
+
// `setup` or `up --regenerate`.
|
|
10
|
+
const fs = require('fs')
|
|
11
|
+
const path = require('path')
|
|
12
|
+
|
|
13
|
+
const rt = require('./runtime')
|
|
14
|
+
|
|
15
|
+
const SPEC_FILE = path.join(rt.RUN_DIR, 'environment-spec.json')
|
|
16
|
+
|
|
17
|
+
// Fetch from the service and cache. `env` defaults to staging: the local stack is a copy of staging, so
|
|
18
|
+
// that is where its shape comes from.
|
|
19
|
+
async function refresh(envName = 'staging') {
|
|
20
|
+
// Required lazily: service-client reaches back into the compose generator for the local port, and a
|
|
21
|
+
// top-level require here would close the loop (manifests -> env-spec -> service-client -> generate ->
|
|
22
|
+
// manifests) and hand out half-initialised modules.
|
|
23
|
+
const remote = require('./remote')
|
|
24
|
+
const serviceClient = require('./service-client')
|
|
25
|
+
const env = remote.resolveEnv(envName)
|
|
26
|
+
const spec = await serviceClient.request(env, { path: '/v1/environment/compose-spec', timeoutMs: 120000 })
|
|
27
|
+
rt.ensureRunDir()
|
|
28
|
+
fs.writeFileSync(SPEC_FILE, JSON.stringify({ fetchedAt: new Date().toISOString(), ...spec }, null, 2))
|
|
29
|
+
return spec
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function cached() {
|
|
33
|
+
try {
|
|
34
|
+
return JSON.parse(fs.readFileSync(SPEC_FILE, 'utf8'))
|
|
35
|
+
} catch (_) {
|
|
36
|
+
return null
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// The generator wants `{ name, image, env, secretEnv, port }` — the same shape the yaml parser produced,
|
|
41
|
+
// so nothing downstream changes.
|
|
42
|
+
function readCachedOrThrow() {
|
|
43
|
+
const spec = cached()
|
|
44
|
+
if (!spec) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
'no environment spec cached — run `dooer-test-env setup` (needs the VPN and an admin session for staging)'
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
return spec
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const services = () => readCachedOrThrow().services || []
|
|
53
|
+
const frontends = () => readCachedOrThrow().frontends || []
|
|
54
|
+
|
|
55
|
+
function imageFor(name) {
|
|
56
|
+
const spec = cached()
|
|
57
|
+
if (!spec) return null
|
|
58
|
+
const hit = [...(spec.services || []), ...(spec.frontends || [])].find((s) => s.name === name)
|
|
59
|
+
return hit ? hit.image : null
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const isCached = () => cached() !== null
|
|
63
|
+
const fetchedAt = () => (cached() || {}).fetchedAt || null
|
|
64
|
+
|
|
65
|
+
module.exports = { SPEC_FILE, refresh, cached, services, frontends, imageFor, isCached, fetchedAt }
|
package/lib/registry.js
CHANGED
|
@@ -20,6 +20,19 @@ function have(cmd) {
|
|
|
20
20
|
return !r.error
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
+
// Pull credentials from service-dooer-test-env — the path that needs no kubectl. Returns the same
|
|
24
|
+
// { host: {username, password} } shape as the kubectl reader below, so the caller cannot tell them apart.
|
|
25
|
+
async function authsFromService(envName = 'staging') {
|
|
26
|
+
const remote = require('./remote')
|
|
27
|
+
const serviceClient = require('./service-client')
|
|
28
|
+
const env = remote.resolveEnv(envName)
|
|
29
|
+
const { registries } = await serviceClient.request(env, { path: '/v1/bootstrap/registry-credentials' })
|
|
30
|
+
const out = {}
|
|
31
|
+
for (const r of registries || [])
|
|
32
|
+
out[r.host.replace(/^https?:\/\//, '')] = { username: r.username, password: r.password }
|
|
33
|
+
return Object.keys(out).length ? out : null
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
// { host: { username, password } } parsed from a k8s dockerconfigjson secret (or null if unreadable)
|
|
24
37
|
function authsFromSecret(secretName, namespace) {
|
|
25
38
|
let b64
|
|
@@ -77,6 +90,21 @@ async function prompt(question, { silent } = {}) {
|
|
|
77
90
|
|
|
78
91
|
// Log docker into both private registries. Returns [{host, ok, via}]. `via` = kubectl | env | prompt.
|
|
79
92
|
async function loginAll({ namespace = 'dooer-staging', interactive = true } = {}) {
|
|
93
|
+
// Service first — that is the path with no kubectl. Falls back to the Secret reader and then to
|
|
94
|
+
// env/prompt, so a developer mid-migration (or with the service unreachable) is never stuck.
|
|
95
|
+
try {
|
|
96
|
+
const fromService = await authsFromService()
|
|
97
|
+
if (fromService) {
|
|
98
|
+
const results = []
|
|
99
|
+
for (const [host, creds] of Object.entries(fromService)) {
|
|
100
|
+
results.push({ host, ok: dockerLogin(host, creds.username, creds.password), via: 'service' })
|
|
101
|
+
}
|
|
102
|
+
if (results.length && results.every((r) => r.ok)) return results
|
|
103
|
+
}
|
|
104
|
+
} catch (_) {
|
|
105
|
+
/* fall through to the kubectl path below */
|
|
106
|
+
}
|
|
107
|
+
|
|
80
108
|
if (!have('docker')) throw new Error('docker is not installed / not on PATH')
|
|
81
109
|
const results = []
|
|
82
110
|
const merged = {}
|
package/lib/remote.js
CHANGED
|
@@ -113,12 +113,18 @@ function tokenFor(envName) {
|
|
|
113
113
|
if (stored) {
|
|
114
114
|
const exp = expiry(stored)
|
|
115
115
|
if (exp && exp.expired) {
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
)
|
|
116
|
+
// The local stack needs no login at all, so an expired session there is not a reason to stop —
|
|
117
|
+
// fall through and mint. Blocking on it turned a stale keychain entry into a hard failure in the
|
|
118
|
+
// middle of a copy.
|
|
119
|
+
if (envName !== 'local') {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`your ${envName} session expired ${exp.at.toISOString().slice(0, 16).replace('T', ' ')} — ` +
|
|
122
|
+
`run: dooer-test-env remote-environment login --env ${envName}`
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
} else {
|
|
126
|
+
return stored
|
|
120
127
|
}
|
|
121
|
-
return stored
|
|
122
128
|
}
|
|
123
129
|
if (envName === 'local') return require('./api').serviceToken()
|
|
124
130
|
throw new Error(`not logged in to ${envName} — run: dooer-test-env remote-environment login --env ${envName}`)
|
package/lib/runtime.js
CHANGED
|
@@ -12,9 +12,12 @@ const OVERRIDE_FILE = path.join(RUN_DIR, 'docker-compose.override.yml')
|
|
|
12
12
|
const STATE_FILE = path.join(RUN_DIR, 'state.json')
|
|
13
13
|
const REGISTRY_FILE = path.join(RUN_DIR, 'registry.json')
|
|
14
14
|
const ROUTER_URL = process.env.DOOER_TEST_ENV_ROUTER || 'http://localhost:8500'
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
// The environment spec now comes from service-dooer-test-env, cached by lib/env-spec.js — no
|
|
16
|
+
// `new-infrastructure` checkout required. This stays as an explicit ESCAPE HATCH: set
|
|
17
|
+
// DOOER_TEST_ENV_MANIFESTS to a manifest directory to generate from local yaml instead, which is useful
|
|
18
|
+
// when working on manifests that are not deployed yet. Undefined by default, so callers passing it
|
|
19
|
+
// through fall to the cached spec.
|
|
20
|
+
const SERVICES_DIR = process.env.DOOER_TEST_ENV_MANIFESTS || undefined
|
|
18
21
|
|
|
19
22
|
function ensureRunDir() {
|
|
20
23
|
fs.mkdirSync(RUN_DIR, { recursive: true })
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/* global fetch, AbortSignal, FormData */
|
|
2
|
+
// Talking to service-dooer-test-env — the service that replaced this CLI's kubectl access.
|
|
3
|
+
//
|
|
4
|
+
// It is deliberately NOT publicly exposed (no ingress, no public-facade route), so it is reached the way
|
|
5
|
+
// anything in the cluster is reached from a laptop: over the WireGuard tunnel, at the ClusterIP of its
|
|
6
|
+
// k8s Service object. Those IPs are shipped as defaults; they are stable because the Service objects are
|
|
7
|
+
// never deleted (Jimmy 2026-09-04), and DNS may replace them later.
|
|
8
|
+
//
|
|
9
|
+
// The LOCAL stack runs its own copy of the service — it appears in the compose spec like every other
|
|
10
|
+
// staging Deployment — reached on its published host port.
|
|
11
|
+
const fs = require('fs')
|
|
12
|
+
const { Readable } = require('stream')
|
|
13
|
+
const { pipeline: streamPipeline } = require('stream/promises')
|
|
14
|
+
|
|
15
|
+
const remote = require('./remote')
|
|
16
|
+
const rt = require('./runtime')
|
|
17
|
+
|
|
18
|
+
const SERVICE_NAME = 'service-dooer-test-env'
|
|
19
|
+
|
|
20
|
+
// Per-environment endpoints. Overridable so nobody is stuck if an IP changes before the default does.
|
|
21
|
+
const CLUSTER_ENDPOINTS = {
|
|
22
|
+
staging: process.env.DOOER_TEST_ENV_SERVICE_STAGING || 'http://10.108.228.242',
|
|
23
|
+
production: process.env.DOOER_TEST_ENV_SERVICE_PRODUCTION || 'http://10.109.163.108',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function endpointFor(envName) {
|
|
27
|
+
if (envName === 'local') {
|
|
28
|
+
// Lazy: the generator requires the manifest reader, which requires env-spec, which requires this
|
|
29
|
+
// module. Deferring the require keeps that cycle from forming at load time.
|
|
30
|
+
const { serviceHostPortMap } = require('./compose/generate')
|
|
31
|
+
const { listServices } = require('./compose/manifests')
|
|
32
|
+
const port = serviceHostPortMap(listServices(rt.SERVICES_DIR))[SERVICE_NAME]
|
|
33
|
+
if (!port) {
|
|
34
|
+
throw new Error(
|
|
35
|
+
`${SERVICE_NAME} is not in the local stack — run \`dooer-test-env up --regenerate\` so the ` +
|
|
36
|
+
`compose file picks it up`
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
return `http://localhost:${port}`
|
|
40
|
+
}
|
|
41
|
+
const url = CLUSTER_ENDPOINTS[envName]
|
|
42
|
+
if (!url) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
`no ${SERVICE_NAME} endpoint known for "${envName}" — it may not be deployed there yet. ` +
|
|
45
|
+
`Set DOOER_TEST_ENV_SERVICE_${envName.toUpperCase()} to override.`
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
return url
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// One request. `env` is a resolved environment from lib/remote.
|
|
52
|
+
async function request(env, { method = 'GET', path, body, raw = false, timeoutMs = 120000 }) {
|
|
53
|
+
const url = `${endpointFor(env.name)}${path}`
|
|
54
|
+
const headers = { authorization: `Bearer ${remote.tokenFor(env.name)}`, 'x-dooer-client': 'dooer-test-env@0' }
|
|
55
|
+
if (body !== undefined) headers['content-type'] = 'application/json'
|
|
56
|
+
let res
|
|
57
|
+
try {
|
|
58
|
+
res = await fetch(url, {
|
|
59
|
+
method,
|
|
60
|
+
headers,
|
|
61
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
62
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
63
|
+
})
|
|
64
|
+
} catch (e) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`cannot reach ${SERVICE_NAME} for ${env.label} at ${url}: ${e.message}` +
|
|
67
|
+
(env.name === 'local' ? '' : ' — is the VPN up?')
|
|
68
|
+
)
|
|
69
|
+
}
|
|
70
|
+
if (raw) {
|
|
71
|
+
if (!res.ok) throw new Error(`${env.label}: HTTP ${res.status} from ${path}`)
|
|
72
|
+
return Buffer.from(await res.arrayBuffer())
|
|
73
|
+
}
|
|
74
|
+
const text = await res.text()
|
|
75
|
+
let json
|
|
76
|
+
try {
|
|
77
|
+
json = JSON.parse(text)
|
|
78
|
+
} catch (_) {
|
|
79
|
+
throw new Error(`${env.label}: HTTP ${res.status} from ${path} (not JSON): ${text.slice(0, 200)}`)
|
|
80
|
+
}
|
|
81
|
+
if (!res.ok) {
|
|
82
|
+
const detail = json.detail || json.title || json.code || `HTTP ${res.status}`
|
|
83
|
+
throw new Error(`${env.label}: ${detail}`)
|
|
84
|
+
}
|
|
85
|
+
return json
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Download a route's body straight to a FILE. A real organization's artifact is hundreds of megabytes —
|
|
89
|
+
// Ghost Inspector is 398 MB gzipped — and holding it in a Buffer (then again inside a Blob) is a needless
|
|
90
|
+
// way to run a laptop out of memory.
|
|
91
|
+
async function downloadTo(env, routePath, filePath, { timeoutMs = 1800000 } = {}) {
|
|
92
|
+
const url = `${endpointFor(env.name)}${routePath}`
|
|
93
|
+
const res = await fetch(url, {
|
|
94
|
+
headers: { authorization: `Bearer ${remote.tokenFor(env.name)}`, 'x-dooer-client': 'dooer-test-env@0' },
|
|
95
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
96
|
+
})
|
|
97
|
+
if (!res.ok) throw new Error(`${env.label}: HTTP ${res.status} downloading ${routePath}`)
|
|
98
|
+
await streamPipeline(Readable.fromWeb(res.body), fs.createWriteStream(filePath))
|
|
99
|
+
return fs.statSync(filePath).size
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Upload the artifact as multipart — the fleet's way of moving binary through a route. Sourced from a
|
|
103
|
+
// file so it is streamed rather than materialised.
|
|
104
|
+
async function uploadArtifactFile(env, path, filePath, { timeoutMs = 1800000 } = {}) {
|
|
105
|
+
const form = new FormData()
|
|
106
|
+
form.append('artifact', await fs.openAsBlob(filePath, { type: 'application/x-ndjson+gzip' }), 'artifact.ndjson.gz')
|
|
107
|
+
const url = `${endpointFor(env.name)}${path}`
|
|
108
|
+
const res = await fetch(url, {
|
|
109
|
+
method: 'POST',
|
|
110
|
+
headers: { authorization: `Bearer ${remote.tokenFor(env.name)}`, 'x-dooer-client': 'dooer-test-env@0' },
|
|
111
|
+
body: form,
|
|
112
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
113
|
+
})
|
|
114
|
+
const text = await res.text()
|
|
115
|
+
let json
|
|
116
|
+
try {
|
|
117
|
+
json = JSON.parse(text)
|
|
118
|
+
} catch (_) {
|
|
119
|
+
throw new Error(`${env.label}: HTTP ${res.status} on import (not JSON): ${text.slice(0, 200)}`)
|
|
120
|
+
}
|
|
121
|
+
if (!res.ok) throw new Error(`${env.label}: ${json.detail || json.code || `HTTP ${res.status}`}`)
|
|
122
|
+
return json
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Operations are async by design — a full organization copy runs for minutes, and a request that long is
|
|
126
|
+
// the wrong shape. Poll until it settles, reporting progress so a long wait does not look like a hang.
|
|
127
|
+
async function pollOperation(env, kind, id, { onTick, intervalMs = 5000, timeoutMs = 3600000 } = {}) {
|
|
128
|
+
const deadline = Date.now() + timeoutMs
|
|
129
|
+
for (;;) {
|
|
130
|
+
const op = await request(env, { path: `/v1/${kind}/${id}` })
|
|
131
|
+
if (onTick) onTick(op)
|
|
132
|
+
if (op.status !== 'running') return op
|
|
133
|
+
if (Date.now() > deadline) {
|
|
134
|
+
throw new Error(`${kind} ${id} still running after ${Math.round(timeoutMs / 60000)} minutes — giving up waiting`)
|
|
135
|
+
}
|
|
136
|
+
await new Promise((r) => setTimeout(r, intervalMs))
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// jsonb comes back off the model as a string; callers want the object.
|
|
141
|
+
function detailOf(operation) {
|
|
142
|
+
const d = operation && operation.detail
|
|
143
|
+
if (!d) return {}
|
|
144
|
+
if (typeof d !== 'string') return d
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(d)
|
|
147
|
+
} catch (_) {
|
|
148
|
+
return {}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
module.exports = {
|
|
153
|
+
SERVICE_NAME,
|
|
154
|
+
endpointFor,
|
|
155
|
+
request,
|
|
156
|
+
downloadTo,
|
|
157
|
+
uploadArtifactFile,
|
|
158
|
+
pollOperation,
|
|
159
|
+
detailOf,
|
|
160
|
+
CLUSTER_ENDPOINTS,
|
|
161
|
+
}
|