@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.
- package/LICENSE +190 -0
- package/README.md +209 -2
- package/bin/uknows-setup.mjs +14 -0
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +327 -0
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +251 -0
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +2736 -0
- package/lib/contract.mjs +149 -0
- package/lib/detect.mjs +627 -0
- package/lib/diff.mjs +147 -0
- package/lib/doc.mjs +98 -0
- package/lib/dockerfile.mjs +455 -0
- package/lib/endpoint.mjs +81 -0
- package/lib/engine.mjs +1753 -0
- package/lib/existing.mjs +562 -0
- package/lib/git.mjs +100 -0
- package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
- package/lib/hooks/pins.mjs +28 -0
- package/lib/hooks/uknows-otel.cjs +82 -0
- package/lib/hooks/uknows_otel.py +179 -0
- package/lib/hosts.mjs +650 -0
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +132 -0
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +468 -0
- package/lib/signin.mjs +259 -0
- package/lib/stacks/azure.mjs +345 -0
- package/lib/stacks/channel.mjs +125 -0
- package/lib/stacks/dotnet.mjs +472 -0
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +227 -0
- package/lib/stacks/index.mjs +18 -0
- package/lib/stacks/java.mjs +57 -0
- package/lib/stacks/k8s.mjs +31 -0
- package/lib/stacks/node.mjs +358 -0
- package/lib/stacks/otel.mjs +63 -0
- package/lib/stacks/python-bootstrap.mjs +71 -0
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +632 -0
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +208 -0
- package/lib/ui.mjs +225 -0
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +201 -0
- 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
|
+
}
|