@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.
@@ -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
- process.exitCode = rt.dc(['stop'])
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
- process.exitCode = rt.dc(['down', ...(argv.volumes ? ['-v'] : [])])
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',
@@ -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
- if (!kubectlOk) throw new Error('kubectl is required.')
51
- if (!nsOk)
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/access namespace ${argv.namespace} connect the VPN and check your kubectl context/RBAC.`
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
- if (!secretsOk) throw new Error(`no access to read secrets in ${argv.namespace} — needed for registry + DB creds.`)
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`)
@@ -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
- DOOER_SQL_MIGRATION_USER: DEV.sqlUser, // migrations run as the dooer superuser; db-migrate sets the schema itself
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
@@ -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`)};
@@ -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
- throw new Error(
117
- `your ${envName} session expired ${exp.at.toISOString().slice(0, 16).replace('T', ' ')} ` +
118
- `run: dooer-test-env remote-environment login --env ${envName}`
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
- const SERVICES_DIR =
16
- process.env.DOOER_TEST_ENV_MANIFESTS ||
17
- path.join(os.homedir(), 'dooer', 'new-infrastructure', 'kubernetes', 'environments', 's-e032-onprem')
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
+ }