@dooer/dooer-test-env 1.16.0 → 1.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,27 +1,33 @@
1
1
  # Getting access (new developer)
2
2
 
3
- `dooer-test-env` reads the staging cluster to build its compose file, log in to the image registries and
4
- fetch the BankID cert. That needs two things, in this order:
3
+ `dooer-test-env` builds your local stack from the staging environment: the service list, the registry
4
+ credentials and the BankID material all come from there. That needs two things, in this order:
5
5
 
6
- 1. **VPN** — the cluster API is not reachable from the open internet.
7
- 2. **kubectl access** a client certificate identifying you to the cluster.
6
+ 1. **VPN** — nothing this tool talks to is reachable from the open internet.
7
+ 2. **An admin session for staging** `remote-environment login`.
8
8
 
9
- **Both come from an admin**: they create your VPN tunnel and your cluster credential, and send you two
10
- files a WireGuard `.conf` and a `kubeconfig`. You install them and check they work. See
11
- [issuing-access.md](./issuing-access.md) for their side.
9
+ **kubectl is no longer required.** It used to be: every developer held a cluster certificate that could
10
+ read every Secret in the namespace. That is now behind
11
+ [`service-dooer-test-env`](../_specification/service-dooer-test-env.md), which exposes only the operations
12
+ this tool needs, each admin-gated and recorded. If you operate the cluster itself you may still want a
13
+ kubectl credential — see [issuing-access.md](./issuing-access.md) — but you do not need one to use this
14
+ tool.
15
+
16
+ **The VPN config comes from an admin**: they create your tunnel and send you a WireGuard `.conf`.
12
17
 
13
18
  ---
14
19
 
15
20
  ## 0. Prerequisites
16
21
 
17
22
  ```bash
18
- brew install wireguard-tools kubernetes-cli
23
+ brew install wireguard-tools
19
24
  ```
20
25
 
21
- `openssl` ships with macOS. Check everything is there:
26
+ Plus Docker (Desktop) and Node 18+. `kubernetes-cli` is no longer needed for this tool — install it only
27
+ if you operate the cluster for other reasons.
22
28
 
23
29
  ```bash
24
- wg --version && kubectl version --client && openssl version
30
+ wg --version && docker info >/dev/null && node --version
25
31
  ```
26
32
 
27
33
  ---
@@ -90,66 +96,23 @@ amount of kubectl configuration will help.
90
96
 
91
97
  ---
92
98
 
93
- ## 2. kubectl access
94
-
95
- **Your admin sends you a ready-made `kubeconfig` file.** You do not generate keys or assemble anything —
96
- they create the credential and hand it over
97
- ([issuing-access.md § 2](./issuing-access.md#2-kubectl--create-their-credential)).
98
-
99
- Like the VPN config, that file contains a **private key**. Same handling: install it, then delete the copy
100
- they sent you.
101
-
102
- ### 2a. Install it
103
-
104
- **If you have no other clusters** — simplest, just put it in place:
105
-
106
- ```bash
107
- mkdir -p ~/.kube
108
- cp ~/Downloads/<yourname>.kubeconfig ~/.kube/config
109
- chmod 600 ~/.kube/config
110
- rm ~/Downloads/<yourname>.kubeconfig
111
- ```
112
-
113
- **If you already use kubectl for something else**, merge instead of overwriting:
114
-
115
- ```bash
116
- cp ~/.kube/config ~/.kube/config.backup # always back up first
117
- KUBECONFIG=~/.kube/config:~/Downloads/<yourname>.kubeconfig \
118
- kubectl config view --flatten > /tmp/merged && mv /tmp/merged ~/.kube/config
119
- chmod 600 ~/.kube/config
120
- rm ~/Downloads/<yourname>.kubeconfig
121
- ```
99
+ ## 2. Log in to staging
122
100
 
123
- Then select the context (the admin will tell you its name, `<firstname>-admin@kubernetes`):
101
+ Everything the tool used to read with kubectl now comes from `service-dooer-test-env`, authorized by your
102
+ own session:
124
103
 
125
104
  ```bash
126
- kubectl config get-contexts
127
- kubectl config use-context <firstname>-admin@kubernetes
105
+ npx @dooer/dooer-test-env@latest remote-environment login --env staging --email you+admin@dooer.com
106
+ npx @dooer/dooer-test-env@latest remote-environment status --env staging
128
107
  ```
129
108
 
130
- ### 2b. Verify
109
+ It has to be an **admin** account, and therefore **email + password** — BankID cannot reach an admin
110
+ account at all (`loginWithBankIdV2` is pinned to `user_type: 'customer'` server-side, and the partner flow
111
+ resolves your `hi` user). `status` prints which of your accounts you ended up as; read the **user type**
112
+ line.
131
113
 
132
- With the VPN up:
133
-
134
- ```bash
135
- kubectl auth whoami
136
- # Username <firstname>-admin
137
- # Groups [system:authenticated]
138
-
139
- kubectl get pods -n dooer-staging | head -3
140
- kubectl get pods -n dooer-production | head -3
141
- ```
142
-
143
- Then the two checks `dooer-test-env` actually depends on:
144
-
145
- ```bash
146
- kubectl auth can-i get secrets -n dooer-staging # yes — registry pull secrets + BankID cert
147
- kubectl get svc dooer-database -n dooer-staging # the database service the base-DB build reads
148
- ```
149
-
150
- All of those must succeed before `dooer-test-env setup` will work.
151
-
152
- ---
114
+ Don't have an admin account? Ask someone who does to create one —
115
+ [user-management.md](./user-management.md) covers `user create`.
153
116
 
154
117
  ## 3. You are done
155
118
 
@@ -157,7 +120,8 @@ All of those must succeed before `dooer-test-env setup` will work.
157
120
  npx @dooer/dooer-test-env@latest setup
158
121
  ```
159
122
 
160
- `setup` re-checks all of the above and tells you exactly which piece is missing if something is wrong.
123
+ `setup` re-checks all of the above and tells you exactly which piece is missing if something is wrong. It
124
+ needs Docker running, the VPN up, and the session from section 2.
161
125
 
162
126
  ---
163
127
 
@@ -165,11 +129,12 @@ npx @dooer/dooer-test-env@latest setup
165
129
 
166
130
  | Symptom | Cause |
167
131
  | --- | --- |
168
- | `dial tcpi/o timeout` from kubectl | VPN is down — `sudo wg show`, then restart it (1b) |
132
+ | `cannot reach service-dooer-test-env is the VPN up?` | VPN is down — `sudo wg show`, then restart it (1b) |
169
133
  | `nc` to the API server hangs | Tunnel is up but not carrying traffic; restart it |
170
- | `error: You must be logged in to the server (Unauthorized)` | The certificate in your kubeconfig is expired or unknown to the cluster ask for a re-issue |
171
- | `Error from server (Forbidden)` | You *are* authenticated; your user has no permissions bound yet — ask the admin to complete their § 2e (bind permissions) |
134
+ | `not logged in to staging` | Run the login in section 2 |
135
+ | `your staging session expired …` | Sessions are short-lived; log in again |
136
+ | `needs an "admin" session` | You are signed in as your customer or `hi` account — log in with your admin email |
172
137
  | `wg-quick: 'roboten' already exists` | The interface is already up — `sudo wg-quick down roboten` first |
173
138
 
174
- Certificates are issued for **one year**. When yours expires, ask your admin for a new kubeconfig and
175
- repeat section 2 your permissions stay in place, so they only have to re-issue the credential.
139
+ Nothing here expires on a yearly cycle any more: your session is short-lived and you simply log in again.
140
+ The VPN tunnel is the only thing an admin has to issue, and it is issued once.
@@ -7,6 +7,15 @@ The other side of [getting-access.md](./getting-access.md). Two independent thin
7
7
  | **VPN tunnel** | you | create it and send them the config |
8
8
  | **kubectl credential** | you | create the certificate, bind permissions, send a kubeconfig |
9
9
 
10
+ > **Using `dooer-test-env` no longer requires §2.** Developers need the VPN (§1) and an admin session —
11
+ > nothing more. Every cluster read the tool used to do is now behind `service-dooer-test-env`, which
12
+ > exposes a fixed set of admin-gated, audited operations instead of an open-ended kubeconfig
13
+ > ([the specification](../_specification/service-dooer-test-env.md) explains the reasoning).
14
+ >
15
+ > Issue a cluster certificate only to people who **operate the cluster** — deploying, editing manifests,
16
+ > restarting pods. That is a much smaller group, and it is the point: a kubeconfig grants arbitrary
17
+ > operations, so the fewer that exist, the smaller the surface.
18
+
10
19
  You need a Robo10 token (§1a), `kubectl` with cluster-admin (`system:masters`), and your own VPN up.
11
20
 
12
21
  ---
package/lib/bankid.js CHANGED
@@ -68,6 +68,15 @@ function keychainDelete(key) {
68
68
  return sh('security', ['delete-generic-password', '-a', KEYCHAIN_ACCOUNT, '-s', keychainService(key)]).status === 0
69
69
  }
70
70
 
71
+ // Pull the BankID material from service-dooer-test-env — no kubectl. Same values, same keys.
72
+ async function pullFromService(envName = 'staging') {
73
+ const remote = require('./remote')
74
+ const serviceClient = require('./service-client')
75
+ const env = remote.resolveEnv(envName)
76
+ const { values } = await serviceClient.request(env, { path: '/v1/bootstrap/bankid' })
77
+ return values
78
+ }
79
+
71
80
  // Pull the bankid secrets out of the staging k8s secret via kubectl. k8s stores each value base64-encoded
72
81
  // in `.data`; we decode to the raw value the staging container would receive (for the PFX that raw value is
73
82
  // itself the base64 PFX string, exactly what @dooer/config's `Buffer.from(pfx,'base64')` expects).
@@ -87,7 +96,24 @@ function pullFromStaging({ namespace = DEFAULT_NAMESPACE, secret = DEFAULT_SECRE
87
96
  }
88
97
 
89
98
  // Pull + store in the keychain. Returns the list of keys stored (never the values).
90
- function pullAndStore(opts = {}) {
99
+ // Async because the service path is an HTTP call. The single caller (`bankid pull`) already awaits.
100
+ async function pullAndStore(opts = {}) {
101
+ // Service first — the path with no kubectl. The k8s reader stays as the fallback so a developer
102
+ // mid-migration, or one whose service is unreachable, is not stuck.
103
+ try {
104
+ const fromService = await pullFromService(opts.env || 'staging')
105
+ if (fromService && Object.keys(fromService).length) {
106
+ const storedFromService = []
107
+ for (const [key, value] of Object.entries(fromService)) {
108
+ keychainSet(key, value)
109
+ storedFromService.push(key)
110
+ }
111
+ return storedFromService
112
+ }
113
+ } catch (_) {
114
+ /* fall through to the kubectl path */
115
+ }
116
+
91
117
  const values = pullFromStaging(opts)
92
118
  const stored = []
93
119
  Object.entries(values).forEach(([key, value]) => {
@@ -119,6 +145,7 @@ function loadEnv() {
119
145
  }
120
146
 
121
147
  module.exports = {
148
+ pullFromService,
122
149
  SECRET_KEYS,
123
150
  DEFAULT_NAMESPACE,
124
151
  DEFAULT_SECRET,
@@ -16,8 +16,8 @@ module.exports = {
16
16
  y2
17
17
  .option('namespace', { type: 'string', default: bankid.DEFAULT_NAMESPACE })
18
18
  .option('secret', { type: 'string', default: bankid.DEFAULT_SECRET }),
19
- handler: (argv) => {
20
- const stored = bankid.pullAndStore({ namespace: argv.namespace, secret: argv.secret })
19
+ handler: async (argv) => {
20
+ const stored = await bankid.pullAndStore({ namespace: argv.namespace, secret: argv.secret })
21
21
  console.log(chalk.green(`bankid: stored ${stored.length} secret(s) in the keychain: ${stored.join(', ')}`))
22
22
  console.log(chalk.gray('run `dooer-test-env up` (or restart service-accounts) to apply — production BankID.'))
23
23
  },
@@ -1,38 +1,17 @@
1
1
  const fs = require('fs')
2
2
  const chalk = require('chalk')
3
- const engine = require('../engine/seed')
4
3
  const { purge } = require('../engine/purge')
5
4
  const { createAccount, SUBSCRIPTION_TYPES } = require('../account')
6
5
 
7
- // Map yargs values back to the engine's own flag argv, then let the engine's proven parser apply its
8
- // defaults + validation. Keeps ONE source of truth for option semantics (lib/engine/seed.js).
9
- function toEngineArgv(argv) {
10
- const a = []
11
- const flag = (name, val) => {
12
- if (val !== undefined && val !== null && val !== false) a.push(`--${name}`, String(val))
13
- }
14
- flag('source', argv.source)
15
- flag('target', argv.target)
16
- flag('name', argv.name)
17
- flag('owner-user', argv.ownerUser)
18
- // `local` is a first-class TARGET/SOURCE, not a k8s namespace — --source-local / --target-local keep the
19
- // two ideas separate rather than overloading --*-namespace (Jimmy 2026-09-03). The engine addresses the
20
- // local stack through the pseudo-namespace `local` internally.
21
- if (argv.sourceNamespace === 'local' || argv.targetNamespace === 'local') {
22
- throw new Error('use --source-local / --target-local for the local env, not --*-namespace local')
23
- }
24
- flag('source-namespace', argv.sourceLocal ? 'local' : argv.sourceNamespace)
25
- flag('target-namespace', argv.targetLocal ? 'local' : argv.targetNamespace)
26
- flag('email', argv.email)
27
- flag('if-target-nonempty', argv.ifTargetNonempty)
28
- flag('salt', argv.salt)
29
- if (argv.only) a.push('--only', [].concat(argv.only).join(','))
30
- if (argv.skipTables) a.push('--skip-tables', [].concat(argv.skipTables).join(','))
31
- if (argv.execute) a.push('--execute')
32
- if (argv.skipFiles) a.push('--skip-files')
33
- if (argv.skipUsers) a.push('--skip-users')
34
- if (argv.confirmProduction) a.push('--confirm-production')
35
- return a
6
+ // `copy` now runs through service-dooer-test-env (export in the source, import in the target) instead of
7
+ // opening two kubectl-sourced database connections. The COMMAND is unchanged same name, same flags —
8
+ // because that split is an implementation detail, not something a user should have to think about
9
+ // (Jimmy 2026-09-04). These map the existing --*-namespace / --*-local flags onto environment names.
10
+ const ENV_FOR_NAMESPACE = { 'dooer-staging': 'staging', 'dooer-production': 'production' }
11
+ const envFromFlags = (local, namespace, fallback) => {
12
+ if (local) return 'local'
13
+ if (!namespace) return fallback
14
+ return ENV_FOR_NAMESPACE[namespace] || namespace
36
15
  }
37
16
 
38
17
  // Resolve which orgs to copy: exactly one of --source / --sourcefile. A source FILE is inherently a batch,
@@ -84,6 +63,7 @@ const copyOptions = (y) =>
84
63
  .option('skip-files', { type: 'boolean', describe: 'skip the S3 file copy' })
85
64
  .option('skip-users', { type: 'boolean', describe: 'skip copying referenced users missing from the target' })
86
65
  .option('confirm-production', { type: 'boolean', describe: 'REQUIRED to write to dooer-production' })
66
+ .option('reason', { type: 'string', describe: 'recorded in the audit trail at both ends' })
87
67
  .option('execute', { type: 'boolean', default: false, describe: 'actually write (default: dry-run)' })
88
68
 
89
69
  module.exports = {
@@ -97,8 +77,18 @@ module.exports = {
97
77
  builder: copyOptions,
98
78
  handler: async (argv) => {
99
79
  const sources = readSources(argv)
80
+ const sourceEnv = envFromFlags(argv.sourceLocal, argv.sourceNamespace, 'staging')
81
+ const targetEnv = envFromFlags(argv.targetLocal, argv.targetNamespace, sourceEnv)
82
+ const { copyOrganization } = require('../copy')
83
+
100
84
  if (sources.length === 1) {
101
- await engine.main(engine.parseArgs(toEngineArgv({ ...argv, source: sources[0] })))
85
+ await copyOrganization({
86
+ organizationId: sources[0],
87
+ sourceEnv,
88
+ targetEnv,
89
+ reason: argv.reason,
90
+ dryRun: !argv.execute,
91
+ })
102
92
  return
103
93
  }
104
94
  // Batch: each org is a full independent copy (the engine scans the source per org). One bad uuid
@@ -108,7 +98,13 @@ module.exports = {
108
98
  for (let i = 0; i < sources.length; i++) {
109
99
  console.log(chalk.cyan(`\n──── [${i + 1}/${sources.length}] ${sources[i]} ────`))
110
100
  try {
111
- await engine.main(engine.parseArgs(toEngineArgv({ ...argv, source: sources[i] })))
101
+ await copyOrganization({
102
+ organizationId: sources[i],
103
+ sourceEnv,
104
+ targetEnv,
105
+ reason: argv.reason,
106
+ dryRun: !argv.execute,
107
+ })
112
108
  } catch (e) {
113
109
  failed.push({ source: sources[i], error: e.message })
114
110
  console.log(chalk.red(` FAILED: ${e.message}`))
package/lib/command/db.js CHANGED
@@ -1,4 +1,5 @@
1
1
  const os = require('os')
2
+ const fs = require('fs')
2
3
  const path = require('path')
3
4
  const chalk = require('chalk')
4
5
  const dbbuild = require('../engine/dbbuild')
@@ -7,6 +8,27 @@ const { shred } = require('../shred')
7
8
  const { auditPii } = require('../shred/audit')
8
9
  const { ensureRoles } = require('../db/roles')
9
10
 
11
+ // Services whose schema exists but holds no tables beyond the migrations ledger — i.e. they were not in
12
+ // the restored dump. Returns compose service names.
13
+ async function servicesWithEmptySchema(local) {
14
+ const { Client } = require('pg')
15
+ const client = new Client({ ...local, ssl: false })
16
+ await client.connect()
17
+ try {
18
+ const { rows } = await client.query(`
19
+ SELECT n.nspname AS schema, count(t.table_name)::int AS tables
20
+ FROM information_schema.schemata n
21
+ LEFT JOIN information_schema.tables t
22
+ ON t.table_schema = n.schema_name AND t.table_name <> 'migrations'
23
+ WHERE n.schema_name LIKE 'service\\_%' AND n.schema_name NOT LIKE '%\\_history'
24
+ GROUP BY n.nspname HAVING count(t.table_name) = 0`)
25
+ // schema service_foo_bar -> compose service service-foo-bar
26
+ return rows.map((r) => r.schema.replace(/_/g, '-'))
27
+ } finally {
28
+ await client.end().catch(() => {})
29
+ }
30
+ }
31
+
10
32
  // Base DB: the customer-free, anonymized artifact and local restore points. `db build` runs ONLY as an
11
33
  // in-cluster k8s Job (devs never run it); devs `db pull`. See ENVIRONMENT-PLAN.md §5.
12
34
 
@@ -96,8 +118,28 @@ module.exports = {
96
118
  await dbbuild.pull({
97
119
  local,
98
120
  download: async () => {
99
- const cfg = obc.obcConfig({ namespace: argv.obcNamespace })
100
121
  const out = path.join(os.tmpdir(), 'dooer-base-db.pulled.dump')
122
+ // Service first — no kubectl, no ObjectBucketClaim credentials on this machine, no
123
+ // rook-ceph ClusterIP lookup. The service streams the artifact from inside the cluster.
124
+ try {
125
+ const remote = require('../remote')
126
+ const serviceClient = require('../service-client')
127
+ const env = remote.resolveEnv(argv.env || 'staging')
128
+ const list = await serviceClient.request(env, { path: '/v1/base-db/artifacts' })
129
+ const newest = (list.artifacts || [])[0]
130
+ if (!newest) throw new Error('no base-DB artifacts published yet')
131
+ console.log(`downloading ${newest.id} (${(newest.bytes / 1e6).toFixed(0)} MB) from ${env.label} …`)
132
+ const bytes = await serviceClient.request(env, {
133
+ path: `/v1/base-db/artifacts/${encodeURIComponent(newest.id)}/content`,
134
+ raw: true,
135
+ timeoutMs: 1800000,
136
+ })
137
+ fs.writeFileSync(out, bytes)
138
+ return out
139
+ } catch (e) {
140
+ console.log(chalk.yellow(`service download unavailable (${e.message}); falling back to the OBC`))
141
+ }
142
+ const cfg = obc.obcConfig({ namespace: argv.obcNamespace })
101
143
  const { file, key } = await obc.download(cfg, out)
102
144
  console.log(chalk.gray(`downloaded ${cfg.bucket}/${key}`))
103
145
  return file
@@ -108,6 +150,21 @@ module.exports = {
108
150
  // Recreate the per-service schema grants + login roles + default search_path that staging has
109
151
  // (the dump carries no cluster-global roles). Without them unqualified queries 42P01. See db/roles.js.
110
152
  const { total, applied } = await ensureRoles({ local })
153
+
154
+ // A restored dump only contains the schemas that existed when it was BUILT. A service
155
+ // deployed since then (service-dooer-test-env was, today) has its tables wiped by the restore
156
+ // and never gets them back: `up` migrated into the old database, and nothing re-migrates
157
+ // afterwards — the symptom is a healthy container failing every query with
158
+ // `relation "…" does not exist`. Restart exactly those services so their start-up migration
159
+ // runs against the database that is actually there now.
160
+ const stale = await servicesWithEmptySchema(local)
161
+ if (stale.length) {
162
+ console.log(`re-migrating ${stale.length} service(s) missing from the dump…`)
163
+ const { migrateServices } = require('../db/migrate-stale')
164
+ for (const r of migrateServices(stale)) {
165
+ console.log(` ${r.ok ? '✓' : '·'} ${r.service}: ${r.detail}`)
166
+ }
167
+ }
111
168
  console.log(chalk.gray(`db roles: ${applied.length}/${total} service roles provisioned`))
112
169
  }
113
170
  },
@@ -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
  {
@@ -235,7 +235,15 @@ module.exports = {
235
235
  }
236
236
  const exp = remote.expiry(token)
237
237
  if (exp && exp.expired) {
238
- console.log(`${head}${chalk.yellow(`session EXPIRED ${exp.at.toISOString()}`)}`)
238
+ // For local an expired session is not a problem — it falls back to a minted service token —
239
+ // so saying EXPIRED there reads as breakage when nothing is broken.
240
+ console.log(
241
+ `${head}${
242
+ name === 'local'
243
+ ? 'stale session ignored — uses a minted service token'
244
+ : chalk.yellow(`session EXPIRED ${exp.at.toISOString()}`)
245
+ }`
246
+ )
239
247
  continue
240
248
  }
241
249
  let me = null
@@ -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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dooer/dooer-test-env",
3
- "version": "1.16.0",
3
+ "version": "1.17.1",
4
4
  "description": "Run the whole Dooer backend locally (staging DB minus customers), copy/purge customers between environments, and shred — one CLI.",
5
5
  "license": "UNLICENSED",
6
6
  "repository": "Dooer/cli-dooer-test-env",
package/readme.md CHANGED
@@ -6,7 +6,8 @@ organizations). Copy specific customers in when you need them, spin up empty tes
6
6
  across the whole stack, and measure resource usage.
7
7
 
8
8
  Runs on an Apple-Silicon Mac (images run under emulation) — 32 GB RAM / ~150 GB free disk is comfortable.
9
- See **[ENVIRONMENT-PLAN.md](./ENVIRONMENT-PLAN.md)** for the full design and rationale.
9
+ See **[ENVIRONMENT-PLAN.md](./ENVIRONMENT-PLAN.md)** for the full design and rationale, and
10
+ **[REVERT-POINT.md](./REVERT-POINT.md)** for the last version that works without `service-dooer-test-env`.
10
11
 
11
12
  ```bash
12
13
  npx @dooer/dooer-test-env <command>
@@ -14,24 +15,32 @@ npx @dooer/dooer-test-env <command>
14
15
 
15
16
  ## First time here?
16
17
 
17
- `setup` needs **VPN access** and a **kubectl credential** for the cluster — the compose file, the registry
18
- logins and the BankID cert all come from there.
18
+ You need exactly two things:
19
19
 
20
- - **[Getting access](./docs/getting-access.md)** — new developer: set up the VPN, get your kubectl
21
- certificate, and verify both actually work.
22
- - **[Issuing access](./docs/issuing-access.md)** admin: sign someone's certificate, bind their
23
- permissions, and revoke them again.
24
- - **[Logins and user management](./docs/user-management.md)** — signing in to an environment as a customer,
25
- partner or admin user; creating users; partner membership; using the token from scripts. Needs no VPN or
26
- kubectl at all.
20
+ 1. **The VPN** — everything this tool talks to lives inside the cluster network.
21
+ 2. **An admin session for staging** — `remote-environment login`. That is what authorizes the tool to
22
+ fetch the environment spec, the registry credentials and the BankID material.
27
23
 
28
- Already have both? Straight to the quick start.
24
+ **No kubectl. No cluster credential. No `new-infrastructure` checkout.** All of that moved behind
25
+ `service-dooer-test-env`, which runs in each environment and exposes only the operations this tool needs
26
+ — each one admin-gated and recorded. See
27
+ [the specification](./_specification/service-dooer-test-env.md) for what it replaced and why.
28
+
29
+ - **[Getting access](./docs/getting-access.md)** — new developer: set up the VPN and log in.
30
+ - **[Logins and user management](./docs/user-management.md)** — signing in as a customer, partner or admin
31
+ user; creating users; partner membership; using the token from scripts.
32
+ - **[Issuing access](./docs/issuing-access.md)** — admin: VPN tunnels, and cluster certificates for the
33
+ people who actually operate the cluster. **Not needed to use this tool any more.**
29
34
 
30
35
  ## Quick start
31
36
 
32
37
  ```bash
33
- # 1. One-time: prereq checks, registry login, compose generation, BankID cert keychain.
34
- # Needs the VPN + kubectl access to dooer-staging (see "First time here?" above).
38
+ # 0. Log in to staging as an ADMIN the local stack is built from staging's live Deployments.
39
+ # BankID cannot reach an admin account; this has to be email + password.
40
+ npx @dooer/dooer-test-env remote-environment login --env staging --email you+admin@dooer.com
41
+
42
+ # 1. One-time: prereqs, registry login, environment spec, compose generation, BankID cert → keychain.
43
+ # Needs the VPN and the admin session above.
35
44
  npx @dooer/dooer-test-env setup
36
45
 
37
46
  # 2. Bring the whole stack up (all staging services + frontends)
@@ -214,9 +223,23 @@ The local env needs no login for this — with no stored session it mints one fr
214
223
 
215
224
  ### Copy a real customer into the local env
216
225
 
217
- `--target-local` addresses this stack (Postgres on 55432 + MinIO); the source can be any k8s namespace
218
- (`--source-local` goes the other way). Reading production needs no confirmation only *writing* to it does. Emails are always
219
- anonymized. Dry-run by default; add `--execute`.
226
+ `--target-local` addresses this stack; the source can be any environment (`--source-local` goes the other
227
+ way). Emails are always anonymized. Dry-run by default; add `--execute`.
228
+
229
+ **You need an admin session in BOTH ends.** A copy is two locally-authorized halves — an export authorized
230
+ by admin in the *source* and an import authorized by admin in the *target* — because each environment
231
+ signs its own tokens and neither service will ever validate the other's. That is a stronger check than the
232
+ kubeconfig it replaces, where reaching the cluster was enough. The command is unchanged; the split is
233
+ internal.
234
+
235
+ ```bash
236
+ npx @dooer/dooer-test-env@latest remote-environment login --env production --email you+admin@dooer.com
237
+ npx @dooer/dooer-test-env@latest remote-environment login --env staging --email you+admin@dooer.com
238
+ ```
239
+
240
+ Both halves are async, so the command prints progress while it waits. If the source could not read a
241
+ table, the copy says so as a **WARNING** rather than quietly producing a partial org, and both ends record
242
+ the operation in their audit trail (`remote-environment` → the service's `/v1/operations`).
220
243
 
221
244
  ```bash
222
245
  # copy Ghost Inspector out of LIVE into the local env, as a new org owned by a local user
@@ -379,6 +402,10 @@ Inbound *input* validation always runs — this only affects response validation
379
402
 
380
403
  ## Prerequisites
381
404
 
382
- Docker (Desktop), `kubectl` with access to the `dooer-staging` namespace (VPN), and Node 18+.
405
+ Docker (Desktop), the **VPN**, an **admin session for staging**, and Node 18+.
406
+
407
+ No kubectl, no cluster certificate, no `new-infrastructure` checkout — `service-dooer-test-env` supplies
408
+ what those used to. Don't have the VPN yet? → **[Getting access](./docs/getting-access.md)**.
383
409
 
384
- Don't have the VPN or a kubectl credential yet? **[Getting access](./docs/getting-access.md)**.
410
+ `DOOER_TEST_ENV_MANIFESTS=<dir>` still generates from local manifest yaml instead of the live cluster,
411
+ which is useful when you are working on a manifest that is not deployed yet.