@uknows/setup 0.1.0 → 0.2.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.
Files changed (48) hide show
  1. package/README.md +99 -32
  2. package/lib/automate.mjs +1299 -0
  3. package/lib/check.mjs +149 -33
  4. package/lib/ci.mjs +21 -0
  5. package/lib/cli.mjs +114 -29
  6. package/lib/cloudid.mjs +456 -0
  7. package/lib/code.mjs +147 -0
  8. package/lib/context.mjs +126 -0
  9. package/lib/contract-docs.mjs +1780 -198
  10. package/lib/contract.mjs +93 -10
  11. package/lib/detect.mjs +96 -44
  12. package/lib/dockerfile.mjs +83 -0
  13. package/lib/endpoint.mjs +36 -5
  14. package/lib/engine.mjs +1272 -126
  15. package/lib/existing.mjs +448 -54
  16. package/lib/git.mjs +53 -0
  17. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  18. package/lib/hooks/pins.mjs +28 -0
  19. package/lib/hooks/uknows-otel.cjs +82 -0
  20. package/lib/hooks/uknows_otel.py +179 -0
  21. package/lib/hosts.mjs +521 -16
  22. package/lib/locks.mjs +353 -0
  23. package/lib/manifest.mjs +27 -7
  24. package/lib/members.mjs +269 -0
  25. package/lib/plan.mjs +130 -0
  26. package/lib/settings.mjs +216 -28
  27. package/lib/signin.mjs +259 -0
  28. package/lib/stacks/azure.mjs +345 -0
  29. package/lib/stacks/channel.mjs +125 -0
  30. package/lib/stacks/dotnet.mjs +336 -29
  31. package/lib/stacks/functions-hooks.mjs +228 -0
  32. package/lib/stacks/functions.mjs +148 -0
  33. package/lib/stacks/guided.mjs +153 -30
  34. package/lib/stacks/index.mjs +10 -2
  35. package/lib/stacks/java.mjs +14 -13
  36. package/lib/stacks/node.mjs +142 -19
  37. package/lib/stacks/otel.mjs +56 -4
  38. package/lib/stacks/python-bootstrap.mjs +27 -7
  39. package/lib/stacks/python-lock.mjs +105 -0
  40. package/lib/stacks/python-sets.mjs +143 -0
  41. package/lib/stacks/python.mjs +261 -63
  42. package/lib/toml.mjs +64 -0
  43. package/lib/types.mjs +52 -5
  44. package/lib/ui.mjs +139 -20
  45. package/lib/updates.mjs +398 -0
  46. package/lib/versions.mjs +115 -0
  47. package/lib/workspace.mjs +4 -1
  48. package/package.json +3 -8
package/lib/settings.mjs CHANGED
@@ -1,15 +1,37 @@
1
- // Where the 5 UKNOWS settings go for 1 service, and the edits that put them there: the final stage of
2
- // the Dockerfile, else the env of the Kubernetes container, else <dir>/otel.env. Never the key.
1
+ // Where the UKNOWS settings go for 1 service, and the edits that put them there: the final stage of
2
+ // the Dockerfile, else the env of the Kubernetes container, else <dir>/otel.env when the repo has it or a repo
3
+ // file loads it (a systemd EnvironmentFile=, a Compose env_file:, --env-file or . ./otel.env). Otherwise no file:
4
+ // a required step prints the settings in the form of the platform. Never the key.
3
5
  // Settings that would keep traces away from UKNOWS (another address, a traces-only override, a Kubernetes
4
6
  // env that replaces the image value) become manual steps, never silent successes.
5
- import { INGEST_ENDPOINT, SECRET, SETTINGS, SETTINGS_FILE, STACKS } from './contract.mjs'
7
+ //
8
+ // The settings depend on the mode of the service (ctx.mode):
9
+ // standard the 5 settings, with OTEL_METRICS_EXPORTER=none and OTEL_LOGS_EXPORTER=otlp (traces and logs).
10
+ // channel the same 5 settings. The agent comes from the UKNOWS agent image (stacks/channel.mjs).
11
+ // otel the 3 shared settings. Never the none lines: the app keeps its metrics and logs. The traces-only
12
+ // names replace the endpoint and protocol when the code also sends metrics or logs over OTLP, and for
13
+ // Node.js, Python and Java, whose SDKs send metrics and logs over OTLP by default (unless the target
14
+ // turns both off with none).
15
+ //
16
+ // An endpoint value that an earlier setup wrote (a change in .uknows/setup.json added that line) is this tool's own,
17
+ // not another address to keep. A newer setup replaces it, for example the ingest address of 0.1.0.
18
+ // azure-monitor the 3 shared settings, and OTEL_SERVICE_NAME only when the app does not set it: it also sets
19
+ // the App Insights Cloud Role Name.
20
+ import { posix } from 'node:path'
21
+ import { AZURE_MONITOR, DOCS_URL, DOTNET_TRACES_ONLY, FUNCTIONS, INGEST_ENDPOINT, SECRET, SETTINGS, SETTINGS_ELSEWHERE, SETTINGS_FILE, SHARED_SETTINGS, STACKS,
22
+ TRACES_ONLY_PLAN, settingsSnippet } from './contract.mjs'
23
+ import { isDockerfile } from './detect.mjs'
6
24
  import { Doc, indentOf } from './doc.mjs'
7
25
  import { parseDockerfile, setEnv } from './dockerfile.mjs'
8
- import { list } from './ui.mjs'
26
+ import { BOM, sha256 } from './manifest.mjs'
27
+ import { list, stackLabel } from './ui.mjs'
9
28
 
10
29
  const NAMES = SETTINGS.map((s) => s.name)
11
- const [SERVICE, ENDPOINT] = NAMES
12
- const COLLECTOR = STACKS.find((s) => s.id === 'otel').notes.find((n) => /Collector/.test(n))
30
+ const [SERVICE, ENDPOINT, PROTOCOL] = NAMES
31
+ const TRACES_ENDPOINT = 'OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', TRACES_PROTOCOL = 'OTEL_EXPORTER_OTLP_TRACES_PROTOCOL'
32
+ const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
33
+ /** The Collector note, with the link to that section: the stack tab of the docs page that has it folded (F-012-09). */
34
+ const COLLECTOR = `${STACKS.find((s) => s.id === 'otel').notes.find((n) => /Collector/.test(n))} See ${DOCS_URL}?method=manual&stack=otel#install-app.`
13
35
  const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
14
36
  const blank = (line) => !line.trim() || /^\s*#/.test(line)
15
37
  const quote = (value) => `"${value.replace(/["\\]/g, '\\$&')}"`
@@ -43,25 +65,75 @@ const overrideRules = (endpoint) => [
43
65
  */
44
66
  export function settingsPlan(service, ctx, extra = []) {
45
67
  const { ws } = ctx
68
+ const mode = ctx.mode ?? 'standard'
46
69
  const ingest = ctx.endpoint ?? INGEST_ENDPOINT
47
- const sameEndpoint = sameAs(ingest)
48
- const pairs = [...SETTINGS.map((s) => [s.name, s.name === SERVICE ? ctx.name : s.name === ENDPOINT ? ingest : s.value]),
49
- ...extra.filter((s) => s.name !== SECRET.name && !NAMES.includes(s.name)).map((s) => [s.name, String(s.value)])]
70
+ const envFile = at(service.dir, SETTINGS_FILE.path)
50
71
  const target = (service.dockerfile && ws.read(service.dockerfile) !== null && dockerTarget(ws, service.dockerfile))
51
72
  || (service.k8s ?? []).map((k) => k8sTarget(ws, k)).find(Boolean)
52
- || envTarget(ws, at(service.dir, SETTINGS_FILE.path))
53
- const plan = { target: { kind: target.kind, file: target.file }, edits: [], summary: [], manual: [], notes: [] }
73
+ || envFileTarget(ws, ctx.repo ?? ws, service, envFile)
74
+ const plan = { target: target.kind === 'none' ? null : { kind: target.kind, file: target.file, ...(target.loader ? { loader: target.loader } : {}) },
75
+ edits: [], summary: [], manual: [], notes: [], pairs: [], tracesOnly: false }
76
+ if (target.loader) plan.notes.push(fill(SETTINGS_ELSEWHERE.loaded, { ...target.loader, path: envFile }))
77
+ const anchors = ctx.existing?.anchors ?? []
78
+ // otel mode: metrics or logs over OTLP in the code (or set in the target) keep the general names. Traces get their own.
79
+ // The SDKs of Node.js, Python and Java send metrics and logs over OTLP by default, unless the target turns both off.
80
+ const signal = anchors.find((a) => a.kind === 'otlp-signal')
81
+ const signals = ['OTEL_METRICS_EXPORTER', 'OTEL_LOGS_EXPORTER']
82
+ const otlpSet = signals.some((n) => /\botlp\b/.test(target.current.get(n) ?? ''))
83
+ const bothOff = signals.every((n) => (target.current.get(n) ?? '').trim().toLowerCase() === 'none')
84
+ const sdkDefault = SDK_SIGNALS.has(service.lang) && !bothOff
85
+ const tracesOnly = mode === 'otel' && Boolean(signal || otlpSet || sdkDefault)
86
+ // A value that an earlier setup wrote in this file: replace it, never keep it as another address.
87
+ const wrote = (name, current) => current !== null && current !== undefined && setupWrote(ctx.manifest, target.file, name, current, target.before)
88
+ const value = (s) => (s.name === SERVICE ? ctx.name : s.name === ENDPOINT ? (tracesOnly ? `${ingest}/v1/traces` : ingest) : s.value)
89
+ const rename = (name) => (!tracesOnly ? name : name === ENDPOINT ? TRACES_ENDPOINT : name === PROTOCOL ? TRACES_PROTOCOL : name)
90
+ let base = (mode === 'standard' || mode === 'channel' ? SETTINGS : SHARED_SETTINGS).map((s) => [rename(s.name), value(s)])
91
+ // azure-monitor: OTEL_SERVICE_NAME is also the App Insights Cloud Role Name. Set it only when the app does not, and
92
+ // say how to see the current role name and keep it.
93
+ if (mode === 'azure-monitor') {
94
+ const elsewhere = anchors.find((a) => a.kind === 'service-name' && a.file !== target.file)
95
+ const set = target.current.has(SERVICE) ? { file: target.file } : elsewhere
96
+ if (set) base = base.filter(([name]) => name !== SERVICE)
97
+ const R = AZURE_MONITOR.plan.roleName
98
+ plan.manual.push(set ? { title: R.title, required: false, lines: [fill(R.kept, { where: `${set.file}${set.line ? ` line ${set.line}` : ''}` })] }
99
+ : { title: R.title, required: false, lines: R.set.map((l) => fill(l, { name: ctx.name })),
100
+ snippets: [{ caption: 'App Insights Logs', language: 'kusto', code: R.query }] })
101
+ }
102
+ const ours = base.map(([name]) => name)
103
+ const endpointName = tracesOnly ? TRACES_ENDPOINT : ENDPOINT
104
+ const sameEndpoint = (v) => sameAs(ingest)(tracesOnly ? v.replace(/\/v1\/traces\/?$/, '') : v)
105
+ const pairs = [...base, ...extra.filter((s) => s.name !== SECRET.name && !NAMES.includes(s.name) && !ours.includes(s.name)).map((s) => [s.name, String(s.value)])]
106
+ plan.pairs = pairs
107
+ plan.tracesOnly = tracesOnly
54
108
 
55
109
  const keyFiles = [...new Set([service.dockerfile, ...(service.k8s ?? []).map((k) => k.file),
56
110
  at(service.dir, SETTINGS_FILE.path), at(service.dir, '.env.example')])].filter((f) => f && hasKey(ws.read(f)))
57
111
  // A file that holds a key is never edited: the diff and the manifest hunks must not show it.
58
112
  const keyInTarget = keyFiles.includes(target.file)
59
113
  if (keyFiles.length) {
60
- plan.manual.push({ title: 'Remove the key from your repo', lines: [...keyFiles.map((f) => `Remove ${SECRET.name} from ${f}.`),
114
+ plan.manual.push({ title: 'Remove the key from your repo', verify: true, lines: [...keyFiles.map((f) => `Remove ${SECRET.name} from ${f}.`),
61
115
  'Get a new key. The old key stays in your git history.', SECRET.summary,
62
116
  ...(keyInTarget ? [`Then run npx @uknows/setup again. It adds the settings to ${target.file}.`] : [])] })
63
117
  }
64
- if (keyInTarget) return plan
118
+ // The settings cannot go in: the service is not set up until the key is out of the file.
119
+ if (keyInTarget) return { ...plan, stop: true }
120
+ // An Azure Functions app (stacks/functions.mjs): the settings are app settings of the function app, never a file.
121
+ if (service.functions && (mode === 'standard' || mode === 'azure-monitor')) {
122
+ const fill = (text) => text.replace(/\{count\}/g, String(pairs.length))
123
+ return { ...plan, target: null, manual: [...plan.manual, { title: FUNCTIONS.settings.title, required: true, lines: FUNCTIONS.settings.lines.map(fill),
124
+ snippets: [settingsSnippet('azure-functions', pairs, ctx.name)] }] }
125
+ }
126
+ if (target.kind === 'none') {
127
+ // No file to put the settings in: the settings go where the app runs, in the form of each platform it runs on.
128
+ const hosts = [...new Set((ctx.detection?.hosts ?? []).map((h) => h.id).filter((id) => id !== 'github-actions'))]
129
+ const snippets = (hosts.length ? hosts : ['vm-docker']).map((id) => settingsSnippet(id, pairs, ctx.name))
130
+ // The same words as the start file advice of Node.js: this tool did not pick a Dockerfile, and these are the ones it saw.
131
+ const seen = unpicked(ctx)
132
+ const why = seen.length ? fill(SETTINGS_ELSEWHERE.notPicked, { dockerfiles: list(seen.slice(0, 5)) }) : SETTINGS_ELSEWHERE.why
133
+ plan.manual.push({ title: SETTINGS_ELSEWHERE.title, lines: [why, ...SETTINGS_ELSEWHERE.steps],
134
+ snippets: snippets.filter((x, i) => snippets.findIndex((y) => y.caption === x.caption) === i) })
135
+ return plan
136
+ }
65
137
  if (target.flow) {
66
138
  plan.manual.push({ title: `Add the UKNOWS settings to ${target.file}`,
67
139
  lines: [`The env of ${target.where} is on 1 line. This tool does not change that format.`, 'Add these settings to the env of the container.'],
@@ -70,38 +142,79 @@ export function settingsPlan(service, ctx, extra = []) {
70
142
  }
71
143
 
72
144
  // Another address is kept. Then its protocol and exporters belong to that pipeline: keep them, add none.
73
- const endpoint = target.current.get(ENDPOINT)
74
- const kept = endpoint !== undefined && !(endpoint !== null && sameEndpoint(endpoint))
145
+ // Traces only: the traces go to the general address now when the traces address is not set.
146
+ const general = tracesOnly && !target.current.has(TRACES_ENDPOINT)
147
+ const keptName = general ? ENDPOINT : endpointName
148
+ const endpoint = target.current.get(keptName)
149
+ const kept = endpoint !== undefined && !(endpoint !== null && (general ? sameAs(ingest)(endpoint) : sameEndpoint(endpoint))) && !wrote(keptName, endpoint)
150
+ if (tracesOnly && !kept) {
151
+ const why = signal || otlpSet ? fill(TRACES_ONLY_PLAN.code, { where: signal ? `${signal.file} line ${signal.line}` : target.where })
152
+ : fill(TRACES_ONLY_PLAN.sdk, { lang: stackLabel(service.lang) })
153
+ plan.notes.push(`${why} ${TRACES_ONLY_PLAN.set} ${TRACES_ONLY_PLAN.key}`)
154
+ // .NET AddOtlpExporter() does not read the traces-only settings: the code must pass the address on.
155
+ const add = anchors.find((a) => a.kind === 'add-otlp-exporter')
156
+ const passed = anchors.some((a) => a.kind === 'traces-endpoint') && anchors.some((a) => a.kind === 'traces-headers')
157
+ if (service.lang === 'dotnet' && add && !anchors.some((a) => a.kind === 'use-otlp-exporter') && !passed) {
158
+ plan.manual.push({ title: 'Pass the traces address on to AddOtlpExporter', verify: true, lines: [`${add.file} line ${add.line} adds an OTLP exporter with AddOtlpExporter().`,
159
+ DOTNET_TRACES_ONLY.note, 'Change the AddOtlpExporter() call of the traces as below. Then traces go to UKNOWS, and metrics and logs keep their address.'],
160
+ snippets: [{ caption: add.file, language: 'csharp', code: DOTNET_TRACES_ONLY.code }] })
161
+ }
162
+ // The key goes in the traces-only variable too (the note above), so the metrics and logs exporters never send it.
163
+ plan.manual.push({ title: TRACES_ONLY_PLAN.keyTitle, required: false, lines: [TRACES_ONLY_PLAN.key, TRACES_ONLY_PLAN.keyCheck] })
164
+ }
75
165
  if (kept) {
76
166
  plan.manual.push({ title: 'Check where this app sends data', lines: [
77
- endpoint === null ? `${target.where} sets ${ENDPOINT} from another source.` : `${target.where} sends data to ${hideUser(endpoint)}.`,
167
+ endpoint === null ? `${target.where} sets ${keptName} from another source.` : `${target.where} sends data to ${hideUser(endpoint)}.`,
78
168
  'This tool keeps that address and the other OpenTelemetry settings of the app.', COLLECTOR,
79
169
  'To send data to UKNOWS directly, set these settings instead.'],
80
- snippets: [{ caption: 'Settings for UKNOWS', language: 'text', code: pairs.filter(([name]) => NAMES.includes(name) && name !== SERVICE)
170
+ snippets: [{ caption: 'Settings for UKNOWS', language: 'text', code: pairs.filter(([name]) => ours.includes(name) && name !== SERVICE)
81
171
  .map(([name, value]) => `${name}=${value}`).join('\n') }] })
82
172
  }
83
173
  const set = []
84
174
  for (const [name, value] of pairs) {
85
175
  const current = target.current.get(name)
86
- if (current === undefined) { if (!kept || !NAMES.includes(name) || name === SERVICE) set.push([name, value]) }
87
- else if (current === value || name === ENDPOINT) continue
176
+ const stale = ours.includes(name) && name !== SERVICE && wrote(name, current)
177
+ if (current === undefined) { if (!kept || !ours.includes(name) || name === SERVICE) set.push([name, value]) }
178
+ else if (current === value || (name === endpointName && !stale)) continue
88
179
  else if (current === null) plan.notes.push(`${target.where} sets ${name} from another source. This tool keeps it.`)
89
- else if (!NAMES.includes(name) || kept) plan.notes.push(`${target.where} already sets ${name}. This tool keeps that value.`)
180
+ else if ((!ours.includes(name) || kept) && !stale) plan.notes.push(`${target.where} already sets ${name}. This tool keeps that value.`)
90
181
  else {
91
182
  if (name !== SERVICE) plan.notes.push(`${target.where} sets ${name} to ${current}. This tool changes it to ${value}.`)
92
183
  set.push([name, value])
93
184
  }
94
185
  }
95
- plan.manual.push(...overrides(service, target, pairs, ingest))
186
+ plan.manual.push(...overrides(service, target, pairs, ingest, { ours, tracesOnly }))
96
187
  if (!set.length) return plan
97
- const added = set.every(([name]) => target.current.get(name) === undefined) && NAMES.every((n) => set.some(([name]) => name === n))
98
- const extras = set.map(([name]) => name).filter((name) => !NAMES.includes(name))
99
- plan.summary.push(added ? `Add the 5 UKNOWS settings${extras.length ? ` and ${list(extras)}` : ''} to ${target.where}.`
188
+ const added = set.every(([name]) => target.current.get(name) === undefined) && ours.every((n) => set.some(([name]) => name === n))
189
+ const extras = set.map(([name]) => name).filter((name) => !ours.includes(name))
190
+ plan.summary.push(added ? `Add the ${ours.length} UKNOWS settings${extras.length ? ` and ${list(extras)}` : ''} to ${target.where}.`
100
191
  : `Set ${list(set.map(([name]) => name))} in ${target.where}.`)
101
192
  plan.edits.push({ file: target.file, before: target.before, after: target.write(set), why: added ? 'Add the UKNOWS settings.' : 'Set the UKNOWS settings.' })
102
193
  return plan
103
194
  }
104
195
 
196
+ /** Languages whose OpenTelemetry SDKs send metrics and logs over OTLP by default. */
197
+ const SDK_SIGNALS = new Set(['node', 'python', 'java'])
198
+
199
+ /**
200
+ * True when an earlier setup run wrote name=value in this file: a change in .uknows/setup.json added that line (an
201
+ * ENV or env-file line, or a Kubernetes name: and value: pair), or created the file (text, as it is now) and it is
202
+ * unchanged since.
203
+ * @param {{changes: {file: string, created: boolean, afterSha256?: string, hunks: {after: string[]}[]}[]}|null} manifest
204
+ */
205
+ export function setupWrote(manifest, file, name, value, text = null) {
206
+ if (!manifest || !file) return false
207
+ const esc = (t) => t.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
208
+ const line = new RegExp(`(?:^|\\s)${esc(name)}=["']?${esc(value)}["']?(?:\\s|\\\\|$)`)
209
+ const yaml = new RegExp(`^\\s*-?\\s*name:\\s*["']?${esc(name)}["']?\\s*$`)
210
+ const yamlValue = new RegExp(`^\\s*value:\\s*["']?${esc(value)}["']?\\s*$`)
211
+ const lines = (list) => list.map((l) => l.replace(/\r?\n$/, ''))
212
+ const has = (list) => list.some((l, i) => line.test(l) || (yaml.test(l) && yamlValue.test(list[i + 1] ?? '')))
213
+ const same = (sha) => text !== null && [text, BOM + text].some((t) => sha256(Buffer.from(t, 'utf8')) === sha)
214
+ return manifest.changes.some((c) => c.file === file && (c.created ? same(c.afterSha256) && has(text.split(/\r?\n/))
215
+ : c.hunks.some((h) => has(lines(h.after)))))
216
+ }
217
+
105
218
  /**
106
219
  * True when the text sets OTEL_EXPORTER_OTLP_HEADERS to something that is not a placeholder (in any env format,
107
220
  * Kubernetes flow style too), or holds x-uknows-key=<a real value> anywhere. Never returns the value.
@@ -128,7 +241,7 @@ export const redactKey = (line) => line.replace(KEY_HEADER, (m, value) => m.slic
128
241
  * Manual steps for settings that keep traces from UKNOWS: an overrideRules() value where the settings go, and a
129
242
  * Kubernetes container env that replaces the image values with something else.
130
243
  */
131
- function overrides(service, target, pairs, endpoint) {
244
+ function overrides(service, target, pairs, endpoint, { ours = NAMES, tracesOnly = false } = {}) {
132
245
  const sameEndpoint = sameAs(endpoint)
133
246
  const sources = [{ where: target.where, file: target.file, env: target.current }]
134
247
  if (target.kind !== 'k8s') {
@@ -139,18 +252,20 @@ function overrides(service, target, pairs, endpoint) {
139
252
  const blocks = []
140
253
  for (const { where, file, env, k8s } of sources) {
141
254
  for (const [name, bad, fix] of overrideRules(endpoint)) {
255
+ // Traces-only settings that this tool sets itself are not overrides (another address is kept above).
256
+ if (tracesOnly && ours.includes(name) && !k8s) continue
142
257
  if (!env.has(name) || (env.get(name) !== null && !bad(env.get(name)))) continue
143
258
  const value = env.get(name)
144
259
  const shown = value === null ? ' from another source' : /HEADERS/.test(name) ? '' : ` to ${hideUser(value)}`
145
- blocks.push({ title: `Check ${name}`, lines: [`${where} sets ${name}${shown}.`,
260
+ blocks.push({ title: `Check ${name}`, verify: true, lines: [`${where} sets ${name}${shown}.`,
146
261
  name === 'OTEL_SDK_DISABLED' ? 'That turns OpenTelemetry off.' : 'It wins over the UKNOWS settings, so traces would not reach UKNOWS.', fix] })
147
262
  }
148
263
  if (!k8s) continue
149
264
  // The container env replaces the ENV of the image for the same names.
150
265
  const differs = (name, value, current) => current === null || (current !== value && !(name === ENDPOINT && sameEndpoint(current)))
151
- const differ = pairs.filter(([name, value]) => NAMES.includes(name) && env.has(name) && differs(name, value, env.get(name)))
266
+ const differ = pairs.filter(([name, value]) => ours.includes(name) && env.has(name) && differs(name, value, env.get(name)))
152
267
  if (differ.length) {
153
- blocks.push({ title: `Check the UKNOWS settings in ${file}`, lines: [
268
+ blocks.push({ title: `Check the UKNOWS settings in ${file}`, verify: true, lines: [
154
269
  `${where} sets ${list(differ.map(([name]) => name))}. The container env replaces the values in ${target.file}.`,
155
270
  'Set them to the values below there, or remove them.'],
156
271
  snippets: [{ caption: file, language: 'text', code: differ.map(([name, value]) => `${name}=${value}`).join('\n') }] })
@@ -167,6 +282,71 @@ function dockerTarget(ws, file) {
167
282
  return { kind: 'dockerfile', file, where: file, before, current, write: (set) => setEnv(before, set) }
168
283
  }
169
284
 
285
+ /** Repo files that can load otel.env when the app starts. */
286
+ const LOADER_FILE = /\.(?:service|ya?ml|sh|bash|ps1)$|(?:^|\/)(?:Makefile|Procfile|package\.json|justfile)$/
287
+ const COMPOSE_FILE = /(?:^|\/)(?:docker-)?compose(?:\.[^/]+)?\.ya?ml$/
288
+ /** [regex, files]: the group is the path of the env file. systemd EnvironmentFile=, --env-file of docker run or node, and . or source in a shell. */
289
+ const LOADERS = [
290
+ [/^[ \t]*EnvironmentFile[ \t]*=[ \t]*-?["']?([^\s"']*otel\.env)(?=["'\s]|$)/m, /\.service$/],
291
+ [/--env-file(?:[ \t]+|=)["']?([^\s"';&|)]*otel\.env)(?=["'\s;&|)]|$)/m, LOADER_FILE],
292
+ [/(?:^|[\s;&|(`])(?:\.|source)[ \t]+["']?([^\s"';&|)]*otel\.env)(?=["'\s;&|)]|$)/m, LOADER_FILE],
293
+ ]
294
+
295
+ /**
296
+ * otel.env as the target when the repo has it or a repo file loads it. Else a target of kind none: there is no
297
+ * file that the app surely reads, so the settings become a required step.
298
+ */
299
+ function envFileTarget(ws, view, service, file) {
300
+ if (ws.read(file) !== null) return envTarget(ws, file)
301
+ const loader = findLoader(view, service, file)
302
+ return loader ? { ...envTarget(ws, file), loader } : { kind: 'none', file: null, where: null, before: null, current: new Map() }
303
+ }
304
+
305
+ /**
306
+ * The first repo file that loads this otel.env: {file, line}. A relative path counts when it is the otel.env of the
307
+ * service from the folder of that file, the service folder or the repo root. A full path or a path with a variable
308
+ * names a file on the host, so it counts for every service.
309
+ */
310
+ function findLoader(view, service, envFile) {
311
+ let files = []
312
+ try { files = view.files() } catch { /* unreadable */ }
313
+ const ours = (from, path) => /^[/~$]/.test(path) || path.includes('$')
314
+ || [posix.dirname(from), service.dir, '.'].some((dir) => posix.normalize(at(dir, path.replace(/^\.\//, ''))) === envFile)
315
+ for (const from of files) {
316
+ if (from.startsWith('../') || !(LOADER_FILE.test(from) || COMPOSE_FILE.test(from))) continue
317
+ let text = null
318
+ try { text = (view.stat?.(from)?.size ?? 0) <= 262144 ? view.read(from) : null } catch { text = null }
319
+ if (!text?.includes(SETTINGS_FILE.path)) continue
320
+ const code = text.replace(/^[ \t]*#.*$/gm, '')
321
+ const hits = [...(COMPOSE_FILE.test(from) ? composeEnvFiles(code) : []),
322
+ ...LOADERS.filter(([, re]) => re.test(from)).flatMap(([re]) => [...code.matchAll(new RegExp(re.source, 'gm'))]
323
+ .map((m) => ({ path: m[1], line: code.slice(0, m.index + m[0].lastIndexOf(m[1])).split('\n').length })))]
324
+ const hit = hits.find((h) => ours(from, h.path))
325
+ if (hit) return { file: from, line: hit.line }
326
+ }
327
+ return null
328
+ }
329
+
330
+ /** The env files of a Compose file: env_file: with 1 path, a [list], or list items below it (- path or - path: path). */
331
+ function composeEnvFiles(text) {
332
+ const lines = text.split(/\r?\n/), out = []
333
+ const paths = (value, line) => value.replace(/\s#.*$/, '').replace(/^\[|\]$/g, '').split(',')
334
+ .map((v) => v.trim().replace(/^-\s*/, '').replace(/^path\s*:\s*/, '').replace(/^["']|["']$/g, '')).filter((v) => v.endsWith(SETTINGS_FILE.path))
335
+ .forEach((path) => out.push({ path, line }))
336
+ lines.forEach((line, i) => {
337
+ const m = /^(\s*)env_file\s*:\s*(.*)$/.exec(line)
338
+ if (!m) return
339
+ if (m[2].trim()) return paths(m[2], i + 1)
340
+ for (let j = i + 1; j < lines.length; j++) {
341
+ if (!lines[j].trim()) continue
342
+ const indent = /^\s*/.exec(lines[j])[0].length
343
+ if (indent < m[1].length || (indent === m[1].length && !/^\s*-/.test(lines[j]))) break
344
+ paths(lines[j].trim(), j + 1)
345
+ }
346
+ })
347
+ return out
348
+ }
349
+
170
350
  function envTarget(ws, file) {
171
351
  const before = ws.read(file)
172
352
  const doc = new Doc(before ?? '')
@@ -278,3 +458,11 @@ function underContainers(doc, start) {
278
458
  }
279
459
  return false
280
460
  }
461
+
462
+ /** The Dockerfiles of the repo that detection did not pick for any service: the same list for every step that names them. */
463
+ export function unpicked(ctx) {
464
+ let files = []
465
+ try { files = (ctx.repo ?? ctx.ws).files() } catch { /* unreadable */ }
466
+ const picked = new Set((ctx.detection?.services ?? []).map((s) => s.dockerfile).filter(Boolean))
467
+ return files.filter((f) => isDockerfile(f) && !picked.has(f) && !/(?:^|\/)node_modules\//.test(f))
468
+ }
package/lib/signin.mjs ADDED
@@ -0,0 +1,259 @@
1
+ /*
2
+ * Sign in from the terminal. When setup runs in a terminal and has no key, it asks UKNOWS for a sign-in link, opens
3
+ * the browser, and waits while the person signs in and picks the workspace and the environment. The key then comes
4
+ * to setup by itself: nobody creates, shows, copies or pastes it.
5
+ *
6
+ * 1. POST {app}/api/v1/cli/links {client, version, secretHash, app?}: 201 {linkId, userCode, verificationUrl,
7
+ * expiresIn, interval}. secretHash is the base64url SHA-256 of a device secret: 32 random bytes that only this
8
+ * process holds, in memory. So only this process can collect the key.
9
+ * 2. POST {app}/api/v1/cli/links/{linkId}/token {deviceSecret}, every interval seconds: 202 waiting, 200 the key,
10
+ * 403 cancelled in the browser, 410 expired, 429 slow down (Retry-After).
11
+ *
12
+ * A UKNOWS without these routes answers the links request with 404 or 405, or with 401 when its sign-in check runs
13
+ * before its routes (a test copy of UKNOWS older than this sign-in does). The links route itself needs no sign-in, so
14
+ * all 3 mean the same: this UKNOWS cannot make a sign-in link, and setup asks for the key instead. 5xx answers and
15
+ * network errors are failures: setup tries again, then says what went wrong.
16
+ *
17
+ * The key and the device secret never print and are never written anywhere. A pipeline, --yes, --json and a run
18
+ * without a terminal never start a sign-in (engine.mjs decides, before it calls signIn).
19
+ */
20
+ import { createHash, randomBytes } from 'node:crypto'
21
+ import { request } from './check.mjs'
22
+ import { CLI } from './contract.mjs'
23
+ import { VERSION } from './manifest.mjs'
24
+ import { list } from './ui.mjs'
25
+
26
+ export const LINKS_PATH = '/api/v1/cli/links'
27
+ export const CLIENT = '@uknows/setup'
28
+ /** The answers of a UKNOWS that has no links route: it cannot make a sign-in link, so the person pastes the key. */
29
+ export const NO_LINKS_STATUS = new Set([401, 404, 405])
30
+ /** Network errors and 5xx answers in a row before setup says that it cannot reach UKNOWS. */
31
+ const MAX_FAILURES = 5
32
+ const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]'])
33
+
34
+ /** The clock of a real run. Tests pass one whose time moves only when something sleeps. */
35
+ export const REAL_CLOCK = {
36
+ now: () => Date.now(),
37
+ sleep(ms) {
38
+ let timer
39
+ const done = new Promise((resolve) => { timer = setTimeout(resolve, Math.max(0, ms)) })
40
+ done.cancel = () => clearTimeout(timer)
41
+ return done
42
+ },
43
+ }
44
+
45
+ /** base64url of the SHA-256 of the device secret: what UKNOWS keeps, so only this process can collect the key. */
46
+ export const secretHash = (secret) => createHash('sha256').update(secret).digest('base64url')
47
+
48
+ /** The words of the sign-in. Exported for the tests and the README. */
49
+ export const SIGN_IN = {
50
+ opening: 'Sign in to UKNOWS to connect this app. Opening your browser...',
51
+ noBrowser: 'Sign in to UKNOWS to connect this app. Open this link in a browser:',
52
+ code: (code) => `Your code: ${code} (check the browser shows the same code)`,
53
+ keys: 'Press Enter to open the browser again, p to paste a key instead, or Ctrl+C to stop.',
54
+ waitingPlain: 'Waiting for you to sign in in the browser. The link works for {minutes}.',
55
+ waiting: 'Waiting for you to sign in... {left} left',
56
+ slow: ' (UKNOWS is slow to answer, still trying)',
57
+ signedIn: 'Signed in.',
58
+ denied: 'The sign-in was cancelled in the browser.',
59
+ expired: 'The sign-in link expired.',
60
+ noLinks: (host) => `${host} cannot make a sign-in link, so paste your key instead.`,
61
+ /** Sign-in is off: a test address is set (names: the settings that are set), but the app address of that UKNOWS is not. */
62
+ noApp: (names) => [`Setup cannot sign you in from this terminal: ${list(names)} ${names.length > 1 ? 'are' : 'is'} set, but ${CLI.appEnv} is not.`,
63
+ `To sign in, set ${CLI.appEnv} to the app address of your test copy of UKNOWS, for example ${CLI.appEnv}=https://app-test.example.com, and run setup again. For now, paste your key.`],
64
+ }
65
+
66
+ /**
67
+ * Sign in, and come back with the key. Returns one of:
68
+ * {approved: true, key, workspace: {id, name}, environment, environmentLabel, keyId, ingestEndpoint, siteUrl}
69
+ * {paste: true} the person chose to paste a key instead (p, or after a failed sign-in). pressed: p while waiting.
70
+ * {stopped: true} q after a failed sign-in, or the end of the input. interrupt: true for Ctrl+C.
71
+ * ctx: {base: the app address, app?: {name, repo, platform}, env, openUrl(url, env, say) => boolean, request?, clock?}
72
+ */
73
+ export async function signIn(ui, ctx) {
74
+ for (let round = 0; ; round++) {
75
+ const made = await makeLink(ui, ctx)
76
+ let result = made
77
+ if (made.link) result = await waitForSignIn(ui, ctx, made.link, made.deviceSecret)
78
+ if (result.approved || result.paste || result.stopped) return result
79
+ ui.print(result.why)
80
+ const next = await whatNext(ui, result.again)
81
+ if (next !== 'again') return next === 'paste' ? { paste: true } : { stopped: true }
82
+ ui.print()
83
+ }
84
+ }
85
+
86
+ /**
87
+ * POST /api/v1/cli/links. {link, deviceSecret}, or {why, again} when UKNOWS cannot be reached, or {paste} when this
88
+ * UKNOWS has no links route (401, 404 or 405).
89
+ */
90
+ async function makeLink(ui, { base, app, request: send = request, clock = REAL_CLOCK }) {
91
+ const deviceSecret = randomBytes(32).toString('base64url')
92
+ const body = { client: CLIENT, version: VERSION, secretHash: secretHash(deviceSecret), ...(app ? { app: clean(app) } : {}) }
93
+ const url = new URL(`${base}${LINKS_PATH}`)
94
+ for (let failures = 0, tries = 0; tries < 20; tries++) {
95
+ const r = await post(send, url, body)
96
+ if (r.status === 201 || r.status === 200) {
97
+ const link = readLink(r.json)
98
+ return link ? { link, deviceSecret } : { why: `${url.hostname} sent a sign-in link that setup cannot read.`, again: 'try again' }
99
+ }
100
+ if (NO_LINKS_STATUS.has(r.status)) {
101
+ ui.print(SIGN_IN.noLinks(url.hostname))
102
+ return { paste: true }
103
+ }
104
+ if (r.status === 429) { await clock.sleep(retryAfter(r.headers, clock) ?? 5000); continue }
105
+ if (r.error || r.status >= 500) {
106
+ if (++failures >= 3) return { why: unreachable(url, r), again: 'try again' }
107
+ await clock.sleep(1000 * 2 ** (failures - 1))
108
+ continue
109
+ }
110
+ return { why: `${url.hostname} did not make a sign-in link (status ${r.status}).`, again: 'try again' }
111
+ }
112
+ return { why: `${url.hostname} is busy. It asked setup to wait too many times.`, again: 'try again' }
113
+ }
114
+
115
+ /**
116
+ * Show the link and the code, open the browser, and poll until UKNOWS answers. Enter opens the browser again, p goes to
117
+ * the paste question, Ctrl+C stops. Returns {approved, ...}, {paste}, {stopped}, or {why, again} (cancelled in the
118
+ * browser, expired, or no answer from UKNOWS).
119
+ */
120
+ async function waitForSignIn(ui, ctx, link, deviceSecret) {
121
+ const { env, openUrl, request: send = request, clock = REAL_CLOCK } = ctx
122
+ const open = () => openUrl(link.verificationUrl, env, () => {})
123
+ const opened = open()
124
+ ui.print(opened ? SIGN_IN.opening : SIGN_IN.noBrowser)
125
+ ui.print(` ${ui.cyan(link.verificationUrl)}`)
126
+ ui.print(` ${SIGN_IN.code(ui.bold(link.userCode))}`)
127
+ ui.print()
128
+ const keys = ui.keys()
129
+ if (keys) {
130
+ ui.print(ui.dim(` ${SIGN_IN.keys}`))
131
+ ui.print()
132
+ } else ui.print(SIGN_IN.waitingPlain.replace('{minutes}', minutes(link.expiresIn)))
133
+ const expires = clock.now() + link.expiresIn * 1000
134
+ const url = new URL(`${new URL(ctx.base).origin}${LINKS_PATH}/${encodeURIComponent(link.linkId)}/token`)
135
+ let interval = link.interval * 1000, failures = 0, note = ''
136
+ let nextPoll = clock.now() + interval
137
+ const left = () => {
138
+ const s = Math.max(0, Math.ceil((expires - clock.now()) / 1000))
139
+ return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
140
+ }
141
+ const spin = ui.progress(() => `${SIGN_IN.waiting.replace('{left}', left())}${note}`)
142
+ let keyWait = null
143
+ const end = (result, line = null) => { spin.end(line); keys?.close(); return result }
144
+ try {
145
+ for (;;) {
146
+ const now = clock.now()
147
+ if (now >= expires) return end({ why: SIGN_IN.expired, again: 'start a new sign-in' })
148
+ if (now >= nextPoll) {
149
+ const r = await post(send, url, { deviceSecret })
150
+ if (r.status === 200) {
151
+ const data = readToken(r.json)
152
+ if (data) return end({ approved: true, ...data }, `${ui.cyan(ui.marks.done)} ${SIGN_IN.signedIn}`)
153
+ return end({ why: `${url.hostname} sent an answer that setup cannot read.`, again: 'try again' })
154
+ }
155
+ if (r.status === 403) return end({ why: SIGN_IN.denied, again: 'sign in again' })
156
+ if (r.status === 410 || r.status === 404) return end({ why: SIGN_IN.expired, again: 'start a new sign-in' })
157
+ if (r.status === 202) { failures = 0; note = ''; nextPoll = clock.now() + interval; continue }
158
+ if (r.status === 429) {
159
+ // Slow down: wait as long as UKNOWS asks, and poll less often from now on.
160
+ const wait = retryAfter(r.headers, clock)
161
+ interval = Math.min(Math.max(interval, wait ?? interval * 2), 60000)
162
+ nextPoll = clock.now() + Math.max(interval, wait ?? 0)
163
+ continue
164
+ }
165
+ if (r.error || r.status >= 500) {
166
+ if (++failures >= MAX_FAILURES) return end({ why: unreachable(url, r), again: 'try again' })
167
+ note = SIGN_IN.slow
168
+ nextPoll = clock.now() + Math.min(interval * 2 ** failures, 30000)
169
+ continue
170
+ }
171
+ return end({ why: `${url.hostname} answered the sign-in with status ${r.status}.`, again: 'try again' })
172
+ }
173
+ const sleep = clock.sleep(Math.min(nextPoll, expires) - now)
174
+ if (!keys) { await sleep; continue }
175
+ keyWait ??= keys.next()
176
+ const key = await Promise.race([sleep.then(() => undefined), keyWait])
177
+ sleep.cancel?.()
178
+ if (key === undefined) continue
179
+ keyWait = null
180
+ if (key === 'ctrl-c' || key === null) return end({ stopped: true, interrupt: true }, null)
181
+ if (key === 'p') {
182
+ // People often press Enter after p by habit. That Enter belongs to p, not to the paste question.
183
+ await Promise.race([keys.next(), new Promise((resolve) => setTimeout(resolve, 400))])
184
+ return end({ paste: true, pressed: true }, null)
185
+ }
186
+ if (key === 'enter' && !open()) ui.print(` ${ui.cyan(link.verificationUrl)}`)
187
+ }
188
+ } finally {
189
+ spin.stop()
190
+ keys?.close()
191
+ }
192
+ }
193
+
194
+ /** After a failed sign-in: again (Enter), paste (p) or stop (q, or the end of the input). */
195
+ async function whatNext(ui, again) {
196
+ for (let tries = 0; tries < 3; tries++) {
197
+ const answer = await ui.line(`Press Enter to ${again}, type p to paste a key instead, or type q to stop:`)
198
+ if (answer === null) return 'stop'
199
+ const a = answer.toLowerCase()
200
+ if (a === '') return 'again'
201
+ if (a === 'p' || a === 'paste') return 'paste'
202
+ if (a === 'q' || a === 'quit' || a === 'stop') return 'stop'
203
+ ui.print('Press Enter, or type p or q.')
204
+ }
205
+ return 'stop'
206
+ }
207
+
208
+ /** The link of a 201 answer, checked and tidied, or null. */
209
+ function readLink(json) {
210
+ if (!json || typeof json !== 'object') return null
211
+ const { linkId, userCode, verificationUrl } = json
212
+ if (typeof linkId !== 'string' || !/^[A-Za-z0-9._~-]{1,200}$/.test(linkId)) return null
213
+ if (typeof userCode !== 'string' || !/^[A-Za-z0-9-]{4,20}$/.test(userCode)) return null
214
+ let url
215
+ try { url = new URL(String(verificationUrl)) } catch { return null }
216
+ const safe = url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL.has(url.hostname))
217
+ if (!safe || url.username || url.password) return null
218
+ const number = (value, fallback, min, max) => (Number.isFinite(value) && value > 0 ? Math.min(Math.max(value, min), max) : fallback)
219
+ return { linkId, userCode, verificationUrl: url.href, expiresIn: number(json.expiresIn, 600, 30, 3600), interval: number(json.interval, 3, 0.05, 60) }
220
+ }
221
+
222
+ /** The key of a 200 answer, checked, or null. A key fits in a header: printable, no spaces, no commas. */
223
+ function readToken(json) {
224
+ if (!json || typeof json !== 'object' || typeof json.key !== 'string' || !/^[\x21-\x7e]{8,512}$/.test(json.key) || json.key.includes(',')) return null
225
+ const text = (value) => (typeof value === 'string' && value.trim() ? value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 200) : null)
226
+ const workspace = json.workspace && typeof json.workspace === 'object' ? { id: text(json.workspace.id), name: text(json.workspace.name) } : { id: null, name: null }
227
+ return { key: json.key, workspace, environment: text(json.environment), environmentLabel: text(json.environmentLabel),
228
+ keyId: text(json.keyId), ingestEndpoint: text(json.ingestEndpoint), siteUrl: text(json.siteUrl) }
229
+ }
230
+
231
+ /** Retry-After in ms: seconds, or an HTTP date. null when missing. */
232
+ function retryAfter(headers = {}, clock = REAL_CLOCK) {
233
+ const raw = String(headers?.['retry-after'] ?? '').trim()
234
+ if (!raw) return null
235
+ if (/^\d+$/.test(raw)) return Math.min(Number(raw), 120) * 1000
236
+ const at = Date.parse(raw)
237
+ return Number.isFinite(at) ? Math.min(Math.max(at - clock.now(), 0), 120000) : null
238
+ }
239
+
240
+ function unreachable(url, r) {
241
+ return r.error ? `Could not reach ${url.hostname} (${r.error.code || 'network error'}). Check your internet connection.`
242
+ : `${url.hostname} did not answer the sign-in (status ${r.status}).`
243
+ }
244
+
245
+ async function post(send, url, body) {
246
+ const r = await send(url, 'POST', { 'content-type': 'application/json', accept: 'application/json' }, JSON.stringify(body), 10000)
247
+ let json = null
248
+ try { json = r.body ? JSON.parse(r.body) : null } catch { json = null }
249
+ return { ...r, json }
250
+ }
251
+
252
+ /** The app as UKNOWS shows it in the browser: only text fields that are set. */
253
+ function clean(app) {
254
+ const out = {}
255
+ for (const key of ['name', 'repo', 'platform']) if (typeof app[key] === 'string' && app[key].trim()) out[key] = app[key].trim().slice(0, 200)
256
+ return out
257
+ }
258
+
259
+ const minutes = (seconds) => (seconds % 60 === 0 ? `${seconds / 60} ${seconds === 60 ? 'minute' : 'minutes'}` : `${Math.round(seconds)} seconds`)