@dooer/dooer-test-env 1.17.1 → 1.17.2
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/issuing-access.md +13 -185
- package/lib/bankid.js +8 -38
- package/lib/command/customer.js +30 -12
- package/lib/command/db.js +13 -25
- package/lib/obc.js +19 -29
- package/lib/registry.js +48 -134
- package/package.json +1 -1
- package/readme.md +2 -2
package/docs/issuing-access.md
CHANGED
|
@@ -1,22 +1,14 @@
|
|
|
1
1
|
# Issuing access (admin)
|
|
2
2
|
|
|
3
|
-
The other side of [getting-access.md](./getting-access.md).
|
|
3
|
+
The other side of [getting-access.md](./getting-access.md). A developer needs **one** thing from you: a
|
|
4
|
+
**VPN tunnel**. You create it and send them the config.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
They do *not* need a cluster credential. `dooer-test-env` reaches everything through
|
|
7
|
+
`service-dooer-test-env`, authorized by their own admin session — see
|
|
8
|
+
[the specification](../_specification/service-dooer-test-env.md). Issue kubectl credentials only to people
|
|
9
|
+
who operate the cluster itself; that is a separate concern from this tool and is not documented here.
|
|
9
10
|
|
|
10
|
-
|
|
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
|
-
|
|
19
|
-
You need a Robo10 token (§1a), `kubectl` with cluster-admin (`system:masters`), and your own VPN up.
|
|
11
|
+
You need a Robo10 token (§1a) and your own VPN up.
|
|
20
12
|
|
|
21
13
|
---
|
|
22
14
|
|
|
@@ -101,180 +93,16 @@ curl -sS -X DELETE -H "Authorization: Bearer $TOKEN" \
|
|
|
101
93
|
```
|
|
102
94
|
|
|
103
95
|
That invalidates the tunnel server-side, so it is the real off-switch for VPN access when someone leaves —
|
|
104
|
-
|
|
96
|
+
That is the real off-switch for this tool's access when someone leaves.
|
|
105
97
|
|
|
106
98
|
---
|
|
107
99
|
|
|
108
|
-
## 2. kubectl — create their credential
|
|
109
|
-
|
|
110
|
-
The cluster authenticates users with **x509 client certificates** signed by the cluster CA. The username is
|
|
111
|
-
the certificate's **Common Name (CN)**; RBAC is bound to that string.
|
|
112
|
-
|
|
113
|
-
You do all of it and hand over a finished `kubeconfig`.
|
|
114
|
-
|
|
115
|
-
> Everything below was verified end to end against the live cluster (2026-09-04) with a throwaway
|
|
116
|
-
> `guidetest-admin`: the resulting kubeconfig read `dooer-production` and `dooer-staging`, including
|
|
117
|
-
> secrets. The test user was then removed.
|
|
118
|
-
|
|
119
|
-
```bash
|
|
120
|
-
USER_NAME=<firstname>-admin # becomes their cluster username
|
|
121
|
-
WORKDIR=$(mktemp -d) && cd "$WORKDIR"
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
Work in a temp directory — it holds their private key until you hand it over, and you delete it at the end.
|
|
125
|
-
|
|
126
|
-
### 2a. Generate their key and CSR
|
|
127
|
-
|
|
128
|
-
```bash
|
|
129
|
-
openssl genrsa -out "$USER_NAME.key" 2048
|
|
130
|
-
openssl req -new -key "$USER_NAME.key" -out "$USER_NAME.csr" -subj "/CN=$USER_NAME"
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
### 2b. Submit it as a CertificateSigningRequest
|
|
134
|
-
|
|
135
|
-
```bash
|
|
136
|
-
cat <<EOF | kubectl apply -f -
|
|
137
|
-
apiVersion: certificates.k8s.io/v1
|
|
138
|
-
kind: CertificateSigningRequest
|
|
139
|
-
metadata:
|
|
140
|
-
name: $USER_NAME
|
|
141
|
-
spec:
|
|
142
|
-
request: $(base64 -i "$USER_NAME.csr" | tr -d '\n')
|
|
143
|
-
signerName: kubernetes.io/kube-apiserver-client
|
|
144
|
-
expirationSeconds: 31536000 # 1 year
|
|
145
|
-
usages:
|
|
146
|
-
- client auth
|
|
147
|
-
EOF
|
|
148
|
-
```
|
|
149
|
-
|
|
150
|
-
If the name already exists (a renewal), delete the old object first:
|
|
151
|
-
`kubectl delete csr "$USER_NAME" --ignore-not-found`.
|
|
152
|
-
|
|
153
|
-
### 2c. Approve and extract the certificate
|
|
154
|
-
|
|
155
|
-
```bash
|
|
156
|
-
kubectl certificate approve "$USER_NAME"
|
|
157
|
-
|
|
158
|
-
kubectl get csr "$USER_NAME" -o jsonpath='{.status.certificate}' | base64 -d > "$USER_NAME.crt"
|
|
159
|
-
openssl x509 -in "$USER_NAME.crt" -noout -subject -issuer -dates
|
|
160
|
-
```
|
|
161
|
-
|
|
162
|
-
Expect `subject=CN=<firstname>-admin`, `issuer=CN=kubernetes`, and a one-year window.
|
|
163
|
-
|
|
164
|
-
If `.status.certificate` is empty the signer has not run yet — wait a couple of seconds and repeat. If it
|
|
165
|
-
stays empty, check `kubectl describe csr "$USER_NAME"`.
|
|
166
|
-
|
|
167
|
-
### 2d. Extract the cluster CA
|
|
168
|
-
|
|
169
|
-
```bash
|
|
170
|
-
kubectl config view --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}' | base64 -d > ca.crt
|
|
171
|
-
```
|
|
172
|
-
|
|
173
|
-
### 2e. Bind permissions
|
|
174
|
-
|
|
175
|
-
This is what makes the certificate useful — without it they authenticate but can do nothing. These are the
|
|
176
|
-
same five ClusterRoles the existing admins (`jimmy-admin`, `sam-admin`, `trajko-admin`) hold:
|
|
177
|
-
|
|
178
|
-
```bash
|
|
179
|
-
for r in admin gateway-creator namespace-service-role clusterissuer-admin network-admin; do
|
|
180
|
-
kubectl create clusterrolebinding "${r}-user-${USER_NAME}" --clusterrole="$r" --user="$USER_NAME"
|
|
181
|
-
done
|
|
182
|
-
```
|
|
183
|
-
|
|
184
|
-
| ClusterRole | Grants |
|
|
185
|
-
| --- | --- |
|
|
186
|
-
| `admin` | full read/write inside namespaces (the built-in Kubernetes role) |
|
|
187
|
-
| `gateway-creator` | Gateway API resources |
|
|
188
|
-
| `namespace-service-role` | namespace-scoped service management |
|
|
189
|
-
| `clusterissuer-admin` | cert-manager ClusterIssuers (cluster-scoped) |
|
|
190
|
-
| `network-admin` | NetworkAttachmentDefinitions (Multus), cluster-wide |
|
|
191
|
-
|
|
192
|
-
The declarative equivalent lives in
|
|
193
|
-
`rosopr7102-roboten-hosting-services/clusterrole-admin.yaml`. Creating the bindings with `kubectl` as above
|
|
194
|
-
is enough for the cluster, but that file is the record of who has access — **add the new user there and
|
|
195
|
-
commit it**, or the next person reading it will get a false picture.
|
|
196
|
-
|
|
197
|
-
On a renewal, skip this step: the bindings are attached to the username, not to the certificate.
|
|
198
|
-
|
|
199
|
-
### 2f. Assemble the kubeconfig
|
|
200
|
-
|
|
201
|
-
`--embed-certs=true` inlines the key and certificates, so the file you hand over is self-contained:
|
|
202
|
-
|
|
203
|
-
```bash
|
|
204
|
-
SERVER=https://k8s.roboten-infra.com:64430
|
|
205
|
-
KC="$WORKDIR/$USER_NAME.kubeconfig"
|
|
206
|
-
|
|
207
|
-
KUBECONFIG="$KC" kubectl config set-cluster kubernetes \
|
|
208
|
-
--server="$SERVER" --certificate-authority=ca.crt --embed-certs=true
|
|
209
|
-
|
|
210
|
-
KUBECONFIG="$KC" kubectl config set-credentials "$USER_NAME" \
|
|
211
|
-
--client-certificate="$USER_NAME.crt" --client-key="$USER_NAME.key" --embed-certs=true
|
|
212
|
-
|
|
213
|
-
KUBECONFIG="$KC" kubectl config set-context "$USER_NAME@kubernetes" \
|
|
214
|
-
--cluster=kubernetes --user="$USER_NAME"
|
|
215
|
-
|
|
216
|
-
KUBECONFIG="$KC" kubectl config use-context "$USER_NAME@kubernetes"
|
|
217
|
-
```
|
|
218
|
-
|
|
219
|
-
### 2g. Verify it before handing it over
|
|
220
|
-
|
|
221
|
-
Test the actual file, not just the RBAC — with your own VPN up:
|
|
222
|
-
|
|
223
|
-
```bash
|
|
224
|
-
KUBECONFIG="$KC" kubectl auth whoami # Username: <firstname>-admin
|
|
225
|
-
KUBECONFIG="$KC" kubectl get pods -n dooer-staging | head -3
|
|
226
|
-
KUBECONFIG="$KC" kubectl get pods -n dooer-production | head -3
|
|
227
|
-
KUBECONFIG="$KC" kubectl auth can-i get secrets -n dooer-staging # yes
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
If `whoami` works but the reads say `Forbidden`, step 2e did not take.
|
|
231
|
-
|
|
232
|
-
### 2h. Hand it over, then clean up
|
|
233
|
-
|
|
234
|
-
> **The kubeconfig contains their private key.** Send it the same way as the VPN config — a shared vault
|
|
235
|
-
> entry or an expiring secure link, never email, Slack or a ticket.
|
|
236
|
-
|
|
237
|
-
Tell them the context name (`<firstname>-admin@kubernetes`) and point them at
|
|
238
|
-
[getting-access.md § 2](./getting-access.md#2-kubectl-access).
|
|
239
|
-
|
|
240
|
-
Once they confirm it works, destroy your copy — you are holding their key until you do:
|
|
241
|
-
|
|
242
|
-
```bash
|
|
243
|
-
rm -rf "$WORKDIR"
|
|
244
|
-
```
|
|
245
|
-
|
|
246
100
|
---
|
|
247
101
|
|
|
248
|
-
##
|
|
249
|
-
|
|
250
|
-
```bash
|
|
251
|
-
for r in admin gateway-creator namespace-service-role clusterissuer-admin network-admin; do
|
|
252
|
-
kubectl delete clusterrolebinding "${r}-user-${USER_NAME}"
|
|
253
|
-
done
|
|
254
|
-
kubectl delete csr "$USER_NAME" # tidiness only; the CSR object is just a record
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
Also remove them from `clusterrole-admin.yaml` and commit.
|
|
102
|
+
## Revoking access
|
|
258
103
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
> leaves the holder as an authenticated user with no permissions. That is the real control, and it is why
|
|
262
|
-
> certificates are issued for one year rather than indefinitely. If a key is actually compromised, removing
|
|
263
|
-
> the bindings is the immediate mitigation; rotating the cluster CA is the only complete one, and that
|
|
264
|
-
> invalidates **everyone's** certificate at once — do not do it without planning the re-issue for all users.
|
|
104
|
+
Revoke the tunnel (§1e). That is the whole of what this tool's access consists of — there is no
|
|
105
|
+
certificate to delete and no RBAC to unbind.
|
|
265
106
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
## Renewals
|
|
269
|
-
|
|
270
|
-
A certificate expiring is routine. Repeat **§2a–2d and 2f–2h** and send them a fresh kubeconfig; they
|
|
271
|
-
re-run [getting-access.md § 2](./getting-access.md#2-kubectl-access).
|
|
272
|
-
|
|
273
|
-
Skip **2e** — ClusterRoleBindings are bound to the username, not to the certificate, so they survive a
|
|
274
|
-
re-issue untouched.
|
|
275
|
-
|
|
276
|
-
Delete the old CSR object first, or the new one collides on the name:
|
|
277
|
-
|
|
278
|
-
```bash
|
|
279
|
-
kubectl delete csr "$USER_NAME" --ignore-not-found
|
|
280
|
-
```
|
|
107
|
+
If they also hold a Dooer **admin account**, disabling that account removes their ability to use
|
|
108
|
+
`dooer-test-env` against staging or production, since every operation it performs is admin-gated.
|
package/lib/bankid.js
CHANGED
|
@@ -77,49 +77,20 @@ async function pullFromService(envName = 'staging') {
|
|
|
77
77
|
return values
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
// Pull the bankid secrets out of the staging k8s secret via kubectl. k8s stores each value base64-encoded
|
|
81
|
-
// in `.data`; we decode to the raw value the staging container would receive (for the PFX that raw value is
|
|
82
|
-
// itself the base64 PFX string, exactly what @dooer/config's `Buffer.from(pfx,'base64')` expects).
|
|
83
|
-
function pullFromStaging({ namespace = DEFAULT_NAMESPACE, secret = DEFAULT_SECRET } = {}) {
|
|
84
|
-
const r = sh('kubectl', ['-n', namespace, 'get', 'secret', secret, '-o', 'json'])
|
|
85
|
-
if (r.status !== 0) throw new Error(`kubectl get secret ${namespace}/${secret} failed: ${r.stderr || r.stdout}`)
|
|
86
|
-
const data = JSON.parse(r.stdout).data || {}
|
|
87
|
-
const out = {}
|
|
88
|
-
SECRET_KEYS.forEach((key) => {
|
|
89
|
-
if (data[key] == null) return // CA cert may be absent in some envs — skip quietly
|
|
90
|
-
out[key] = Buffer.from(data[key], 'base64').toString('utf8')
|
|
91
|
-
})
|
|
92
|
-
if (!out.SECRET_DOOER_BANK_ID_PFX) {
|
|
93
|
-
throw new Error(`secret ${namespace}/${secret} has no SECRET_DOOER_BANK_ID_PFX — wrong secret?`)
|
|
94
|
-
}
|
|
95
|
-
return out
|
|
96
|
-
}
|
|
97
|
-
|
|
98
80
|
// Pull + store in the keychain. Returns the list of keys stored (never the values).
|
|
99
|
-
//
|
|
81
|
+
// Fetch the BankID material from service-dooer-test-env and store it in the keychain. Same values the
|
|
82
|
+
// cluster Secret holds — this used to read that Secret with kubectl, which required a credential that
|
|
83
|
+
// could read every Secret in the namespace.
|
|
100
84
|
async function pullAndStore(opts = {}) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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 */
|
|
85
|
+
const values = await pullFromService(opts.env || 'staging')
|
|
86
|
+
if (!values || !Object.keys(values).length) {
|
|
87
|
+
throw new Error('the service returned no BankID material')
|
|
115
88
|
}
|
|
116
|
-
|
|
117
|
-
const values = pullFromStaging(opts)
|
|
118
89
|
const stored = []
|
|
119
|
-
|
|
90
|
+
for (const [key, value] of Object.entries(values)) {
|
|
120
91
|
keychainSet(key, value)
|
|
121
92
|
stored.push(key)
|
|
122
|
-
}
|
|
93
|
+
}
|
|
123
94
|
return stored
|
|
124
95
|
}
|
|
125
96
|
|
|
@@ -149,7 +120,6 @@ module.exports = {
|
|
|
149
120
|
SECRET_KEYS,
|
|
150
121
|
DEFAULT_NAMESPACE,
|
|
151
122
|
DEFAULT_SECRET,
|
|
152
|
-
pullFromStaging,
|
|
153
123
|
pullAndStore,
|
|
154
124
|
status,
|
|
155
125
|
clear,
|
package/lib/command/customer.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require('fs')
|
|
2
2
|
const chalk = require('chalk')
|
|
3
|
-
const
|
|
3
|
+
const remote = require('../remote')
|
|
4
|
+
const serviceClient = require('../service-client')
|
|
4
5
|
const { createAccount, SUBSCRIPTION_TYPES } = require('../account')
|
|
5
6
|
|
|
6
7
|
// `copy` now runs through service-dooer-test-env (export in the source, import in the target) instead of
|
|
@@ -125,19 +126,36 @@ module.exports = {
|
|
|
125
126
|
builder: (y2) =>
|
|
126
127
|
y2
|
|
127
128
|
.option('org', { type: 'string', demandOption: true, describe: 'org (companies_pk) to delete' })
|
|
128
|
-
.option('namespace', { type: 'string', default: 'dooer-staging', describe: '
|
|
129
|
-
.option('local', { type: 'boolean', describe: 'purge from the LOCAL env instead
|
|
130
|
-
.option('skip-files', { type: 'boolean', describe: 'do not delete the org’s S3 objects' })
|
|
129
|
+
.option('namespace', { type: 'string', default: 'dooer-staging', describe: 'environment to delete from' })
|
|
130
|
+
.option('local', { type: 'boolean', describe: 'purge from the LOCAL env instead' })
|
|
131
131
|
.option('confirm-production', { type: 'boolean', describe: 'REQUIRED to delete from dooer-production' })
|
|
132
|
+
.option('reason', { type: 'string', describe: 'recorded in the audit trail' })
|
|
132
133
|
.option('execute', { type: 'boolean', default: false, describe: 'actually delete (default: dry-run)' }),
|
|
133
|
-
handler: (argv) =>
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
134
|
+
handler: async (argv) => {
|
|
135
|
+
// Through the service, like copy — no kubeconfig, and the deletion is recorded in that
|
|
136
|
+
// environment's audit trail rather than happening invisibly.
|
|
137
|
+
const env = remote.resolveEnv(envFromFlags(argv.local, argv.namespace, 'staging'))
|
|
138
|
+
if (env.production && !argv.confirmProduction) {
|
|
139
|
+
throw new Error('refusing to delete from dooer-production without --confirm-production')
|
|
140
|
+
}
|
|
141
|
+
const started = await serviceClient.request(env, {
|
|
142
|
+
method: 'POST',
|
|
143
|
+
path: '/v1/purges',
|
|
144
|
+
body: { organizationId: argv.org, execute: !!argv.execute, reason: argv.reason },
|
|
145
|
+
})
|
|
146
|
+
const op = await serviceClient.pollOperation(env, 'purges', started.id, {
|
|
147
|
+
onTick: (o) => process.stdout.write(o.status === 'running' ? '.' : `\n ${o.status}`),
|
|
148
|
+
})
|
|
149
|
+
if (op.status !== 'succeeded') throw new Error(`\npurge failed: ${op.error || 'unknown error'}`)
|
|
150
|
+
const d = serviceClient.detailOf(op)
|
|
151
|
+
console.log(
|
|
152
|
+
(argv.execute ? chalk.green('\ndeleted') : chalk.yellow('\nDRY RUN — nothing deleted')) +
|
|
153
|
+
` ${d.rows} rows across ${(d.tables || []).length} tables in ${env.label}`
|
|
154
|
+
)
|
|
155
|
+
if (d.note) console.log(chalk.yellow(` ${d.note}`))
|
|
156
|
+
if (!argv.execute) console.log(' Re-run with --execute to apply.')
|
|
157
|
+
console.log(` audit: ${op.id}\n`)
|
|
158
|
+
},
|
|
141
159
|
})
|
|
142
160
|
.command({
|
|
143
161
|
command: 'new <name>',
|
package/lib/command/db.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
const os = require('os')
|
|
2
|
-
const fs = require('fs')
|
|
3
2
|
const path = require('path')
|
|
4
3
|
const chalk = require('chalk')
|
|
5
4
|
const dbbuild = require('../engine/dbbuild')
|
|
@@ -119,30 +118,19 @@ module.exports = {
|
|
|
119
118
|
local,
|
|
120
119
|
download: async () => {
|
|
121
120
|
const out = path.join(os.tmpdir(), 'dooer-base-db.pulled.dump')
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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 })
|
|
143
|
-
const { file, key } = await obc.download(cfg, out)
|
|
144
|
-
console.log(chalk.gray(`downloaded ${cfg.bucket}/${key}`))
|
|
145
|
-
return file
|
|
121
|
+
// The service streams the artifact from inside the cluster. This used to read the
|
|
122
|
+
// ObjectBucketClaim's credentials with kubectl and resolve a rook-ceph ClusterIP; now the
|
|
123
|
+
// S3 credentials never leave the cluster and this machine needs no kubeconfig.
|
|
124
|
+
const remote = require('../remote')
|
|
125
|
+
const serviceClient = require('../service-client')
|
|
126
|
+
const env = remote.resolveEnv(argv.env || 'staging')
|
|
127
|
+
const list = await serviceClient.request(env, { path: '/v1/base-db/artifacts' })
|
|
128
|
+
const newest = (list.artifacts || [])[0]
|
|
129
|
+
if (!newest) throw new Error(`no base-DB artifacts published in ${env.label} yet`)
|
|
130
|
+
console.log(`downloading ${newest.id} (${(newest.bytes / 1e6).toFixed(0)} MB) from ${env.label} …`)
|
|
131
|
+
// Straight to a file: the base DB is ~300 MB and has no business sitting in the heap.
|
|
132
|
+
await serviceClient.downloadTo(env, `/v1/base-db/artifacts/${encodeURIComponent(newest.id)}/content`, out)
|
|
133
|
+
return out
|
|
146
134
|
},
|
|
147
135
|
execute: argv.execute,
|
|
148
136
|
})
|
package/lib/obc.js
CHANGED
|
@@ -1,39 +1,29 @@
|
|
|
1
1
|
// obc.js — access the base-artifact bucket provisioned by an ObjectBucketClaim (Rook-Ceph). An OBC yields
|
|
2
2
|
// a ConfigMap (BUCKET_NAME/BUCKET_HOST/BUCKET_PORT) and a Secret (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY).
|
|
3
|
-
//
|
|
4
|
-
|
|
3
|
+
// IN-CLUSTER ONLY. It used to fall back to reading the OBC with kubectl so a laptop could `db pull`
|
|
4
|
+
// directly; that path is gone — `db pull` streams the artifact from service-dooer-test-env, and no
|
|
5
|
+
// developer machine holds these credentials. What remains serves the base-DB BUILD, which runs as a
|
|
6
|
+
// CronJob inside the cluster and gets everything from its own environment.
|
|
5
7
|
const { S3Client, PutObjectCommand, GetObjectCommand, ListObjectsV2Command } = require('@aws-sdk/client-s3')
|
|
6
8
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
let host = process.env.BUCKET_HOST
|
|
15
|
-
let port = process.env.BUCKET_PORT
|
|
16
|
-
let ak = process.env.AWS_ACCESS_KEY_ID
|
|
17
|
-
let sk = process.env.AWS_SECRET_ACCESS_KEY
|
|
9
|
+
// Resolve the OBC connection from the environment the Job runs with.
|
|
10
|
+
function obcConfig({ obcName = 'dooer-test-env-base' } = {}) {
|
|
11
|
+
const bucket = process.env.BUCKET_NAME
|
|
12
|
+
const host = process.env.BUCKET_HOST
|
|
13
|
+
const port = process.env.BUCKET_PORT
|
|
14
|
+
const ak = process.env.AWS_ACCESS_KEY_ID
|
|
15
|
+
const sk = process.env.AWS_SECRET_ACCESS_KEY
|
|
18
16
|
if (!(bucket && host && ak && sk)) {
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
port = cm.data.BUCKET_PORT || '80'
|
|
25
|
-
ak = Buffer.from(sec.data.AWS_ACCESS_KEY_ID, 'base64').toString('utf8')
|
|
26
|
-
sk = Buffer.from(sec.data.AWS_SECRET_ACCESS_KEY, 'base64').toString('utf8')
|
|
27
|
-
}
|
|
28
|
-
// BUCKET_HOST is a k8s service name in-cluster; over VPN a dev resolves it to the rook-ceph ClusterIP
|
|
29
|
-
let endpoint = `http://${host}:${port || 80}`
|
|
30
|
-
if (namespace && !process.env.BUCKET_HOST) {
|
|
31
|
-
const svc = host.split('.')[0]
|
|
32
|
-
const ip = sh('kubectl', ['get', 'svc', svc, '-n', 'rook-ceph', '-o', 'jsonpath={.spec.clusterIP}'])
|
|
33
|
-
if (ip) endpoint = `http://${ip}:${port || 80}`
|
|
17
|
+
throw new Error(
|
|
18
|
+
`OBC ${obcName} is not in this environment (needs BUCKET_NAME/BUCKET_HOST/AWS_ACCESS_KEY_ID/` +
|
|
19
|
+
'AWS_SECRET_ACCESS_KEY). This path runs in-cluster only; from a laptop use `dooer-test-env db ' +
|
|
20
|
+
'pull`, which streams the artifact through the service.'
|
|
21
|
+
)
|
|
34
22
|
}
|
|
23
|
+
// In-cluster BUCKET_HOST is a Service name and DNS resolves it. (Reaching this from outside used to
|
|
24
|
+
// need a rook-ceph ClusterIP lookup; nothing calls it from outside any more.)
|
|
35
25
|
const client = new S3Client({
|
|
36
|
-
endpoint
|
|
26
|
+
endpoint: `http://${host}:${port || 80}`,
|
|
37
27
|
region: 'us-east-1',
|
|
38
28
|
forcePathStyle: true,
|
|
39
29
|
credentials: { accessKeyId: ak, secretAccessKey: sk },
|
package/lib/registry.js
CHANGED
|
@@ -1,151 +1,65 @@
|
|
|
1
|
-
// registry.js — make `docker pull` work for the two private registries
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
// registry.js — make `docker pull` work for the two private registries without any manual steps.
|
|
2
|
+
//
|
|
3
|
+
// The credentials come from service-dooer-test-env's `/v1/bootstrap/registry-credentials`, authorized by
|
|
4
|
+
// your admin session. This used to read the cluster's imagePullSecrets with kubectl, which meant every
|
|
5
|
+
// developer held a credential that could read EVERY Secret in the namespace to obtain two of them.
|
|
6
|
+
//
|
|
7
|
+
// Env vars remain as an escape hatch for a machine with no session (CI, a first run while the service is
|
|
8
|
+
// being deployed); the interactive prompt is gone with the kubectl path, since there is nothing left that
|
|
9
|
+
// can half-work and leave you guessing.
|
|
10
|
+
const { spawnSync } = require('child_process')
|
|
8
11
|
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
12
|
+
function dockerLogin(host, username, password) {
|
|
13
|
+
const r = spawnSync('docker', ['login', host, '-u', username, '--password-stdin'], {
|
|
14
|
+
input: password,
|
|
15
|
+
encoding: 'utf8',
|
|
16
|
+
})
|
|
17
|
+
return r.status === 0
|
|
14
18
|
}
|
|
15
19
|
|
|
16
|
-
// is the binary on PATH? "ran at all" (no ENOENT), regardless of exit code
|
|
17
|
-
// invalid flag but still proves kubectl is installed.
|
|
20
|
+
// is the binary on PATH? "ran at all" (no ENOENT), regardless of exit code.
|
|
18
21
|
function have(cmd) {
|
|
19
22
|
const r = spawnSync(cmd, ['--version'], { encoding: 'utf8' })
|
|
20
23
|
return !r.error
|
|
21
24
|
}
|
|
22
25
|
|
|
23
|
-
//
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
const
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
26
|
+
// DOOER_ECR_* override, for a machine that cannot reach the service yet.
|
|
27
|
+
function fromEnv() {
|
|
28
|
+
const host = process.env.DOOER_ECR_REGISTRY
|
|
29
|
+
const user = process.env.DOOER_ECR_USERNAME
|
|
30
|
+
const pass = process.env.DOOER_ECR_PASSWORD
|
|
31
|
+
if (!(host && user && pass)) return null
|
|
32
|
+
return [{ host, ok: dockerLogin(host, user, pass), via: 'env' }]
|
|
34
33
|
}
|
|
35
34
|
|
|
36
|
-
//
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
try {
|
|
40
|
-
b64 = sh('kubectl', ['get', 'secret', secretName, '-n', namespace, '-o', 'jsonpath={.data.\\.dockerconfigjson}'])
|
|
41
|
-
} catch (_) {
|
|
42
|
-
return null
|
|
43
|
-
}
|
|
44
|
-
if (!b64) return null
|
|
45
|
-
let cfg
|
|
46
|
-
try {
|
|
47
|
-
cfg = JSON.parse(Buffer.from(b64, 'base64').toString('utf8'))
|
|
48
|
-
} catch (_) {
|
|
49
|
-
return null
|
|
50
|
-
}
|
|
51
|
-
const out = {}
|
|
52
|
-
for (const [host, entry] of Object.entries(cfg.auths || {})) {
|
|
53
|
-
let { username, password } = entry
|
|
54
|
-
if ((!username || !password) && entry.auth) {
|
|
55
|
-
const [u, ...p] = Buffer.from(entry.auth, 'base64').toString('utf8').split(':')
|
|
56
|
-
username = username || u
|
|
57
|
-
password = password || p.join(':')
|
|
58
|
-
}
|
|
59
|
-
if (username && password) out[host] = { username, password }
|
|
60
|
-
}
|
|
61
|
-
return out
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
function dockerLogin(host, username, password) {
|
|
65
|
-
const r = spawnSync('docker', ['login', host, '-u', username, '--password-stdin'], {
|
|
66
|
-
input: password,
|
|
67
|
-
encoding: 'utf8',
|
|
68
|
-
})
|
|
69
|
-
return r.status === 0
|
|
70
|
-
}
|
|
35
|
+
// Log docker into both private registries. Returns [{ host, ok, via }].
|
|
36
|
+
async function loginAll({ env = 'staging' } = {}) {
|
|
37
|
+
if (!have('docker')) throw new Error('docker is not installed / not on PATH')
|
|
71
38
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
const answer = await new Promise((resolve) => {
|
|
76
|
-
if (silent) {
|
|
77
|
-
// hide typed characters (password)
|
|
78
|
-
const onData = () => rl.output.write('\r' + question + '*'.repeat(0))
|
|
79
|
-
rl._writeToOutput = () => rl.output.write('')
|
|
80
|
-
rl.question(question, (a) => resolve(a))
|
|
81
|
-
rl.input.on('data', onData)
|
|
82
|
-
} else {
|
|
83
|
-
rl.question(question, (a) => resolve(a))
|
|
84
|
-
}
|
|
85
|
-
})
|
|
86
|
-
rl.close()
|
|
87
|
-
if (silent) process.stdout.write('\n')
|
|
88
|
-
return answer.trim()
|
|
89
|
-
}
|
|
39
|
+
const remote = require('./remote')
|
|
40
|
+
const serviceClient = require('./service-client')
|
|
41
|
+
const resolved = remote.resolveEnv(env)
|
|
90
42
|
|
|
91
|
-
|
|
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.
|
|
43
|
+
let registries
|
|
95
44
|
try {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
45
|
+
;({ registries } = await serviceClient.request(resolved, { path: '/v1/bootstrap/registry-credentials' }))
|
|
46
|
+
} catch (e) {
|
|
47
|
+
const viaEnv = fromEnv()
|
|
48
|
+
if (viaEnv) return viaEnv
|
|
49
|
+
throw new Error(
|
|
50
|
+
`could not obtain registry credentials from ${resolved.label}: ${e.message}\n` +
|
|
51
|
+
` · is the VPN up, and are you logged in? ` +
|
|
52
|
+
`\`dooer-test-env remote-environment login --env ${resolved.name} --email <admin>\`\n` +
|
|
53
|
+
` · or set DOOER_ECR_REGISTRY / DOOER_ECR_USERNAME / DOOER_ECR_PASSWORD`
|
|
54
|
+
)
|
|
106
55
|
}
|
|
107
56
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
115
|
-
const hosts = Object.keys(merged)
|
|
116
|
-
if (hosts.length) {
|
|
117
|
-
for (const host of hosts) {
|
|
118
|
-
const ok = dockerLogin(host, merged[host].username, merged[host].password)
|
|
119
|
-
results.push({ host, ok, via: 'kubectl' })
|
|
120
|
-
}
|
|
121
|
-
return results
|
|
122
|
-
}
|
|
123
|
-
// kubectl/secret unavailable — try env, then prompt
|
|
124
|
-
const envPairs = [
|
|
125
|
-
{
|
|
126
|
-
host: process.env.DOOER_ECR_REGISTRY,
|
|
127
|
-
user: process.env.DOOER_ECR_USERNAME,
|
|
128
|
-
pass: process.env.DOOER_ECR_PASSWORD,
|
|
129
|
-
},
|
|
130
|
-
].filter((p) => p.host && p.user && p.pass)
|
|
131
|
-
if (envPairs.length) {
|
|
132
|
-
for (const p of envPairs) results.push({ host: p.host, ok: dockerLogin(p.host, p.user, p.pass), via: 'env' })
|
|
133
|
-
return results
|
|
134
|
-
}
|
|
135
|
-
if (interactive && process.stdin.isTTY) {
|
|
136
|
-
const host = await prompt('Registry host (e.g. …dkr.ecr.eu-west-1.amazonaws.com): ')
|
|
137
|
-
const user = await prompt('Username: ')
|
|
138
|
-
const pass = await prompt('Password/token: ', { silent: true })
|
|
139
|
-
if (host && user && pass) {
|
|
140
|
-
results.push({ host, ok: dockerLogin(host, user, pass), via: 'prompt' })
|
|
141
|
-
return results
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
throw new Error(
|
|
145
|
-
'could not obtain registry credentials: kubectl could not read the cluster pull secrets ' +
|
|
146
|
-
`(${PULL_SECRETS.join(', ')}) in "${namespace}", and no DOOER_ECR_* env vars were set. ` +
|
|
147
|
-
'Check VPN + kubectl context, or set DOOER_ECR_REGISTRY/USERNAME/PASSWORD.'
|
|
148
|
-
)
|
|
57
|
+
return (registries || []).map((r) => {
|
|
58
|
+
// The service reports hosts as the Secret stores them, sometimes scheme-prefixed; `docker login`
|
|
59
|
+
// wants a bare host.
|
|
60
|
+
const host = r.host.replace(/^https?:\/\//, '')
|
|
61
|
+
return { host, ok: dockerLogin(host, r.username, r.password), via: 'service' }
|
|
62
|
+
})
|
|
149
63
|
}
|
|
150
64
|
|
|
151
|
-
module.exports = { loginAll,
|
|
65
|
+
module.exports = { loginAll, dockerLogin, have }
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dooer/dooer-test-env",
|
|
3
|
-
"version": "1.17.
|
|
3
|
+
"version": "1.17.2",
|
|
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
|
@@ -29,8 +29,8 @@ You need exactly two things:
|
|
|
29
29
|
- **[Getting access](./docs/getting-access.md)** — new developer: set up the VPN and log in.
|
|
30
30
|
- **[Logins and user management](./docs/user-management.md)** — signing in as a customer, partner or admin
|
|
31
31
|
user; creating users; partner membership; using the token from scripts.
|
|
32
|
-
- **[Issuing access](./docs/issuing-access.md)** — admin:
|
|
33
|
-
|
|
32
|
+
- **[Issuing access](./docs/issuing-access.md)** — admin: how to create and hand over a VPN tunnel. Still
|
|
33
|
+
required — a new developer cannot reach anything without one.
|
|
34
34
|
|
|
35
35
|
## Quick start
|
|
36
36
|
|