@uknows/setup 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -32
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +149 -33
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +114 -29
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +1780 -198
- package/lib/contract.mjs +93 -10
- package/lib/detect.mjs +96 -44
- package/lib/dockerfile.mjs +83 -0
- package/lib/endpoint.mjs +36 -5
- package/lib/engine.mjs +1272 -126
- package/lib/existing.mjs +448 -54
- package/lib/git.mjs +53 -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 +521 -16
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +27 -7
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +216 -28
- 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 +336 -29
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +153 -30
- package/lib/stacks/index.mjs +10 -2
- package/lib/stacks/java.mjs +14 -13
- package/lib/stacks/node.mjs +142 -19
- package/lib/stacks/otel.mjs +56 -4
- package/lib/stacks/python-bootstrap.mjs +27 -7
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +261 -63
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +52 -5
- package/lib/ui.mjs +139 -20
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +4 -1
- package/package.json +3 -8
package/lib/stacks/index.mjs
CHANGED
|
@@ -5,6 +5,14 @@ import { dotnet } from './dotnet.mjs'
|
|
|
5
5
|
import { java } from './java.mjs'
|
|
6
6
|
import { go, php, ruby } from './guided.mjs'
|
|
7
7
|
import { otel } from './otel.mjs'
|
|
8
|
+
import { azure } from './azure.mjs'
|
|
9
|
+
import { channel } from './channel.mjs'
|
|
10
|
+
import { functions } from './functions.mjs'
|
|
8
11
|
|
|
9
|
-
/**
|
|
10
|
-
|
|
12
|
+
/**
|
|
13
|
+
* azure is not a stack of the docs: the engine uses it for every service in azure-monitor mode. channel is the agent
|
|
14
|
+
* channel (--channel): the engine uses it for a Java, .NET or Node.js service with a Dockerfile in channel mode.
|
|
15
|
+
* functions: an Azure Functions app (Node.js, Python or .NET isolated) in standard mode: 1 line in host.json.
|
|
16
|
+
* @type {Record<import('../types.mjs').StackId|'azure'|'channel', import('../types.mjs').Applier>}
|
|
17
|
+
*/
|
|
18
|
+
export const APPLIERS = { node, python, dotnet, java, go, php, ruby, otel, azure, channel, functions }
|
package/lib/stacks/java.mjs
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
|
-
// Java:
|
|
2
|
-
// the
|
|
3
|
-
// the last RUN, so build steps (mvn, gradle) never load the agent.
|
|
1
|
+
// Java: copy the pinned OpenTelemetry Java agent into the final image with the docs COPY --from line
|
|
2
|
+
// (the image digest pins the jar) and load it with JAVA_TOOL_OPTIONS. Build files are never touched.
|
|
3
|
+
// Both lines go after the last RUN, so build steps (mvn, gradle) never load the agent.
|
|
4
4
|
import { JAVA_AGENT, STACKS } from '../contract.mjs'
|
|
5
5
|
import { addOption, insertBeforeStart, parseDockerfile } from '../dockerfile.mjs'
|
|
6
6
|
import { envOverrides } from './k8s.mjs'
|
|
7
7
|
|
|
8
8
|
const DOCS = STACKS.find((s) => s.id === 'java')
|
|
9
|
-
const
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
const
|
|
9
|
+
const COPY_LINE = DOCS.dockerfile.split('\n').find((l) => l.startsWith('COPY --from='))
|
|
10
|
+
// 0.1.0 wrote an ADD line with --checksum and --chmod, which need BuildKit. Earlier /docs gave --chmod=644.
|
|
11
|
+
// A Dockerfile with one of those exact lines gets the COPY line instead.
|
|
12
|
+
const ADD_LINE = `ADD --checksum=sha256:${JAVA_AGENT.sha256} --chmod=755 ${JAVA_AGENT.url} /otel/${JAVA_AGENT.file}`
|
|
13
|
+
const OLD_LINES = [ADD_LINE, ADD_LINE.replace('--chmod=755', '--chmod=644')]
|
|
13
14
|
const AGENT_OPTION = /JAVA_TOOL_OPTIONS="([^"]+)"/.exec(DOCS.dockerfile)[1]
|
|
14
15
|
const AGENT_PATH = AGENT_OPTION.replace(/^-javaagent:/, '')
|
|
15
16
|
const note = (re) => DOCS.notes.find((n) => re.test(n))
|
|
@@ -35,19 +36,19 @@ export const java = {
|
|
|
35
36
|
}
|
|
36
37
|
const has = (value) => value.includes(AGENT_OPTION)
|
|
37
38
|
plan.manual.push(...envOverrides(service, 'JAVA_TOOL_OPTIONS', AGENT_OPTION, has))
|
|
38
|
-
const fixed =
|
|
39
|
-
const added =
|
|
39
|
+
const fixed = OLD_LINES.reduce((out, line) => out.split(line).join(COPY_LINE), text)
|
|
40
|
+
const added = [JAVA_AGENT.url, JAVA_AGENT.image, AGENT_PATH].some((s) => fixed.includes(s)) ? fixed : insertBeforeStart(fixed, [COPY_LINE])
|
|
40
41
|
const after = addOption(added, 'JAVA_TOOL_OPTIONS', AGENT_OPTION, has)
|
|
41
42
|
if (after === text) return plan
|
|
42
43
|
if (after === fixed) {
|
|
43
|
-
plan.edits.push({ file: service.dockerfile, before: text, after, why: '
|
|
44
|
-
plan.summary.push(`
|
|
44
|
+
plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Copy the OpenTelemetry Java agent from the pinned image.' })
|
|
45
|
+
plan.summary.push(`Replace the ADD line of the agent in ${service.dockerfile} with a COPY line from the pinned OpenTelemetry image. It needs no BuildKit.`)
|
|
45
46
|
return plan
|
|
46
47
|
}
|
|
47
48
|
plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Add the OpenTelemetry Java agent and load it when the JVM starts.' })
|
|
48
|
-
plan.summary.push(`Add the OpenTelemetry Java agent ${JAVA_AGENT.version} to ${service.dockerfile}
|
|
49
|
+
plan.summary.push(`Add the OpenTelemetry Java agent ${JAVA_AGENT.version} to ${service.dockerfile}, from the image pinned by its digest.`,
|
|
49
50
|
`Load the agent when the JVM starts (JAVA_TOOL_OPTIONS in ${service.dockerfile}).`)
|
|
50
|
-
plan.notes.push(note(/
|
|
51
|
+
plan.notes.push(note(/digest pins/))
|
|
51
52
|
if (info.env.has('JAVA_TOOL_OPTIONS') || info.inherited.has('JAVA_TOOL_OPTIONS')) {
|
|
52
53
|
plan.notes.push(`${service.dockerfile} already sets JAVA_TOOL_OPTIONS. A new ENV line before the start keeps that value and adds the agent.`)
|
|
53
54
|
}
|
package/lib/stacks/node.mjs
CHANGED
|
@@ -1,22 +1,37 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Node.js: add @opentelemetry/api and @opentelemetry/auto-instrumentations-node
|
|
3
|
-
* with the package manager of the repo, then load
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* with the package manager of the repo, then load OpenTelemetry when node
|
|
4
|
+
* starts: NODE_OPTIONS in the Dockerfile, or the option right after `node` in
|
|
5
|
+
* scripts.start.
|
|
6
|
+
*
|
|
7
|
+
* It loads the start file uknows-otel.cjs (install.ts NODE_REGISTER_FILE), which
|
|
8
|
+
* this tool adds next to package.json: it stops the app cleanly on SIGTERM after
|
|
9
|
+
* the last data. In an image, a COPY line in the final stage puts it in the
|
|
10
|
+
* WORKDIR. When the build context, the WORKDIR or .dockerignore makes that
|
|
11
|
+
* unsure, it loads the upstream register line instead and says why.
|
|
6
12
|
*
|
|
7
13
|
* Safety rule: node crashes at start when the register module is missing, so
|
|
8
14
|
* the start changes only when the packages surely reach the running app.
|
|
9
15
|
* Package managers run with install scripts turned off, so no code of the
|
|
10
16
|
* project or its dependencies runs.
|
|
11
17
|
*/
|
|
18
|
+
import { buildContext, finalWorkdir, ignoredBy } from '../context.mjs'
|
|
19
|
+
import { unpicked } from '../settings.mjs'
|
|
12
20
|
import { Doc, shellWords } from '../doc.mjs'
|
|
13
|
-
import { addOption, dockerStart, parseDockerfile } from '../dockerfile.mjs'
|
|
14
|
-
import { NODE_REGISTER, PINS, STACKS } from '../contract.mjs'
|
|
21
|
+
import { addOption, dockerStart, insertBeforeStart, parseDockerfile } from '../dockerfile.mjs'
|
|
22
|
+
import { NODE_REGISTER, NODE_REGISTER_FILE, NODE_START_FILE, PINS, RESOLVE, STACKS } from '../contract.mjs'
|
|
15
23
|
import { list } from '../ui.mjs'
|
|
16
24
|
import { upFrom } from '../workspace.mjs'
|
|
17
25
|
import { envOverrides } from './k8s.mjs'
|
|
18
26
|
|
|
19
27
|
const STACK = STACKS.find((s) => s.id === 'node')
|
|
28
|
+
const START_FILE = NODE_REGISTER_FILE.path
|
|
29
|
+
const START_TEXT = `${NODE_REGISTER_FILE.content}\n`
|
|
30
|
+
/** The docs option for the start file. Its steps go in the advice when this tool cannot add the file. */
|
|
31
|
+
const SIGTERM = STACK.options.find((o) => o.title.startsWith('Stops cleanly on SIGTERM'))
|
|
32
|
+
/** A NODE_OPTIONS value or a start script that loads OpenTelemetry already: the upstream register or the start file. */
|
|
33
|
+
const loads = (value) => value.includes(NODE_REGISTER) || value.includes(START_FILE)
|
|
34
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
20
35
|
const LOCKS = [['package-lock.json', 'npm'], ['npm-shrinkwrap.json', 'npm'], ['pnpm-lock.yaml', 'pnpm'], ['yarn.lock', 'yarn'],
|
|
21
36
|
['bun.lockb', 'bun'], ['bun.lock', 'bun']]
|
|
22
37
|
const DEFAULT_LOCK = { npm: 'package-lock.json', pnpm: 'pnpm-lock.yaml', yarn: 'yarn.lock' }
|
|
@@ -59,10 +74,15 @@ export const node = {
|
|
|
59
74
|
const script = typeof pkg.scripts?.start === 'string' ? pkg.scripts.start : null
|
|
60
75
|
const entry = entryOf(info ? start?.argv : service.start?.argv ?? shellWords(script ?? ''), script)
|
|
61
76
|
const esm = /\.m[jt]s$/.test(entry ?? '') || (pkg.type === 'module' && !/\.c[jt]s$/.test(entry ?? ''))
|
|
62
|
-
const flag =
|
|
77
|
+
const flag = PINS.node.flag(esm)
|
|
63
78
|
|
|
64
79
|
const guided = guidedBlock(service, ws, pkg, info, entry, flag)
|
|
65
80
|
if (guided) { out.manual.push(guided); return out }
|
|
81
|
+
// The docs settings for Node.js: only the resource detectors that ask no cloud metadata address (NETWORK.node).
|
|
82
|
+
for (const line of STACK.extraSettings?.lines ?? []) {
|
|
83
|
+
const at = line.indexOf('=')
|
|
84
|
+
out.extraSettings.push({ name: line.slice(0, at), value: line.slice(at + 1) })
|
|
85
|
+
}
|
|
66
86
|
const pm = packageManager(service.dir, pkg, ctx.repo ?? ws)
|
|
67
87
|
if (pm.outside) {
|
|
68
88
|
out.manual.push({ title: 'Run setup from the repo root', lines: [
|
|
@@ -83,10 +103,18 @@ export const node = {
|
|
|
83
103
|
return out
|
|
84
104
|
}
|
|
85
105
|
const added = wanted.filter((name) => !pkg.dependencies?.[name])
|
|
106
|
+
// npm here writes the lock that npm ci in the image reads: a newer npm than the image has stops the run.
|
|
107
|
+
const npmBlock = added.length && pm.cmd === 'npm' && pm.lock && info ? npmGuard(service, info, ctx.tools?.npm) : null
|
|
108
|
+
if (npmBlock) {
|
|
109
|
+
out.manual.push(npmBlock)
|
|
110
|
+
out.stop = true
|
|
111
|
+
return out
|
|
112
|
+
}
|
|
86
113
|
if (added.length) {
|
|
87
114
|
const lock = pm.lock ?? (pm.cmd === 'npm' ? null : join(service.dir, DEFAULT_LOCK[pm.cmd]))
|
|
88
|
-
// No install scripts
|
|
89
|
-
|
|
115
|
+
// No install scripts, no audit and no funding lines (they are about the app, not this change). npm with a
|
|
116
|
+
// lockfile and pnpm write only package.json and the lockfile.
|
|
117
|
+
const flags = pm.cmd === 'npm' ? ['--ignore-scripts', '--no-audit', '--no-fund', pm.lock ? '--package-lock-only' : '--no-package-lock']
|
|
90
118
|
: pm.cmd === 'pnpm' ? ['--lockfile-only', ...(ws.exists(join(service.dir, 'pnpm-workspace.yaml')) ? ['-w'] : [])]
|
|
91
119
|
: [pm.berry ? '--mode=skip-build' : '--ignore-scripts']
|
|
92
120
|
const why = `Add ${list(added)} with ${pm.cmd}.`
|
|
@@ -96,29 +124,45 @@ export const node = {
|
|
|
96
124
|
undo: { cmd: pm.cmd, args: [pm.cmd === 'npm' ? 'uninstall' : 'remove', ...flags, ...added] },
|
|
97
125
|
touches: [pkgFile, ...(lock ? [lock] : [])] })
|
|
98
126
|
out.summary.push(why)
|
|
99
|
-
if (lockOnly) out.notes.push(
|
|
127
|
+
if (lockOnly) out.notes.push(`${pm.cmd} updates only package.json and ${base(lock)} here, so node_modules on this computer does not have the new packages yet. Run ${pm.cmd} install before you start the app on this computer. Your build installs them from ${base(lock)}.`)
|
|
100
128
|
}
|
|
101
129
|
|
|
102
130
|
if (info) {
|
|
103
131
|
const runsNode = start && (start.argv ?? [start.raw]).flatMap((w) => w.split(/\s+/)).some((w) => RUNS_NODE.test(base(w.replace(/^["']|["']$/g, ''))))
|
|
104
132
|
if (!runsNode) out.manual.push(startBlock(flag, entry, `the start command in ${service.dockerfile}`))
|
|
105
133
|
else {
|
|
106
|
-
const
|
|
107
|
-
const
|
|
134
|
+
const current = info.env.get('NODE_OPTIONS') ?? info.inherited.get('NODE_OPTIONS')
|
|
135
|
+
const done = Boolean(current && loads(current.value))
|
|
136
|
+
const place = done ? null : placeStartFile(service, ctx.repo ?? ws, info, ctx)
|
|
137
|
+
const option = place?.path ? PINS.node.flag(esm, place.path) : flag
|
|
138
|
+
let after = docker
|
|
139
|
+
if (place?.path) {
|
|
140
|
+
addStartFile(out, ws, place.file)
|
|
141
|
+
const copied = info.final.some((x) => (x.keyword === 'COPY' || x.keyword === 'ADD') && x.args.includes(START_FILE))
|
|
142
|
+
if (!copied) after = insertBeforeStart(after, [place.copy])
|
|
143
|
+
} else if (place) {
|
|
144
|
+
out.manual.push({ title: SIGTERM.title, required: false, lines: [place.why, NODE_START_FILE.fallback, ...SIGTERM.steps], snippets: SIGTERM.snippets })
|
|
145
|
+
}
|
|
146
|
+
after = addOption(after, 'NODE_OPTIONS', option, loads)
|
|
108
147
|
if (after !== docker) {
|
|
109
|
-
out.edits.push({ file: service.dockerfile, before: docker, after, why: 'Load OpenTelemetry when node starts.' })
|
|
148
|
+
out.edits.push({ file: service.dockerfile, before: docker, after, why: place?.path ? `Load OpenTelemetry when node starts. ${NODE_START_FILE.copy}` : 'Load OpenTelemetry when node starts.' })
|
|
110
149
|
out.summary.push(`Load OpenTelemetry when node starts (NODE_OPTIONS in ${service.dockerfile}).`)
|
|
111
|
-
if (
|
|
150
|
+
if (current) {
|
|
112
151
|
out.notes.push(`${service.dockerfile} already sets NODE_OPTIONS. A new ENV line before the start keeps that value and adds the option.`)
|
|
113
152
|
}
|
|
114
153
|
}
|
|
115
|
-
out.manual.push(...envOverrides(service, 'NODE_OPTIONS',
|
|
154
|
+
out.manual.push(...envOverrides(service, 'NODE_OPTIONS', option, loads))
|
|
116
155
|
}
|
|
117
156
|
} else if (script !== null && service.start?.kind !== 'procfile' && shellWords(script)?.[0] === 'node') {
|
|
118
|
-
if (!script
|
|
119
|
-
|
|
157
|
+
if (!loads(script)) {
|
|
158
|
+
// npm start runs the script in the package folder, so ./uknows-otel.cjs is the file next to package.json.
|
|
159
|
+
// The start file is only for a start that surely has it: no Dockerfile that could build this app without it, not ignored by git.
|
|
160
|
+
const unsure = scriptStartUnsure(service, ctx)
|
|
161
|
+
const after = editStartScript(pkgText, unsure ? flag : PINS.node.flag(esm, `./${START_FILE}`))
|
|
120
162
|
if (after === null) out.manual.push(startBlock(flag, entry, `scripts.start in ${pkgFile}`))
|
|
121
163
|
else {
|
|
164
|
+
if (unsure) out.manual.push({ title: SIGTERM.title, required: false, lines: [unsure, NODE_START_FILE.fallback, ...SIGTERM.steps], snippets: SIGTERM.snippets })
|
|
165
|
+
else addStartFile(out, ws, join(service.dir, START_FILE))
|
|
122
166
|
out.edits.push({ file: pkgFile, before: pkgText, after, why: 'Load OpenTelemetry when node starts.' })
|
|
123
167
|
out.summary.push(`Load OpenTelemetry when node starts (start script in ${pkgFile}).`)
|
|
124
168
|
}
|
|
@@ -130,6 +174,85 @@ export const node = {
|
|
|
130
174
|
},
|
|
131
175
|
}
|
|
132
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Where the start file goes in the image: {file, path, copy}, or {why} when this tool is not sure. The file sits
|
|
179
|
+
* next to package.json. The COPY line takes it from the build context into the WORKDIR of the final stage.
|
|
180
|
+
*/
|
|
181
|
+
function placeStartFile(service, view, info, ctx = {}) {
|
|
182
|
+
const file = join(service.dir, START_FILE)
|
|
183
|
+
const values = { file, dockerfile: service.dockerfile }
|
|
184
|
+
const workdir = finalWorkdir(info)
|
|
185
|
+
if (!workdir) return { why: fill(NODE_START_FILE.noWorkdir, values) }
|
|
186
|
+
const context = buildContext(view, service.dockerfile, info, service.dir)
|
|
187
|
+
if (context === null) return { why: fill(NODE_START_FILE.noContext, values) }
|
|
188
|
+
if (context !== '.' && !file.startsWith(`${context}/`)) return { why: fill(NODE_START_FILE.outside, values) }
|
|
189
|
+
const source = context === '.' ? file : file.slice(context.length + 1)
|
|
190
|
+
const ignore = ignoredBy(view, context, service.dockerfile, source)
|
|
191
|
+
if (ignore) return { why: fill(NODE_START_FILE.ignored, { ...values, ignore }) }
|
|
192
|
+
// The pipeline builds from a checkout: a file that git ignores never reaches it, and the COPY line fails there.
|
|
193
|
+
if (!read(view, file) && ctx.ignored?.(file)) return { why: fill(NODE_START_FILE.gitignored, values) }
|
|
194
|
+
const dir = workdir === '/' ? '' : workdir
|
|
195
|
+
return { file, path: `${dir}/${START_FILE}`, copy: `COPY ${source} ${dir}/` }
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Why a start script must not load the start file, or null when it can: git ignores the file, so a checkout lacks it,
|
|
200
|
+
* or the repo has a Dockerfile that this tool did not pick for any service (an image built from it would not copy the
|
|
201
|
+
* file, and node stops at start when a --require file is missing). The upstream register line has no such file.
|
|
202
|
+
*/
|
|
203
|
+
function scriptStartUnsure(service, ctx) {
|
|
204
|
+
const view = ctx.repo ?? ctx.ws
|
|
205
|
+
const file = join(service.dir, START_FILE)
|
|
206
|
+
if (!read(view, file) && ctx.ignored?.(file)) return fill(NODE_START_FILE.gitignored, { file })
|
|
207
|
+
const others = unpicked(ctx)
|
|
208
|
+
return others.length ? fill(NODE_START_FILE.dockerfiles, { file, dockerfiles: list(others.slice(0, 5)) }) : null
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Add the start file when it is not there. A file that is there stays as it is: it can be the user's own copy. */
|
|
212
|
+
function addStartFile(out, ws, file) {
|
|
213
|
+
const current = read(ws, file)
|
|
214
|
+
if (current === null) {
|
|
215
|
+
out.edits.push({ file, before: null, after: START_TEXT, why: fill(NODE_START_FILE.add, { file }) })
|
|
216
|
+
out.summary.push(fill(NODE_START_FILE.add, { file }))
|
|
217
|
+
} else if (current !== START_TEXT) out.notes.push(fill(NODE_START_FILE.keep, { file }))
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* A required step when npm on this computer is newer (by major version) than the npm of the image that runs npm ci:
|
|
222
|
+
* the npm of a RUN npm install -g npm@X line, else the npm that the node image of that stage comes with (PINS.node.npm).
|
|
223
|
+
* null when the versions fit or are not known.
|
|
224
|
+
*/
|
|
225
|
+
export function npmGuard(service, info, have) {
|
|
226
|
+
const haveMajor = Number(/^(\d+)\./.exec(have ?? '')?.[1] ?? NaN)
|
|
227
|
+
if (!Number.isFinite(haveMajor)) return null
|
|
228
|
+
const runs = (stage) => stage.instructions.filter((x) => x.keyword === 'RUN')
|
|
229
|
+
const text = (x) => (x.argv ? x.argv.join(' ') : x.args)
|
|
230
|
+
let image = null, line = null, need = null
|
|
231
|
+
for (const stage of info.stages) {
|
|
232
|
+
for (const x of runs(stage)) {
|
|
233
|
+
const pin = /\bnpm\s+(?:install|i)\s+(?:-g|--global)\s+npm@(\d+)/.exec(text(x))
|
|
234
|
+
if (pin) { need = Number(pin[1]); image = `npm@${pin[1]}`; line = x.start + 1 }
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
if (need === null) {
|
|
238
|
+
const stages = info.stages, byName = new Map(stages.filter((st) => st.name).map((st) => [st.name.toLowerCase(), st]))
|
|
239
|
+
const stage = stages.find((st) => runs(st).some((x) => /\bnpm\s+(?:ci|install|i)\b/.test(text(x)))) ?? stages.at(-1)
|
|
240
|
+
if (!stage) return null
|
|
241
|
+
let from = stage.image
|
|
242
|
+
for (let hop = 0; hop < 5 && byName.has(from.toLowerCase()); hop++) from = byName.get(from.toLowerCase()).image
|
|
243
|
+
const m = /(?:^|\/)node:(\d+)(?:\.(\d+))?/.exec(from)
|
|
244
|
+
if (!m) return null
|
|
245
|
+
const v = [Number(m[1]), m[2] === undefined ? 99 : Number(m[2])]
|
|
246
|
+
need = PINS.node.npm.find(([[a, b]]) => v[0] > a || (v[0] === a && v[1] >= b))?.[1] ?? null
|
|
247
|
+
image = from
|
|
248
|
+
line = (info.instructions.find((x) => x.keyword === 'FROM' && x.args.split(/\s+/).includes(stage.image))?.start ?? 0) + 1
|
|
249
|
+
}
|
|
250
|
+
if (need === null || haveMajor <= need) return null
|
|
251
|
+
const values = { file: service.dockerfile, line, image, need, have }
|
|
252
|
+
const fill = (t) => t.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
253
|
+
return { title: fill(RESOLVE.npm.title), lines: RESOLVE.npm.lines.map(fill) }
|
|
254
|
+
}
|
|
255
|
+
|
|
133
256
|
/** Cases where loading the register module cannot work: guided only, no packages. */
|
|
134
257
|
function guidedBlock(service, ws, pkg, info, entry, flag) {
|
|
135
258
|
if (service.frameworks.includes('next')) {
|
|
@@ -140,7 +263,7 @@ function guidedBlock(service, ws, pkg, info, entry, flag) {
|
|
|
140
263
|
const install = info.final.some((x) => x.keyword === 'RUN' && /\b(npm (ci|install|i)|pnpm (install|i)|yarn)\b/.test(x.args))
|
|
141
264
|
const copies = info.final.filter((x) => x.keyword === 'COPY' || x.keyword === 'ADD')
|
|
142
265
|
if (!install && !copies.some((x) => /node_modules/.test(x.args) || !/\.output/.test(x.args))) {
|
|
143
|
-
return { title: 'Put node_modules in the final image', lines: [`The final stage of ${service.dockerfile} copies only the built server, so node cannot load OpenTelemetry.`,
|
|
266
|
+
return { title: 'Put node_modules in the final image', verify: true, lines: [`The final stage of ${service.dockerfile} copies only the built server, so node cannot load OpenTelemetry.`,
|
|
144
267
|
'Install the packages in the final stage, or copy node_modules into it.', 'Then run npx @uknows/setup again.'] }
|
|
145
268
|
}
|
|
146
269
|
}
|
|
@@ -160,7 +283,7 @@ function guidedBlock(service, ws, pkg, info, entry, flag) {
|
|
|
160
283
|
* The search goes on above the --path folder, up to the top of the git repo (view.top). A workspace root or
|
|
161
284
|
* its lockfile up there is `outside`: this tool cannot update it.
|
|
162
285
|
*/
|
|
163
|
-
function packageManager(dir, pkg, view) {
|
|
286
|
+
export function packageManager(dir, pkg, view) {
|
|
164
287
|
const field = typeof pkg.packageManager === 'string' ? /^(npm|pnpm|yarn|bun)@(\d+)/.exec(pkg.packageManager) : null
|
|
165
288
|
let cmd = field ? field[1] : typeof pkg.packageManager === 'string' ? 'other' : null
|
|
166
289
|
let lock = null, outside = null
|
|
@@ -181,7 +304,7 @@ function packageManager(dir, pkg, view) {
|
|
|
181
304
|
}
|
|
182
305
|
|
|
183
306
|
/** The JS file that node runs, from a start argv. `npm start` and friends read scripts.start. */
|
|
184
|
-
function entryOf(argv, script) {
|
|
307
|
+
export function entryOf(argv, script) {
|
|
185
308
|
let words = argv ?? null
|
|
186
309
|
if (words && /^(npm|yarn|pnpm)$/.test(base(words[0])) && (words[1] === 'start' || (words[1] === 'run' && words[2] === 'start'))) words = shellWords(script ?? '')
|
|
187
310
|
if (!words?.length) return null
|
package/lib/stacks/otel.mjs
CHANGED
|
@@ -1,11 +1,63 @@
|
|
|
1
|
-
// The app already uses OpenTelemetry: keep its packages and start command. settings.mjs adds the
|
|
2
|
-
|
|
1
|
+
// The app already uses OpenTelemetry: keep its packages and start command. settings.mjs adds the 3 shared
|
|
2
|
+
// settings (no none lines, so the metrics and logs of the app keep going where they go now).
|
|
3
|
+
import { AZURE_MONITOR, DOCS_URL, DOTNET_TRACES_ONLY, INGEST_ENDPOINT, OTHER_EXPORTERS_NOTE, STACKS, TRACES_ONLY_NOTE, TRACES_ONLY_PLAN } from '../contract.mjs'
|
|
4
|
+
|
|
5
|
+
const STACK = STACKS.find((s) => s.id === 'otel')
|
|
6
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The notes of the docs page, and after its .NET note the 2 notes that only setup prints: the pages name UKNOWS only,
|
|
10
|
+
* and these name products that setup finds in a repo.
|
|
11
|
+
*/
|
|
12
|
+
export const OTEL_NOTES = STACK.notes.flatMap((n) => (n === DOTNET_TRACES_ONLY.note ? [n, AZURE_MONITOR.otelNote, OTHER_EXPORTERS_NOTE] : [n]))
|
|
13
|
+
|
|
14
|
+
/** The notes that name languages or features. A run keeps the parts that fit what it found. */
|
|
15
|
+
const NOTE = {
|
|
16
|
+
/** "For Python, use ... For Go, use ...": 1 sentence for each language. */
|
|
17
|
+
exporter: OTEL_NOTES.find((n) => /^For Python, use /.test(n)),
|
|
18
|
+
collector: OTEL_NOTES.find((n) => /Collector/.test(n)),
|
|
19
|
+
azure: AZURE_MONITOR.otelNote,
|
|
20
|
+
}
|
|
21
|
+
/** The label that each sentence of NOTE.exporter starts with. */
|
|
22
|
+
const EXPORTER_LANG = { python: 'Python', go: 'Go' }
|
|
23
|
+
/** The SDKs that send metrics and logs over OTLP by default. settings.mjs uses the traces-only settings for them. */
|
|
24
|
+
const SDK_SIGNALS = new Set(['node', 'python', 'java'])
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The notes of the docs page for this run. With no language known (the menu pick "I already use OpenTelemetry"),
|
|
28
|
+
* every note stays. Else only the notes for this language and for what the repo uses:
|
|
29
|
+
* - the exporter sentence of this language only (Python, Go),
|
|
30
|
+
* - the traces-only note only when settings.mjs did not already say what this run does with traces only,
|
|
31
|
+
* and in a form for this language,
|
|
32
|
+
* - the .NET note only for .NET, and only when this run does not use the traces-only settings,
|
|
33
|
+
* - the Azure Monitor note only when the code calls Azure Monitor,
|
|
34
|
+
* - the other-exporters note never: existing.mjs gives it as advice when the repo has another exporter.
|
|
35
|
+
* @param {string[]} notes
|
|
36
|
+
* @param {{lang?: string|null, existing?: Partial<import('../types.mjs').Existing>, tracesOnly?: boolean, endpoint?: string, docs?: string}} found
|
|
37
|
+
* endpoint and docs: the ingest address and the docs page of this run.
|
|
38
|
+
* @returns {string[]}
|
|
39
|
+
*/
|
|
40
|
+
export function otelNotes(notes, { lang = null, existing = {}, tracesOnly = false, endpoint = INGEST_ENDPOINT, docs = DOCS_URL } = {}) {
|
|
41
|
+
if (!lang) return notes
|
|
42
|
+
const anchors = existing.anchors ?? []
|
|
43
|
+
return notes.flatMap((note) => {
|
|
44
|
+
if (note === NOTE.exporter) {
|
|
45
|
+
const label = EXPORTER_LANG[lang]
|
|
46
|
+
return note.split(/(?<=\.) (?=For )/).filter((sentence) => label && sentence.startsWith(`For ${label}, `))
|
|
47
|
+
}
|
|
48
|
+
if (note === NOTE.collector) return [`${note} See ${docs}?method=manual&stack=otel#install-app.`]
|
|
49
|
+
if (note === TRACES_ONLY_NOTE) return tracesOnly || SDK_SIGNALS.has(lang) ? [] : [fill(TRACES_ONLY_PLAN.none, { endpoint })]
|
|
50
|
+
if (note === DOTNET_TRACES_ONLY.note) return lang === 'dotnet' && !tracesOnly ? [note] : []
|
|
51
|
+
if (note === NOTE.azure) return anchors.some((a) => a.kind === 'azure-call' || a.kind === 'appinsights-call') ? [note] : []
|
|
52
|
+
if (note === OTHER_EXPORTERS_NOTE) return []
|
|
53
|
+
return [note]
|
|
54
|
+
})
|
|
55
|
+
}
|
|
3
56
|
|
|
4
57
|
/** @type {import('../types.mjs').Applier} */
|
|
5
58
|
export const otel = {
|
|
6
59
|
id: 'otel',
|
|
7
60
|
plan() {
|
|
8
|
-
return { summary: [
|
|
9
|
-
extraSettings: [], manual: [], notes: [...STACKS.find((s) => s.id === 'otel').notes] }
|
|
61
|
+
return { summary: [STACK.summary], edits: [], commands: [], extraSettings: [], manual: [], notes: [...OTEL_NOTES] }
|
|
10
62
|
},
|
|
11
63
|
}
|
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
*
|
|
8
8
|
* Left out on purpose: openai-v2 and vertexai (not pinned to 0.66b1),
|
|
9
9
|
* psutil -> system-metrics (metrics are off) and the upstream quirk that maps
|
|
10
|
-
* pydantic to tortoiseorm.
|
|
10
|
+
* pydantic to tortoiseorm. The default logging package comes only with the UKNOWS
|
|
11
|
+
* log bridge (logs: true). On its own, in 0.66b1, it adds an OpenTelemetry
|
|
12
|
+
* LoggingHandler to the root logger, also with OTEL_LOGS_EXPORTER=none. Then Python
|
|
13
|
+
* never uses its last-resort handler, so the warnings and errors of loggers with no
|
|
14
|
+
* handler no longer reach stderr. With the bridge, OTEL_PYTHON_LOG_AUTO_INSTRUMENTATION
|
|
15
|
+
* =false keeps it off the root logger and the package gives only its LoggingHandler.
|
|
11
16
|
*/
|
|
17
|
+
import { compareVersion } from '../versions.mjs'
|
|
12
18
|
|
|
13
19
|
/** Always printed. */
|
|
14
|
-
export const DEFAULTS = ['asyncio', 'dbapi', 'exceptions', '
|
|
20
|
+
export const DEFAULTS = ['asyncio', 'dbapi', 'exceptions', 'sqlite3', 'threading', 'urllib', 'wsgi']
|
|
15
21
|
|
|
16
22
|
/** [normalized library name, instrumentation suffixes], in upstream order. */
|
|
17
23
|
export const LIBRARIES = [
|
|
@@ -29,6 +35,15 @@ export const LIBRARIES = [
|
|
|
29
35
|
['tortoise-orm', ['tortoiseorm']], ['urllib3', ['urllib3']],
|
|
30
36
|
]
|
|
31
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The first contrib release of each instrumentation package on PyPI (tools/check-python-sets.mjs). A set older than
|
|
40
|
+
* this leaves the package out, because it does not exist there. Packages missing here are older than every set.
|
|
41
|
+
*/
|
|
42
|
+
export const SINCE = { exceptions: '0.64b0', structlog: '0.65b0', asyncclick: '0.53b0', pymssql: '0.51b0', click: '0.50b0',
|
|
43
|
+
aiokafka: '0.49b0', threading: '0.46b0', psycopg: '0.45b0', asyncio: '0.44b0', 'aiohttp-server': '0.42b0', cassandra: '0.41b0',
|
|
44
|
+
mysqlclient: '0.40b0', tortoiseorm: '0.36b0', 'aio-pika': '0.32b0', boto3sqs: '0.32b0', 'confluent-kafka': '0.32b0',
|
|
45
|
+
remoulade: '0.32b0', 'kafka-python': '0.28b0', pika: '0.25b0', httpx: '0.23b0', urllib3: '0.20b0', urllib: '0.17b0' }
|
|
46
|
+
|
|
32
47
|
/** Packages that a framework always installs, so upstream would see them too. */
|
|
33
48
|
export const IMPLIED = { fastapi: ['starlette'], django: ['asgiref'], flask: ['jinja2'], requests: ['urllib3'], boto3: ['botocore'] }
|
|
34
49
|
|
|
@@ -37,15 +52,20 @@ export const normalize = (name) => name.toLowerCase().replace(/[-_.]+/g, '-')
|
|
|
37
52
|
|
|
38
53
|
/**
|
|
39
54
|
* The instrumentation packages for these requirement names: the defaults,
|
|
40
|
-
* then 1 per matching library in table order, without repeats.
|
|
55
|
+
* then 1 per matching library in table order, without repeats. Packages that
|
|
56
|
+
* are newer than version (SINCE) are left out.
|
|
41
57
|
* @param {Iterable<string>} names normalized requirement names
|
|
42
|
-
* @param {string} version e.g. '0.66b1'
|
|
58
|
+
* @param {string} version the contrib release of the set, e.g. '0.66b1'
|
|
59
|
+
* @param {{floor?: string|null, logs?: boolean}} [options] floor: write '>=' limits from this release (or the first
|
|
60
|
+
* release of the package, when it is newer) instead of '==' pins, for uv and Poetry projects. logs: add the logging
|
|
61
|
+
* package, for the UKNOWS log bridge
|
|
43
62
|
* @returns {string[]} e.g. ['opentelemetry-instrumentation-asyncio==0.66b1', ...]
|
|
44
63
|
*/
|
|
45
|
-
export function bootstrap(names, version) {
|
|
64
|
+
export function bootstrap(names, version, { floor = null, logs = false } = {}) {
|
|
46
65
|
const have = new Set(names)
|
|
47
66
|
for (const name of [...have]) for (const extra of IMPLIED[name] ?? []) have.add(extra)
|
|
48
|
-
const suffixes = [...DEFAULTS]
|
|
67
|
+
const suffixes = [...DEFAULTS, ...(logs ? ['logging'] : [])]
|
|
49
68
|
for (const [library, list] of LIBRARIES) if (have.has(library)) suffixes.push(...list)
|
|
50
|
-
return [...new Set(suffixes)].
|
|
69
|
+
return [...new Set(suffixes)].filter((x) => !SINCE[x] || compareVersion(SINCE[x], version) <= 0)
|
|
70
|
+
.map((x) => `opentelemetry-instrumentation-${x}${floor ? `>=${[floor, SINCE[x] ?? floor].sort(compareVersion).at(-1)}` : `==${version}`}`)
|
|
51
71
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The lock refresh of uv and Poetry projects (uv lock in the workspace root, poetry lock), shared by the Python and
|
|
3
|
+
* the Azure Monitor appliers. The tool on this computer writes the lock, and the image reads it, so the run stops
|
|
4
|
+
* with a required step when:
|
|
5
|
+
* - the lock is above the --path folder (the command would write outside it),
|
|
6
|
+
* - the tool is missing (the engine probed it: ctx.tools[tool] === null),
|
|
7
|
+
* - it does not meet [tool.uv] required-version (or uv.toml), [tool.poetry] requires-poetry, or the version that
|
|
8
|
+
* the Dockerfile pins (COPY --from=ghcr.io/astral-sh/uv:0.11.29, pip install uv==X, the astral.sh/uv/<version>/install.sh
|
|
9
|
+
* installer with ENV UV_VERSION, POETRY_VERSION).
|
|
10
|
+
* After the run, the engine rolls back when the lockfile format line changed (keep): version = N in uv.lock,
|
|
11
|
+
* lock-version in poetry.lock. An older tool in the image cannot read a newer format.
|
|
12
|
+
*/
|
|
13
|
+
import { posix } from 'node:path'
|
|
14
|
+
import { RESOLVE } from '../contract.mjs'
|
|
15
|
+
import { parseDockerfile } from '../dockerfile.mjs'
|
|
16
|
+
import { tomlString, tomlTable } from '../toml.mjs'
|
|
17
|
+
import { satisfies } from '../versions.mjs'
|
|
18
|
+
|
|
19
|
+
/** Lockfile name -> tool. PDM has no automatic refresh. */
|
|
20
|
+
export const LOCK_TOOLS = { 'uv.lock': 'uv', 'poetry.lock': 'poetry' }
|
|
21
|
+
const KEEP = { 'uv.lock': '^version = \\d+$', 'poetry.lock': '^lock-version = "[^"]*"$' }
|
|
22
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
23
|
+
const read = (view, path) => { try { return view.read(path) } catch { return null } }
|
|
24
|
+
const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The lock command for lockFile, or the required step that replaces it.
|
|
28
|
+
* @param {{view: any, lockFile: string, tools?: Record<string, string|null>, dockerfile?: string|null, docker?: string|null, packages: string}} opts
|
|
29
|
+
* packages: what the lock adds, for the required step ('the OpenTelemetry packages').
|
|
30
|
+
* @returns {{command: import('../types.mjs').Command}|{block: import('../types.mjs').Block}|null} null for a lockfile with no tool (pdm.lock).
|
|
31
|
+
*/
|
|
32
|
+
export function lockStep({ view, lockFile, tools, dockerfile = null, docker = null, packages }) {
|
|
33
|
+
const name = posix.basename(lockFile), tool = LOCK_TOOLS[name]
|
|
34
|
+
if (!tool) return null
|
|
35
|
+
const dir = posix.dirname(lockFile)
|
|
36
|
+
const version = tools && Object.prototype.hasOwnProperty.call(tools, tool) ? tools[tool] : undefined
|
|
37
|
+
const poetry1 = tool === 'poetry' && /^1\./.test(version ?? '')
|
|
38
|
+
const args = poetry1 ? ['lock', '--no-update'] : ['lock']
|
|
39
|
+
const command = [tool, ...args].join(' ')
|
|
40
|
+
if (lockFile.startsWith('../')) {
|
|
41
|
+
return { block: { title: RESOLVE.outside.title, lines: RESOLVE.outside.lines.map((l) => fill(l, { lock: lockFile, command, dir })) } }
|
|
42
|
+
}
|
|
43
|
+
const needs = toolNeeds(view, dir, tool, dockerfile, docker)
|
|
44
|
+
const pinned = needs.find((n) => n.exact)
|
|
45
|
+
const block = (reason) => ({ title: fill(RESOLVE.tool.title, { tool, version: pinned ? ` ${pinned.version}` : '' }),
|
|
46
|
+
lines: [fill(RESOLVE.tool.why, { lock: lockFile, tool, packages }), reason,
|
|
47
|
+
fill(RESOLVE.tool.install, { tool, version: pinned ? ` ${pinned.version}` : '' }), RESOLVE.again] })
|
|
48
|
+
if (version === null) return { block: block(fill(RESOLVE.tool.missing, { tool })) }
|
|
49
|
+
if (version) {
|
|
50
|
+
const unmet = needs.find((n) => !satisfies(version, n.spec))
|
|
51
|
+
if (unmet) {
|
|
52
|
+
return { block: block(fill(unmet.line ? RESOLVE.tool.image : RESOLVE.tool.version,
|
|
53
|
+
{ file: unmet.file, line: unmet.line, tool, need: unmet.version ?? unmet.spec, have: version })) }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { command: { cwd: dir, cmd: tool, args, touches: [lockFile], added: [], kind: 'lock', undo: null,
|
|
57
|
+
keep: [{ file: lockFile, pattern: KEEP[name] }], why: fill(RESOLVE.update, { lock: name, command }) } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The versions of the tool that the project asks for: required-version (uv) or requires-poetry in the pyproject.toml
|
|
62
|
+
* next to the lock, uv.toml, and the version the Dockerfile installs. exact: a pinned image or package version.
|
|
63
|
+
* @returns {{spec: string, version?: string, file: string, line?: number, exact: boolean}[]}
|
|
64
|
+
*/
|
|
65
|
+
export function toolNeeds(view, dir, tool, dockerfile, docker) {
|
|
66
|
+
const out = []
|
|
67
|
+
const pyproject = read(view, at(dir, 'pyproject.toml'))
|
|
68
|
+
if (tool === 'uv') {
|
|
69
|
+
const spec = tomlString(tomlTable(pyproject ?? '', 'tool.uv'), 'required-version')
|
|
70
|
+
if (spec) out.push({ spec, file: at(dir, 'pyproject.toml'), exact: false })
|
|
71
|
+
const own = tomlString(read(view, at(dir, 'uv.toml')) ?? '', 'required-version')
|
|
72
|
+
if (own) out.push({ spec: own, file: at(dir, 'uv.toml'), exact: false })
|
|
73
|
+
} else {
|
|
74
|
+
const spec = tomlString(tomlTable(pyproject ?? '', 'tool.poetry'), 'requires-poetry')
|
|
75
|
+
if (spec) out.push({ spec, file: at(dir, 'pyproject.toml'), exact: false })
|
|
76
|
+
}
|
|
77
|
+
if (docker && dockerfile) {
|
|
78
|
+
const image = tool === 'uv' ? /\bghcr\.io\/astral-sh\/uv:(\d+(?:\.\d+){0,2})(?=[\s@-]|$)/ : null
|
|
79
|
+
const pip = new RegExp(`\\bpip3?\\s+install\\b[^\\n&|;]*?["']?\\b${tool}==(\\d+(?:\\.\\d+){1,2})\\b`)
|
|
80
|
+
const env = tool === 'poetry' ? /\bPOETRY_VERSION=["']?(\d+(?:\.\d+){1,2})\b/ : null
|
|
81
|
+
// The versioned installer script: astral.sh/uv/0.11.29/install.sh, or astral.sh/uv/${UV_VERSION}/install.sh with an
|
|
82
|
+
// ENV or ARG UV_VERSION line before it.
|
|
83
|
+
const script = tool === 'uv' ? /astral\.sh\/uv\/(?:\$\{?UV_VERSION\}?|(\d+(?:\.\d+){1,2}))\/install\.sh/ : null
|
|
84
|
+
const instructions = parseDockerfile(docker).instructions
|
|
85
|
+
const uvVersion = (upTo) => instructions.slice(0, upTo).filter((y) => y.keyword === 'ENV' || y.keyword === 'ARG')
|
|
86
|
+
.map((y) => ({ y, m: /\bUV_VERSION=["']?(\d+(?:\.\d+){1,2})\b/.exec(y.args) })).filter((e) => e.m).at(-1) ?? null
|
|
87
|
+
for (const [k, x] of instructions.entries()) {
|
|
88
|
+
const text = x.argv ? x.argv.join(' ') : x.args
|
|
89
|
+
let m = (image && (x.keyword === 'FROM' || x.keyword === 'COPY') && image.exec(text)) || (x.keyword === 'RUN' && pip.exec(text))
|
|
90
|
+
|| (env && (x.keyword === 'ENV' || x.keyword === 'ARG') && env.exec(text))
|
|
91
|
+
let at = x
|
|
92
|
+
const run = !m && script && x.keyword === 'RUN' ? script.exec(text) : null
|
|
93
|
+
if (run) {
|
|
94
|
+
const found = run[1] ? null : uvVersion(k)
|
|
95
|
+
m = run[1] ? run : found?.m ?? null
|
|
96
|
+
at = found?.y ?? x
|
|
97
|
+
}
|
|
98
|
+
if (!m) continue
|
|
99
|
+
const v = m[1]
|
|
100
|
+
out.push({ spec: v.split('.').length < 3 ? `==${v}.*` : `==${v}`, version: v, file: dockerfile, line: at.start + 1, exact: true })
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out
|
|
105
|
+
}
|