@uknows/setup 0.0.0-stage → 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 (53) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +209 -2
  3. package/bin/uknows-setup.mjs +14 -0
  4. package/lib/automate.mjs +1299 -0
  5. package/lib/check.mjs +327 -0
  6. package/lib/ci.mjs +21 -0
  7. package/lib/cli.mjs +251 -0
  8. package/lib/cloudid.mjs +456 -0
  9. package/lib/code.mjs +147 -0
  10. package/lib/context.mjs +126 -0
  11. package/lib/contract-docs.mjs +2736 -0
  12. package/lib/contract.mjs +149 -0
  13. package/lib/detect.mjs +627 -0
  14. package/lib/diff.mjs +147 -0
  15. package/lib/doc.mjs +98 -0
  16. package/lib/dockerfile.mjs +455 -0
  17. package/lib/endpoint.mjs +81 -0
  18. package/lib/engine.mjs +1753 -0
  19. package/lib/existing.mjs +562 -0
  20. package/lib/git.mjs +100 -0
  21. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  22. package/lib/hooks/pins.mjs +28 -0
  23. package/lib/hooks/uknows-otel.cjs +82 -0
  24. package/lib/hooks/uknows_otel.py +179 -0
  25. package/lib/hosts.mjs +650 -0
  26. package/lib/locks.mjs +353 -0
  27. package/lib/manifest.mjs +132 -0
  28. package/lib/members.mjs +269 -0
  29. package/lib/plan.mjs +130 -0
  30. package/lib/settings.mjs +468 -0
  31. package/lib/signin.mjs +259 -0
  32. package/lib/stacks/azure.mjs +345 -0
  33. package/lib/stacks/channel.mjs +125 -0
  34. package/lib/stacks/dotnet.mjs +472 -0
  35. package/lib/stacks/functions-hooks.mjs +228 -0
  36. package/lib/stacks/functions.mjs +148 -0
  37. package/lib/stacks/guided.mjs +227 -0
  38. package/lib/stacks/index.mjs +18 -0
  39. package/lib/stacks/java.mjs +57 -0
  40. package/lib/stacks/k8s.mjs +31 -0
  41. package/lib/stacks/node.mjs +358 -0
  42. package/lib/stacks/otel.mjs +63 -0
  43. package/lib/stacks/python-bootstrap.mjs +71 -0
  44. package/lib/stacks/python-lock.mjs +105 -0
  45. package/lib/stacks/python-sets.mjs +143 -0
  46. package/lib/stacks/python.mjs +632 -0
  47. package/lib/toml.mjs +64 -0
  48. package/lib/types.mjs +208 -0
  49. package/lib/ui.mjs +225 -0
  50. package/lib/updates.mjs +398 -0
  51. package/lib/versions.mjs +115 -0
  52. package/lib/workspace.mjs +201 -0
  53. package/package.json +37 -4
@@ -0,0 +1,632 @@
1
+ /*
2
+ * Python: add opentelemetry-distro, the OTLP/HTTP exporter and the
3
+ * instrumentation packages that `opentelemetry-bootstrap -a requirements`
4
+ * would print, then start the app with opentelemetry-instrument.
5
+ *
6
+ * Versions: pip apps (requirements.txt, or a pyproject.toml with no lock) get
7
+ * exact pins from the first known-good set that fits the app (python-sets.mjs).
8
+ * uv and Poetry apps get floors, and the tool runs their own resolver (uv lock in
9
+ * the workspace root, poetry lock). The engine records the lock it rewrites as a
10
+ * text change. The set decides which instrumentation packages exist for them.
11
+ *
12
+ * Logs: the OpenTelemetry logging package on the root logger changes what the app prints (Python's last-resort output,
13
+ * logging.warning() before any setup and Flask's own handler stop: install proof, 22 Oct 2026). So it stays off the
14
+ * root logger (OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION=false) and the UKNOWS log bridge (PYTHON_LOG_BRIDGE, a folder that
15
+ * the image copies and PYTHONPATH names) sends the log records instead. Only an image whose start gets
16
+ * opentelemetry-instrument gets the bridge and the logging package. Without it the app sends no logs.
17
+ *
18
+ * Safety rule: the start command gets opentelemetry-instrument only when the
19
+ * current build surely installs the packages: a RUN line of the image installs
20
+ * the file this tool changed (uv sync --package installs only that member), or
21
+ * (without a Dockerfile) the platform installs it, and its lock has them after
22
+ * this run. Otherwise the image would fail to start, so the start goes into a
23
+ * manual step instead. A Procfile is never changed: the hosts this tool supports
24
+ * do not read it, so its line is a required step.
25
+ */
26
+ import { buildContext, ignoredBy } from '../context.mjs'
27
+ import { Doc, indentOf } from '../doc.mjs'
28
+ import { dockerStart, formatEnv, insertBeforeStart, insertStart, parseDockerfile, reachingStages } from '../dockerfile.mjs'
29
+ import { PINS, PYTHON_BRIDGE_COPY, PYTHON_BRIDGE_PATH, PYTHON_LOG_BRIDGE, RESOLVE, STACKS } from '../contract.mjs'
30
+ import { pyName, tomlString, tomlTable } from '../toml.mjs'
31
+ import { upFrom } from '../workspace.mjs'
32
+ import { startOverrides } from './k8s.mjs'
33
+ import { bootstrap, normalize } from './python-bootstrap.mjs'
34
+ import { LOCK_TOOLS, lockStep } from './python-lock.mjs'
35
+ import { directLimits, lockedLimit, pickSet, projectPython, requirement, requirementName } from './python-sets.mjs'
36
+
37
+ const STACK = STACKS.find((s) => s.id === 'python')
38
+ const INSTRUMENT = 'opentelemetry-instrument'
39
+ const RUNNERS = /^(poetry|uv|pipenv|pdm|hatch|rye)$/
40
+ const LOCKS = { 'uv.lock': 'uv lock', 'poetry.lock': 'poetry lock', 'pdm.lock': 'pdm lock' }
41
+ const SECRET = /(password|passwd|token|secret|credential|authorization|api[-_]?key|connection[-_]?string)/i
42
+ const AGAIN = 'Then run npx @uknows/setup again. It puts opentelemetry-instrument in front of the start command.'
43
+ const DISTRO = 'opentelemetry-distro', EXPORTER = 'opentelemetry-exporter-otlp-proto-http'
44
+ const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
45
+
46
+ /**
47
+ * Options of uv run and poetry run before the command: options that take a value, options that do not, and options
48
+ * that change what the command is (opentelemetry-instrument cannot go in front of a module or a script).
49
+ */
50
+ const RUN_OPTIONS = {
51
+ uv: {
52
+ value: new Set(['--extra', '--group', '--no-group', '--only-group', '--package', '--with', '--with-editable', '--with-requirements',
53
+ '--python', '-p', '--env-file', '--index', '--default-index', '--index-url', '-i', '--extra-index-url', '--find-links', '-f',
54
+ '--upgrade-package', '-P', '--reinstall-package', '--no-build-package', '--no-binary-package', '--index-strategy', '--keyring-provider',
55
+ '--resolution', '--prerelease', '--fork-strategy', '--exclude-newer', '--exclude-newer-package', '--no-sources-package', '--link-mode',
56
+ '--config-setting', '-C', '--config-settings-package', '--python-platform', '--directory', '--project', '--config-file', '--cache-dir',
57
+ '--color', '--python-preference', '--allow-insecure-host', '--refresh-package', '--no-extra']),
58
+ flag: new Set(['--no-sync', '--frozen', '--locked', '--no-dev', '--dev', '--all-extras', '--all-groups', '--no-default-groups', '--only-dev',
59
+ '--no-editable', '--exact', '--inexact', '--isolated', '--active', '--no-active', '--no-project', '--no-config', '--offline', '--no-cache', '-n',
60
+ '-q', '--quiet', '-v', '--verbose', '--native-tls', '--no-progress', '--all-packages', '--no-env-file', '--compile-bytecode',
61
+ '--no-build-isolation', '--no-build', '--no-binary', '--reinstall', '--upgrade', '-U', '--refresh', '--no-python-downloads',
62
+ '--managed-python', '--no-managed-python', '--preview', '--no-sources', '--no-install-project', '--no-install-workspace', '--no-install-local']),
63
+ refuse: new Set(['-m', '--module', '-s', '--script', '--gui-script', '-w']),
64
+ },
65
+ poetry: {
66
+ value: new Set(['-C', '--directory', '-P', '--project']),
67
+ flag: new Set(['-q', '--quiet', '-v', '-vv', '-vvv', '--verbose', '-n', '--no-interaction', '--ansi', '--no-ansi', '--no-plugins', '--no-cache']),
68
+ refuse: new Set(),
69
+ },
70
+ }
71
+
72
+ /** @type {import('../types.mjs').Applier} */
73
+ export const python = {
74
+ id: 'python',
75
+ plan(service, ctx) {
76
+ const { ws } = ctx
77
+ const view = ctx.repo ?? ws
78
+ const out = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
79
+ const docker = service.dockerfile ? read(ws, service.dockerfile) : null
80
+ const deps = pickDeps(service, ws, docker)
81
+ const lockFile = deps.kind === 'pep621' || deps.kind === 'poetry' ? findLock(view, dirOf(deps.file)) : null
82
+ const tool = lockFile ? LOCK_TOOLS[base(lockFile)] ?? null : null
83
+ const installed = ctx.installed ?? ctx.existing?.installed ?? null
84
+
85
+ // The OpenTelemetry versions: the first known-good set that fits the limits and the Python of the app.
86
+ const files = deps.file ? [deps.file, ...(tool && lockFile ? [join(dirOf(lockFile), 'pyproject.toml')] : [])] : []
87
+ const limits = [...directLimits(view, [...new Set(files)]), ...(tool ? [lockedLimit(installed, 'protobuf')].filter(Boolean) : [])]
88
+ const runtime = /^(\d+)\.(\d+)/.exec(service.runtimeVersion ?? '')?.slice(1, 3).map(Number) ?? null
89
+ const pythonVersion = (tool && projectPython(view, [...new Set(files)])) || runtime
90
+ const picked = pickSet({ limits, python: pythonVersion, tool: tool && `${tool} lock` })
91
+ if (picked.block) {
92
+ out.manual.push(picked.block)
93
+ out.stop = true
94
+ return out
95
+ }
96
+ const { set } = picked
97
+ if (picked.why) out.notes.push(picked.why)
98
+ if (!pythonVersion) out.notes.push(`Check that the app uses Python ${set.minPython.join('.')} or later. ${DISTRO} ${set.contrib} needs it.`)
99
+
100
+ // uv and Poetry: floors, and the names in the lock closure count too, as opentelemetry-bootstrap reads what is
101
+ // installed. Packages that arrive only through OpenTelemetry packages do not count, so a second run adds nothing.
102
+ const floor = tool ? PINS.python.floors : null
103
+ const app = (item) => item.direct || !item.via.length || item.via.some((v) => !v.startsWith('opentelemetry-'))
104
+ const names = new Set([...deps.names, ...(tool && installed ? [...installed.packages].filter(([, item]) => app(item)).map(([key]) => key) : [])])
105
+ // The log bridge: only where the image installs the packages and its start can get opentelemetry-instrument.
106
+ const bridge = docker !== null && deps.proven ? placeBridge(service, view, docker, ctx) : null
107
+ const wanted = [requirement(DISTRO, set.contrib, floor?.contrib), requirement(EXPORTER, set.core, floor?.core),
108
+ ...bootstrap(names, set.contrib, { floor: floor?.contrib ?? null, logs: Boolean(bridge?.copy) })]
109
+ const missing = wanted.filter((req) => !deps.names.has(requirementName(req)))
110
+ const lock = lockFile && { file: lockFile, step: tool ? lockStep({ view, lockFile, tools: ctx.tools, dockerfile: service.dockerfile, docker, packages: 'the OpenTelemetry packages' }) : null }
111
+ const ready = addPackages(deps, missing, ws, view, out, lock)
112
+ if (out.stop) return out
113
+
114
+ if (ready && deps.proven) {
115
+ wrapStart(service, ws, docker, out)
116
+ if (bridge) addBridge(service, ws, docker, bridge, out)
117
+ } else if (ready) {
118
+ out.manual.push({ title: `Install ${deps.file} in the image`, verify: true, lines: [
119
+ `${service.dockerfile} does not install ${deps.file}, so the image would not have opentelemetry-instrument. This tool did not change the start command.`,
120
+ `Install ${deps.file} in the final image, for example with ${deps.kind === 'requirements' ? `RUN pip install -r ${base(deps.file)}` : 'RUN pip install .'}.`,
121
+ AGAIN] })
122
+ }
123
+ const startText = docker !== null ? (dockerStart(parseDockerfile(docker))?.raw ?? '') : (service.start?.raw ?? procfileWeb(ws, service)?.cmd ?? '')
124
+ startNotes(startText, out)
125
+
126
+ // Django: the instrumentation reads the settings at startup, so DJANGO_SETTINGS_MODULE must be set.
127
+ // Keep a value that the app sets already. Else use the value of the module that the start command loads.
128
+ const django = deps.names.has('django') || service.frameworks.includes('django')
129
+ if (django && !alreadySet('DJANGO_SETTINGS_MODULE', service, ws)) {
130
+ const found = djangoSettings(service, ws, startText)
131
+ if (found) {
132
+ out.extraSettings.push({ name: 'DJANGO_SETTINGS_MODULE', value: found.value })
133
+ out.notes.push(`Set DJANGO_SETTINGS_MODULE to ${found.value}, the value that ${found.file} uses.`)
134
+ } else {
135
+ out.manual.push({ title: 'Set DJANGO_SETTINGS_MODULE', lines: [note(/DJANGO_SETTINGS_MODULE/),
136
+ 'This tool cannot tell which settings module the app uses in production.',
137
+ 'The module that the start command loads, or manage.py, wsgi.py and asgi.py together, must give 1 value.',
138
+ 'Add the production value with the 5 UKNOWS settings.'].filter(Boolean) })
139
+ }
140
+ }
141
+ // The settings of the docs for Python: the logging package off the root logger, and the host name of the app.
142
+ for (const line of STACK.extraSettings?.lines ?? []) out.extraSettings.push({ name: line.slice(0, line.indexOf('=')), value: line.slice(line.indexOf('=') + 1) })
143
+ return out
144
+ },
145
+ }
146
+
147
+ const BRIDGE_FILES = PYTHON_LOG_BRIDGE.files.map((file) => ({ path: file.path, text: `${file.content}\n` }))
148
+
149
+ /**
150
+ * Where the log bridge goes: {dir, copy} with the COPY line from the build context, or {why} when this tool is not sure
151
+ * that the image would have it. The folder sits next to the dependency file of the service.
152
+ */
153
+ function placeBridge(service, view, docker, ctx = {}) {
154
+ const dir = join(service.dir, PYTHON_LOG_BRIDGE.dir)
155
+ const info = parseDockerfile(docker)
156
+ const start = dockerStart(info)
157
+ // The bridge needs opentelemetry-instrument in the start, which wrapStart adds where it can.
158
+ if (!start || instrumentAt(start.argv ?? start.raw.replace(/^exec\s+/, '').split(/\s+/)) === null) {
159
+ return { why: `this tool cannot start the app with opentelemetry-instrument in ${service.dockerfile}` }
160
+ }
161
+ const context = buildContext(view, service.dockerfile, info, service.dir)
162
+ if (context === null) return { why: `this tool cannot tell the build context of ${service.dockerfile} from its COPY lines` }
163
+ if (context !== '.' && !dir.startsWith(`${context}/`)) return { why: `${dir} is outside the build context of ${service.dockerfile}` }
164
+ const source = context === '.' ? dir : dir.slice(context.length + 1)
165
+ for (const file of BRIDGE_FILES) {
166
+ const ignore = ignoredBy(view, context, service.dockerfile, `${source}/${file.path}`)
167
+ if (ignore) return { why: `${ignore} leaves ${dir} out of the build context` }
168
+ }
169
+ if (!read(view, join(dir, BRIDGE_FILES[0].path)) && ctx.ignored?.(join(dir, BRIDGE_FILES[0].path))) {
170
+ return { why: `.gitignore leaves ${dir} out of git, so the build in your pipeline would not have it` }
171
+ }
172
+ return { dir, copy: PYTHON_BRIDGE_COPY.replace(` ${PYTHON_LOG_BRIDGE.dir} `, ` ${source} `) }
173
+ }
174
+
175
+ /**
176
+ * The bridge in the repo and in the image: its 3 files (a file that is there stays as it is), the COPY line before the
177
+ * start of the final stage, and PYTHONPATH. Or, when the image would not have it, a note that the app sends no logs.
178
+ */
179
+ function addBridge(service, ws, docker, bridge, out) {
180
+ if (!bridge.copy) {
181
+ out.notes.push(`This app sends no logs to UKNOWS: ${bridge.why}. Its traces go as usual.`)
182
+ return
183
+ }
184
+ for (const file of BRIDGE_FILES) {
185
+ const path = join(bridge.dir, file.path)
186
+ const current = read(ws, path)
187
+ if (current === null) out.edits.push({ file: path, before: null, after: file.text, why: 'Send the log records of the app without changing what it prints.' })
188
+ else if (current !== file.text) out.notes.push(`${path} is there already. This tool keeps it as it is.`)
189
+ }
190
+ // The COPY line and PYTHONPATH, in 1 block before the start. PYTHONPATH keeps a value that the image has (${...} is
191
+ // left for Docker to fill in, so it is not escaped).
192
+ const earlier = out.edits.find((e) => e.file === service.dockerfile)
193
+ const now = earlier ? earlier.after : docker
194
+ const lines = now.split('\n').map((l) => l.trim())
195
+ const path = formatEnv([[PYTHON_BRIDGE_PATH.slice(0, PYTHON_BRIDGE_PATH.indexOf('=')), PYTHON_BRIDGE_PATH.slice(PYTHON_BRIDGE_PATH.indexOf('=') + 1)]],
196
+ { raw: true, escape: parseDockerfile(now).escape })
197
+ const missing = [...(lines.includes(bridge.copy) ? [] : [bridge.copy]), ...path.filter((l) => !lines.includes(l.trim()))]
198
+ const after = missing.length ? insertBeforeStart(now, missing) : now
199
+ if (after !== now) {
200
+ if (earlier) earlier.after = after
201
+ else out.edits.push({ file: service.dockerfile, before: docker, after, why: 'Copy the UKNOWS log bridge into the image.' })
202
+ }
203
+ out.summary.push(`Send the log records of the app with the UKNOWS log bridge (${bridge.dir}). What the app prints stays the same.`)
204
+ }
205
+
206
+ /**
207
+ * Add the missing packages. Returns true when the dependency list has them after this run, and its lockfile too.
208
+ * lock: the uv, Poetry or PDM lockfile of the project, and the lock step for it. A uv or Poetry lock that lacks the
209
+ * packages gets a lock command, which runs after the edits. When the tool cannot run it, nothing changes (stop).
210
+ */
211
+ function addPackages(deps, missing, ws, view, out, lock) {
212
+ const lockReady = !lock || /name\s*=\s*"opentelemetry-distro"/.test(read(view, lock.file) ?? '')
213
+ const relock = Boolean(lock) && (missing.length > 0 || !lockReady)
214
+ if (relock && lock.step?.block) {
215
+ out.manual.push(lock.step.block)
216
+ out.stop = true
217
+ return false
218
+ }
219
+ const refresh = () => {
220
+ if (!relock) return true
221
+ if (lock.step?.command) { out.commands.push(lock.step.command); return true }
222
+ out.manual.push(relockBlock(lock.file, deps.file))
223
+ return false
224
+ }
225
+ if (!missing.length) return refresh()
226
+ const snippet = { caption: 'Packages to add', language: 'text', code: missing.join('\n') }
227
+ const stop = (title, lines, code) => {
228
+ out.manual.push({ title, lines: [...lines, AGAIN], snippets: [code ? { ...snippet, code } : snippet] })
229
+ return false
230
+ }
231
+ if (!deps.file) {
232
+ return stop('Add the OpenTelemetry packages', ['This tool did not find a dependency list that it can change, such as requirements.txt.',
233
+ 'Add these packages to the file that lists the app dependencies.'])
234
+ }
235
+ if (deps.kind === 'pipfile') {
236
+ return stop('Add the OpenTelemetry packages', ['This app uses Pipenv. Add the packages with pipenv install.'],
237
+ `pipenv install ${missing.join(' ')}`)
238
+ }
239
+ const compiled = deps.kind === 'requirements' ? compiledRequirements(ws, deps.file) : null
240
+ if (compiled) return stop('Add the OpenTelemetry packages', compiled)
241
+ const before = read(ws, deps.file)
242
+ const after = deps.kind === 'requirements' ? appendLines(before, missing) : editPyproject(before, deps, missing)
243
+ if (after === null) return stop('Add the OpenTelemetry packages', [`This tool cannot change the dependency list in ${deps.file}.`, 'Add these packages to it.'])
244
+ out.edits.push({ file: deps.file, before, after, why: 'Add the OpenTelemetry packages.' })
245
+ out.summary.push(`Add ${missing.length} OpenTelemetry packages to ${deps.file}${lock?.step?.command ? `, as lower limits. ${lock.step.command.cmd} lock picks the versions` : ''}.`)
246
+ return refresh()
247
+ }
248
+
249
+ function relockBlock(lock, file) {
250
+ const name = lock.split('/').pop()
251
+ return { title: `Update ${name}`, lines: [`${file} lists the OpenTelemetry packages, but ${name} does not have them yet.`,
252
+ `Run ${LOCKS[name]} in ${dirOf(lock) === '.' ? 'the repo root' : dirOf(lock)}.`,
253
+ ...(name === 'poetry.lock' ? ['On Poetry 1, run poetry lock --no-update instead.'] : []), `Commit ${name}.`, AGAIN] }
254
+ }
255
+
256
+ /**
257
+ * Where opentelemetry-instrument goes in a start command: 0, or after uv run or poetry run and their options.
258
+ * null when it cannot go in safely (another runner, a module or script option, an option this tool does not know).
259
+ */
260
+ export function instrumentAt(words) {
261
+ if (!words?.length) return null
262
+ const runner = base(words[0])
263
+ if (!RUNNERS.test(runner) || words[1] !== 'run') return 0
264
+ const options = RUN_OPTIONS[runner]
265
+ if (!options) return null
266
+ for (let i = 2; i < words.length; i++) {
267
+ const w = words[i]
268
+ if (w === '--') return i + 1 < words.length ? i + 1 : null
269
+ if (!w.startsWith('-')) return i
270
+ const flag = w.split('=')[0]
271
+ if (options.refuse.has(flag)) return null
272
+ if (w.includes('=') && (options.value.has(flag) || options.flag.has(flag))) continue
273
+ if (options.value.has(flag)) { i++; continue }
274
+ if (!options.flag.has(flag)) return null
275
+ }
276
+ return null
277
+ }
278
+
279
+ function wrapStart(service, ws, docker, out) {
280
+ const done = (file, before, after, where) => {
281
+ out.edits.push({ file, before, after, why: 'Start the app with opentelemetry-instrument.' })
282
+ out.summary.push(`Start the app with opentelemetry-instrument (${where}).`)
283
+ }
284
+ if (docker !== null) {
285
+ const info = parseDockerfile(docker)
286
+ const start = dockerStart(info)
287
+ if (!start) { out.manual.push(startBlock(null)); return }
288
+ const words = start.argv ?? start.raw.replace(/^exec\s+/, '').split(/\s+/)
289
+ const instruction = info.entrypoint ? 'ENTRYPOINT' : 'CMD'
290
+ const overrides = startOverrides(service, instruction, INSTRUMENT)
291
+ const at = instrumentAt(words)
292
+ if (at !== null && [base(words[at]), base(words[0])].includes(INSTRUMENT)) { out.manual.push(...overrides); return }
293
+ const where = `${instruction} in ${service.dockerfile}`
294
+ // A venv that is not on PATH: use the opentelemetry-instrument next to the start program.
295
+ const word = at === 0 && /venv\/bin\/[^/]+$/.test(words[0]) ? words[0].replace(/[^/]+$/, INSTRUMENT) : INSTRUMENT
296
+ const after = at === null ? null : insertStart(docker, at, [word])
297
+ if (after === null) out.manual.push(startBlock(`the ${where}`))
298
+ else {
299
+ if (after !== docker) done(service.dockerfile, docker, after, where)
300
+ out.manual.push(...overrides)
301
+ }
302
+ return
303
+ }
304
+ const web = !service.start || service.start.kind === 'procfile' ? procfileWeb(ws, service) : null
305
+ if (!web) { out.manual.push(startBlock(null, service.start?.kind === 'answer' ? service.start.raw : null)); return }
306
+ // A Procfile is never changed. The supported hosts do not read it, so the line is a step for platforms that do.
307
+ const words = web.cmd.trim().split(/\s+/)
308
+ const at = instrumentAt(words)
309
+ if (at !== null && base(words[at]) === INSTRUMENT) return
310
+ const safe = at !== null && words[0] && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]) && !/[;|`]|&&|&\s*$/.test(web.cmd)
311
+ const lines = RESOLVE.python.procfile.lines.map((l) => fill(l, { file: web.file }))
312
+ if (!safe) {
313
+ out.manual.push({ title: RESOLVE.python.procfile.title, verify: true, lines: [lines[0], note(/in front of the command/)].filter(Boolean),
314
+ snippets: [{ caption: 'Start command', language: 'bash', code: STACK.start }] })
315
+ return
316
+ }
317
+ const line = `${web.head}${[...words.slice(0, at), INSTRUMENT, ...words.slice(at)].join(' ')}`
318
+ out.manual.push({ title: RESOLVE.python.procfile.title, verify: true, lines, snippets: [{ caption: web.file, language: 'text', code: line }] })
319
+ }
320
+
321
+ function startBlock(where, answer = null) {
322
+ const code = answer && !SECRET.test(answer) ? `${INSTRUMENT} ${answer}` : STACK.start
323
+ return { title: 'Start the app with opentelemetry-instrument',
324
+ lines: [where ? `This tool cannot change ${where} safely.` : 'This tool did not find the command that starts the app.',
325
+ note(/in front of the command/)].filter(Boolean), snippets: [{ caption: 'Start command', language: 'bash', code }] }
326
+ }
327
+
328
+ /** Docs notes for start commands that opentelemetry-instrument does not handle well. */
329
+ function startNotes(text, out) {
330
+ const words = text.split(/[\s"',[\]]+/).filter(Boolean)
331
+ const flag = words.findIndex((w) => w === '-w' || w === '--workers')
332
+ const workers = flag >= 0 ? Number(words[flag + 1]) : Number(/(?:^|\s|")(?:--workers=|-w)(\d+)/.exec(text)?.[1] ?? 1)
333
+ const dev = (words.includes('flask') && words.includes('run')) || (words.includes('runserver') && words.some((w) => base(w) === 'manage.py'))
334
+ out.notes.push(...[workers > 1 && note(/worker/), words.includes('--reload') && note(/--reload/),
335
+ dev && !words.some((w) => /^--no-?reload$/.test(w))
336
+ && 'The development server restarts the app when files change, and that stops OpenTelemetry. Use Gunicorn or uvicorn in production.',
337
+ ].filter(Boolean))
338
+ }
339
+
340
+ /**
341
+ * The dependency file the build uses, and the requirement names in it. proven: the build surely installs it.
342
+ * With a Dockerfile, only its RUN lines prove that. Without one, the platform installs the usual files.
343
+ */
344
+ function pickDeps(service, ws, docker) {
345
+ const dir = service.dir
346
+ const installs = docker === null ? null : dockerInstalls(service, docker)
347
+ const requirements = (file, proven) => ({ kind: 'requirements', file, proven, ...requirementNames(ws, file) })
348
+ const project = (proven) => {
349
+ const text = read(ws, join(dir, 'pyproject.toml'))
350
+ const parsed = text === null ? null : parsePyproject(text)
351
+ return parsed && { file: join(dir, 'pyproject.toml'), hashes: false, proven, ...parsed }
352
+ }
353
+ const pipfile = (proven) => read(ws, join(dir, 'Pipfile')) !== null && { kind: 'pipfile', file: join(dir, 'Pipfile'), names: new Set(), hashes: false, proven }
354
+ if (installs) {
355
+ const file = installs.files.find((f) => !/\.toml$/.test(f) && read(ws, f) !== null)
356
+ if (file) return requirements(file, true)
357
+ // uv sync --package <name> installs that workspace member only: the pyproject.toml of another member is not proven.
358
+ const own = tomlString(tomlTable(read(ws, join(dir, 'pyproject.toml')) ?? '', 'project'), 'name')
359
+ const member = installs.packages === null || (own !== null && installs.packages.includes(pyName(own)))
360
+ const found = (installs.project && member && project(true)) || (installs.pipfile && pipfile(true))
361
+ if (found) return found
362
+ }
363
+ // Not installed by a RUN line (or no Dockerfile): the usual files.
364
+ const platform = docker === null
365
+ if (read(ws, join(dir, 'requirements.txt')) !== null) return requirements(join(dir, 'requirements.txt'), platform)
366
+ return project(platform) || pipfile(platform) || { kind: null, file: null, names: new Set(), hashes: false, proven: false }
367
+ }
368
+
369
+ /**
370
+ * The dependency files that the image surely installs, from the RUN lines of the stages that reach the final image:
371
+ * files given to pip install -r, the project (pip install . with any flags, pip install -r pyproject.toml, poetry
372
+ * install, uv sync, pdm install, or an export of them), and the Pipfile (pipenv install). Paths are repo paths, found from the WORKDIR.
373
+ * packages: when every project install is uv sync --package <name>, the names of those workspace members (PEP 503).
374
+ * uv sync installs only that member and what it depends on. null: the whole project (or every member).
375
+ * @returns {{files: string[], project: boolean, pipfile: boolean, packages: string[]|null}}
376
+ */
377
+ export function dockerInstalls(service, docker) {
378
+ const out = { files: [], project: false, pipfile: false, packages: null }
379
+ const named = [], whole = []
380
+ let exported = false
381
+ for (const stage of reachingStages(parseDockerfile(docker))) {
382
+ let workdir = ''
383
+ for (const x of stage.instructions) {
384
+ if (x.keyword === 'WORKDIR') {
385
+ const w = x.args.replace(/^["']|["']$/g, '').replace(/\/+$/, '')
386
+ workdir = w.startsWith('/') ? w : `${workdir}/${w}`
387
+ }
388
+ if (x.keyword !== 'RUN') continue
389
+ for (const part of (x.argv ? x.argv.join(' ') : x.args).split(/&&|\|\||[;|\n]/)) {
390
+ if (/\b(?:poetry|uv|pdm)\s+export\b/.test(part)) exported = true
391
+ if (/\b(?:poetry\s+(?:install|export)|uv\s+(?:sync|export)|pdm\s+(?:install|sync|export))\b/.test(part)) {
392
+ out.project = true
393
+ const member = /\buv\s+(?:sync|export)\b.*?\s--package(?:\s+|=)["']?([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(part)
394
+ if (member && !/\s--all-packages\b/.test(part)) named.push(pyName(member[1]))
395
+ else whole.push(part)
396
+ }
397
+ if (/\bpipenv\s+(?:install|sync)\b/.test(part)) out.pipfile = true
398
+ if (!/\bpip3?\s+install\b/.test(part)) continue
399
+ const words = part.trim().split(/\s+/).map((w) => w.replace(/^["']|["']$/g, ''))
400
+ for (let k = 0; k < words.length; k++) {
401
+ const flag = /^(?:-r|--requirement)(?:=(.+))?$/.exec(words[k])
402
+ const file = flag ? flag[1] ?? words[++k] ?? '' : null
403
+ // uv pip install -r pyproject.toml installs the dependencies of the project, not a requirements file.
404
+ if (file !== null && /\.toml$/.test(file)) { out.project = true; whole.push(part) }
405
+ else if (file !== null) out.files.push(...repoPaths(service, workdir, file))
406
+ else if (/^\.\/?(?:\[[^\]]*\])?$/.test(words[k])) { out.project = true; whole.push(part) }
407
+ }
408
+ }
409
+ }
410
+ }
411
+ // An exported list (poetry export -o requirements.txt) is built from the lockfile, not from a file in the repo.
412
+ if (exported) out.files = []
413
+ if (named.length && !whole.length) out.packages = [...new Set(named)]
414
+ return out
415
+ }
416
+
417
+ /** Repo paths that a path in the image can be: relative to the service folder and to the Dockerfile folder. */
418
+ function repoPaths(service, workdir, raw) {
419
+ let path = raw.replace(/^\.\//, '')
420
+ if (!path) return []
421
+ if (path.startsWith('/')) path = workdir && path.startsWith(`${workdir}/`) ? path.slice(workdir.length + 1) : path.split('/').pop()
422
+ return [resolvePath(service.dir, path), resolvePath(dirOf(service.dockerfile), path)].filter(Boolean)
423
+ }
424
+
425
+ /**
426
+ * Why this tool must not add lines to a requirements file, as the lines to print: it uses --hash (pip then needs a
427
+ * hash for every line), a .in file next to it builds it (pip-tools), or pip-compile or uv pip compile wrote it (the
428
+ * next compile drops lines that this tool adds). null when the tool can add lines.
429
+ */
430
+ export function compiledRequirements(ws, file) {
431
+ if (requirementNames(ws, file).hashes) {
432
+ return [`${file} uses --hash, so this tool cannot add lines to it.`, 'Add these packages with the tool that writes the file, such as pip-compile --generate-hashes.']
433
+ }
434
+ const source = file.replace(/\.txt$/, '.in')
435
+ if (source !== file && read(ws, source) !== null) return [`${file} is built from ${source} by pip-tools.`, `Add these lines to ${source}.`, `Run pip-compile ${base(source)}.`]
436
+ const head = (read(ws, file) ?? '').split(/\r?\n/).slice(0, 12).join('\n')
437
+ const tool = /^#\s*This file (?:is|was) autogenerated by (pip-compile|uv)\b/m.exec(head)?.[1]
438
+ if (tool) {
439
+ const name = tool === 'uv' ? 'uv pip compile' : 'pip-compile'
440
+ return [`${name} writes ${file}, so the next ${name} run would drop lines that this tool adds.`, `Add these packages to the file that ${name} reads. Then run ${name} again.`]
441
+ }
442
+ return null
443
+ }
444
+
445
+ /** Requirement names in a requirements file, following -r includes 1 level deep. */
446
+ function requirementNames(ws, file, depth = 0) {
447
+ const text = read(ws, file) ?? ''
448
+ const names = new Set()
449
+ let hashes = /(^|\s)--hash[=\s]/.test(text)
450
+ for (const raw of text.replace(/\\\r?\n/g, ' ').split(/\r?\n/)) {
451
+ const line = raw.replace(/(^|\s)#.*$/, '').trim()
452
+ const include = /^(?:-r|--requirement)(?:\s+|=)(\S+)/.exec(line)
453
+ if (include && depth === 0) {
454
+ const path = resolvePath(dirOf(file), include[1])
455
+ const sub = path ? requirementNames(ws, path, 1) : { names: [], hashes: false }
456
+ for (const name of sub.names) names.add(name)
457
+ hashes ||= sub.hashes
458
+ } else if (line && !line.startsWith('-')) {
459
+ const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(line)
460
+ if (m) names.add(normalize(m[1]))
461
+ }
462
+ }
463
+ return { names, hashes }
464
+ }
465
+
466
+ /** The PEP 621 [project] dependencies array, else the [tool.poetry.dependencies] table. null when neither. */
467
+ export function parsePyproject(text) {
468
+ const doc = new Doc(text)
469
+ const table = (re) => {
470
+ const head = doc.find((line) => re.test(line.replace(/#.*$/, '').trim()))
471
+ if (head < 0) return null
472
+ const next = doc.find(/^\s*\[/, head + 1)
473
+ return { head, end: next < 0 ? doc.length : next }
474
+ }
475
+ const project = table(/^\[project\]$/)
476
+ const at = project ? doc.find((line, i) => i < project.end && /^\s*dependencies\s*=\s*\[/.test(line), project.head + 1) : -1
477
+ if (at >= 0) {
478
+ const array = scanArray(doc, at, doc.get(at).indexOf('[', doc.get(at).indexOf('=')))
479
+ if (!array) return null
480
+ const names = new Set(array.items.map((item) => /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(item.value)?.[1]).filter(Boolean).map(normalize))
481
+ return { kind: 'pep621', names, array, at }
482
+ }
483
+ const poetry = table(/^\[tool\.poetry\.dependencies\]$/)
484
+ if (!poetry) return null
485
+ const names = new Set()
486
+ let last = poetry.head
487
+ for (let i = poetry.head + 1; i < poetry.end; i++) {
488
+ const m = /^\s*["']?([A-Za-z0-9][A-Za-z0-9._-]*)["']?\s*=/.exec(doc.get(i))
489
+ if (m) names.add(normalize(m[1]))
490
+ if (doc.get(i).trim()) last = i
491
+ }
492
+ return { kind: 'poetry', names, head: poetry.head, last }
493
+ }
494
+
495
+ /** Items and the closing bracket of the TOML array whose '[' is at (line, col). Strings and comments are skipped. */
496
+ function scanArray(doc, line, col) {
497
+ const items = []
498
+ let depth = 0
499
+ for (let i = line, j = col; i < doc.length; i++, j = 0) {
500
+ const s = doc.get(i)
501
+ for (; j < s.length; j++) {
502
+ const c = s[j]
503
+ if (c === '#') break
504
+ if (c === '"' || c === "'") {
505
+ let k = j + 1
506
+ while (k < s.length && s[k] !== c) k += c === '"' && s[k] === '\\' ? 2 : 1
507
+ if (depth === 1) items.push({ line: i, end: k + 1, quote: c, value: s.slice(j + 1, k) })
508
+ j = k
509
+ } else if (c === '[' || c === '{') depth++
510
+ else if ((c === ']' || c === '}') && --depth === 0) return { items, close: { line: i, col: j } }
511
+ }
512
+ }
513
+ return null
514
+ }
515
+
516
+ export function editPyproject(text, deps, missing) {
517
+ const doc = new Doc(text)
518
+ if (deps.kind === 'poetry') {
519
+ const indent = deps.last > deps.head ? indentOf(doc.get(deps.last)) : ''
520
+ // Poetry writes an exact version as "1.2" and a limit as ">=1.2".
521
+ const entry = (req) => {
522
+ const [, name, op, version] = /^([A-Za-z0-9][A-Za-z0-9._-]*)\s*(==|>=|<=|~=|!=|>|<)?\s*(.*)$/.exec(req)
523
+ return `${indent}${name} = "${!op || op === '==' ? version || '*' : `${op}${version}`}"`
524
+ }
525
+ doc.insert(deps.last + 1, ...missing.map(entry))
526
+ return doc.toString()
527
+ }
528
+ const { items, close } = deps.array
529
+ const last = items[items.length - 1]
530
+ const q = (req) => `${last?.quote ?? '"'}${req}${last?.quote ?? '"'}`
531
+ const closeLine = doc.get(close.line)
532
+ // Text between the last item and the closing bracket, without comments: does it hold a trailing comma?
533
+ const between = []
534
+ for (let i = last ? last.line : close.line; i <= close.line; i++) {
535
+ const s = doc.get(i).slice(last && i === last.line ? last.end : 0, i === close.line ? close.col : undefined)
536
+ between.push(s.replace(/#.*$/, ''))
537
+ }
538
+ const trailing = between.join(' ').includes(',')
539
+ if (close.line > deps.at && !closeLine.slice(0, close.col).trim()) {
540
+ // Multi-line array: 1 item per line, before the closing bracket, in the same style.
541
+ const indent = last && last.line > deps.at ? indentOf(doc.get(last.line)) : `${indentOf(closeLine)} `
542
+ if (last && !trailing) doc.set(last.line, doc.get(last.line).slice(0, last.end) + ',' + doc.get(last.line).slice(last.end))
543
+ doc.insert(close.line, ...missing.map((req, k) => `${indent}${q(req)}${trailing || !last || k < missing.length - 1 ? ',' : ''}`))
544
+ } else {
545
+ // Inline array: append inside the brackets.
546
+ const head = closeLine.slice(0, close.col)
547
+ const lead = !items.length ? head : `${head.replace(/\s+$/, '')}${trailing ? ' ' : ', '}`
548
+ doc.set(close.line, lead + missing.map(q).join(', ') + closeLine.slice(close.col))
549
+ }
550
+ return doc.toString()
551
+ }
552
+
553
+ export function appendLines(text, lines) {
554
+ const eol = new Doc(text).eol
555
+ return (text === '' || text.endsWith('\n') ? text : text + eol) + lines.map((line) => line + eol).join('')
556
+ }
557
+
558
+ /** The nearest uv, Poetry or PDM lockfile, up to the top of the git repo (above the --path folder too). */
559
+ export function findLock(view, dir) {
560
+ for (const d of upFrom(dir, view.top ?? '.')) {
561
+ for (const name of Object.keys(LOCKS)) if (read(view, join(d, name)) !== null) return join(d, name)
562
+ }
563
+ return null
564
+ }
565
+
566
+ /** The web: line of the service Procfile. */
567
+ function procfileWeb(ws, service) {
568
+ const file = service.start?.kind === 'procfile' && service.start.file ? service.start.file : join(service.dir, 'Procfile')
569
+ const text = read(ws, file)
570
+ if (text === null) return null
571
+ const doc = new Doc(text)
572
+ const index = doc.find(/^web:/)
573
+ if (index < 0) return null
574
+ const m = /^(web:\s*)(.*)$/.exec(doc.get(index))
575
+ return { file, text, index, head: m[1], cmd: m[2] }
576
+ }
577
+
578
+ /**
579
+ * The settings module that production picks: the setdefault in the wsgi or asgi module that the start command
580
+ * loads (gunicorn config.wsgi, uvicorn config.asgi:application, --wsgi-file config/wsgi.py), or in manage.py for
581
+ * runserver. When the start shows none, manage.py, wsgi.py and asgi.py must agree. null when unclear.
582
+ * @returns {{value: string, file: string}|null}
583
+ */
584
+ function djangoSettings(service, ws, startText) {
585
+ const files = ws.files(service.dir)
586
+ const valueIn = (file) => /DJANGO_SETTINGS_MODULE["']\s*,\s*["']([\w.]+)["']/.exec(read(ws, file) ?? '')?.[1] ?? null
587
+ const found = (file) => { const value = file && valueIn(file); return value ? { value, file } : null }
588
+ const path = /(?:^|[\s"'=,[])(\/?(?:[\w.-]+\/)*)(wsgi|asgi)\.py(?=$|[\s"',\]])/.exec(startText)
589
+ const dotted = /(?:^|[\s"'=,[])((?:[A-Za-z_]\w*\.)*)(wsgi|asgi)(?::[\w.]+)?(?=$|[\s"',\]])/.exec(startText)
590
+ const entry = path ? `${path[1]}${path[2]}.py` : dotted ? `${dotted[1].replace(/\./g, '/')}${dotted[2]}.py` : null
591
+ if (entry) {
592
+ // The module path can start with folders of the image (/app/config/wsgi.py): match the longest tail that names 1 file.
593
+ const parts = entry.split('/').filter(Boolean)
594
+ for (let k = 0; k <= parts.length - Math.min(2, parts.length); k++) {
595
+ const tail = parts.slice(k).join('/')
596
+ const hits = files.filter((f) => f === join(service.dir, tail) || f.endsWith(`/${tail}`))
597
+ const exact = hits.find((f) => f === join(service.dir, tail))
598
+ if (exact || hits.length === 1) return found(exact ?? hits[0])
599
+ }
600
+ return null
601
+ }
602
+ if (/\bmanage\.py\b/.test(startText)) return found(join(service.dir, 'manage.py'))
603
+ const all = files.filter((f) => /(?:^|\/)(?:manage|wsgi|asgi)\.py$/.test(f)).map(found).filter(Boolean)
604
+ return all.length && all.every((x) => x.value === all[0].value) ? all.find((x) => /wsgi|asgi/.test(x.file)) ?? all[0] : null
605
+ }
606
+
607
+ /** The setting is set already where the app keeps its settings. */
608
+ function alreadySet(name, service, ws) {
609
+ const docker = service.dockerfile ? read(ws, service.dockerfile) : null
610
+ const info = docker === null ? null : parseDockerfile(docker)
611
+ if (info && (info.env.has(name) || info.inherited.has(name))) return true
612
+ if (new RegExp(`^\\s*(export\\s+)?${name}=`, 'm').test(read(ws, join(service.dir, 'otel.env')) ?? '')) return true
613
+ return service.k8s.some((k) => new RegExp(`name:\\s*["']?${name}\\b`).test(read(ws, k.file) ?? ''))
614
+ }
615
+
616
+ const note = (re) => STACK.notes.find((text) => re.test(text)) ?? null
617
+ const base = (word) => (word ?? '').split('/').pop()
618
+ const join = (dir, file) => (dir === '.' ? file : `${dir}/${file}`)
619
+ const dirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
620
+
621
+ function read(ws, path) {
622
+ try { return ws.read(path) } catch { return null }
623
+ }
624
+
625
+ /** dir + a relative path, with . and .. resolved. null when it leaves the repo. */
626
+ function resolvePath(dir, rel) {
627
+ const parts = dir === '.' ? [] : dir.split('/')
628
+ for (const part of rel.split('/')) {
629
+ if (part === '..') { if (!parts.length) return null; parts.pop() } else if (part && part !== '.') parts.push(part)
630
+ }
631
+ return parts.length ? parts.join('/') : null
632
+ }