@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
@@ -5,6 +5,14 @@ import { dotnet } from './dotnet.mjs'
5
5
  import { java } from './java.mjs'
6
6
  import { go, php, ruby } from './guided.mjs'
7
7
  import { otel } from './otel.mjs'
8
+ import { azure } from './azure.mjs'
9
+ import { channel } from './channel.mjs'
10
+ import { functions } from './functions.mjs'
8
11
 
9
- /** @type {Record<import('../types.mjs').StackId, import('../types.mjs').Applier>} */
10
- export const APPLIERS = { node, python, dotnet, java, go, php, ruby, otel }
12
+ /**
13
+ * azure is not a stack of the docs: the engine uses it for every service in azure-monitor mode. channel is the agent
14
+ * channel (--channel): the engine uses it for a Java, .NET or Node.js service with a Dockerfile in channel mode.
15
+ * functions: an Azure Functions app (Node.js, Python or .NET isolated) in standard mode: 1 line in host.json.
16
+ * @type {Record<import('../types.mjs').StackId|'azure'|'channel', import('../types.mjs').Applier>}
17
+ */
18
+ export const APPLIERS = { node, python, dotnet, java, go, php, ruby, otel, azure, channel, functions }
@@ -1,15 +1,16 @@
1
- // Java: add the pinned OpenTelemetry Java agent to the final image with the docs ADD line (Docker checks
2
- // the checksum) and load it with JAVA_TOOL_OPTIONS. Build files are never touched. Both lines go after
3
- // the last RUN, so build steps (mvn, gradle) never load the agent.
1
+ // Java: copy the pinned OpenTelemetry Java agent into the final image with the docs COPY --from line
2
+ // (the image digest pins the jar) and load it with JAVA_TOOL_OPTIONS. Build files are never touched.
3
+ // Both lines go after the last RUN, so build steps (mvn, gradle) never load the agent.
4
4
  import { JAVA_AGENT, STACKS } from '../contract.mjs'
5
5
  import { addOption, insertBeforeStart, parseDockerfile } from '../dockerfile.mjs'
6
6
  import { envOverrides } from './k8s.mjs'
7
7
 
8
8
  const DOCS = STACKS.find((s) => s.id === 'java')
9
- const ADD_LINE = DOCS.dockerfile.split('\n').find((l) => l.startsWith('ADD '))
10
- // Earlier /docs gave --chmod=644. BuildKit also gives that mode to the /otel folder it creates, so a
11
- // non-root USER cannot load the agent and the JVM stops. A Dockerfile with that exact line gets the new one.
12
- const OLD_ADD_LINE = ADD_LINE.replace('--chmod=755', '--chmod=644')
9
+ const COPY_LINE = DOCS.dockerfile.split('\n').find((l) => l.startsWith('COPY --from='))
10
+ // 0.1.0 wrote an ADD line with --checksum and --chmod, which need BuildKit. Earlier /docs gave --chmod=644.
11
+ // A Dockerfile with one of those exact lines gets the COPY line instead.
12
+ const ADD_LINE = `ADD --checksum=sha256:${JAVA_AGENT.sha256} --chmod=755 ${JAVA_AGENT.url} /otel/${JAVA_AGENT.file}`
13
+ const OLD_LINES = [ADD_LINE, ADD_LINE.replace('--chmod=755', '--chmod=644')]
13
14
  const AGENT_OPTION = /JAVA_TOOL_OPTIONS="([^"]+)"/.exec(DOCS.dockerfile)[1]
14
15
  const AGENT_PATH = AGENT_OPTION.replace(/^-javaagent:/, '')
15
16
  const note = (re) => DOCS.notes.find((n) => re.test(n))
@@ -35,19 +36,19 @@ export const java = {
35
36
  }
36
37
  const has = (value) => value.includes(AGENT_OPTION)
37
38
  plan.manual.push(...envOverrides(service, 'JAVA_TOOL_OPTIONS', AGENT_OPTION, has))
38
- const fixed = text.split(OLD_ADD_LINE).join(ADD_LINE)
39
- const added = fixed.includes(JAVA_AGENT.url) || fixed.includes(AGENT_PATH) ? fixed : insertBeforeStart(fixed, [ADD_LINE])
39
+ const fixed = OLD_LINES.reduce((out, line) => out.split(line).join(COPY_LINE), text)
40
+ const added = [JAVA_AGENT.url, JAVA_AGENT.image, AGENT_PATH].some((s) => fixed.includes(s)) ? fixed : insertBeforeStart(fixed, [COPY_LINE])
40
41
  const after = addOption(added, 'JAVA_TOOL_OPTIONS', AGENT_OPTION, has)
41
42
  if (after === text) return plan
42
43
  if (after === fixed) {
43
- plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Let a non-root user load the OpenTelemetry Java agent.' })
44
- plan.summary.push(`Give the agent folder in ${service.dockerfile} mode 755, so an app that runs as a non-root user can load the agent.`)
44
+ plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Copy the OpenTelemetry Java agent from the pinned image.' })
45
+ plan.summary.push(`Replace the ADD line of the agent in ${service.dockerfile} with a COPY line from the pinned OpenTelemetry image. It needs no BuildKit.`)
45
46
  return plan
46
47
  }
47
48
  plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Add the OpenTelemetry Java agent and load it when the JVM starts.' })
48
- plan.summary.push(`Add the OpenTelemetry Java agent ${JAVA_AGENT.version} to ${service.dockerfile}. Docker checks its checksum.`,
49
+ plan.summary.push(`Add the OpenTelemetry Java agent ${JAVA_AGENT.version} to ${service.dockerfile}, from the image pinned by its digest.`,
49
50
  `Load the agent when the JVM starts (JAVA_TOOL_OPTIONS in ${service.dockerfile}).`)
50
- plan.notes.push(note(/Docker 25/))
51
+ plan.notes.push(note(/digest pins/))
51
52
  if (info.env.has('JAVA_TOOL_OPTIONS') || info.inherited.has('JAVA_TOOL_OPTIONS')) {
52
53
  plan.notes.push(`${service.dockerfile} already sets JAVA_TOOL_OPTIONS. A new ENV line before the start keeps that value and adds the agent.`)
53
54
  }
@@ -1,22 +1,37 @@
1
1
  /*
2
2
  * Node.js: add @opentelemetry/api and @opentelemetry/auto-instrumentations-node
3
- * with the package manager of the repo, then load the register module when
4
- * node starts: NODE_OPTIONS in the Dockerfile, or the option right after
5
- * `node` in scripts.start.
3
+ * with the package manager of the repo, then load OpenTelemetry when node
4
+ * starts: NODE_OPTIONS in the Dockerfile, or the option right after `node` in
5
+ * scripts.start.
6
+ *
7
+ * It loads the start file uknows-otel.cjs (install.ts NODE_REGISTER_FILE), which
8
+ * this tool adds next to package.json: it stops the app cleanly on SIGTERM after
9
+ * the last data. In an image, a COPY line in the final stage puts it in the
10
+ * WORKDIR. When the build context, the WORKDIR or .dockerignore makes that
11
+ * unsure, it loads the upstream register line instead and says why.
6
12
  *
7
13
  * Safety rule: node crashes at start when the register module is missing, so
8
14
  * the start changes only when the packages surely reach the running app.
9
15
  * Package managers run with install scripts turned off, so no code of the
10
16
  * project or its dependencies runs.
11
17
  */
18
+ import { buildContext, finalWorkdir, ignoredBy } from '../context.mjs'
19
+ import { unpicked } from '../settings.mjs'
12
20
  import { Doc, shellWords } from '../doc.mjs'
13
- import { addOption, dockerStart, parseDockerfile } from '../dockerfile.mjs'
14
- import { NODE_REGISTER, PINS, STACKS } from '../contract.mjs'
21
+ import { addOption, dockerStart, insertBeforeStart, parseDockerfile } from '../dockerfile.mjs'
22
+ import { NODE_REGISTER, NODE_REGISTER_FILE, NODE_START_FILE, PINS, RESOLVE, STACKS } from '../contract.mjs'
15
23
  import { list } from '../ui.mjs'
16
24
  import { upFrom } from '../workspace.mjs'
17
25
  import { envOverrides } from './k8s.mjs'
18
26
 
19
27
  const STACK = STACKS.find((s) => s.id === 'node')
28
+ const START_FILE = NODE_REGISTER_FILE.path
29
+ const START_TEXT = `${NODE_REGISTER_FILE.content}\n`
30
+ /** The docs option for the start file. Its steps go in the advice when this tool cannot add the file. */
31
+ const SIGTERM = STACK.options.find((o) => o.title.startsWith('Stops cleanly on SIGTERM'))
32
+ /** A NODE_OPTIONS value or a start script that loads OpenTelemetry already: the upstream register or the start file. */
33
+ const loads = (value) => value.includes(NODE_REGISTER) || value.includes(START_FILE)
34
+ const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
20
35
  const LOCKS = [['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm'], ['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'],
21
36
  ['bun.lockb', 'bun'], ['bun.lock', 'bun']]
22
37
  const DEFAULT_LOCK = { npm: 'package-lock.json', pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock' }
@@ -59,10 +74,15 @@ export const node = {
59
74
  const script = typeof pkg.scripts?.start === 'string' ? pkg.scripts.start : null
60
75
  const entry = entryOf(info ? start?.argv : service.start?.argv ?? shellWords(script ?? ''), script)
61
76
  const esm = /\.m[jt]s$/.test(entry ?? '') || (pkg.type === 'module' && !/\.c[jt]s$/.test(entry ?? ''))
62
- const flag = esm ? PINS.node.esmFlag : PINS.node.requireFlag
77
+ const flag = PINS.node.flag(esm)
63
78
 
64
79
  const guided = guidedBlock(service, ws, pkg, info, entry, flag)
65
80
  if (guided) { out.manual.push(guided); return out }
81
+ // The docs settings for Node.js: only the resource detectors that ask no cloud metadata address (NETWORK.node).
82
+ for (const line of STACK.extraSettings?.lines ?? []) {
83
+ const at = line.indexOf('=')
84
+ out.extraSettings.push({ name: line.slice(0, at), value: line.slice(at + 1) })
85
+ }
66
86
  const pm = packageManager(service.dir, pkg, ctx.repo ?? ws)
67
87
  if (pm.outside) {
68
88
  out.manual.push({ title: 'Run setup from the repo root', lines: [
@@ -83,10 +103,18 @@ export const node = {
83
103
  return out
84
104
  }
85
105
  const added = wanted.filter((name) => !pkg.dependencies?.[name])
106
+ // npm here writes the lock that npm ci in the image reads: a newer npm than the image has stops the run.
107
+ const npmBlock = added.length && pm.cmd === 'npm' && pm.lock && info ? npmGuard(service, info, ctx.tools?.npm) : null
108
+ if (npmBlock) {
109
+ out.manual.push(npmBlock)
110
+ out.stop = true
111
+ return out
112
+ }
86
113
  if (added.length) {
87
114
  const lock = pm.lock ?? (pm.cmd === 'npm' ? null : join(service.dir, DEFAULT_LOCK[pm.cmd]))
88
- // No install scripts. npm with a lockfile and pnpm write only package.json and the lockfile.
89
- const flags = pm.cmd === 'npm' ? ['--ignore-scripts', pm.lock ? '--package-lock-only' : '--no-package-lock']
115
+ // No install scripts, no audit and no funding lines (they are about the app, not this change). npm with a
116
+ // lockfile and pnpm write only package.json and the lockfile.
117
+ const flags = pm.cmd === 'npm' ? ['--ignore-scripts', '--no-audit', '--no-fund', pm.lock ? '--package-lock-only' : '--no-package-lock']
90
118
  : pm.cmd === 'pnpm' ? ['--lockfile-only', ...(ws.exists(join(service.dir, 'pnpm-workspace.yaml')) ? ['-w'] : [])]
91
119
  : [pm.berry ? '--mode=skip-build' : '--ignore-scripts']
92
120
  const why = `Add ${list(added)} with ${pm.cmd}.`
@@ -96,29 +124,45 @@ export const node = {
96
124
  undo: { cmd: pm.cmd, args: [pm.cmd === 'npm' ? 'uninstall' : 'remove', ...flags, ...added] },
97
125
  touches: [pkgFile, ...(lock ? [lock] : [])] })
98
126
  out.summary.push(why)
99
- if (lockOnly) out.notes.push(`To start the app on this computer, run ${pm.cmd} install first. Your build installs the packages from ${base(lock)}.`)
127
+ if (lockOnly) out.notes.push(`${pm.cmd} updates only package.json and ${base(lock)} here, so node_modules on this computer does not have the new packages yet. Run ${pm.cmd} install before you start the app on this computer. Your build installs them from ${base(lock)}.`)
100
128
  }
101
129
 
102
130
  if (info) {
103
131
  const runsNode = start && (start.argv ?? [start.raw]).flatMap((w) => w.split(/\s+/)).some((w) => RUNS_NODE.test(base(w.replace(/^["']|["']$/g, ''))))
104
132
  if (!runsNode) out.manual.push(startBlock(flag, entry, `the start command in ${service.dockerfile}`))
105
133
  else {
106
- const has = (value) => value.includes(NODE_REGISTER)
107
- const after = addOption(docker, 'NODE_OPTIONS', flag, has)
134
+ const current = info.env.get('NODE_OPTIONS') ?? info.inherited.get('NODE_OPTIONS')
135
+ const done = Boolean(current && loads(current.value))
136
+ const place = done ? null : placeStartFile(service, ctx.repo ?? ws, info, ctx)
137
+ const option = place?.path ? PINS.node.flag(esm, place.path) : flag
138
+ let after = docker
139
+ if (place?.path) {
140
+ addStartFile(out, ws, place.file)
141
+ const copied = info.final.some((x) => (x.keyword === 'COPY' || x.keyword === 'ADD') && x.args.includes(START_FILE))
142
+ if (!copied) after = insertBeforeStart(after, [place.copy])
143
+ } else if (place) {
144
+ out.manual.push({ title: SIGTERM.title, required: false, lines: [place.why, NODE_START_FILE.fallback, ...SIGTERM.steps], snippets: SIGTERM.snippets })
145
+ }
146
+ after = addOption(after, 'NODE_OPTIONS', option, loads)
108
147
  if (after !== docker) {
109
- out.edits.push({ file: service.dockerfile, before: docker, after, why: 'Load OpenTelemetry when node starts.' })
148
+ out.edits.push({ file: service.dockerfile, before: docker, after, why: place?.path ? `Load OpenTelemetry when node starts. ${NODE_START_FILE.copy}` : 'Load OpenTelemetry when node starts.' })
110
149
  out.summary.push(`Load OpenTelemetry when node starts (NODE_OPTIONS in ${service.dockerfile}).`)
111
- if (info.env.has('NODE_OPTIONS') || info.inherited.has('NODE_OPTIONS')) {
150
+ if (current) {
112
151
  out.notes.push(`${service.dockerfile} already sets NODE_OPTIONS. A new ENV line before the start keeps that value and adds the option.`)
113
152
  }
114
153
  }
115
- out.manual.push(...envOverrides(service, 'NODE_OPTIONS', flag, has))
154
+ out.manual.push(...envOverrides(service, 'NODE_OPTIONS', option, loads))
116
155
  }
117
156
  } else if (script !== null && service.start?.kind !== 'procfile' && shellWords(script)?.[0] === 'node') {
118
- if (!script.includes(NODE_REGISTER)) {
119
- const after = editStartScript(pkgText, flag)
157
+ if (!loads(script)) {
158
+ // npm start runs the script in the package folder, so ./uknows-otel.cjs is the file next to package.json.
159
+ // The start file is only for a start that surely has it: no Dockerfile that could build this app without it, not ignored by git.
160
+ const unsure = scriptStartUnsure(service, ctx)
161
+ const after = editStartScript(pkgText, unsure ? flag : PINS.node.flag(esm, `./${START_FILE}`))
120
162
  if (after === null) out.manual.push(startBlock(flag, entry, `scripts.start in ${pkgFile}`))
121
163
  else {
164
+ if (unsure) out.manual.push({ title: SIGTERM.title, required: false, lines: [unsure, NODE_START_FILE.fallback, ...SIGTERM.steps], snippets: SIGTERM.snippets })
165
+ else addStartFile(out, ws, join(service.dir, START_FILE))
122
166
  out.edits.push({ file: pkgFile, before: pkgText, after, why: 'Load OpenTelemetry when node starts.' })
123
167
  out.summary.push(`Load OpenTelemetry when node starts (start script in ${pkgFile}).`)
124
168
  }
@@ -130,6 +174,85 @@ export const node = {
130
174
  },
131
175
  }
132
176
 
177
+ /**
178
+ * Where the start file goes in the image: {file, path, copy}, or {why} when this tool is not sure. The file sits
179
+ * next to package.json. The COPY line takes it from the build context into the WORKDIR of the final stage.
180
+ */
181
+ function placeStartFile(service, view, info, ctx = {}) {
182
+ const file = join(service.dir, START_FILE)
183
+ const values = { file, dockerfile: service.dockerfile }
184
+ const workdir = finalWorkdir(info)
185
+ if (!workdir) return { why: fill(NODE_START_FILE.noWorkdir, values) }
186
+ const context = buildContext(view, service.dockerfile, info, service.dir)
187
+ if (context === null) return { why: fill(NODE_START_FILE.noContext, values) }
188
+ if (context !== '.' && !file.startsWith(`${context}/`)) return { why: fill(NODE_START_FILE.outside, values) }
189
+ const source = context === '.' ? file : file.slice(context.length + 1)
190
+ const ignore = ignoredBy(view, context, service.dockerfile, source)
191
+ if (ignore) return { why: fill(NODE_START_FILE.ignored, { ...values, ignore }) }
192
+ // The pipeline builds from a checkout: a file that git ignores never reaches it, and the COPY line fails there.
193
+ if (!read(view, file) && ctx.ignored?.(file)) return { why: fill(NODE_START_FILE.gitignored, values) }
194
+ const dir = workdir === '/' ? '' : workdir
195
+ return { file, path: `${dir}/${START_FILE}`, copy: `COPY ${source} ${dir}/` }
196
+ }
197
+
198
+ /**
199
+ * Why a start script must not load the start file, or null when it can: git ignores the file, so a checkout lacks it,
200
+ * or the repo has a Dockerfile that this tool did not pick for any service (an image built from it would not copy the
201
+ * file, and node stops at start when a --require file is missing). The upstream register line has no such file.
202
+ */
203
+ function scriptStartUnsure(service, ctx) {
204
+ const view = ctx.repo ?? ctx.ws
205
+ const file = join(service.dir, START_FILE)
206
+ if (!read(view, file) && ctx.ignored?.(file)) return fill(NODE_START_FILE.gitignored, { file })
207
+ const others = unpicked(ctx)
208
+ return others.length ? fill(NODE_START_FILE.dockerfiles, { file, dockerfiles: list(others.slice(0, 5)) }) : null
209
+ }
210
+
211
+ /** Add the start file when it is not there. A file that is there stays as it is: it can be the user's own copy. */
212
+ function addStartFile(out, ws, file) {
213
+ const current = read(ws, file)
214
+ if (current === null) {
215
+ out.edits.push({ file, before: null, after: START_TEXT, why: fill(NODE_START_FILE.add, { file }) })
216
+ out.summary.push(fill(NODE_START_FILE.add, { file }))
217
+ } else if (current !== START_TEXT) out.notes.push(fill(NODE_START_FILE.keep, { file }))
218
+ }
219
+
220
+ /**
221
+ * A required step when npm on this computer is newer (by major version) than the npm of the image that runs npm ci:
222
+ * the npm of a RUN npm install -g npm@X line, else the npm that the node image of that stage comes with (PINS.node.npm).
223
+ * null when the versions fit or are not known.
224
+ */
225
+ export function npmGuard(service, info, have) {
226
+ const haveMajor = Number(/^(\d+)\./.exec(have ?? '')?.[1] ?? NaN)
227
+ if (!Number.isFinite(haveMajor)) return null
228
+ const runs = (stage) => stage.instructions.filter((x) => x.keyword === 'RUN')
229
+ const text = (x) => (x.argv ? x.argv.join(' ') : x.args)
230
+ let image = null, line = null, need = null
231
+ for (const stage of info.stages) {
232
+ for (const x of runs(stage)) {
233
+ const pin = /\bnpm\s+(?:install|i)\s+(?:-g|--global)\s+npm@(\d+)/.exec(text(x))
234
+ if (pin) { need = Number(pin[1]); image = `npm@${pin[1]}`; line = x.start + 1 }
235
+ }
236
+ }
237
+ if (need === null) {
238
+ const stages = info.stages, byName = new Map(stages.filter((st) => st.name).map((st) => [st.name.toLowerCase(), st]))
239
+ const stage = stages.find((st) => runs(st).some((x) => /\bnpm\s+(?:ci|install|i)\b/.test(text(x)))) ?? stages.at(-1)
240
+ if (!stage) return null
241
+ let from = stage.image
242
+ for (let hop = 0; hop < 5 && byName.has(from.toLowerCase()); hop++) from = byName.get(from.toLowerCase()).image
243
+ const m = /(?:^|\/)node:(\d+)(?:\.(\d+))?/.exec(from)
244
+ if (!m) return null
245
+ const v = [Number(m[1]), m[2] === undefined ? 99 : Number(m[2])]
246
+ need = PINS.node.npm.find(([[a, b]]) => v[0] > a || (v[0] === a && v[1] >= b))?.[1] ?? null
247
+ image = from
248
+ line = (info.instructions.find((x) => x.keyword === 'FROM' && x.args.split(/\s+/).includes(stage.image))?.start ?? 0) + 1
249
+ }
250
+ if (need === null || haveMajor <= need) return null
251
+ const values = { file: service.dockerfile, line, image, need, have }
252
+ const fill = (t) => t.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
253
+ return { title: fill(RESOLVE.npm.title), lines: RESOLVE.npm.lines.map(fill) }
254
+ }
255
+
133
256
  /** Cases where loading the register module cannot work: guided only, no packages. */
134
257
  function guidedBlock(service, ws, pkg, info, entry, flag) {
135
258
  if (service.frameworks.includes('next')) {
@@ -140,7 +263,7 @@ function guidedBlock(service, ws, pkg, info, entry, flag) {
140
263
  const install = info.final.some((x) => x.keyword === 'RUN' && /\b(npm (ci|install|i)|pnpm (install|i)|yarn)\b/.test(x.args))
141
264
  const copies = info.final.filter((x) => x.keyword === 'COPY' || x.keyword === 'ADD')
142
265
  if (!install && !copies.some((x) => /node_modules/.test(x.args) || !/\.output/.test(x.args))) {
143
- return { title: 'Put node_modules in the final image', lines: [`The final stage of ${service.dockerfile} copies only the built server, so node cannot load OpenTelemetry.`,
266
+ return { title: 'Put node_modules in the final image', verify: true, lines: [`The final stage of ${service.dockerfile} copies only the built server, so node cannot load OpenTelemetry.`,
144
267
  'Install the packages in the final stage, or copy node_modules into it.', 'Then run npx @uknows/setup again.'] }
145
268
  }
146
269
  }
@@ -160,7 +283,7 @@ function guidedBlock(service, ws, pkg, info, entry, flag) {
160
283
  * The search goes on above the --path folder, up to the top of the git repo (view.top). A workspace root or
161
284
  * its lockfile up there is `outside`: this tool cannot update it.
162
285
  */
163
- function packageManager(dir, pkg, view) {
286
+ export function packageManager(dir, pkg, view) {
164
287
  const field = typeof pkg.packageManager === 'string' ? /^(npm|pnpm|yarn|bun)@(\d+)/.exec(pkg.packageManager) : null
165
288
  let cmd = field ? field[1] : typeof pkg.packageManager === 'string' ? 'other' : null
166
289
  let lock = null, outside = null
@@ -181,7 +304,7 @@ function packageManager(dir, pkg, view) {
181
304
  }
182
305
 
183
306
  /** The JS file that node runs, from a start argv. `npm start` and friends read scripts.start. */
184
- function entryOf(argv, script) {
307
+ export function entryOf(argv, script) {
185
308
  let words = argv ?? null
186
309
  if (words && /^(npm|yarn|pnpm)$/.test(base(words[0])) && (words[1] === 'start' || (words[1] === 'run' && words[2] === 'start'))) words = shellWords(script ?? '')
187
310
  if (!words?.length) return null
@@ -1,11 +1,63 @@
1
- // The app already uses OpenTelemetry: keep its packages and start command. settings.mjs adds the 5 settings.
2
- import { STACKS } from '../contract.mjs'
1
+ // The app already uses OpenTelemetry: keep its packages and start command. settings.mjs adds the 3 shared
2
+ // settings (no none lines, so the metrics and logs of the app keep going where they go now).
3
+ import { AZURE_MONITOR, DOCS_URL, DOTNET_TRACES_ONLY, INGEST_ENDPOINT, OTHER_EXPORTERS_NOTE, STACKS, TRACES_ONLY_NOTE, TRACES_ONLY_PLAN } from '../contract.mjs'
4
+
5
+ const STACK = STACKS.find((s) => s.id === 'otel')
6
+ const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
7
+
8
+ /**
9
+ * The notes of the docs page, and after its .NET note the 2 notes that only setup prints: the pages name UKNOWS only,
10
+ * and these name products that setup finds in a repo.
11
+ */
12
+ export const OTEL_NOTES = STACK.notes.flatMap((n) => (n === DOTNET_TRACES_ONLY.note ? [n, AZURE_MONITOR.otelNote, OTHER_EXPORTERS_NOTE] : [n]))
13
+
14
+ /** The notes that name languages or features. A run keeps the parts that fit what it found. */
15
+ const NOTE = {
16
+ /** "For Python, use ... For Go, use ...": 1 sentence for each language. */
17
+ exporter: OTEL_NOTES.find((n) => /^For Python, use /.test(n)),
18
+ collector: OTEL_NOTES.find((n) => /Collector/.test(n)),
19
+ azure: AZURE_MONITOR.otelNote,
20
+ }
21
+ /** The label that each sentence of NOTE.exporter starts with. */
22
+ const EXPORTER_LANG = { python: 'Python', go: 'Go' }
23
+ /** The SDKs that send metrics and logs over OTLP by default. settings.mjs uses the traces-only settings for them. */
24
+ const SDK_SIGNALS = new Set(['node', 'python', 'java'])
25
+
26
+ /**
27
+ * The notes of the docs page for this run. With no language known (the menu pick "I already use OpenTelemetry"),
28
+ * every note stays. Else only the notes for this language and for what the repo uses:
29
+ * - the exporter sentence of this language only (Python, Go),
30
+ * - the traces-only note only when settings.mjs did not already say what this run does with traces only,
31
+ * and in a form for this language,
32
+ * - the .NET note only for .NET, and only when this run does not use the traces-only settings,
33
+ * - the Azure Monitor note only when the code calls Azure Monitor,
34
+ * - the other-exporters note never: existing.mjs gives it as advice when the repo has another exporter.
35
+ * @param {string[]} notes
36
+ * @param {{lang?: string|null, existing?: Partial<import('../types.mjs').Existing>, tracesOnly?: boolean, endpoint?: string, docs?: string}} found
37
+ * endpoint and docs: the ingest address and the docs page of this run.
38
+ * @returns {string[]}
39
+ */
40
+ export function otelNotes(notes, { lang = null, existing = {}, tracesOnly = false, endpoint = INGEST_ENDPOINT, docs = DOCS_URL } = {}) {
41
+ if (!lang) return notes
42
+ const anchors = existing.anchors ?? []
43
+ return notes.flatMap((note) => {
44
+ if (note === NOTE.exporter) {
45
+ const label = EXPORTER_LANG[lang]
46
+ return note.split(/(?<=\.) (?=For )/).filter((sentence) => label && sentence.startsWith(`For ${label}, `))
47
+ }
48
+ if (note === NOTE.collector) return [`${note} See ${docs}?method=manual&stack=otel#install-app.`]
49
+ if (note === TRACES_ONLY_NOTE) return tracesOnly || SDK_SIGNALS.has(lang) ? [] : [fill(TRACES_ONLY_PLAN.none, { endpoint })]
50
+ if (note === DOTNET_TRACES_ONLY.note) return lang === 'dotnet' && !tracesOnly ? [note] : []
51
+ if (note === NOTE.azure) return anchors.some((a) => a.kind === 'azure-call' || a.kind === 'appinsights-call') ? [note] : []
52
+ if (note === OTHER_EXPORTERS_NOTE) return []
53
+ return [note]
54
+ })
55
+ }
3
56
 
4
57
  /** @type {import('../types.mjs').Applier} */
5
58
  export const otel = {
6
59
  id: 'otel',
7
60
  plan() {
8
- return { summary: ['Keep your OpenTelemetry packages. Point them at UKNOWS with the 5 settings.'], edits: [], commands: [],
9
- extraSettings: [], manual: [], notes: [...STACKS.find((s) => s.id === 'otel').notes] }
61
+ return { summary: [STACK.summary], edits: [], commands: [], extraSettings: [], manual: [], notes: [...OTEL_NOTES] }
10
62
  },
11
63
  }
@@ -7,11 +7,17 @@
7
7
  *
8
8
  * Left out on purpose: openai-v2 and vertexai (not pinned to 0.66b1),
9
9
  * psutil -> system-metrics (metrics are off) and the upstream quirk that maps
10
- * pydantic to tortoiseorm.
10
+ * pydantic to tortoiseorm. The default logging package comes only with the UKNOWS
11
+ * log bridge (logs: true). On its own, in 0.66b1, it adds an OpenTelemetry
12
+ * LoggingHandler to the root logger, also with OTEL_LOGS_EXPORTER=none. Then Python
13
+ * never uses its last-resort handler, so the warnings and errors of loggers with no
14
+ * handler no longer reach stderr. With the bridge, OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION
15
+ * =false keeps it off the root logger and the package gives only its LoggingHandler.
11
16
  */
17
+ import { compareVersion } from '../versions.mjs'
12
18
 
13
19
  /** Always printed. */
14
- export const DEFAULTS = ['asyncio', 'dbapi', 'exceptions', 'logging', 'sqlite3', 'threading', 'urllib', 'wsgi']
20
+ export const DEFAULTS = ['asyncio', 'dbapi', 'exceptions', 'sqlite3', 'threading', 'urllib', 'wsgi']
15
21
 
16
22
  /** [normalized library name, instrumentation suffixes], in upstream order. */
17
23
  export const LIBRARIES = [
@@ -29,6 +35,15 @@ export const LIBRARIES = [
29
35
  ['tortoise-orm', ['tortoiseorm']], ['urllib3', ['urllib3']],
30
36
  ]
31
37
 
38
+ /**
39
+ * The first contrib release of each instrumentation package on PyPI (tools/check-python-sets.mjs). A set older than
40
+ * this leaves the package out, because it does not exist there. Packages missing here are older than every set.
41
+ */
42
+ export const SINCE = { exceptions: '0.64b0', structlog: '0.65b0', asyncclick: '0.53b0', pymssql: '0.51b0', click: '0.50b0',
43
+ aiokafka: '0.49b0', threading: '0.46b0', psycopg: '0.45b0', asyncio: '0.44b0', 'aiohttp-server': '0.42b0', cassandra: '0.41b0',
44
+ mysqlclient: '0.40b0', tortoiseorm: '0.36b0', 'aio-pika': '0.32b0', boto3sqs: '0.32b0', 'confluent-kafka': '0.32b0',
45
+ remoulade: '0.32b0', 'kafka-python': '0.28b0', pika: '0.25b0', httpx: '0.23b0', urllib3: '0.20b0', urllib: '0.17b0' }
46
+
32
47
  /** Packages that a framework always installs, so upstream would see them too. */
33
48
  export const IMPLIED = { fastapi: ['starlette'], django: ['asgiref'], flask: ['jinja2'], requests: ['urllib3'], boto3: ['botocore'] }
34
49
 
@@ -37,15 +52,20 @@ export const normalize = (name) => name.toLowerCase().replace(/[-_.]+/g, '-')
37
52
 
38
53
  /**
39
54
  * The instrumentation packages for these requirement names: the defaults,
40
- * then 1 per matching library in table order, without repeats.
55
+ * then 1 per matching library in table order, without repeats. Packages that
56
+ * are newer than version (SINCE) are left out.
41
57
  * @param {Iterable<string>} names normalized requirement names
42
- * @param {string} version e.g. '0.66b1'
58
+ * @param {string} version the contrib release of the set, e.g. '0.66b1'
59
+ * @param {{floor?: string|null, logs?: boolean}} [options] floor: write '>=' limits from this release (or the first
60
+ * release of the package, when it is newer) instead of '==' pins, for uv and Poetry projects. logs: add the logging
61
+ * package, for the UKNOWS log bridge
43
62
  * @returns {string[]} e.g. ['opentelemetry-instrumentation-asyncio==0.66b1', ...]
44
63
  */
45
- export function bootstrap(names, version) {
64
+ export function bootstrap(names, version, { floor = null, logs = false } = {}) {
46
65
  const have = new Set(names)
47
66
  for (const name of [...have]) for (const extra of IMPLIED[name] ?? []) have.add(extra)
48
- const suffixes = [...DEFAULTS]
67
+ const suffixes = [...DEFAULTS, ...(logs ? ['logging'] : [])]
49
68
  for (const [library, list] of LIBRARIES) if (have.has(library)) suffixes.push(...list)
50
- return [...new Set(suffixes)].map((x) => `opentelemetry-instrumentation-${x}==${version}`)
69
+ return [...new Set(suffixes)].filter((x) => !SINCE[x] || compareVersion(SINCE[x], version) <= 0)
70
+ .map((x) => `opentelemetry-instrumentation-${x}${floor ? `>=${[floor, SINCE[x] ?? floor].sort(compareVersion).at(-1)}` : `==${version}`}`)
51
71
  }
@@ -0,0 +1,105 @@
1
+ /*
2
+ * The lock refresh of uv and Poetry projects (uv lock in the workspace root, poetry lock), shared by the Python and
3
+ * the Azure Monitor appliers. The tool on this computer writes the lock, and the image reads it, so the run stops
4
+ * with a required step when:
5
+ * - the lock is above the --path folder (the command would write outside it),
6
+ * - the tool is missing (the engine probed it: ctx.tools[tool] === null),
7
+ * - it does not meet [tool.uv] required-version (or uv.toml), [tool.poetry] requires-poetry, or the version that
8
+ * the Dockerfile pins (COPY --from=ghcr.io/astral-sh/uv:0.11.29, pip install uv==X, the astral.sh/uv/<version>/install.sh
9
+ * installer with ENV UV_VERSION, POETRY_VERSION).
10
+ * After the run, the engine rolls back when the lockfile format line changed (keep): version = N in uv.lock,
11
+ * lock-version in poetry.lock. An older tool in the image cannot read a newer format.
12
+ */
13
+ import { posix } from 'node:path'
14
+ import { RESOLVE } from '../contract.mjs'
15
+ import { parseDockerfile } from '../dockerfile.mjs'
16
+ import { tomlString, tomlTable } from '../toml.mjs'
17
+ import { satisfies } from '../versions.mjs'
18
+
19
+ /** Lockfile name -> tool. PDM has no automatic refresh. */
20
+ export const LOCK_TOOLS = { 'uv.lock': 'uv', 'poetry.lock': 'poetry' }
21
+ const KEEP = { 'uv.lock': '^version = \\d+$', 'poetry.lock': '^lock-version = "[^"]*"$' }
22
+ const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
23
+ const read = (view, path) => { try { return view.read(path) } catch { return null } }
24
+ const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
25
+
26
+ /**
27
+ * The lock command for lockFile, or the required step that replaces it.
28
+ * @param {{view: any, lockFile: string, tools?: Record<string, string|null>, dockerfile?: string|null, docker?: string|null, packages: string}} opts
29
+ * packages: what the lock adds, for the required step ('the OpenTelemetry packages').
30
+ * @returns {{command: import('../types.mjs').Command}|{block: import('../types.mjs').Block}|null} null for a lockfile with no tool (pdm.lock).
31
+ */
32
+ export function lockStep({ view, lockFile, tools, dockerfile = null, docker = null, packages }) {
33
+ const name = posix.basename(lockFile), tool = LOCK_TOOLS[name]
34
+ if (!tool) return null
35
+ const dir = posix.dirname(lockFile)
36
+ const version = tools && Object.prototype.hasOwnProperty.call(tools, tool) ? tools[tool] : undefined
37
+ const poetry1 = tool === 'poetry' && /^1\./.test(version ?? '')
38
+ const args = poetry1 ? ['lock', '--no-update'] : ['lock']
39
+ const command = [tool, ...args].join(' ')
40
+ if (lockFile.startsWith('../')) {
41
+ return { block: { title: RESOLVE.outside.title, lines: RESOLVE.outside.lines.map((l) => fill(l, { lock: lockFile, command, dir })) } }
42
+ }
43
+ const needs = toolNeeds(view, dir, tool, dockerfile, docker)
44
+ const pinned = needs.find((n) => n.exact)
45
+ const block = (reason) => ({ title: fill(RESOLVE.tool.title, { tool, version: pinned ? ` ${pinned.version}` : '' }),
46
+ lines: [fill(RESOLVE.tool.why, { lock: lockFile, tool, packages }), reason,
47
+ fill(RESOLVE.tool.install, { tool, version: pinned ? ` ${pinned.version}` : '' }), RESOLVE.again] })
48
+ if (version === null) return { block: block(fill(RESOLVE.tool.missing, { tool })) }
49
+ if (version) {
50
+ const unmet = needs.find((n) => !satisfies(version, n.spec))
51
+ if (unmet) {
52
+ return { block: block(fill(unmet.line ? RESOLVE.tool.image : RESOLVE.tool.version,
53
+ { file: unmet.file, line: unmet.line, tool, need: unmet.version ?? unmet.spec, have: version })) }
54
+ }
55
+ }
56
+ return { command: { cwd: dir, cmd: tool, args, touches: [lockFile], added: [], kind: 'lock', undo: null,
57
+ keep: [{ file: lockFile, pattern: KEEP[name] }], why: fill(RESOLVE.update, { lock: name, command }) } }
58
+ }
59
+
60
+ /**
61
+ * The versions of the tool that the project asks for: required-version (uv) or requires-poetry in the pyproject.toml
62
+ * next to the lock, uv.toml, and the version the Dockerfile installs. exact: a pinned image or package version.
63
+ * @returns {{spec: string, version?: string, file: string, line?: number, exact: boolean}[]}
64
+ */
65
+ export function toolNeeds(view, dir, tool, dockerfile, docker) {
66
+ const out = []
67
+ const pyproject = read(view, at(dir, 'pyproject.toml'))
68
+ if (tool === 'uv') {
69
+ const spec = tomlString(tomlTable(pyproject ?? '', 'tool.uv'), 'required-version')
70
+ if (spec) out.push({ spec, file: at(dir, 'pyproject.toml'), exact: false })
71
+ const own = tomlString(read(view, at(dir, 'uv.toml')) ?? '', 'required-version')
72
+ if (own) out.push({ spec: own, file: at(dir, 'uv.toml'), exact: false })
73
+ } else {
74
+ const spec = tomlString(tomlTable(pyproject ?? '', 'tool.poetry'), 'requires-poetry')
75
+ if (spec) out.push({ spec, file: at(dir, 'pyproject.toml'), exact: false })
76
+ }
77
+ if (docker && dockerfile) {
78
+ const image = tool === 'uv' ? /\bghcr\.io\/astral-sh\/uv:(\d+(?:\.\d+){0,2})(?=[\s@-]|$)/ : null
79
+ const pip = new RegExp(`\\bpip3?\\s+install\\b[^\\n&|;]*?["']?\\b${tool}==(\\d+(?:\\.\\d+){1,2})\\b`)
80
+ const env = tool === 'poetry' ? /\bPOETRY_VERSION=["']?(\d+(?:\.\d+){1,2})\b/ : null
81
+ // The versioned installer script: astral.sh/uv/0.11.29/install.sh, or astral.sh/uv/${UV_VERSION}/install.sh with an
82
+ // ENV or ARG UV_VERSION line before it.
83
+ const script = tool === 'uv' ? /astral\.sh\/uv\/(?:\$\{?UV_VERSION\}?|(\d+(?:\.\d+){1,2}))\/install\.sh/ : null
84
+ const instructions = parseDockerfile(docker).instructions
85
+ const uvVersion = (upTo) => instructions.slice(0, upTo).filter((y) => y.keyword === 'ENV' || y.keyword === 'ARG')
86
+ .map((y) => ({ y, m: /\bUV_VERSION=["']?(\d+(?:\.\d+){1,2})\b/.exec(y.args) })).filter((e) => e.m).at(-1) ?? null
87
+ for (const [k, x] of instructions.entries()) {
88
+ const text = x.argv ? x.argv.join(' ') : x.args
89
+ let m = (image && (x.keyword === 'FROM' || x.keyword === 'COPY') && image.exec(text)) || (x.keyword === 'RUN' && pip.exec(text))
90
+ || (env && (x.keyword === 'ENV' || x.keyword === 'ARG') && env.exec(text))
91
+ let at = x
92
+ const run = !m && script && x.keyword === 'RUN' ? script.exec(text) : null
93
+ if (run) {
94
+ const found = run[1] ? null : uvVersion(k)
95
+ m = run[1] ? run : found?.m ?? null
96
+ at = found?.y ?? x
97
+ }
98
+ if (!m) continue
99
+ const v = m[1]
100
+ out.push({ spec: v.split('.').length < 3 ? `==${v}.*` : `==${v}`, version: v, file: dockerfile, line: at.start + 1, exact: true })
101
+ break
102
+ }
103
+ }
104
+ return out
105
+ }