@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
@@ -0,0 +1,143 @@
1
+ /*
2
+ * Which OpenTelemetry set (PINS.python.sets, newest first) a Python app gets. The first set that fits the direct
3
+ * limits of the app (protobuf and opentelemetry-* in its dependency files) and its Python version wins. pip apps get
4
+ * exact pins from that set. uv and Poetry apps get floors, and the set only decides which instrumentation packages
5
+ * exist for them (bootstrap SINCE). The plan says why when it is not the newest set.
6
+ */
7
+ import { PINS, RESOLVE } from '../contract.mjs'
8
+ import { tomlArray, tomlEntries, tomlTable } from '../toml.mjs'
9
+ import { clauses, compareVersion, lowestRelease, overlaps, satisfies } from '../versions.mjs'
10
+ import { normalize } from './python-bootstrap.mjs'
11
+
12
+ /** opentelemetry-* packages on the core release line (1.x), and on the contrib line (0.xb). Other names are ignored. */
13
+ const CORE = /^opentelemetry-(?:api|sdk|proto|exporter-otlp(?:-proto-(?:http|grpc|common))?)$/
14
+ const CONTRIB = /^opentelemetry-(?:distro|instrumentation(?:-[\w-]+)?|semantic-conventions|util-http)$/
15
+ const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
16
+ const read = (ws, path) => { try { return ws.read(path) } catch { return null } }
17
+
18
+ /**
19
+ * @typedef {{name: string, spec: string, style: 'python'|'poetry', file: string, line: number, value: string}} Limit
20
+ */
21
+
22
+ /**
23
+ * The direct limits that decide the set: protobuf and opentelemetry-* entries with a version limit, in requirements
24
+ * files (1 level of -r), [project] dependencies and [tool.poetry.dependencies] of these pyproject.toml files.
25
+ * @param {any} ws
26
+ * @param {string[]} files dependency files: requirements*.txt or pyproject.toml
27
+ * @returns {Limit[]}
28
+ */
29
+ export function directLimits(ws, files) {
30
+ const out = []
31
+ const relevant = (name) => name === 'protobuf' || CORE.test(name) || CONTRIB.test(name)
32
+ const seen = new Set()
33
+ const fromRequirements = (file, depth) => {
34
+ if (seen.has(file)) return
35
+ seen.add(file)
36
+ const text = read(ws, file)
37
+ if (text === null) return
38
+ text.split(/\r?\n/).forEach((raw, i) => {
39
+ const line = raw.replace(/(^|\s)#.*$/, '').trim()
40
+ const include = /^(?:-r|--requirement)(?:\s+|=)(\S+)/.exec(line)
41
+ if (include && depth === 0) return fromRequirements(join(dirOf(file), include[1]), 1)
42
+ const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*([<>=!~][^;]*)?/.exec(line)
43
+ if (m?.[2] && relevant(normalize(m[1]))) out.push({ name: normalize(m[1]), spec: m[2].trim(), style: 'python', file, line: i + 1, value: line.split(';')[0].trim() })
44
+ })
45
+ }
46
+ for (const file of files) {
47
+ if (/\.txt$/.test(file)) { fromRequirements(file, 0); continue }
48
+ const text = read(ws, file)
49
+ if (text === null) continue
50
+ const lines = text.split(/\r?\n/)
51
+ for (const req of tomlArray(tomlTable(text, 'project'), 'dependencies')) {
52
+ const m = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*([<>=!~][^;]*)?/.exec(req)
53
+ if (!m?.[2] || !relevant(normalize(m[1]))) continue
54
+ const line = lines.findIndex((l) => l.includes(req)) + 1
55
+ out.push({ name: normalize(m[1]), spec: m[2].trim(), style: 'python', file, line, value: req.split(';')[0].trim() })
56
+ }
57
+ for (const { key, value, line } of tomlEntries(text, 'tool.poetry.dependencies')) {
58
+ const name = normalize(key)
59
+ const spec = /^["']([^"']*)["']/.exec(value)?.[1] ?? /\bversion\s*=\s*["']([^"']*)["']/.exec(value)?.[1]
60
+ if (spec && spec !== '*' && relevant(name)) out.push({ name, spec, style: 'poetry', file, line, value: `${key} = ${value}` })
61
+ }
62
+ }
63
+ return out
64
+ }
65
+
66
+ /** The Python versions a uv or Poetry project must support: the lowest of requires-python, or python in Poetry. */
67
+ export function projectPython(ws, files) {
68
+ for (const file of files.filter((f) => /pyproject\.toml$/.test(f))) {
69
+ const text = read(ws, file) ?? ''
70
+ const requires = /^\s*requires-python\s*=\s*["']([^"']+)["']/m.exec(tomlTable(text, 'project'))?.[1]
71
+ const low = requires ? lowestRelease(requires) : null
72
+ if (low) return low
73
+ const poetry = tomlEntries(text, 'tool.poetry.dependencies').find((e) => e.key === 'python')
74
+ const spec = poetry && /^["']([^"']*)["']/.exec(poetry.value)?.[1]
75
+ const lowPoetry = spec ? lowestRelease(spec, 'poetry') : null
76
+ if (lowPoetry) return lowPoetry
77
+ }
78
+ return null
79
+ }
80
+
81
+ /** True when the set allows the limit. */
82
+ function fits(set, limit) {
83
+ if (limit.name === 'protobuf') return overlaps(limit.spec, set.protobuf, limit.style)
84
+ if (CORE.test(limit.name)) return satisfies(set.core, limit.spec, limit.style)
85
+ if (CONTRIB.test(limit.name)) return satisfies(set.contrib, limit.spec, limit.style)
86
+ return true
87
+ }
88
+
89
+ /**
90
+ * The first set that fits. why: the plan sentence when it is not the newest set. When none fits: the required step.
91
+ * @param {{limits: Limit[], python: [number, number]|null, tool?: string|null}} opts tool: uv or poetry for floors, else pins.
92
+ * @returns {{set: typeof PINS.python.sets[number], why: string|null}|{block: import('../types.mjs').Block}}
93
+ */
94
+ export function pickSet({ limits, python, tool = null }) {
95
+ const sets = PINS.python.sets
96
+ const pyOk = (set) => !python || python[0] * 1000 + python[1] >= set.minPython[0] * 1000 + set.minPython[1]
97
+ const index = sets.findIndex((set) => pyOk(set) && limits.every((l) => fits(set, l)))
98
+ if (index < 0) {
99
+ const limit = limits.find((l) => sets.filter(pyOk).every((set) => !fits(set, l)))
100
+ if (!limit) return { block: oldPython(python) }
101
+ return { block: { title: RESOLVE.python.noSet.title, lines: RESOLVE.python.noSet.lines.map((l) => fill(l, limit)) } }
102
+ }
103
+ const set = sets[index]
104
+ if (index === 0) return { set, why: null }
105
+ const newest = sets[0]
106
+ const limit = limits.find((l) => !fits(newest, l))
107
+ const values = { ...limit, python: python?.join('.'), newest: newest.core, need: newest.minPython.join('.'), core: set.core, contrib: set.contrib, tool }
108
+ const text = limit ? (tool ? RESOLVE.python.floorLimit : RESOLVE.python.olderLimit) : tool ? RESOLVE.python.floorPython : RESOLVE.python.olderPython
109
+ return { set, why: fill(text, values) }
110
+ }
111
+
112
+ /** The step for a Python version below every set. */
113
+ function oldPython(python) {
114
+ const oldest = PINS.python.sets.at(-1)
115
+ const need = oldest.minPython.join('.')
116
+ return { title: `Use Python ${need} or later`, lines: [`This app uses Python ${python.join('.')}.`,
117
+ `opentelemetry-distro ${oldest.contrib} needs Python ${need} or later.`, 'Update the Python version of the app. Then run npx @uknows/setup again.'] }
118
+ }
119
+
120
+ /** Requirement text of 1 package: name==version or name>=floor. */
121
+ export const requirement = (name, version, floor = null) => (floor ? `${name}>=${floor}` : `${name}==${version}`)
122
+
123
+ /** The package name of a requirement text. */
124
+ export const requirementName = (req) => normalize(/^[A-Za-z0-9][A-Za-z0-9._-]*/.exec(req)?.[0] ?? req)
125
+
126
+ /** The lowest protobuf release a lock has, as a limit, so the set fits a lock that keeps protobuf below 5. */
127
+ export function lockedLimit(installed, name) {
128
+ const item = installed?.packages?.get(name)
129
+ if (!item?.version || !clauses(`==${item.version}`).length) return null
130
+ return { name, spec: `==${item.version}`, style: 'python', file: item.file, line: item.line ?? 0, value: `${item.name} ${item.version}` }
131
+ }
132
+
133
+ export { compareVersion }
134
+
135
+ const dirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
136
+ function join(dir, rel) {
137
+ const parts = dir === '.' ? [] : dir.split('/')
138
+ for (const part of rel.split('/')) {
139
+ if (part === '..') parts.pop()
140
+ else if (part && part !== '.') parts.push(part)
141
+ }
142
+ return parts.join('/') || '.'
143
+ }
@@ -3,18 +3,36 @@
3
3
  * instrumentation packages that `opentelemetry-bootstrap -a requirements`
4
4
  * would print, then start the app with opentelemetry-instrument.
5
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
+ *
6
18
  * Safety rule: the start command gets opentelemetry-instrument only when the
7
19
  * current build surely installs the packages: a RUN line of the image installs
8
- * the file this tool changed, or (without a Dockerfile) the platform installs
9
- * it. Otherwise the image would fail to start, so the start goes into a
10
- * manual step instead.
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.
11
25
  */
26
+ import { buildContext, ignoredBy } from '../context.mjs'
12
27
  import { Doc, indentOf } from '../doc.mjs'
13
- import { dockerStart, parseDockerfile, prefixStart, reachingStages } from '../dockerfile.mjs'
14
- import { PINS, STACKS } from '../contract.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'
15
31
  import { upFrom } from '../workspace.mjs'
16
32
  import { startOverrides } from './k8s.mjs'
17
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'
18
36
 
19
37
  const STACK = STACKS.find((s) => s.id === 'python')
20
38
  const INSTRUMENT = 'opentelemetry-instrument'
@@ -22,32 +40,82 @@ const RUNNERS = /^(poetry|uv|pipenv|pdm|hatch|rye)$/
22
40
  const LOCKS = { 'uv.lock': 'uv lock', 'poetry.lock': 'poetry lock', 'pdm.lock': 'pdm lock' }
23
41
  const SECRET = /(password|passwd|token|secret|credential|authorization|api[-_]?key|connection[-_]?string)/i
24
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
+ }
25
71
 
26
72
  /** @type {import('../types.mjs').Applier} */
27
73
  export const python = {
28
74
  id: 'python',
29
75
  plan(service, ctx) {
30
76
  const { ws } = ctx
77
+ const view = ctx.repo ?? ws
31
78
  const out = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
32
- const version = /^(\d+)\.(\d+)/.exec(service.runtimeVersion ?? '')
33
- const [major, minor] = PINS.python.minPython
34
- if (version && Number(version[1]) * 1000 + Number(version[2]) < major * 1000 + minor) {
35
- out.manual.push({ title: `Use Python ${major}.${minor} or later`, lines: [`This app uses Python ${version[0]}.`,
36
- `${PINS.python.distro.split('==').join(' ')} needs Python ${major}.${minor} or later.`,
37
- 'Update the Python version of the app. Then run npx @uknows/setup again.'] })
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
38
94
  return out
39
95
  }
40
- if (!service.runtimeVersion) out.notes.push(`Check that the app uses Python ${major}.${minor} or later. ${PINS.python.distro.split('==')[0]} needs it.`)
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.`)
41
99
 
42
- const docker = service.dockerfile ? read(ws, service.dockerfile) : null
43
- const deps = pickDeps(service, ws, docker)
44
- const wanted = [PINS.python.distro, PINS.python.exporter, ...bootstrap(deps.names, PINS.python.contrib)]
45
- const missing = wanted.filter((req) => !deps.names.has(normalize(req.split('==')[0])))
46
- const ready = addPackages(deps, missing, ws, ctx.repo ?? ws, out)
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
47
113
 
48
- if (ready && deps.proven) wrapStart(service, ws, docker, out)
49
- else if (ready) {
50
- out.manual.push({ title: `Install ${deps.file} in the image`, lines: [
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: [
51
119
  `${service.dockerfile} does not install ${deps.file}, so the image would not have opentelemetry-instrument. This tool did not change the start command.`,
52
120
  `Install ${deps.file} in the final image, for example with ${deps.kind === 'requirements' ? `RUN pip install -r ${base(deps.file)}` : 'RUN pip install .'}.`,
53
121
  AGAIN] })
@@ -70,21 +138,91 @@ export const python = {
70
138
  'Add the production value with the 5 UKNOWS settings.'].filter(Boolean) })
71
139
  }
72
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) })
73
143
  return out
74
144
  },
75
145
  }
76
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
+
77
206
  /**
78
207
  * Add the missing packages. Returns true when the dependency list has them after this run, and its lockfile too.
79
- * view reads lockfiles above the --path folder.
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).
80
210
  */
81
- function addPackages(deps, missing, ws, view, out) {
82
- const lock = deps.kind === 'pep621' || deps.kind === 'poetry' ? findLock(view, dirOf(deps.file)) : null
83
- const lockReady = !lock || /name\s*=\s*"opentelemetry-distro"/.test(read(view, lock) ?? '')
84
- if (!missing.length) {
85
- if (!lockReady) out.manual.push(relockBlock(lock, deps.file))
86
- return lockReady
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
87
224
  }
225
+ if (!missing.length) return refresh()
88
226
  const snippet = { caption: 'Packages to add', language: 'text', code: missing.join('\n') }
89
227
  const stop = (title, lines, code) => {
90
228
  out.manual.push({ title, lines: [...lines, AGAIN], snippets: [code ? { ...snippet, code } : snippet] })
@@ -98,22 +236,14 @@ function addPackages(deps, missing, ws, view, out) {
98
236
  return stop('Add the OpenTelemetry packages', ['This app uses Pipenv. Add the packages with pipenv install.'],
99
237
  `pipenv install ${missing.join(' ')}`)
100
238
  }
101
- if (deps.hashes) {
102
- return stop('Add the OpenTelemetry packages', [`${deps.file} uses --hash, so this tool cannot add lines to it.`,
103
- 'Add these packages with the tool that writes the file, such as pip-compile --generate-hashes.'])
104
- }
105
- const source = deps.kind === 'requirements' ? deps.file.replace(/\.txt$/, '.in') : null
106
- if (source && source !== deps.file && read(ws, source) !== null) {
107
- return stop('Add the OpenTelemetry packages', [`${deps.file} is built from ${source} by pip-tools.`,
108
- `Add these lines to ${source}.`, `Run pip-compile ${source.split('/').pop()}.`])
109
- }
239
+ const compiled = deps.kind === 'requirements' ? compiledRequirements(ws, deps.file) : null
240
+ if (compiled) return stop('Add the OpenTelemetry packages', compiled)
110
241
  const before = read(ws, deps.file)
111
242
  const after = deps.kind === 'requirements' ? appendLines(before, missing) : editPyproject(before, deps, missing)
112
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.'])
113
244
  out.edits.push({ file: deps.file, before, after, why: 'Add the OpenTelemetry packages.' })
114
- out.summary.push(`Add ${missing.length} OpenTelemetry packages to ${deps.file}.`)
115
- if (!lockReady) out.manual.push(relockBlock(lock, deps.file))
116
- return lockReady
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()
117
247
  }
118
248
 
119
249
  function relockBlock(lock, file) {
@@ -123,6 +253,29 @@ function relockBlock(lock, file) {
123
253
  ...(name === 'poetry.lock' ? ['On Poetry 1, run poetry lock --no-update instead.'] : []), `Commit ${name}.`, AGAIN] }
124
254
  }
125
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
+
126
279
  function wrapStart(service, ws, docker, out) {
127
280
  const done = (file, before, after, where) => {
128
281
  out.edits.push({ file, before, after, why: 'Start the app with opentelemetry-instrument.' })
@@ -135,11 +288,12 @@ function wrapStart(service, ws, docker, out) {
135
288
  const words = start.argv ?? start.raw.replace(/^exec\s+/, '').split(/\s+/)
136
289
  const instruction = info.entrypoint ? 'ENTRYPOINT' : 'CMD'
137
290
  const overrides = startOverrides(service, instruction, INSTRUMENT)
138
- if (base(words[0]) === INSTRUMENT) { out.manual.push(...overrides); return }
291
+ const at = instrumentAt(words)
292
+ if (at !== null && [base(words[at]), base(words[0])].includes(INSTRUMENT)) { out.manual.push(...overrides); return }
139
293
  const where = `${instruction} in ${service.dockerfile}`
140
294
  // A venv that is not on PATH: use the opentelemetry-instrument next to the start program.
141
- const word = /venv\/bin\/[^/]+$/.test(words[0]) ? words[0].replace(/[^/]+$/, INSTRUMENT) : INSTRUMENT
142
- const after = RUNNERS.test(base(words[0])) && words[1] === 'run' ? null : prefixStart(docker, [word])
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])
143
297
  if (after === null) out.manual.push(startBlock(`the ${where}`))
144
298
  else {
145
299
  if (after !== docker) done(service.dockerfile, docker, after, where)
@@ -149,16 +303,19 @@ function wrapStart(service, ws, docker, out) {
149
303
  }
150
304
  const web = !service.start || service.start.kind === 'procfile' ? procfileWeb(ws, service) : null
151
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.
152
307
  const words = web.cmd.trim().split(/\s+/)
153
- if (base(words[0]) === INSTRUMENT) return
154
- // Same limits as prefixStart: no leading NAME=value, no command lists or pipes.
155
- if (!words[0] || /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]) || /[;|`]|&&|&\s*$/.test(web.cmd) || (RUNNERS.test(words[0]) && words[1] === 'run')) {
156
- out.manual.push(startBlock(`the web line in ${web.file}`))
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 }] })
157
315
  return
158
316
  }
159
- const doc = new Doc(web.text)
160
- doc.set(web.index, `${web.head}${INSTRUMENT} ${web.cmd}`)
161
- done(web.file, web.text, doc.toString(), `web line in ${web.file}`)
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 }] })
162
319
  }
163
320
 
164
321
  function startBlock(where, answer = null) {
@@ -195,9 +352,12 @@ function pickDeps(service, ws, docker) {
195
352
  }
196
353
  const pipfile = (proven) => read(ws, join(dir, 'Pipfile')) !== null && { kind: 'pipfile', file: join(dir, 'Pipfile'), names: new Set(), hashes: false, proven }
197
354
  if (installs) {
198
- const file = installs.files.find((f) => read(ws, f) !== null)
355
+ const file = installs.files.find((f) => !/\.toml$/.test(f) && read(ws, f) !== null)
199
356
  if (file) return requirements(file, true)
200
- const found = (installs.project && project(true)) || (installs.pipfile && pipfile(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))
201
361
  if (found) return found
202
362
  }
203
363
  // Not installed by a RUN line (or no Dockerfile): the usual files.
@@ -208,11 +368,15 @@ function pickDeps(service, ws, docker) {
208
368
 
209
369
  /**
210
370
  * The dependency files that the image surely installs, from the RUN lines of the stages that reach the final image:
211
- * files given to pip install -r, the project (pip install . with any flags, poetry install, uv sync, pdm install,
212
- * or an export of them), and the Pipfile (pipenv install). Paths are repo paths, found from the WORKDIR.
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}}
213
376
  */
214
- function dockerInstalls(service, docker) {
215
- const out = { files: [], project: false, pipfile: false }
377
+ export function dockerInstalls(service, docker) {
378
+ const out = { files: [], project: false, pipfile: false, packages: null }
379
+ const named = [], whole = []
216
380
  let exported = false
217
381
  for (const stage of reachingStages(parseDockerfile(docker))) {
218
382
  let workdir = ''
@@ -224,20 +388,29 @@ function dockerInstalls(service, docker) {
224
388
  if (x.keyword !== 'RUN') continue
225
389
  for (const part of (x.argv ? x.argv.join(' ') : x.args).split(/&&|\|\||[;|\n]/)) {
226
390
  if (/\b(?:poetry|uv|pdm)\s+export\b/.test(part)) exported = true
227
- if (/\b(?:poetry\s+(?:install|export)|uv\s+(?:sync|export)|pdm\s+(?:install|sync|export))\b/.test(part)) out.project = 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
+ }
228
397
  if (/\bpipenv\s+(?:install|sync)\b/.test(part)) out.pipfile = true
229
398
  if (!/\bpip3?\s+install\b/.test(part)) continue
230
399
  const words = part.trim().split(/\s+/).map((w) => w.replace(/^["']|["']$/g, ''))
231
400
  for (let k = 0; k < words.length; k++) {
232
401
  const flag = /^(?:-r|--requirement)(?:=(.+))?$/.exec(words[k])
233
- if (flag) out.files.push(...repoPaths(service, workdir, flag[1] ?? words[++k] ?? ''))
234
- else if (/^\.\/?(?:\[[^\]]*\])?$/.test(words[k])) out.project = true
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) }
235
407
  }
236
408
  }
237
409
  }
238
410
  }
239
411
  // An exported list (poetry export -o requirements.txt) is built from the lockfile, not from a file in the repo.
240
412
  if (exported) out.files = []
413
+ if (named.length && !whole.length) out.packages = [...new Set(named)]
241
414
  return out
242
415
  }
243
416
 
@@ -249,6 +422,26 @@ function repoPaths(service, workdir, raw) {
249
422
  return [resolvePath(service.dir, path), resolvePath(dirOf(service.dockerfile), path)].filter(Boolean)
250
423
  }
251
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
+
252
445
  /** Requirement names in a requirements file, following -r includes 1 level deep. */
253
446
  function requirementNames(ws, file, depth = 0) {
254
447
  const text = read(ws, file) ?? ''
@@ -271,7 +464,7 @@ function requirementNames(ws, file, depth = 0) {
271
464
  }
272
465
 
273
466
  /** The PEP 621 [project] dependencies array, else the [tool.poetry.dependencies] table. null when neither. */
274
- function parsePyproject(text) {
467
+ export function parsePyproject(text) {
275
468
  const doc = new Doc(text)
276
469
  const table = (re) => {
277
470
  const head = doc.find((line) => re.test(line.replace(/#.*$/, '').trim()))
@@ -320,11 +513,16 @@ function scanArray(doc, line, col) {
320
513
  return null
321
514
  }
322
515
 
323
- function editPyproject(text, deps, missing) {
516
+ export function editPyproject(text, deps, missing) {
324
517
  const doc = new Doc(text)
325
518
  if (deps.kind === 'poetry') {
326
519
  const indent = deps.last > deps.head ? indentOf(doc.get(deps.last)) : ''
327
- doc.insert(deps.last + 1, ...missing.map((req) => `${indent}${req.split('==')[0]} = "${req.split('==')[1]}"`))
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))
328
526
  return doc.toString()
329
527
  }
330
528
  const { items, close } = deps.array
@@ -352,13 +550,13 @@ function editPyproject(text, deps, missing) {
352
550
  return doc.toString()
353
551
  }
354
552
 
355
- function appendLines(text, lines) {
553
+ export function appendLines(text, lines) {
356
554
  const eol = new Doc(text).eol
357
555
  return (text === '' || text.endsWith('\n') ? text : text + eol) + lines.map((line) => line + eol).join('')
358
556
  }
359
557
 
360
558
  /** The nearest uv, Poetry or PDM lockfile, up to the top of the git repo (above the --path folder too). */
361
- function findLock(view, dir) {
559
+ export function findLock(view, dir) {
362
560
  for (const d of upFrom(dir, view.top ?? '.')) {
363
561
  for (const name of Object.keys(LOCKS)) if (read(view, join(d, name)) !== null) return join(d, name)
364
562
  }