@dooer/dooer-test-env 1.17.0 → 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.
@@ -1,22 +1,14 @@
1
1
  # Issuing access (admin)
2
2
 
3
- The other side of [getting-access.md](./getting-access.md). Two independent things:
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
- | | Who does it | What you do |
6
- | --- | --- | --- |
7
- | **VPN tunnel** | you | create it and send them the config |
8
- | **kubectl credential** | you | create the certificate, bind permissions, send a kubeconfig |
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
- > **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
-
19
- You need a Robo10 token (§1a), `kubectl` with cluster-admin (`system:masters`), and your own VPN up.
11
+ You need a Robo10 token1a) 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
- do it *and* revoke their kubectl permissions (§3).
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
- ## 3. Revoking access
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
- > **A signed certificate cannot be revoked.** Kubernetes has no CRL or OCSP: until it expires, that
260
- > certificate still *authenticates* deleting the bindings only removes what it is *allowed to do*, which
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
- // Async because the service path is an HTTP call. The single caller (`bankid pull`) already awaits.
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
- // 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 */
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
- Object.entries(values).forEach(([key, value]) => {
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,
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs')
2
2
  const chalk = require('chalk')
3
- const { purge } = require('../engine/purge')
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: 'k8s namespace' })
129
- .option('local', { type: 'boolean', describe: 'purge from the LOCAL env instead of a k8s namespace' })
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
- purge({
135
- org: argv.org,
136
- namespace: argv.local ? 'local' : argv.namespace,
137
- skipFiles: argv.skipFiles,
138
- confirmProduction: argv.confirmProduction,
139
- execute: argv.execute,
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
- // 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 })
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
  })
@@ -235,7 +235,15 @@ module.exports = {
235
235
  }
236
236
  const exp = remote.expiry(token)
237
237
  if (exp && exp.expired) {
238
- console.log(`${head}${chalk.yellow(`session EXPIRED ${exp.at.toISOString()}`)}`)
238
+ // For local an expired session is not a problem — it falls back to a minted service token —
239
+ // so saying EXPIRED there reads as breakage when nothing is broken.
240
+ console.log(
241
+ `${head}${
242
+ name === 'local'
243
+ ? 'stale session ignored — uses a minted service token'
244
+ : chalk.yellow(`session EXPIRED ${exp.at.toISOString()}`)
245
+ }`
246
+ )
239
247
  continue
240
248
  }
241
249
  let me = null
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
- // In-cluster the Job mounts these as env; a dev `db pull` reads them via kubectl from the OBC's namespace.
4
- const { execFileSync } = require('child_process')
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
- function sh(file, args) {
8
- return execFileSync(file, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }).trim()
9
- }
10
-
11
- // resolve OBC connection: prefer env (in-cluster), else kubectl-read the OBC ConfigMap+Secret.
12
- function obcConfig({ obcName = 'dooer-test-env-base', namespace } = {}) {
13
- let bucket = process.env.BUCKET_NAME
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
- if (!namespace) throw new Error('OBC not in env; pass --obc-namespace to read the OBC via kubectl')
20
- const cm = JSON.parse(sh('kubectl', ['get', 'configmap', obcName, '-n', namespace, '-o', 'json']))
21
- const sec = JSON.parse(sh('kubectl', ['get', 'secret', obcName, '-n', namespace, '-o', 'json']))
22
- bucket = cm.data.BUCKET_NAME
23
- host = cm.data.BUCKET_HOST
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 WITHOUT any manual steps. The
2
- // cluster already holds pull secrets (imagePullSecrets); we read them via kubectl, decode the
3
- // dockerconfigjson, and `docker login` each registry. If kubectl/the secret isn't available we fall back
4
- // to env vars and, on a TTY, an interactive prompt so a dev is only ever asked when creds truly can't
5
- // be found. Used by `setup` and implicitly by `up`.
6
- const { execFileSync, spawnSync } = require('child_process')
7
- const readline = require('readline')
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
- // the imagePullSecrets the s-e032 manifests reference
10
- const PULL_SECRETS = ['aws-ecr-secret', 'push-secret-robo10']
11
-
12
- function sh(file, args, opts = {}) {
13
- return execFileSync(file, args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024, ...opts }).trim()
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 — `kubectl --version` is an
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
- // 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
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
- // { host: { username, password } } parsed from a k8s dockerconfigjson secret (or null if unreadable)
37
- function authsFromSecret(secretName, namespace) {
38
- let b64
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
- async function prompt(question, { silent } = {}) {
73
- if (!process.stdin.isTTY) return null
74
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true })
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
- // Log docker into both private registries. Returns [{host, ok, via}]. `via` = kubectl | env | prompt.
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
- 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 */
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
- if (!have('docker')) throw new Error('docker is not installed / not on PATH')
109
- const results = []
110
- const merged = {}
111
- for (const s of PULL_SECRETS) {
112
- const a = authsFromSecret(s, namespace)
113
- if (a) Object.assign(merged, a)
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, authsFromSecret, dockerLogin, have, PULL_SECRETS }
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.0",
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: VPN tunnels, and cluster certificates for the
33
- people who actually operate the cluster. **Not needed to use this tool any more.**
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