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