@uknows/setup 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +99 -32
  2. package/lib/automate.mjs +1299 -0
  3. package/lib/check.mjs +149 -33
  4. package/lib/ci.mjs +21 -0
  5. package/lib/cli.mjs +114 -29
  6. package/lib/cloudid.mjs +456 -0
  7. package/lib/code.mjs +147 -0
  8. package/lib/context.mjs +126 -0
  9. package/lib/contract-docs.mjs +1780 -198
  10. package/lib/contract.mjs +93 -10
  11. package/lib/detect.mjs +96 -44
  12. package/lib/dockerfile.mjs +83 -0
  13. package/lib/endpoint.mjs +36 -5
  14. package/lib/engine.mjs +1272 -126
  15. package/lib/existing.mjs +448 -54
  16. package/lib/git.mjs +53 -0
  17. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  18. package/lib/hooks/pins.mjs +28 -0
  19. package/lib/hooks/uknows-otel.cjs +82 -0
  20. package/lib/hooks/uknows_otel.py +179 -0
  21. package/lib/hosts.mjs +521 -16
  22. package/lib/locks.mjs +353 -0
  23. package/lib/manifest.mjs +27 -7
  24. package/lib/members.mjs +269 -0
  25. package/lib/plan.mjs +130 -0
  26. package/lib/settings.mjs +216 -28
  27. package/lib/signin.mjs +259 -0
  28. package/lib/stacks/azure.mjs +345 -0
  29. package/lib/stacks/channel.mjs +125 -0
  30. package/lib/stacks/dotnet.mjs +336 -29
  31. package/lib/stacks/functions-hooks.mjs +228 -0
  32. package/lib/stacks/functions.mjs +148 -0
  33. package/lib/stacks/guided.mjs +153 -30
  34. package/lib/stacks/index.mjs +10 -2
  35. package/lib/stacks/java.mjs +14 -13
  36. package/lib/stacks/node.mjs +142 -19
  37. package/lib/stacks/otel.mjs +56 -4
  38. package/lib/stacks/python-bootstrap.mjs +27 -7
  39. package/lib/stacks/python-lock.mjs +105 -0
  40. package/lib/stacks/python-sets.mjs +143 -0
  41. package/lib/stacks/python.mjs +261 -63
  42. package/lib/toml.mjs +64 -0
  43. package/lib/types.mjs +52 -5
  44. package/lib/ui.mjs +139 -20
  45. package/lib/updates.mjs +398 -0
  46. package/lib/versions.mjs +115 -0
  47. package/lib/workspace.mjs +4 -1
  48. package/package.json +3 -8
@@ -1,9 +1,26 @@
1
1
  // .NET: add the OpenTelemetry.AutoInstrumentation package to the web project and start the app with instrument.sh.
2
2
  // The csproj is edited as text (no dotnet add), so its BOM, line endings and layout stay. Directory.*.props files
3
3
  // above the --path folder count too: with central package versions there, the step is manual.
4
- import { PINS, STACKS } from '../contract.mjs'
4
+ //
5
+ // StackExchange.Redis in [2.6.122, 4.0.0) needs 1 more package, or the build check of the package fails. It counts
6
+ // when the lock or obj/project.assets.json resolves it (also through NRedisStack), else when the project references it
7
+ // or a package that is known to bring it (PINS.dotnet.redis.carriers).
8
+ //
9
+ // Locked restore (packages.lock.json, RestorePackagesWithLockFile, RestoreLockedMode, or --locked-mode in the
10
+ // Dockerfile or a workflow): the tool runs dotnet restore <csproj> --force-evaluate, as a lock command, with the -r of
11
+ // the runtime that the lock has packages for (none when the lock has none). With no lock yet, the -r of the Dockerfile
12
+ // restore. The engine records the lock it rewrites as a text change, runs it after the edits and
13
+ // rolls every file back when it fails, so the start changes only with a lock that has the packages. After the
14
+ // restore, obj/project.assets.json shows the packages the build check sees: a Redis that the plan did not know about
15
+ // rolls the run back, and the next run plans it. A Dockerfile that restores with --network=none cannot get new
16
+ // packages: the run changes nothing and prints the steps.
17
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
18
+ import { join as joinPath, posix, relative, resolve } from 'node:path'
19
+ import { PINS, RESOLVE, STACKS } from '../contract.mjs'
5
20
  import { Doc } from '../doc.mjs'
6
- import { dockerStart, parseDockerfile, prefixStart } from '../dockerfile.mjs'
21
+ import { dockerStart, finalImage, parseDockerfile, prefixStart } from '../dockerfile.mjs'
22
+ import { nugetAssets } from '../locks.mjs'
23
+ import { compareVersion } from '../versions.mjs'
7
24
  import { upFrom } from '../workspace.mjs'
8
25
  import { startOverrides } from './k8s.mjs'
9
26
 
@@ -14,9 +31,13 @@ const ENTRYPOINT = DOCS.dockerfile.split('\n').find((l) => l.startsWith('ENTRYPO
14
31
  const SCRIPT_NOTE = DOCS.notes.find((n) => /instrument\.sh/.test(n))
15
32
  const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
16
33
  const dirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
34
+ const base = (path) => path.split('/').pop()
17
35
  const above = (path) => path.startsWith('../')
18
36
  const read = (view, path) => { try { return view.read(path) } catch { return null } }
19
37
  const tag = (text, name) => new RegExp(`<${name}>\\s*([^<]*?)\\s*</${name}>`, 'i').exec(text)?.[1] ?? null
38
+ const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
39
+ const inRedisRange = (v) => compareVersion(v, REDIS.range[0]) >= 0 && compareVersion(v, REDIS.range[1]) < 0
40
+ const LOCK = 'packages.lock.json'
20
41
 
21
42
  /** @type {import('../types.mjs').Applier} */
22
43
  export const dotnet = {
@@ -24,8 +45,7 @@ export const dotnet = {
24
45
  plan(service, ctx) {
25
46
  const { ws } = ctx
26
47
  const plan = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
27
- const projects = [...service.manifests, ...ws.list(service.dir).map((n) => at(service.dir, n))].filter((p) => p.endsWith('.csproj'))
28
- const csproj = projects.find((p) => /Microsoft\.NET\.Sdk\.Web/.test(ws.read(p) ?? '')) ?? projects[0]
48
+ const csproj = webProject(service, ws)
29
49
  if (!csproj) {
30
50
  plan.manual.push({ title: 'Add OpenTelemetry to your .NET app', lines: ['This tool did not find the .csproj file of the web app.',
31
51
  `Run dotnet add package ${PACKAGE} in the folder of the web app.`, 'Then start the app with instrument.sh.', SCRIPT_NOTE] })
@@ -43,23 +63,320 @@ export const dotnet = {
43
63
  return plan
44
64
  }
45
65
  if (major === null) plan.notes.push(`This tool could not read the target framework of ${csproj}. ${USE_NET8}`)
46
- const added = addPackages(csproj, text, props, plan)
66
+ const dll = `${tag(text, 'AssemblyName') ?? csproj.split('/').pop().replace(/\.csproj$/, '')}.dll`
67
+ const redis = redisNeed(text, ctx.installed ?? ctx.existing?.installed ?? null, props('Directory.Packages.props')[0]?.text ?? null)
68
+ const wanted = [[PACKAGE, VERSION], ...(redis.add ? [[REDIS.package, REDIS.version]] : [])]
69
+ if (redis.note) plan.notes.push(redis.note)
70
+
71
+ // A restore that cannot run, or cannot get the packages, changes nothing: the steps are printed instead.
72
+ const guided = (block) => {
73
+ const code = [...wanted.map(([name, v]) => `dotnet add package ${name} --version ${v}`)]
74
+ plan.manual.push({ ...block, snippets: [{ caption: 'Terminal', language: 'bash', code: code.join('\n') },
75
+ { caption: service.dockerfile ? 'Dockerfile' : 'Start', language: service.dockerfile ? 'dockerfile' : 'bash',
76
+ code: service.dockerfile ? ENTRYPOINT.replace('OrdersApi.dll', dll) : DOCS.start.replace('OrdersApi.dll', dll) }] })
77
+ plan.stop = true
78
+ return plan
79
+ }
80
+ const docker = service.dockerfile ? read(ws, service.dockerfile) : null
81
+ const offline = docker === null ? null : offlineRestore(docker)
82
+ // New packages need a restore: they are not in the csproj yet, or not in its packages.lock.json yet.
83
+ const lockText = read(view, at(dirOf(csproj), LOCK))
84
+ const stale = wanted.some(([name]) => !hasReference(text, name) || (lockText !== null && !new RegExp(`"${name.replace(/\./g, '\\.')}"\\s*:`, 'i').test(lockText)))
85
+ if (offline && stale) {
86
+ return guided({ title: RESOLVE.dotnet.offline.title, lines: RESOLVE.dotnet.offline.lines.map((l) => fill(l, { file: service.dockerfile, line: offline.line, csproj })) })
87
+ }
88
+ const restore = stale ? restoreStep(service, ctx, csproj, major) : null
89
+ if (restore?.block) return guided(restore.block)
90
+
91
+ const added = addPackages(csproj, text, props, plan, wanted, redis)
47
92
  if (added !== true) {
48
93
  plan.stop = added === 'outside'
49
94
  return plan
50
95
  }
51
- const dll = `${tag(text, 'AssemblyName') ?? csproj.split('/').pop().replace(/\.csproj$/, '')}.dll`
96
+ if (restore?.command) {
97
+ // After the restore, a Redis that the plan did not know about must stop the run (the build check would fail).
98
+ if (!redis.add && !hasReference(text, REDIS.package)) {
99
+ restore.command.verify = { assets: at(dirOf(csproj), 'obj/project.assets.json'), library: REDIS.library, range: REDIS.range,
100
+ package: REDIS.package, version: REDIS.version }
101
+ }
102
+ plan.commands.push(...restore.before, restore.command)
103
+ plan.notes.push(...restore.notes)
104
+ if (restore.note) plan.notes.push(restore.note)
105
+ }
52
106
  // The docs settings for .NET add a log folder that the app can write. Without it, the app can stop at start.
53
107
  for (const line of DOCS.extraSettings?.lines ?? []) {
54
108
  const at = line.indexOf('=')
55
109
  plan.extraSettings.push({ name: line.slice(0, at), value: line.slice(at + 1) })
56
110
  }
57
- addStart(ws, service, dll, plan)
111
+ addStart(ws, service, dll, plan, { csproj, text })
58
112
  return plan
59
113
  },
60
114
  }
61
115
 
62
- function targetFramework(text, buildProps) {
116
+ /** The web project of the service: the csproj with Microsoft.NET.Sdk.Web, else the first one. */
117
+ export function webProject(service, ws) {
118
+ const projects = [...service.manifests, ...ws.list(service.dir).map((n) => at(service.dir, n))].filter((p) => p.endsWith('.csproj'))
119
+ return projects.find((p) => /Microsoft\.NET\.Sdk\.Web/.test(read(ws, p) ?? '')) ?? projects[0] ?? null
120
+ }
121
+
122
+ const hasReference = (text, name) => new RegExp(`<PackageReference\\s[^>]*?Include\\s*=\\s*"${name.replace(/\./g, '\\.')}"`, 'i').test(text)
123
+
124
+ /**
125
+ * Does the build check need the Redis package? From the lock or assets file when there is one (the resolved
126
+ * StackExchange.Redis, also through another package), else from the project: a StackExchange.Redis reference in the
127
+ * range, or a package that brings it in.
128
+ * @returns {{add: boolean, version: string|null, via: string[], note: string|null}}
129
+ */
130
+ function redisNeed(text, installed, props) {
131
+ const no = { add: false, version: null, via: [], note: null }
132
+ if (hasReference(text, REDIS.package)) return no
133
+ const resolved = installed?.packages.get(REDIS.library.toLowerCase())
134
+ if (resolved?.version) {
135
+ const via = resolved.direct ? [] : resolved.via.map((k) => installed.packages.get(k)?.name ?? k)
136
+ return { add: inRedisRange(resolved.version), version: resolved.version, via, note: null }
137
+ }
138
+ if (installed?.files.length) return no
139
+ const refs = [...text.matchAll(/<PackageReference\s[^>]*?Include\s*=\s*"([^"]+)"[^>]*?(?:\/>|>([\s\S]*?)<\/PackageReference>)/gi)]
140
+ const redis = refs.find((m) => m[1].toLowerCase() === REDIS.library.toLowerCase())
141
+ if (redis) {
142
+ const version = /Version\s*=\s*"([^"]+)"/i.exec(redis[0])?.[1] ?? tag(redis[2] ?? '', 'Version')
143
+ ?? new RegExp(`<PackageVersion\\s+Include="${REDIS.library}"\\s+Version="([^"]+)"`, 'i').exec(props ?? '')?.[1]
144
+ if (!/^\d+\.\d+\.\d+/.test(version ?? '')) return { ...no, note: `This app uses ${REDIS.library}. If the build fails, add ${REDIS.package} ${REDIS.version}.` }
145
+ return { add: inRedisRange(version), version, via: [], note: null }
146
+ }
147
+ const carrier = refs.find((m) => REDIS.carriers.some((c) => c.toLowerCase() === m[1].toLowerCase()))
148
+ return carrier ? { add: true, version: null, via: [carrier[1]], note: null } : no
149
+ }
150
+
151
+ /**
152
+ * Locked restore is on: a packages.lock.json next to the project, RestorePackagesWithLockFile or RestoreLockedMode in
153
+ * the project or a Directory.Build.props above it, or --locked-mode in the Dockerfile or a workflow.
154
+ */
155
+ export function lockedMode(service, view, csproj = webProject(service, view)) {
156
+ if (!csproj) return false
157
+ if (read(view, at(dirOf(csproj), LOCK)) !== null) return true
158
+ const on = (t) => /<(RestorePackagesWithLockFile|RestoreLockedMode)>\s*true\s*</i.test(t ?? '')
159
+ if (on(read(view, csproj)) || upFrom(dirOf(csproj), view.top ?? '.').some((d) => on(read(view, at(d, 'Directory.Build.props'))))) return true
160
+ const docker = service.dockerfile ? read(view, service.dockerfile) : null
161
+ if (/\bdotnet\s+restore\b[^\n]*--locked-mode\b/.test(docker ?? '')) return true
162
+ let files = []
163
+ try { files = view.files() } catch { /* unreadable */ }
164
+ return files.filter((f) => /^\.github\/workflows\/[^/]+\.ya?ml$|(?:^|\/)azure-pipelines[^/]*\.ya?ml$/.test(f))
165
+ .some((f) => /\bdotnet\s+restore\b[^\n]*--locked-mode\b/.test(read(view, f) ?? ''))
166
+ }
167
+
168
+ /**
169
+ * The restore that refreshes packages.lock.json after the csproj edit, or the required step when it cannot run:
170
+ * no .NET SDK of the target major version or later on this computer (ctx.tools.dotnet), or the Dockerfile restores
171
+ * for more than 1 runtime or for one this tool cannot read ($TARGETARCH). null when restore is not locked.
172
+ * @returns {{command: import('../types.mjs').Command}|{block: import('../types.mjs').Block}|null}
173
+ */
174
+ export function restoreStep(service, ctx, csproj, major) {
175
+ const view = ctx.repo ?? ctx.ws
176
+ if (!lockedMode(service, view, csproj)) return null
177
+ const lock = at(dirOf(csproj), LOCK)
178
+ const sdk = ctx.tools && Object.prototype.hasOwnProperty.call(ctx.tools, 'dotnet') ? ctx.tools.dotnet : undefined
179
+ const need = major !== null ? Number(major) : null
180
+ if (sdk === null || (sdk && need !== null && Number(/^(\d+)/.exec(sdk)?.[1] ?? 0) < need)) {
181
+ return { block: { title: fill(RESOLVE.dotnet.sdk.title, { need: need ?? minNet }),
182
+ lines: RESOLVE.dotnet.sdk.lines.map((l) => fill(l, { lock, need: need ?? minNet, have: sdk ?? 'none' })) } }
183
+ }
184
+ // NuGet checks a locked restore against the runtimes in the lock. So a lock decides the -r, never a publish line:
185
+ // a runtime that the lock does not have would break every other locked restore of the project (NU1004).
186
+ const fromLock = lockRuntimes(read(view, lock))
187
+ const docker = service.dockerfile ? read(view, service.dockerfile) : null
188
+ const rid = fromLock ?? (docker === null ? { rids: [] } : runtimes(docker))
189
+ if (rid.unknown || rid.rids.length > 1) {
190
+ const where = rid.unknown ?? rid.at
191
+ const lines = RESOLVE.dotnet.runtimes.lines.map((l) => fill(l, { file: service.dockerfile, line: where?.line, value: rid.unknown?.value ?? rid.rids.join(' and ') }))
192
+ if (fromLock) lines[0] = fill(RESOLVE.dotnet.runtimes.lock, { lock, value: rid.rids.join(' and ') })
193
+ return { block: { title: RESOLVE.dotnet.runtimes.title, lines } }
194
+ }
195
+ const args = ['restore', base(csproj), '--force-evaluate', ...(rid.rids.length ? ['-r', rid.rids[0]] : [])]
196
+ // A restore rewrites the lock of every project in the graph: the lockfiles of the referenced projects count too.
197
+ const members = (ctx.existing?.members ?? []).map((m) => at(m.dir, LOCK)).filter((f) => f !== lock && read(view, f) !== null)
198
+ // Projects that reference this one (a test project) and have their own lock list its packages too, so a locked
199
+ // restore of the solution fails (NU1004) unless their locks are refreshed as well. Each with the runtime of its own
200
+ // lock. They run first: a restore of such a project also rewrites the lock of this one, and this restore, last,
201
+ // writes it with its own runtime again.
202
+ const before = [], notes = []
203
+ for (const dependent of referencingProjects(ctx, csproj)) {
204
+ const values = { lock: dependent.lock, project: dependent.csproj, csproj }
205
+ if (dependent.csproj.startsWith('../')) {
206
+ return { block: { title: RESOLVE.dotnet.dependentOutside.title, lines: RESOLVE.dotnet.dependentOutside.lines.map((l) => fill(l, values)) } }
207
+ }
208
+ const theirs = lockRuntimes(read(view, dependent.lock)) ?? { rids: [] }
209
+ if (theirs.rids.length > 1) {
210
+ return { block: { title: RESOLVE.dotnet.runtimes.title, lines: [fill(RESOLVE.dotnet.runtimes.lock, { lock: dependent.lock, value: theirs.rids.join(' and ') }),
211
+ RESOLVE.dotnet.runtimes.lines[1]] } }
212
+ }
213
+ const depArgs = ['restore', base(dependent.csproj), '--force-evaluate', ...(theirs.rids.length ? ['-r', theirs.rids[0]] : [])]
214
+ const command = ['dotnet', ...depArgs].join(' ')
215
+ before.push({ cwd: dirOf(dependent.csproj), cmd: 'dotnet', args: depArgs, touches: [...new Set([dependent.lock, lock, ...members, ...dependent.locks])],
216
+ added: [], kind: 'lock', undo: null, keep: [], why: fill(RESOLVE.dotnet.dependentUpdate, { ...values, command }) })
217
+ notes.push(fill(RESOLVE.dotnet.dependent, { ...values, command }))
218
+ }
219
+ // The restore leaves obj/ folders. Without a .dockerignore that keeps them out, a local docker build copies them in.
220
+ const ignored = service.dockerfile && [dirOf(service.dockerfile), '.'].some((d) => /^\s*(?:\*\*\/)?obj\/?\s*$/m.test(read(view, at(d, '.dockerignore')) ?? ''))
221
+ return { before, notes, command: { cwd: dirOf(csproj), cmd: 'dotnet', args, touches: [lock, ...members], added: [], kind: 'lock', undo: null, keep: [],
222
+ why: fill(RESOLVE.update, { lock, command: ['dotnet', ...args].join(' ') }) }, note: service.dockerfile && !ignored ? RESOLVE.dotnet.dockerignore : null }
223
+ }
224
+
225
+ const WALK_SKIP = new Set(['node_modules', 'bin', 'obj', 'packages', 'vendor'])
226
+ const WALK_LIMIT = 20000
227
+
228
+ /**
229
+ * The projects of the git repo that reference csproj, directly or through other projects, and have a
230
+ * packages.lock.json: {csproj, lock, locks}, as paths from the root of this run ('../' for a project above a --path
231
+ * folder). locks: the lockfiles of the projects that each one references, which its restore rewrites too. Test
232
+ * folders count here, unlike for the services. Reads the files on disk only.
233
+ */
234
+ export function referencingProjects(ctx, csproj) {
235
+ const root = ctx.ws?.root
236
+ if (!root) return []
237
+ const top = resolve(root, ctx.repo?.top ?? '.')
238
+ const rel = relative(top, root).split(/[\\/]/).filter(Boolean).join('/')
239
+ const toTop = (p) => posix.normalize(rel ? `${rel}/${p}` : p)
240
+ const toRoot = (p) => (rel ? posix.relative(rel, p) : p) || '.'
241
+ const projects = []
242
+ let seen = 0
243
+ const walk = (dir) => {
244
+ let entries = []
245
+ try { entries = readdirSync(joinPath(top, ...dir.split('/').filter(Boolean)), { withFileTypes: true }) } catch { return }
246
+ for (const entry of entries) {
247
+ if (++seen > WALK_LIMIT) return
248
+ const path = dir ? `${dir}/${entry.name}` : entry.name
249
+ if (entry.isDirectory()) { if (!entry.name.startsWith('.') && !WALK_SKIP.has(entry.name)) walk(path) }
250
+ else if (entry.isFile() && entry.name.endsWith('.csproj')) projects.push(path)
251
+ }
252
+ }
253
+ walk('')
254
+ const text = (p) => { try { return readFileSync(joinPath(top, ...p.split('/')), 'utf8') } catch { return '' } }
255
+ const refs = new Map(projects.map((p) => [p, [...text(p).matchAll(/<ProjectReference\s[^>]*?Include\s*=\s*"([^"]+)"/gi)]
256
+ .map((m) => posix.normalize(posix.join(posix.dirname(p), m[1].replace(/\\/g, '/'))))]))
257
+ const closure = (start) => {
258
+ const out = new Set(), queue = [...(refs.get(start) ?? [])]
259
+ while (queue.length) { const p = queue.shift(); if (out.has(p)) continue; out.add(p); queue.push(...(refs.get(p) ?? [])) }
260
+ return out
261
+ }
262
+ const app = toTop(csproj)
263
+ const lockOf = (p) => `${posix.dirname(p) === '.' ? '' : `${posix.dirname(p)}/`}${LOCK}`
264
+ return projects.filter((p) => p !== app && closure(p).has(app) && existsSync(joinPath(top, ...lockOf(p).split('/'))))
265
+ .map((p) => ({ csproj: toRoot(p), lock: toRoot(lockOf(p)),
266
+ locks: [...closure(p)].filter((q) => existsSync(joinPath(top, ...lockOf(q).split('/')))).map((q) => toRoot(lockOf(q))) }))
267
+ }
268
+
269
+ /**
270
+ * The runtime identifiers that a packages.lock.json has packages for: its targets named <framework>/<rid>.
271
+ * null when there is no lock, or it is not JSON with a dependencies object.
272
+ * @returns {{rids: string[]}|null}
273
+ */
274
+ export function lockRuntimes(text) {
275
+ if (text === null) return null
276
+ let json
277
+ try { json = JSON.parse(text.replace(/^\uFEFF/, '')) } catch { return null }
278
+ if (!json?.dependencies || typeof json.dependencies !== 'object') return null
279
+ const targets = Object.keys(json.dependencies)
280
+ return { rids: [...new Set(targets.filter((t) => t.includes('/')).map((t) => t.slice(t.indexOf('/') + 1)))].sort() }
281
+ }
282
+
283
+ /**
284
+ * The runtime identifiers of the restore in the Dockerfile (when there is no lock yet): its dotnet restore lines, else the dotnet publish or
285
+ * build lines that restore on their own. -r, --runtime, and --os with --arch (or -a) count. A $ value is unknown.
286
+ * @returns {{rids: string[], at?: {line: number}, unknown?: {line: number, value: string}}}
287
+ */
288
+ export function runtimes(docker) {
289
+ const runs = parseDockerfile(docker).instructions.filter((x) => x.keyword === 'RUN')
290
+ const parts = runs.flatMap((x) => (x.argv ? x.argv.join(' ') : x.args).split(/&&|\|\||[;\n]/).map((part) => ({ part, line: x.start + 1 })))
291
+ const restores = parts.filter((p) => /\bdotnet\s+restore\b/.test(p.part))
292
+ const lines = restores.length ? restores : parts.filter((p) => /\bdotnet\s+(?:publish|build)\b/.test(p.part) && !/--no-restore\b/.test(p.part))
293
+ const rids = new Set()
294
+ let first = null
295
+ for (const { part, line } of lines) {
296
+ const words = part.trim().split(/\s+/)
297
+ const value = (names) => {
298
+ for (let k = 0; k < words.length; k++) {
299
+ const m = new RegExp(`^(?:${names})(?:=(.+))?$`).exec(words[k])
300
+ if (m) return m[1] ?? words[k + 1] ?? ''
301
+ }
302
+ return null
303
+ }
304
+ const r = value('-r|--runtime'), os = value('--os'), arch = value('-a|--arch')
305
+ const raw = [r, os, arch].find((v) => v !== null && (v.includes('$') || !/^[\w.-]+$/.test(v)))
306
+ if (raw !== undefined) return { rids: [...rids], unknown: { line, value: raw } }
307
+ const found = r ?? (os || arch ? `${os ?? 'linux'}-${arch === 'amd64' ? 'x64' : arch ?? 'x64'}` : null)
308
+ if (found === null) continue
309
+ rids.add(found)
310
+ first ??= { line }
311
+ }
312
+ return { rids: [...rids], at: first ?? undefined }
313
+ }
314
+
315
+ /**
316
+ * The runtime that the Dockerfile publishes for: -r (or --runtime) of its dotnet publish line, else of its dotnet build
317
+ * line, else the 1 RuntimeIdentifier of the project. null with none: then the publish holds the native files of every
318
+ * platform. {rid, where}.
319
+ */
320
+ export function publishRuntime(docker, csprojText = null, csproj = null) {
321
+ const runs = parseDockerfile(docker).instructions.filter((x) => x.keyword === 'RUN')
322
+ const parts = runs.flatMap((x) => (x.argv ? x.argv.join(' ') : x.args).split(/&&|\|\||[;\n]/).map((part) => ({ part, line: x.start + 1 })))
323
+ for (const verb of ['publish', 'build']) {
324
+ for (const { part, line } of parts.filter((p) => new RegExp(`\\bdotnet\\s+${verb}\\b`).test(p.part))) {
325
+ const m = /(?:^|\s)(?:-r|--runtime)(?:=|\s+)([\w.-]+)(?=\s|$)/.exec(part)
326
+ if (m) return { rid: m[1], where: `line ${line}` }
327
+ }
328
+ }
329
+ const own = csprojText ? tag(csprojText, 'RuntimeIdentifier') : null
330
+ return own && /^[\w.-]+$/.test(own) ? { rid: own, where: csproj, project: true } : null
331
+ }
332
+
333
+ /**
334
+ * Advice when the publish names a Linux runtime for another C library than the final image (glibc on Alpine, or musl
335
+ * on a glibc image): the native profiler of OpenTelemetry does not load there. No advice without a runtime (proven on
336
+ * glibc and Alpine images: instrument.sh then picks runtimes/<os>-<arch>/native), and none for a runtime that fits.
337
+ */
338
+ function libcAdvice(service, docker, info, { csproj = null, text = null } = {}) {
339
+ const found = publishRuntime(docker, text, csproj)
340
+ if (!found || !/^linux-/.test(found.rid)) return null
341
+ // The image of the final stage through the stage names: FROM base AS final runs on the image of base.
342
+ const image = finalImage(info)
343
+ const alpine = /alpine/i.test(image ?? '')
344
+ const musl = /^linux-musl-/.test(found.rid)
345
+ if (alpine === musl) return null
346
+ const values = { where: found.project ? found.where : `${service.dockerfile} ${found.where}`, rid: found.rid, libc: musl ? 'musl (Alpine)' : 'glibc',
347
+ image, imageLibc: alpine ? 'musl (Alpine)' : 'glibc', want: musl ? found.rid.replace(/^linux-musl-/, 'linux-') : found.rid.replace(/^linux-/, 'linux-musl-') }
348
+ const L = RESOLVE.dotnet.libc
349
+ return { title: L.title, required: false, lines: L.lines.map((l) => fill(l, values)) }
350
+ }
351
+
352
+ /** The RUN line that restores with --network=none: dotnet restore, or a publish or build that restores on its own. */
353
+ export function offlineRestore(docker) {
354
+ for (const x of parseDockerfile(docker).instructions.filter((i) => i.keyword === 'RUN')) {
355
+ const flags = /^((?:--[\w-]+(?:=\S+)?\s+)*)/.exec(x.args)[1]
356
+ if (!/--network=none\b/.test(flags)) continue
357
+ const command = x.args.slice(flags.length)
358
+ if (/\bdotnet\s+restore\b/.test(command) || (/\bdotnet\s+(?:publish|build)\b/.test(command) && !/--no-restore\b/.test(command))) return { line: x.start + 1 }
359
+ }
360
+ return null
361
+ }
362
+
363
+ /**
364
+ * After the restore: obj/project.assets.json shows a StackExchange.Redis in the range that the plan did not add the
365
+ * package for. Returns the block to print (the engine rolls back), or null. No assets file: nothing to check.
366
+ * @param {{assets: string, library: string, range: string[], package: string, version: string}} verify
367
+ * @param {(file: string) => string|null} readText
368
+ */
369
+ export function verifyRestore(verify, readText) {
370
+ const text = readText(verify.assets)
371
+ const set = text === null ? null : nugetAssets(verify.assets, text)
372
+ const item = set?.packages.get(verify.library.toLowerCase())
373
+ if (!item?.version || compareVersion(item.version, verify.range[0]) < 0 || compareVersion(item.version, verify.range[1]) >= 0) return null
374
+ const via = item.direct ? [verify.library] : item.via.map((k) => set.packages.get(k)?.name ?? k)
375
+ const values = { assets: verify.assets, library: verify.library, version: item.version, via: via.join(', '), package: verify.package, need: verify.version }
376
+ return { title: fill(RESOLVE.dotnet.assets.title, values), lines: RESOLVE.dotnet.assets.lines.map((l) => fill(l, values)) }
377
+ }
378
+
379
+ export function targetFramework(text, buildProps) {
63
380
  for (const source of [text, ...buildProps.map((p) => p.text)]) {
64
381
  const found = source && (tag(source, 'TargetFramework') ?? tag(source, 'TargetFrameworks')?.split(';')[0])
65
382
  if (found) return found.includes('$(') ? null : found
@@ -71,21 +388,11 @@ function targetFramework(text, buildProps) {
71
388
  * Add the package references. Returns true when they are there after this run. Else the start must not change:
72
389
  * 'outside' when the versions live in a file above the --path folder (run from the repo root), else false.
73
390
  */
74
- function addPackages(csproj, text, findProps, plan) {
75
- const refs = [...text.matchAll(/<PackageReference\s[^>]*?Include\s*=\s*"([^"]+)"[^>]*?(?:\/>|>([\s\S]*?)<\/PackageReference>)/gi)]
76
- const has = (name) => refs.some((m) => m[1].toLowerCase() === name.toLowerCase())
391
+ function addPackages(csproj, text, findProps, plan, wanted, redis) {
77
392
  const { file: propsFile = null, text: props = null } = findProps('Directory.Packages.props')[0] ?? {}
78
393
  const central = [text, props, ...findProps('Directory.Build.props').map((p) => p.text)]
79
394
  .some((t) => /<ManagePackageVersionsCentrally>\s*true\s*</i.test(t ?? ''))
80
- const add = has(PACKAGE) ? [] : [[PACKAGE, VERSION]]
81
- const redis = refs.find((m) => m[1].toLowerCase() === REDIS.library.toLowerCase())
82
- if (redis && !has(REDIS.package)) {
83
- const version = /Version\s*=\s*"([^"]+)"/i.exec(redis[0])?.[1] ?? tag(redis[2] ?? '', 'Version')
84
- ?? new RegExp(`<PackageVersion\\s+Include="${REDIS.library}"\\s+Version="([^"]+)"`, 'i').exec(props ?? '')?.[1]
85
- const v = /^(\d+)\.(\d+)\.(\d+)/.exec(version ?? '')?.slice(1).map(Number)
86
- if (!v) plan.notes.push(`This app uses ${REDIS.library}. If the build fails, add ${REDIS.package} ${REDIS.version}.`)
87
- else if (v[0] < 4 && (v[0] > 2 || (v[0] === 2 && (v[1] > 6 || (v[1] === 6 && v[2] >= 122))))) add.push([REDIS.package, REDIS.version])
88
- }
395
+ const add = wanted.filter(([name]) => !hasReference(text, name))
89
396
  if (!add.length) return true
90
397
  const reference = ([name, version]) => `<PackageReference Include="${name}"${central ? '' : ` Version="${version}"`} />`
91
398
  const version = ([name, v]) => `<PackageVersion Include="${name}" Version="${v}" />`
@@ -111,15 +418,13 @@ function addPackages(csproj, text, findProps, plan) {
111
418
  after: addItems(props, versions.map(version), /<PackageVersion\s/) ?? props })
112
419
  }
113
420
  }
114
- add.forEach(([name, version]) => plan.summary.push(`Add ${name} ${version} to ${csproj}${name === PACKAGE ? '' : `, because the app uses ${REDIS.library}`}.`))
115
- if (findProps('packages.lock.json')[0]?.file === at(dirOf(csproj), 'packages.lock.json')) {
116
- plan.manual.push({ title: 'Update packages.lock.json', lines: ['Run dotnet restore --force-evaluate.', 'Then commit packages.lock.json.'] })
117
- }
421
+ const because = redis.via.length ? `${REDIS.library}${redis.version ? ` ${redis.version}` : ''} through ${redis.via.join(', ')}` : REDIS.library
422
+ add.forEach(([name, v]) => plan.summary.push(`Add ${name} ${v} to ${csproj}${name === PACKAGE ? '' : `, because the app uses ${because}`}.`))
118
423
  return true
119
424
  }
120
425
 
121
426
  /** Add item lines after the last single-line item like them (not in a conditional ItemGroup), else in a new ItemGroup before </Project>. */
122
- function addItems(text, items, like) {
427
+ export function addItems(text, items, like) {
123
428
  const doc = new Doc(text)
124
429
  const lines = doc.lines
125
430
  const lastItem = lines.findLastIndex((l) => like.test(l) && /\/>\s*$/.test(l))
@@ -138,7 +443,7 @@ function addItems(text, items, like) {
138
443
  return doc.toString()
139
444
  }
140
445
 
141
- function addStart(ws, service, dll, plan) {
446
+ function addStart(ws, service, dll, plan, project = {}) {
142
447
  const entrypoint = ENTRYPOINT.replace('OrdersApi.dll', dll)
143
448
  const manual = (lines) => plan.manual.push({ title: 'Start the app with instrument.sh', lines: [...lines, SCRIPT_NOTE],
144
449
  snippets: [{ caption: service.dockerfile ? 'Dockerfile' : 'Start', language: service.dockerfile ? 'dockerfile' : 'bash',
@@ -146,8 +451,9 @@ function addStart(ws, service, dll, plan) {
146
451
  const text = service.dockerfile && ws.read(service.dockerfile)
147
452
  if (!text) return manual(['This app does not start from a Dockerfile. Start it with the command below.'])
148
453
  const info = parseDockerfile(text)
149
- if (/chiseled|distroless|nanoserver|windowsservercore/i.test(info.image ?? '')) {
150
- return manual([`The final image ${info.image} has no /bin/sh. instrument.sh needs it.`,
454
+ const image = finalImage(info)
455
+ if (/chiseled|distroless|nanoserver|windowsservercore/i.test(image ?? '')) {
456
+ return manual([`The final image ${image} has no /bin/sh. instrument.sh needs it.`,
151
457
  'Use a final image with a shell, such as mcr.microsoft.com/dotnet/aspnet. Then start the app like this.'])
152
458
  }
153
459
  const argv = dockerStart(info)?.argv ?? []
@@ -157,7 +463,8 @@ function addStart(ws, service, dll, plan) {
157
463
  : target && (target.includes('/') ? `${dirOf(target)}/instrument.sh` : './instrument.sh')
158
464
  const after = script && prefixStart(text, ['sh', script])
159
465
  if (!after) return manual([`This tool cannot change the start command in ${service.dockerfile}.`, 'Put sh ./instrument.sh in front of the command that starts your app.'])
160
- if (/alpine/i.test(info.image ?? '')) plan.notes.push('The final image uses Alpine. If monitoring does not start, publish with -r linux-musl-x64.')
466
+ const libc = libcAdvice(service, text, info, project)
467
+ if (libc) plan.manual.push(libc)
161
468
  plan.manual.push(...startOverrides(service, info.entrypoint ? 'ENTRYPOINT' : 'CMD', `sh ${script}`))
162
469
  if (after === text) return
163
470
  plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Start the app with instrument.sh.' })