@dooer/dooer-test-env 1.15.0 → 1.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/getting-access.md +35 -70
- package/docs/issuing-access.md +9 -0
- package/docs/user-management.md +246 -0
- package/lib/bankid.js +28 -1
- package/lib/command/bankid.js +2 -2
- package/lib/command/customer.js +28 -32
- package/lib/command/db.js +58 -1
- package/lib/command/env.js +15 -3
- package/lib/command/remote-environment.js +11 -0
- package/lib/command/setup.js +25 -16
- package/lib/compose/generate.js +5 -1
- package/lib/compose/manifests.js +7 -0
- package/lib/copy.js +101 -0
- package/lib/db/migrate-stale.js +60 -0
- package/lib/db/roles.js +6 -0
- package/lib/env-spec.js +65 -0
- package/lib/registry.js +28 -0
- package/lib/remote.js +11 -5
- package/lib/runtime.js +6 -3
- package/lib/service-client.js +161 -0
- package/package.json +1 -1
- package/readme.md +65 -45
package/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
|
---
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
# Logins and user management
|
|
2
|
+
|
|
3
|
+
These commands talk to an environment through its **public GraphQL endpoint** — the same one the frontends
|
|
4
|
+
use. No VPN, no kubectl, no cluster credentials: just an ordinary HTTPS call and a session.
|
|
5
|
+
|
|
6
|
+
| environment | endpoint | `--env` |
|
|
7
|
+
| --- | --- | --- |
|
|
8
|
+
| your local stack | `http://localhost:4000/graphql` | `local` (the default) |
|
|
9
|
+
| staging | `https://api.s-e032.com/graphql` | `staging`, or `dooer-staging` / `s-e032` |
|
|
10
|
+
| live | `https://api.dooer.com/graphql` | `production`, or `live` / `dooer-production` |
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## 1. The three kinds of user
|
|
15
|
+
|
|
16
|
+
One person normally has **several user rows** — commonly `you@dooer.com` as a customer and
|
|
17
|
+
`you+hi@dooer.com` as a professional. They are separate accounts with separate passwords, and *which one
|
|
18
|
+
you sign in as* decides what you can see and do. This is the single most important thing on this page: most
|
|
19
|
+
confusing "why can't I…" moments are a session that belongs to the wrong one of your accounts.
|
|
20
|
+
|
|
21
|
+
| | who uses it | what it can see | how to log in |
|
|
22
|
+
| --- | --- | --- | --- |
|
|
23
|
+
| **customer** | end users | only the organizations attached to that user | email **or** BankID |
|
|
24
|
+
| **partner** (`hi`) | accountants | every organization belonging to their partner | email **or** BankID |
|
|
25
|
+
| **admin** | internal staff | everything, and can create users | email **only** |
|
|
26
|
+
|
|
27
|
+
**BankID cannot reach an admin account.** The general BankID route (`loginWithBankIdV2`) is pinned to
|
|
28
|
+
`user_type: 'customer'` in service-accounts, and the partner route resolves your `hi` account — so an admin
|
|
29
|
+
session always comes from an email + password login.
|
|
30
|
+
|
|
31
|
+
### Partners worth knowing
|
|
32
|
+
|
|
33
|
+
A partner is an accounting firm; its `domain` is how it is addressed on the command line.
|
|
34
|
+
|
|
35
|
+
| partner | domain | what it actually is |
|
|
36
|
+
| --- | --- | --- |
|
|
37
|
+
| Dooer | `dooer` | **Voitto** — the in-house accounting firm |
|
|
38
|
+
| Dooer Sales | `dooersales` | **Dooer free customers** |
|
|
39
|
+
|
|
40
|
+
`dooer-test-env partner list` shows the rest for whichever environment you point it at.
|
|
41
|
+
|
|
42
|
+
---
|
|
43
|
+
|
|
44
|
+
## 2. Logging in
|
|
45
|
+
|
|
46
|
+
The session is stored in your macOS keychain, one per environment, and every other command picks it up.
|
|
47
|
+
|
|
48
|
+
### As a customer user
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# email + password (you are prompted for the password; it is not echoed)
|
|
52
|
+
dooer-test-env remote-environment login --env staging --email you@dooer.com
|
|
53
|
+
|
|
54
|
+
# BankID — --customer selects the customer route
|
|
55
|
+
dooer-test-env remote-environment login --env staging --bankid --customer
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
### As a partner user (accountant)
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
# BankID — this is the default, and is exactly what HQ does
|
|
62
|
+
dooer-test-env remote-environment login --env staging
|
|
63
|
+
|
|
64
|
+
# email + password, using your professional account's address
|
|
65
|
+
dooer-test-env remote-environment login --env staging --email you+hi@dooer.com
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
The BankID flow never asks which partner you are signing in to — like HQ, it submits an empty domain and
|
|
69
|
+
lets the server resolve it from your BankID identity.
|
|
70
|
+
|
|
71
|
+
### As an admin
|
|
72
|
+
|
|
73
|
+
```bash
|
|
74
|
+
dooer-test-env remote-environment login --env staging --email you+admin@dooer.com
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Email only, for the reason in §1.
|
|
78
|
+
|
|
79
|
+
### What a BankID login looks like
|
|
80
|
+
|
|
81
|
+
An animated QR is drawn in the terminal and refreshed once a second (the payload is time-based, so a frozen
|
|
82
|
+
QR stops scanning). Open the BankID app, scan, confirm — the command prints who you ended up as:
|
|
83
|
+
|
|
84
|
+
```
|
|
85
|
+
logged in to staging (s-e032) as Jimmy Bergman <jimmy.bergman+hi@dooer.com>
|
|
86
|
+
user id 203b9010-abb9-4f7c-9a5e-8bbf6f75ca7b
|
|
87
|
+
user type hi
|
|
88
|
+
session stored in keychain, valid until 2026-09-11T09:31:17.000Z
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Always read the **user type** line. It is the quickest way to catch "I meant to be my other account".
|
|
92
|
+
|
|
93
|
+
---
|
|
94
|
+
|
|
95
|
+
## 3. Becoming a partner user from an admin session
|
|
96
|
+
|
|
97
|
+
Signed in as an admin, you can assume a partner user without their BankID — the same thing back-office's
|
|
98
|
+
**“Become user in X”** button does:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
dooer-test-env remote-environment become someone+hi@dooer.com --env staging
|
|
102
|
+
dooer-test-env remote-environment become someone+hi@dooer.com --env staging --partner "Dooer Sales"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`--partner` is only needed when they belong to more than one; otherwise it resolves on its own.
|
|
106
|
+
|
|
107
|
+
Becoming replaces the stored session, so the admin token that authorised it is stashed first:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
dooer-test-env remote-environment become --revert --env staging
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
## 4. Who am I, and where
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
dooer-test-env remote-environment status --env staging
|
|
119
|
+
dooer-test-env remote-environment status --all # every environment at once
|
|
120
|
+
dooer-test-env remote-environment list # endpoints + whether you are logged in
|
|
121
|
+
dooer-test-env remote-environment logout --env staging
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 5. Using the token from scripts
|
|
127
|
+
|
|
128
|
+
`remote-environment token` writes **only** the token to stdout — no colour, no label, no trailing text — so
|
|
129
|
+
it drops straight into a variable or a pipe. Errors go to stderr, and stdout stays empty when they do.
|
|
130
|
+
|
|
131
|
+
```bash
|
|
132
|
+
dooer-test-env remote-environment token # local
|
|
133
|
+
dooer-test-env remote-environment token --env staging
|
|
134
|
+
dooer-test-env remote-environment token --env staging | pbcopy
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
Calling the API by hand with it:
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
TOKEN=$(dooer-test-env remote-environment token --env staging)
|
|
141
|
+
|
|
142
|
+
curl -s https://api.s-e032.com/graphql \
|
|
143
|
+
-H "content-type: application/json" \
|
|
144
|
+
-H "authorization: Bearer $TOKEN" \
|
|
145
|
+
-d '{"query":"{ currentUser { id email userType } }"}'
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
The same works against the local stack on `http://localhost:4000/graphql`. The local environment needs no
|
|
149
|
+
login at all — with no stored session it mints a service token from the shared dev keypair, so
|
|
150
|
+
`token --env local` always returns something usable.
|
|
151
|
+
|
|
152
|
+
> The token is a bearer credential for whichever account you logged in as. Treat it like a password: it is
|
|
153
|
+
> fine in a shell variable, not in a commit, a ticket or a chat message.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 6. Managing users
|
|
158
|
+
|
|
159
|
+
```bash
|
|
160
|
+
dooer-test-env user search # the most recent users
|
|
161
|
+
dooer-test-env user search anna # email, name, personnummer or id
|
|
162
|
+
dooer-test-env user search --role customer # only customers
|
|
163
|
+
dooer-test-env user get anna@dooer.com # details + partner memberships
|
|
164
|
+
dooer-test-env user partners anna@dooer.com # just the memberships, with levels
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
Users are addressable by **id or email** everywhere.
|
|
168
|
+
|
|
169
|
+
### Creating a user
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
# password generated and printed once
|
|
173
|
+
dooer-test-env user create --email anna@dooer.com --execute
|
|
174
|
+
|
|
175
|
+
# a customer, with your own password and a personnummer
|
|
176
|
+
dooer-test-env user create --email kim@example.com \
|
|
177
|
+
--role customer --password 'hunter2hunter2' --personnummer 19900101-1239 --execute
|
|
178
|
+
|
|
179
|
+
# a professional placed straight into a partner
|
|
180
|
+
dooer-test-env user create --email anna@dooer.com --partner "Dooer Sales" --level admin --execute
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
This calls `users.create`, the same mutation back-office's `/users/new` uses, so the service hashes the
|
|
184
|
+
password and applies its own validation. Notes:
|
|
185
|
+
|
|
186
|
+
- **Needs an admin session** in any remote environment.
|
|
187
|
+
- `--role` defaults to `hi`; the others are `customer` and `admin`.
|
|
188
|
+
- The password is shown **once** and cannot be read back. It works on the ordinary email+password login.
|
|
189
|
+
- First and last name default to something derived from the address; override with `--first-name` /
|
|
190
|
+
`--last-name`.
|
|
191
|
+
- The personnummer must be a **real** one — the API checks the Luhn digit, so an invented number is
|
|
192
|
+
rejected. The CLI checks it first and tells you which rule failed.
|
|
193
|
+
- Writes are **dry-run until `--execute`**.
|
|
194
|
+
|
|
195
|
+
---
|
|
196
|
+
|
|
197
|
+
## 7. Partner membership
|
|
198
|
+
|
|
199
|
+
```bash
|
|
200
|
+
dooer-test-env partner list # every partner in the environment
|
|
201
|
+
dooer-test-env partner users "Dooer Sales" # its users and their level
|
|
202
|
+
|
|
203
|
+
dooer-test-env partner assign anna@dooer.com dooersales --level admin --execute
|
|
204
|
+
dooer-test-env partner unassign anna@dooer.com dooersales --execute
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
Partners are addressable by **id, name or domain**.
|
|
208
|
+
|
|
209
|
+
`--level` (`member` or `admin`) is the level *within* the partner — not the same axis as the user's
|
|
210
|
+
`--role` at Dooer. Only **`hi`** users can belong to a partner; the CLI applies the same
|
|
211
|
+
`invalid-user-type` rule the API does, so it will not write a row HQ would trip over.
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## 8. What each session may do
|
|
216
|
+
|
|
217
|
+
| operation | required session |
|
|
218
|
+
| --- | --- |
|
|
219
|
+
| `user search`, `user get`, `user partners` | any |
|
|
220
|
+
| `partner assign`, `partner unassign`, `partner users` | partner |
|
|
221
|
+
| `partner list` | admin |
|
|
222
|
+
| `user create` | admin |
|
|
223
|
+
|
|
224
|
+
When a session lacks the level, the CLI names the one that is required and who you are currently signed in
|
|
225
|
+
as, instead of surfacing a raw `Forbidden`:
|
|
226
|
+
|
|
227
|
+
```
|
|
228
|
+
creating a user needs an "admin" session. You are signed in to staging as jimmy.bergman+hi@dooer.com (hi).
|
|
229
|
+
|
|
230
|
+
Log in as an account that has it:
|
|
231
|
+
dooer-test-env remote-environment login --env staging --email <your-admin-email>
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
## Troubleshooting
|
|
237
|
+
|
|
238
|
+
| Symptom | Cause |
|
|
239
|
+
| --- | --- |
|
|
240
|
+
| `not logged in to <env>` | No session for that environment — log in (§2) |
|
|
241
|
+
| `your <env> session expired …` | Sessions are short-lived; log in again |
|
|
242
|
+
| `needs an "admin" session` | Wrong one of your accounts — see §1 and §8 |
|
|
243
|
+
| `only "hi" users can belong to a partner` | You aimed a partner command at a customer/admin account |
|
|
244
|
+
| `"x@y.com" matches N users` | That address exists once per user type; address the user by id instead |
|
|
245
|
+
| `personnummer … has an invalid check digit` | Not a real personnummer — the API validates the Luhn digit |
|
|
246
|
+
| BankID QR never completes on **local** | The local stack lost its BankID certs; `dooer-test-env up` re-injects them |
|
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
|
},
|