@uknows/setup 0.0.0-stage → 0.1.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.
@@ -0,0 +1,434 @@
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
+ * Safety rule: the start command gets opentelemetry-instrument only when the
7
+ * 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.
11
+ */
12
+ import { Doc, indentOf } from '../doc.mjs'
13
+ import { dockerStart, parseDockerfile, prefixStart, reachingStages } from '../dockerfile.mjs'
14
+ import { PINS, STACKS } from '../contract.mjs'
15
+ import { upFrom } from '../workspace.mjs'
16
+ import { startOverrides } from './k8s.mjs'
17
+ import { bootstrap, normalize } from './python-bootstrap.mjs'
18
+
19
+ const STACK = STACKS.find((s) => s.id === 'python')
20
+ const INSTRUMENT = 'opentelemetry-instrument'
21
+ const RUNNERS = /^(poetry|uv|pipenv|pdm|hatch|rye)$/
22
+ const LOCKS = { 'uv.lock': 'uv lock', 'poetry.lock': 'poetry lock', 'pdm.lock': 'pdm lock' }
23
+ const SECRET = /(password|passwd|token|secret|credential|authorization|api[-_]?key|connection[-_]?string)/i
24
+ const AGAIN = 'Then run npx @uknows/setup again. It puts opentelemetry-instrument in front of the start command.'
25
+
26
+ /** @type {import('../types.mjs').Applier} */
27
+ export const python = {
28
+ id: 'python',
29
+ plan(service, ctx) {
30
+ const { ws } = ctx
31
+ 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.'] })
38
+ return out
39
+ }
40
+ if (!service.runtimeVersion) out.notes.push(`Check that the app uses Python ${major}.${minor} or later. ${PINS.python.distro.split('==')[0]} needs it.`)
41
+
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)
47
+
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: [
51
+ `${service.dockerfile} does not install ${deps.file}, so the image would not have opentelemetry-instrument. This tool did not change the start command.`,
52
+ `Install ${deps.file} in the final image, for example with ${deps.kind === 'requirements' ? `RUN pip install -r ${base(deps.file)}` : 'RUN pip install .'}.`,
53
+ AGAIN] })
54
+ }
55
+ const startText = docker !== null ? (dockerStart(parseDockerfile(docker))?.raw ?? '') : (service.start?.raw ?? procfileWeb(ws, service)?.cmd ?? '')
56
+ startNotes(startText, out)
57
+
58
+ // Django: the instrumentation reads the settings at startup, so DJANGO_SETTINGS_MODULE must be set.
59
+ // Keep a value that the app sets already. Else use the value of the module that the start command loads.
60
+ const django = deps.names.has('django') || service.frameworks.includes('django')
61
+ if (django && !alreadySet('DJANGO_SETTINGS_MODULE', service, ws)) {
62
+ const found = djangoSettings(service, ws, startText)
63
+ if (found) {
64
+ out.extraSettings.push({ name: 'DJANGO_SETTINGS_MODULE', value: found.value })
65
+ out.notes.push(`Set DJANGO_SETTINGS_MODULE to ${found.value}, the value that ${found.file} uses.`)
66
+ } else {
67
+ out.manual.push({ title: 'Set DJANGO_SETTINGS_MODULE', lines: [note(/DJANGO_SETTINGS_MODULE/),
68
+ 'This tool cannot tell which settings module the app uses in production.',
69
+ 'The module that the start command loads, or manage.py, wsgi.py and asgi.py together, must give 1 value.',
70
+ 'Add the production value with the 5 UKNOWS settings.'].filter(Boolean) })
71
+ }
72
+ }
73
+ return out
74
+ },
75
+ }
76
+
77
+ /**
78
+ * 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.
80
+ */
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
87
+ }
88
+ const snippet = { caption: 'Packages to add', language: 'text', code: missing.join('\n') }
89
+ const stop = (title, lines, code) => {
90
+ out.manual.push({ title, lines: [...lines, AGAIN], snippets: [code ? { ...snippet, code } : snippet] })
91
+ return false
92
+ }
93
+ if (!deps.file) {
94
+ return stop('Add the OpenTelemetry packages', ['This tool did not find a dependency list that it can change, such as requirements.txt.',
95
+ 'Add these packages to the file that lists the app dependencies.'])
96
+ }
97
+ if (deps.kind === 'pipfile') {
98
+ return stop('Add the OpenTelemetry packages', ['This app uses Pipenv. Add the packages with pipenv install.'],
99
+ `pipenv install ${missing.join(' ')}`)
100
+ }
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
+ }
110
+ const before = read(ws, deps.file)
111
+ const after = deps.kind === 'requirements' ? appendLines(before, missing) : editPyproject(before, deps, missing)
112
+ 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
+ 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
117
+ }
118
+
119
+ function relockBlock(lock, file) {
120
+ const name = lock.split('/').pop()
121
+ return { title: `Update ${name}`, lines: [`${file} lists the OpenTelemetry packages, but ${name} does not have them yet.`,
122
+ `Run ${LOCKS[name]} in ${dirOf(lock) === '.' ? 'the repo root' : dirOf(lock)}.`,
123
+ ...(name === 'poetry.lock' ? ['On Poetry 1, run poetry lock --no-update instead.'] : []), `Commit ${name}.`, AGAIN] }
124
+ }
125
+
126
+ function wrapStart(service, ws, docker, out) {
127
+ const done = (file, before, after, where) => {
128
+ out.edits.push({ file, before, after, why: 'Start the app with opentelemetry-instrument.' })
129
+ out.summary.push(`Start the app with opentelemetry-instrument (${where}).`)
130
+ }
131
+ if (docker !== null) {
132
+ const info = parseDockerfile(docker)
133
+ const start = dockerStart(info)
134
+ if (!start) { out.manual.push(startBlock(null)); return }
135
+ const words = start.argv ?? start.raw.replace(/^exec\s+/, '').split(/\s+/)
136
+ const instruction = info.entrypoint ? 'ENTRYPOINT' : 'CMD'
137
+ const overrides = startOverrides(service, instruction, INSTRUMENT)
138
+ if (base(words[0]) === INSTRUMENT) { out.manual.push(...overrides); return }
139
+ const where = `${instruction} in ${service.dockerfile}`
140
+ // 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])
143
+ if (after === null) out.manual.push(startBlock(`the ${where}`))
144
+ else {
145
+ if (after !== docker) done(service.dockerfile, docker, after, where)
146
+ out.manual.push(...overrides)
147
+ }
148
+ return
149
+ }
150
+ const web = !service.start || service.start.kind === 'procfile' ? procfileWeb(ws, service) : null
151
+ if (!web) { out.manual.push(startBlock(null, service.start?.kind === 'answer' ? service.start.raw : null)); return }
152
+ 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}`))
157
+ return
158
+ }
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}`)
162
+ }
163
+
164
+ function startBlock(where, answer = null) {
165
+ const code = answer && !SECRET.test(answer) ? `${INSTRUMENT} ${answer}` : STACK.start
166
+ return { title: 'Start the app with opentelemetry-instrument',
167
+ lines: [where ? `This tool cannot change ${where} safely.` : 'This tool did not find the command that starts the app.',
168
+ note(/in front of the command/)].filter(Boolean), snippets: [{ caption: 'Start command', language: 'bash', code }] }
169
+ }
170
+
171
+ /** Docs notes for start commands that opentelemetry-instrument does not handle well. */
172
+ function startNotes(text, out) {
173
+ const words = text.split(/[\s"',[\]]+/).filter(Boolean)
174
+ const flag = words.findIndex((w) => w === '-w' || w === '--workers')
175
+ const workers = flag >= 0 ? Number(words[flag + 1]) : Number(/(?:^|\s|")(?:--workers=|-w)(\d+)/.exec(text)?.[1] ?? 1)
176
+ const dev = (words.includes('flask') && words.includes('run')) || (words.includes('runserver') && words.some((w) => base(w) === 'manage.py'))
177
+ out.notes.push(...[workers > 1 && note(/worker/), words.includes('--reload') && note(/--reload/),
178
+ dev && !words.some((w) => /^--no-?reload$/.test(w))
179
+ && 'The development server restarts the app when files change, and that stops OpenTelemetry. Use Gunicorn or uvicorn in production.',
180
+ ].filter(Boolean))
181
+ }
182
+
183
+ /**
184
+ * The dependency file the build uses, and the requirement names in it. proven: the build surely installs it.
185
+ * With a Dockerfile, only its RUN lines prove that. Without one, the platform installs the usual files.
186
+ */
187
+ function pickDeps(service, ws, docker) {
188
+ const dir = service.dir
189
+ const installs = docker === null ? null : dockerInstalls(service, docker)
190
+ const requirements = (file, proven) => ({ kind: 'requirements', file, proven, ...requirementNames(ws, file) })
191
+ const project = (proven) => {
192
+ const text = read(ws, join(dir, 'pyproject.toml'))
193
+ const parsed = text === null ? null : parsePyproject(text)
194
+ return parsed && { file: join(dir, 'pyproject.toml'), hashes: false, proven, ...parsed }
195
+ }
196
+ const pipfile = (proven) => read(ws, join(dir, 'Pipfile')) !== null && { kind: 'pipfile', file: join(dir, 'Pipfile'), names: new Set(), hashes: false, proven }
197
+ if (installs) {
198
+ const file = installs.files.find((f) => read(ws, f) !== null)
199
+ if (file) return requirements(file, true)
200
+ const found = (installs.project && project(true)) || (installs.pipfile && pipfile(true))
201
+ if (found) return found
202
+ }
203
+ // Not installed by a RUN line (or no Dockerfile): the usual files.
204
+ const platform = docker === null
205
+ if (read(ws, join(dir, 'requirements.txt')) !== null) return requirements(join(dir, 'requirements.txt'), platform)
206
+ return project(platform) || pipfile(platform) || { kind: null, file: null, names: new Set(), hashes: false, proven: false }
207
+ }
208
+
209
+ /**
210
+ * 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.
213
+ */
214
+ function dockerInstalls(service, docker) {
215
+ const out = { files: [], project: false, pipfile: false }
216
+ let exported = false
217
+ for (const stage of reachingStages(parseDockerfile(docker))) {
218
+ let workdir = ''
219
+ for (const x of stage.instructions) {
220
+ if (x.keyword === 'WORKDIR') {
221
+ const w = x.args.replace(/^["']|["']$/g, '').replace(/\/+$/, '')
222
+ workdir = w.startsWith('/') ? w : `${workdir}/${w}`
223
+ }
224
+ if (x.keyword !== 'RUN') continue
225
+ for (const part of (x.argv ? x.argv.join(' ') : x.args).split(/&&|\|\||[;|\n]/)) {
226
+ 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
228
+ if (/\bpipenv\s+(?:install|sync)\b/.test(part)) out.pipfile = true
229
+ if (!/\bpip3?\s+install\b/.test(part)) continue
230
+ const words = part.trim().split(/\s+/).map((w) => w.replace(/^["']|["']$/g, ''))
231
+ for (let k = 0; k < words.length; k++) {
232
+ 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
235
+ }
236
+ }
237
+ }
238
+ }
239
+ // An exported list (poetry export -o requirements.txt) is built from the lockfile, not from a file in the repo.
240
+ if (exported) out.files = []
241
+ return out
242
+ }
243
+
244
+ /** Repo paths that a path in the image can be: relative to the service folder and to the Dockerfile folder. */
245
+ function repoPaths(service, workdir, raw) {
246
+ let path = raw.replace(/^\.\//, '')
247
+ if (!path) return []
248
+ if (path.startsWith('/')) path = workdir && path.startsWith(`${workdir}/`) ? path.slice(workdir.length + 1) : path.split('/').pop()
249
+ return [resolvePath(service.dir, path), resolvePath(dirOf(service.dockerfile), path)].filter(Boolean)
250
+ }
251
+
252
+ /** Requirement names in a requirements file, following -r includes 1 level deep. */
253
+ function requirementNames(ws, file, depth = 0) {
254
+ const text = read(ws, file) ?? ''
255
+ const names = new Set()
256
+ let hashes = /(^|\s)--hash[=\s]/.test(text)
257
+ for (const raw of text.replace(/\\\r?\n/g, ' ').split(/\r?\n/)) {
258
+ const line = raw.replace(/(^|\s)#.*$/, '').trim()
259
+ const include = /^(?:-r|--requirement)(?:\s+|=)(\S+)/.exec(line)
260
+ if (include && depth === 0) {
261
+ const path = resolvePath(dirOf(file), include[1])
262
+ const sub = path ? requirementNames(ws, path, 1) : { names: [], hashes: false }
263
+ for (const name of sub.names) names.add(name)
264
+ hashes ||= sub.hashes
265
+ } else if (line && !line.startsWith('-')) {
266
+ const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(line)
267
+ if (m) names.add(normalize(m[1]))
268
+ }
269
+ }
270
+ return { names, hashes }
271
+ }
272
+
273
+ /** The PEP 621 [project] dependencies array, else the [tool.poetry.dependencies] table. null when neither. */
274
+ function parsePyproject(text) {
275
+ const doc = new Doc(text)
276
+ const table = (re) => {
277
+ const head = doc.find((line) => re.test(line.replace(/#.*$/, '').trim()))
278
+ if (head < 0) return null
279
+ const next = doc.find(/^\s*\[/, head + 1)
280
+ return { head, end: next < 0 ? doc.length : next }
281
+ }
282
+ const project = table(/^\[project\]$/)
283
+ const at = project ? doc.find((line, i) => i < project.end && /^\s*dependencies\s*=\s*\[/.test(line), project.head + 1) : -1
284
+ if (at >= 0) {
285
+ const array = scanArray(doc, at, doc.get(at).indexOf('[', doc.get(at).indexOf('=')))
286
+ if (!array) return null
287
+ 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))
288
+ return { kind: 'pep621', names, array, at }
289
+ }
290
+ const poetry = table(/^\[tool\.poetry\.dependencies\]$/)
291
+ if (!poetry) return null
292
+ const names = new Set()
293
+ let last = poetry.head
294
+ for (let i = poetry.head + 1; i < poetry.end; i++) {
295
+ const m = /^\s*["']?([A-Za-z0-9][A-Za-z0-9._-]*)["']?\s*=/.exec(doc.get(i))
296
+ if (m) names.add(normalize(m[1]))
297
+ if (doc.get(i).trim()) last = i
298
+ }
299
+ return { kind: 'poetry', names, head: poetry.head, last }
300
+ }
301
+
302
+ /** Items and the closing bracket of the TOML array whose '[' is at (line, col). Strings and comments are skipped. */
303
+ function scanArray(doc, line, col) {
304
+ const items = []
305
+ let depth = 0
306
+ for (let i = line, j = col; i < doc.length; i++, j = 0) {
307
+ const s = doc.get(i)
308
+ for (; j < s.length; j++) {
309
+ const c = s[j]
310
+ if (c === '#') break
311
+ if (c === '"' || c === "'") {
312
+ let k = j + 1
313
+ while (k < s.length && s[k] !== c) k += c === '"' && s[k] === '\\' ? 2 : 1
314
+ if (depth === 1) items.push({ line: i, end: k + 1, quote: c, value: s.slice(j + 1, k) })
315
+ j = k
316
+ } else if (c === '[' || c === '{') depth++
317
+ else if ((c === ']' || c === '}') && --depth === 0) return { items, close: { line: i, col: j } }
318
+ }
319
+ }
320
+ return null
321
+ }
322
+
323
+ function editPyproject(text, deps, missing) {
324
+ const doc = new Doc(text)
325
+ if (deps.kind === 'poetry') {
326
+ 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]}"`))
328
+ return doc.toString()
329
+ }
330
+ const { items, close } = deps.array
331
+ const last = items[items.length - 1]
332
+ const q = (req) => `${last?.quote ?? '"'}${req}${last?.quote ?? '"'}`
333
+ const closeLine = doc.get(close.line)
334
+ // Text between the last item and the closing bracket, without comments: does it hold a trailing comma?
335
+ const between = []
336
+ for (let i = last ? last.line : close.line; i <= close.line; i++) {
337
+ const s = doc.get(i).slice(last && i === last.line ? last.end : 0, i === close.line ? close.col : undefined)
338
+ between.push(s.replace(/#.*$/, ''))
339
+ }
340
+ const trailing = between.join(' ').includes(',')
341
+ if (close.line > deps.at && !closeLine.slice(0, close.col).trim()) {
342
+ // Multi-line array: 1 item per line, before the closing bracket, in the same style.
343
+ const indent = last && last.line > deps.at ? indentOf(doc.get(last.line)) : `${indentOf(closeLine)} `
344
+ if (last && !trailing) doc.set(last.line, doc.get(last.line).slice(0, last.end) + ',' + doc.get(last.line).slice(last.end))
345
+ doc.insert(close.line, ...missing.map((req, k) => `${indent}${q(req)}${trailing || !last || k < missing.length - 1 ? ',' : ''}`))
346
+ } else {
347
+ // Inline array: append inside the brackets.
348
+ const head = closeLine.slice(0, close.col)
349
+ const lead = !items.length ? head : `${head.replace(/\s+$/, '')}${trailing ? ' ' : ', '}`
350
+ doc.set(close.line, lead + missing.map(q).join(', ') + closeLine.slice(close.col))
351
+ }
352
+ return doc.toString()
353
+ }
354
+
355
+ function appendLines(text, lines) {
356
+ const eol = new Doc(text).eol
357
+ return (text === '' || text.endsWith('\n') ? text : text + eol) + lines.map((line) => line + eol).join('')
358
+ }
359
+
360
+ /** 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) {
362
+ for (const d of upFrom(dir, view.top ?? '.')) {
363
+ for (const name of Object.keys(LOCKS)) if (read(view, join(d, name)) !== null) return join(d, name)
364
+ }
365
+ return null
366
+ }
367
+
368
+ /** The web: line of the service Procfile. */
369
+ function procfileWeb(ws, service) {
370
+ const file = service.start?.kind === 'procfile' && service.start.file ? service.start.file : join(service.dir, 'Procfile')
371
+ const text = read(ws, file)
372
+ if (text === null) return null
373
+ const doc = new Doc(text)
374
+ const index = doc.find(/^web:/)
375
+ if (index < 0) return null
376
+ const m = /^(web:\s*)(.*)$/.exec(doc.get(index))
377
+ return { file, text, index, head: m[1], cmd: m[2] }
378
+ }
379
+
380
+ /**
381
+ * The settings module that production picks: the setdefault in the wsgi or asgi module that the start command
382
+ * loads (gunicorn config.wsgi, uvicorn config.asgi:application, --wsgi-file config/wsgi.py), or in manage.py for
383
+ * runserver. When the start shows none, manage.py, wsgi.py and asgi.py must agree. null when unclear.
384
+ * @returns {{value: string, file: string}|null}
385
+ */
386
+ function djangoSettings(service, ws, startText) {
387
+ const files = ws.files(service.dir)
388
+ const valueIn = (file) => /DJANGO_SETTINGS_MODULE["']\s*,\s*["']([\w.]+)["']/.exec(read(ws, file) ?? '')?.[1] ?? null
389
+ const found = (file) => { const value = file && valueIn(file); return value ? { value, file } : null }
390
+ const path = /(?:^|[\s"'=,[])(\/?(?:[\w.-]+\/)*)(wsgi|asgi)\.py(?=$|[\s"',\]])/.exec(startText)
391
+ const dotted = /(?:^|[\s"'=,[])((?:[A-Za-z_]\w*\.)*)(wsgi|asgi)(?::[\w.]+)?(?=$|[\s"',\]])/.exec(startText)
392
+ const entry = path ? `${path[1]}${path[2]}.py` : dotted ? `${dotted[1].replace(/\./g, '/')}${dotted[2]}.py` : null
393
+ if (entry) {
394
+ // The module path can start with folders of the image (/app/config/wsgi.py): match the longest tail that names 1 file.
395
+ const parts = entry.split('/').filter(Boolean)
396
+ for (let k = 0; k <= parts.length - Math.min(2, parts.length); k++) {
397
+ const tail = parts.slice(k).join('/')
398
+ const hits = files.filter((f) => f === join(service.dir, tail) || f.endsWith(`/${tail}`))
399
+ const exact = hits.find((f) => f === join(service.dir, tail))
400
+ if (exact || hits.length === 1) return found(exact ?? hits[0])
401
+ }
402
+ return null
403
+ }
404
+ if (/\bmanage\.py\b/.test(startText)) return found(join(service.dir, 'manage.py'))
405
+ const all = files.filter((f) => /(?:^|\/)(?:manage|wsgi|asgi)\.py$/.test(f)).map(found).filter(Boolean)
406
+ return all.length && all.every((x) => x.value === all[0].value) ? all.find((x) => /wsgi|asgi/.test(x.file)) ?? all[0] : null
407
+ }
408
+
409
+ /** The setting is set already where the app keeps its settings. */
410
+ function alreadySet(name, service, ws) {
411
+ const docker = service.dockerfile ? read(ws, service.dockerfile) : null
412
+ const info = docker === null ? null : parseDockerfile(docker)
413
+ if (info && (info.env.has(name) || info.inherited.has(name))) return true
414
+ if (new RegExp(`^\\s*(export\\s+)?${name}=`, 'm').test(read(ws, join(service.dir, 'otel.env')) ?? '')) return true
415
+ return service.k8s.some((k) => new RegExp(`name:\\s*["']?${name}\\b`).test(read(ws, k.file) ?? ''))
416
+ }
417
+
418
+ const note = (re) => STACK.notes.find((text) => re.test(text)) ?? null
419
+ const base = (word) => (word ?? '').split('/').pop()
420
+ const join = (dir, file) => (dir === '.' ? file : `${dir}/${file}`)
421
+ const dirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
422
+
423
+ function read(ws, path) {
424
+ try { return ws.read(path) } catch { return null }
425
+ }
426
+
427
+ /** dir + a relative path, with . and .. resolved. null when it leaves the repo. */
428
+ function resolvePath(dir, rel) {
429
+ const parts = dir === '.' ? [] : dir.split('/')
430
+ for (const part of rel.split('/')) {
431
+ if (part === '..') { if (!parts.length) return null; parts.pop() } else if (part && part !== '.') parts.push(part)
432
+ }
433
+ return parts.length ? parts.join('/') : null
434
+ }
package/lib/types.mjs ADDED
@@ -0,0 +1,161 @@
1
+ /*
2
+ * Shared types for @uknows/setup. JSDoc only: this module has no runtime code.
3
+ * Import types with: @typedef {import('./types.mjs').Service} Service
4
+ *
5
+ * Path rule: every path in these types, in the manifest and in the --json
6
+ * report is relative to the repo root (the --path folder, default the current
7
+ * folder) and uses '/' on every OS. The root folder itself is '.'.
8
+ */
9
+
10
+ /** @typedef {'node'|'python'|'dotnet'|'java'|'go'|'php'|'ruby'} Lang */
11
+ /** @typedef {Lang|'otel'} StackId Same ids as contract STACKS. 'otel' = the app already uses OpenTelemetry: settings only. */
12
+ /** @typedef {'azure-container-apps'|'kubernetes'|'alibaba-sae'|'vm-docker'|'github-actions'} HostId Same ids as contract HOSTS. */
13
+
14
+ /**
15
+ * @typedef {{file: string, line?: number, what: string}} Evidence `what` is plain English, e.g. 'express in dependencies'.
16
+ * Host evidence can name a file above the --path folder in the same git repo, as '../../k8s/api.yaml'. It is shown, never written.
17
+ */
18
+ /** @typedef {{code: string, message: string}} Problem `message` is 1 plain-English sentence. */
19
+
20
+ /**
21
+ * @typedef {Object} Start How the service starts, as the repo declares it.
22
+ * @property {'dockerfile'|'package.json'|'procfile'|'answer'} kind 'answer' = the user typed it at the prompt (file '', line 0).
23
+ * @property {string} file
24
+ * @property {number} line 1-based line of the ENTRYPOINT/CMD (ENTRYPOINT when both), the "start" key, or the web: line.
25
+ * @property {'exec'|'shell'} form exec = Dockerfile JSON array. Everything else is shell.
26
+ * @property {string} raw The command text. Dockerfile: ENTRYPOINT then CMD args, as written.
27
+ * @property {string[]|null} argv Literal argv when the text has no shell syntax, else null.
28
+ */
29
+
30
+ /**
31
+ * @typedef {Object} K8sTarget A container in a plain Kubernetes manifest that runs this service.
32
+ * @property {string} file e.g. 'k8s/api.yaml'
33
+ * @property {number} doc 0-based index of the YAML document in the file ('---' separated)
34
+ * @property {string} container the container name
35
+ * @property {Record<string, string|null>} env Its env list: name -> value, null for valueFrom. It replaces the image ENV.
36
+ * @property {boolean} command It sets command:, which replaces the image ENTRYPOINT and CMD.
37
+ * @property {boolean} args It sets args:, which replaces the image CMD.
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} Service One deployable app folder.
42
+ * @property {string} dir '.' or 'services/api'
43
+ * @property {Lang|null} lang null = nothing recognized. The CLI then shows the language menu.
44
+ * @property {string[]} frameworks Canonical ids, sorted:
45
+ * node: express fastify nestjs koa hapi nitro nuxt next | python: fastapi django flask starlette falcon tornado
46
+ * dotnet: aspnetcore | java: spring-boot quarkus micronaut | go: echo gin chi | php: laravel symfony slim | ruby: rails sinatra
47
+ * @property {string[]} manifests Manifest paths in this folder, sorted.
48
+ * @property {string} name Proposed OTEL_SERVICE_NAME: [a-z0-9-], 1 to 63 chars, no leading or trailing '-'.
49
+ * @property {string} nameFrom 'existing' (an OTEL_SERVICE_NAME already in the repo), a manifest basename, or 'folder'.
50
+ * @property {string|null} dockerfile The Dockerfile that builds this service.
51
+ * @property {Start|null} start The single start declaration. null when there is none or it is ambiguous (see problems).
52
+ * @property {string|null} runtimeVersion Version from the final FROM image tag ('3.12', '8.0', '22'), else from an exact declaration
53
+ * (.python-version, TargetFramework 'net8.0' -> '8.0', go directive). Ranges such as
54
+ * requires-python do not count. null when unknown.
55
+ * @property {K8sTarget[]} k8s Plain k8s containers that run this service. Files with '{{' (Helm) are never listed.
56
+ * @property {Problem[]} problems codes: start-ambiguous, start-hooks, start-unknown, dockerfile-ambiguous, frontend
57
+ * @property {Evidence[]} evidence Why this folder is a service.
58
+ */
59
+
60
+ /**
61
+ * @typedef {Object} Host
62
+ * @property {HostId} id
63
+ * @property {Evidence[]} evidence Empty for the vm-docker fallback.
64
+ */
65
+
66
+ /**
67
+ * @typedef {Object} Detection What detect() returns. Static reading only: no commands, no network.
68
+ * @property {string} root Absolute native path of the repo root.
69
+ * @property {Service[]} services Sorted by dir. When nothing is recognized: 1 service {dir: '.', lang: null}.
70
+ * @property {Host[]} hosts Runtime hosts in HOSTS order, then 'github-actions'. Never empty ('vm-docker' fallback).
71
+ * @property {Problem[]} problems Repo-level notes, such as skipped large files.
72
+ */
73
+
74
+ /** @typedef {{vendor: string, evidence: Evidence[]}} Apm vendor: Datadog, New Relic, Elastic APM, Azure Monitor, Dynatrace, Instana or Sentry. */
75
+
76
+ /**
77
+ * @typedef {Object} Existing What findExisting() returns for 1 service.
78
+ * @property {Evidence[]} otel OpenTelemetry SDK-level packages or agents already in use.
79
+ * @property {Apm[]} apm Other APM agents. Any entry stops the run (exit 1).
80
+ */
81
+
82
+ /**
83
+ * @typedef {Object} Edit One change to one text file.
84
+ * @property {string} file Repo-relative path.
85
+ * @property {string|null} before Exactly ctx.ws.read(file) when the producer read it (BOM removed). null = new file.
86
+ * @property {string} after The full new text, without BOM. The engine puts the original BOM back.
87
+ * @property {string} why 1 plain-English sentence, e.g. 'Load OpenTelemetry when node starts.'
88
+ */
89
+
90
+ /**
91
+ * @typedef {Object} Command A package manager run. The engine runs it after it writes the edits, only on apply.
92
+ * @property {string} cwd Repo-relative folder to run in.
93
+ * @property {string} cmd 'npm' | 'pnpm' | 'yarn'
94
+ * @property {string[]} args Exact argv, e.g. ['install', '--save', '@opentelemetry/api', '@opentelemetry/auto-instrumentations-node'].
95
+ * @property {string[]} touches Repo-relative files it changes. The engine restores them when any step fails.
96
+ * @property {string[]} added Packages it adds that were not there before. The manifest records them.
97
+ * @property {boolean} [lockOnly] It writes only the files in touches (no node_modules).
98
+ * @property {{cmd: string, args: string[]}} undo Removes `added`. The remove command runs it.
99
+ * @property {string} why Plan sentence, e.g. 'Add @opentelemetry/api and @opentelemetry/auto-instrumentations-node with npm.'
100
+ */
101
+
102
+ /** @typedef {{caption: string, language: string, code: string}} Snippet Same shape as contract snippets. */
103
+
104
+ /**
105
+ * @typedef {Object} Block A printable section: a manual step, a key step or a stop reason.
106
+ * @property {string} title Sentence case, no final period, e.g. 'Set your key once on Kubernetes'.
107
+ * @property {string[]} lines Plain-English sentences. The CLI prints them as a numbered list.
108
+ * @property {Snippet[]} [snippets]
109
+ */
110
+
111
+ /** @typedef {{name: string, value: string}} Setting */
112
+
113
+ /**
114
+ * @typedef {Object} Ctx What appliers and settingsPlan() get.
115
+ * @property {import('./workspace.mjs').Workspace} ws Reads include the edits staged earlier in this run.
116
+ * @property {string} name The final OTEL_SERVICE_NAME of this service.
117
+ * @property {Detection} detection
118
+ * @property {import('./workspace.mjs').RepoView} [repo] Reads files above the root too ('../x'), for --path runs. Never written.
119
+ * @property {string} [endpoint] The ingest address for OTEL_EXPORTER_OTLP_ENDPOINT. Default: INGEST_ENDPOINT.
120
+ */
121
+
122
+ /**
123
+ * @typedef {Object} StackPlan What Applier.plan() returns.
124
+ * @property {string[]} summary Plan sentences, e.g. 'Load OpenTelemetry when node starts (NODE_OPTIONS in Dockerfile).'
125
+ * @property {Edit[]} edits
126
+ * @property {Command[]} commands
127
+ * @property {Setting[]} extraSettings Settings to place with the 5, e.g. DJANGO_SETTINGS_MODULE or OTEL_PHP_AUTOLOAD_ENABLED.
128
+ * @property {Block[]} manual Steps the user must do. Any entry makes the run exit 3.
129
+ * @property {string[]} notes Warnings and tips, 1 sentence each.
130
+ * @property {boolean} [stop] Change nothing for this service, settings included: the user must run this tool elsewhere.
131
+ */
132
+
133
+ /**
134
+ * @typedef {Object} Applier
135
+ * @property {StackId} id
136
+ * @property {(service: Service, ctx: Ctx) => StackPlan} plan
137
+ * Pure: reads ctx.ws only. Never writes, runs, prompts or reads process.env.
138
+ * Idempotent: planning its own output returns no edits and no commands.
139
+ */
140
+
141
+ /**
142
+ * @typedef {Object} SettingsPlan What settingsPlan() returns.
143
+ * @property {{kind: 'dockerfile'|'k8s'|'env-file', file: string}} target
144
+ * @property {Edit[]} edits Empty when every setting is already there with the right value.
145
+ * @property {string[]} summary
146
+ * @property {Block[]} manual e.g. a key committed in the repo, or an endpoint that points at another collector. Any entry: exit 3.
147
+ * @property {string[]} notes
148
+ */
149
+
150
+ /**
151
+ * @typedef {Object} CheckResult What runCheck() returns.
152
+ * @property {boolean} ok
153
+ * @property {number|null} status HTTP status. null on a network error.
154
+ * @property {string|null} workspace
155
+ * @property {string|null} environment
156
+ * @property {string|null} [keyState] From the site: active, retiring, or revoked. null when the site was not asked.
157
+ * @property {string[]} lines What to print. Never contains the key.
158
+ * @property {0|1|2} exit 0 reached, 1 rejected or network error, 2 OTEL_EXPORTER_OTLP_HEADERS is not set.
159
+ */
160
+
161
+ export {}