@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.
- package/LICENSE +190 -0
- package/README.md +142 -2
- package/bin/uknows-setup.mjs +14 -0
- package/lib/check.mjs +211 -0
- package/lib/cli.mjs +166 -0
- package/lib/contract-docs.mjs +1154 -0
- package/lib/contract.mjs +66 -0
- package/lib/detect.mjs +575 -0
- package/lib/diff.mjs +147 -0
- package/lib/doc.mjs +98 -0
- package/lib/dockerfile.mjs +372 -0
- package/lib/endpoint.mjs +50 -0
- package/lib/engine.mjs +607 -0
- package/lib/existing.mjs +168 -0
- package/lib/git.mjs +47 -0
- package/lib/hosts.mjs +145 -0
- package/lib/manifest.mjs +112 -0
- package/lib/settings.mjs +280 -0
- package/lib/stacks/dotnet.mjs +165 -0
- package/lib/stacks/guided.mjs +104 -0
- package/lib/stacks/index.mjs +10 -0
- package/lib/stacks/java.mjs +56 -0
- package/lib/stacks/k8s.mjs +31 -0
- package/lib/stacks/node.mjs +235 -0
- package/lib/stacks/otel.mjs +11 -0
- package/lib/stacks/python-bootstrap.mjs +51 -0
- package/lib/stacks/python.mjs +434 -0
- package/lib/types.mjs +161 -0
- package/lib/ui.mjs +106 -0
- package/lib/workspace.mjs +198 -0
- package/package.json +42 -4
|
@@ -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 {}
|