@uknows/setup 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +209 -2
  3. package/bin/uknows-setup.mjs +14 -0
  4. package/lib/automate.mjs +1299 -0
  5. package/lib/check.mjs +327 -0
  6. package/lib/ci.mjs +21 -0
  7. package/lib/cli.mjs +251 -0
  8. package/lib/cloudid.mjs +456 -0
  9. package/lib/code.mjs +147 -0
  10. package/lib/context.mjs +126 -0
  11. package/lib/contract-docs.mjs +2736 -0
  12. package/lib/contract.mjs +149 -0
  13. package/lib/detect.mjs +627 -0
  14. package/lib/diff.mjs +147 -0
  15. package/lib/doc.mjs +98 -0
  16. package/lib/dockerfile.mjs +455 -0
  17. package/lib/endpoint.mjs +81 -0
  18. package/lib/engine.mjs +1753 -0
  19. package/lib/existing.mjs +562 -0
  20. package/lib/git.mjs +100 -0
  21. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  22. package/lib/hooks/pins.mjs +28 -0
  23. package/lib/hooks/uknows-otel.cjs +82 -0
  24. package/lib/hooks/uknows_otel.py +179 -0
  25. package/lib/hosts.mjs +650 -0
  26. package/lib/locks.mjs +353 -0
  27. package/lib/manifest.mjs +132 -0
  28. package/lib/members.mjs +269 -0
  29. package/lib/plan.mjs +130 -0
  30. package/lib/settings.mjs +468 -0
  31. package/lib/signin.mjs +259 -0
  32. package/lib/stacks/azure.mjs +345 -0
  33. package/lib/stacks/channel.mjs +125 -0
  34. package/lib/stacks/dotnet.mjs +472 -0
  35. package/lib/stacks/functions-hooks.mjs +228 -0
  36. package/lib/stacks/functions.mjs +148 -0
  37. package/lib/stacks/guided.mjs +227 -0
  38. package/lib/stacks/index.mjs +18 -0
  39. package/lib/stacks/java.mjs +57 -0
  40. package/lib/stacks/k8s.mjs +31 -0
  41. package/lib/stacks/node.mjs +358 -0
  42. package/lib/stacks/otel.mjs +63 -0
  43. package/lib/stacks/python-bootstrap.mjs +71 -0
  44. package/lib/stacks/python-lock.mjs +105 -0
  45. package/lib/stacks/python-sets.mjs +143 -0
  46. package/lib/stacks/python.mjs +632 -0
  47. package/lib/toml.mjs +64 -0
  48. package/lib/types.mjs +208 -0
  49. package/lib/ui.mjs +225 -0
  50. package/lib/updates.mjs +398 -0
  51. package/lib/versions.mjs +115 -0
  52. package/lib/workspace.mjs +201 -0
  53. package/package.json +37 -4
@@ -0,0 +1,125 @@
1
+ // The agent channel (--channel stable|early): for a Java, .NET or Node.js service that builds from a Dockerfile, the
2
+ // agent comes from the UKNOWS agent image at every build. The change is 1 COPY line and the start settings in the
3
+ // final stage, after the last RUN. Packages, lock files and the start command do not change.
4
+ //
5
+ // COPY --from=<registry>/agent-<lang>:<channel> /uknows/ /uknows/
6
+ // Java JAVA_TOOL_OPTIONS -javaagent:/uknows/java/uknows-loader.jar
7
+ // Node.js NODE_OPTIONS --require /uknows/node/register.cjs (ES modules: --import /uknows/node/register.mjs)
8
+ // .NET CORECLR_* (the OpenTelemetry profiler for glibc or musl), OTEL_DOTNET_AUTO_HOME, DOTNET_STARTUP_HOOKS
9
+ //
10
+ // Each language starts through a small UKNOWS loader in the image. It checks the agent files (size and checksum)
11
+ // and starts the agent, or prints 1 line and lets the app start without it. So an agent file that is missing or
12
+ // broken never stops the app.
13
+ import { AGENT_CHANNELS, AGENT_REGISTRY, STACKS } from '../contract.mjs'
14
+ import { addOption, dockerStart, insertBeforeStart, parseDockerfile, setEnv } from '../dockerfile.mjs'
15
+ import { entryOf } from './node.mjs'
16
+ import { targetFramework, webProject } from './dotnet.mjs'
17
+
18
+ export const CHANNELS = AGENT_CHANNELS
19
+ export const LANGS = ['java', 'dotnet', 'node']
20
+ const DOTNET_PROFILER = '{918728DD-259F-4A6A-AC2B-B85E1B658318}'
21
+ const WINDOWS = /windows|nanoserver|servercore/i
22
+
23
+ /** The registry and path that hold the agent images, for example registry.example.com/uknows. No scheme, no tag. */
24
+ export function validRegistry(value) {
25
+ return typeof value === 'string' && AGENT_REGISTRY.test(value)
26
+ }
27
+
28
+ export const image = (registry, lang, channel) => `${registry}/agent-${lang}:${channel}`
29
+ export const copyLine = (registry, lang, channel) => `COPY --from=${image(registry, lang, channel)} /uknows/ /uknows/`
30
+
31
+ /** The settings that start the agent of 1 language. */
32
+ export function startSettings(lang, { esm = false, musl = false } = {}) {
33
+ if (lang === 'java') return { option: ['JAVA_TOOL_OPTIONS', '-javaagent:/uknows/java/uknows-loader.jar'] }
34
+ if (lang === 'node') return { option: ['NODE_OPTIONS', esm ? '--import /uknows/node/register.mjs' : '--require /uknows/node/register.cjs'] }
35
+ return { env: [
36
+ ['CORECLR_ENABLE_PROFILING', '1'],
37
+ ['CORECLR_PROFILER', DOTNET_PROFILER],
38
+ ['CORECLR_PROFILER_PATH', `/uknows/dotnet/${musl ? 'musl' : 'glibc'}/OpenTelemetry.AutoInstrumentation.Native.so`],
39
+ ['OTEL_DOTNET_AUTO_HOME', '/uknows/dotnet'],
40
+ ['DOTNET_STARTUP_HOOKS', '/uknows/dotnet/net/OpenTelemetry.AutoInstrumentation.StartupHook.dll'],
41
+ ] }
42
+ }
43
+
44
+ /** Does this service fit the channel? null when it does, else the reason (it then keeps the package route). */
45
+ export function channelMisfit(service) {
46
+ if (!LANGS.includes(service.lang)) return 'The agent channel has Java, .NET and Node.js.'
47
+ if (!service.dockerfile) return 'The agent channel needs a Dockerfile, and this service does not build from one.'
48
+ return null
49
+ }
50
+
51
+ const read = (ws, path) => { try { return ws.read(path) } catch { return null } }
52
+
53
+ /** @type {import('../types.mjs').Applier} */
54
+ export const channel = {
55
+ id: 'channel',
56
+ plan(service, ctx) {
57
+ const plan = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
58
+ const { registry, name: channelName } = ctx.channel ?? {}
59
+ if (!validRegistry(registry) || !CHANNELS.includes(channelName)) throw new Error('The agent channel needs its registry and its name.')
60
+ const lang = service.lang
61
+ const text = read(ctx.ws, service.dockerfile)
62
+ const info = text === null ? null : parseDockerfile(text)
63
+ const base = info?.image ?? ''
64
+ if (!info) {
65
+ plan.manual.push({ title: 'Add the UKNOWS agent', lines: [`This tool cannot read ${service.dockerfile}.`] })
66
+ return plan
67
+ }
68
+ if (WINDOWS.test(base)) {
69
+ plan.manual.push({ title: 'Add the UKNOWS agent', lines: [`The final image ${base} is a Windows image. The agent channel has Linux images only.`] })
70
+ return plan
71
+ }
72
+ const musl = /alpine/i.test(base)
73
+ let esm = false
74
+ if (lang === 'node') {
75
+ const v = /^(\d+)(?:\.(\d+))?/.exec(service.runtimeVersion ?? '')
76
+ const [major, minor] = v ? [Number(v[1]), v[2] === undefined ? 99 : Number(v[2])] : [99, 99]
77
+ if (major < 18 || major === 19 || (major === 18 && minor < 19) || (major === 20 && minor < 6)) {
78
+ plan.manual.push({ title: 'Use Node.js 18.19 or later', lines: [`This app uses Node.js ${service.runtimeVersion}.`,
79
+ 'The UKNOWS agent needs Node.js 18.19 or later on version 18, or 20.6 or later.', 'Update the Node.js version of the app. Then run npx @uknows/setup again.'] })
80
+ return plan
81
+ }
82
+ let pkg = {}
83
+ try { pkg = JSON.parse(read(ctx.ws, service.dir === '.' ? 'package.json' : `${service.dir}/package.json`) ?? '{}') } catch { /* not JSON: CommonJS */ }
84
+ const entry = entryOf(dockerStart(info)?.argv, typeof pkg.scripts?.start === 'string' ? pkg.scripts.start : null)
85
+ esm = /\.m[jt]s$/.test(entry ?? '') || (pkg.type === 'module' && !/\.c[jt]s$/.test(entry ?? ''))
86
+ }
87
+ if (lang === 'dotnet') {
88
+ const csproj = webProject(service, ctx.ws)
89
+ const framework = csproj ? targetFramework(read(ctx.ws, csproj) ?? '', []) : null
90
+ const major = /^net(?:coreapp)?(\d+)\./.exec(framework ?? '')?.[1] ?? (/^net\d+$/.test(framework ?? '') ? '4' : null)
91
+ if (major !== null && Number(major) < 8) {
92
+ plan.manual.push({ title: 'Use .NET 8 or later', lines: [`${csproj} targets ${framework}.`, 'The UKNOWS agent needs .NET 8 or later.',
93
+ 'Change the target framework to net8.0 or later. Then run npx @uknows/setup again.'] })
94
+ return plan
95
+ }
96
+ }
97
+
98
+ const line = copyLine(registry, lang, channelName)
99
+ const has = (l) => l.trim().startsWith(`COPY --from=${registry}/agent-${lang}:`)
100
+ let after = text.split('\n').some(has) ? text : insertBeforeStart(text, [line])
101
+ const settings = startSettings(lang, { esm, musl })
102
+ if (settings.option) {
103
+ const [name, option] = settings.option
104
+ after = addOption(after, name, option)
105
+ if (info.env.has(name) || info.inherited.has(name)) plan.notes.push(`${service.dockerfile} already sets ${name}. A new ENV line before the start keeps that value and adds the agent.`)
106
+ } else {
107
+ const hooks = info.env.get('DOTNET_STARTUP_HOOKS') ?? info.inherited.get('DOTNET_STARTUP_HOOKS')
108
+ const pairs = settings.env.map(([n, v]) => (n === 'DOTNET_STARTUP_HOOKS' && hooks?.value && !hooks.value.includes(v) ? [n, `${v}:${hooks.value}`] : [n, v]))
109
+ after = setEnv(after, pairs)
110
+ // The docs settings for .NET add a log folder that the app can write.
111
+ for (const l of STACKS.find((s) => s.id === 'dotnet').extraSettings?.lines ?? []) plan.extraSettings.push({ name: l.slice(0, l.indexOf('=')), value: l.slice(l.indexOf('=') + 1) })
112
+ }
113
+ if (lang === 'node') {
114
+ for (const l of STACKS.find((s) => s.id === 'node').extraSettings?.lines ?? []) plan.extraSettings.push({ name: l.slice(0, l.indexOf('=')), value: l.slice(l.indexOf('=') + 1) })
115
+ }
116
+ if (after === text) return plan
117
+ plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Add the UKNOWS agent from the agent image, and start it.' })
118
+ plan.summary.push(`Copy the UKNOWS agent into the image at each build, from ${image(registry, lang, channelName)} (the ${channelName} channel).`,
119
+ `Start the agent with the app (${settings.option ? settings.option[0] : `the .NET profiler settings${musl ? ' for musl (Alpine)' : ''}`} in ${service.dockerfile}).`)
120
+ plan.notes.push(`Each build copies the current agent of the ${channelName} channel. A new agent reaches the app at its next build and deploy, with no change to this repo.`,
121
+ 'If an agent file is missing or broken, the app starts without the agent and prints 1 line that starts with "UKNOWS agent:". UKNOWS_AGENT=off turns the agent off.')
122
+ if (lang === 'node' && esm) plan.notes.push('This app runs as ES modules, so node loads the agent with --import.')
123
+ return plan
124
+ },
125
+ }
@@ -0,0 +1,472 @@
1
+ // .NET: add the OpenTelemetry.AutoInstrumentation package to the web project and start the app with instrument.sh.
2
+ // The csproj is edited as text (no dotnet add), so its BOM, line endings and layout stay. Directory.*.props files
3
+ // above the --path folder count too: with central package versions there, the step is manual.
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'
20
+ import { Doc } from '../doc.mjs'
21
+ import { dockerStart, finalImage, parseDockerfile, prefixStart } from '../dockerfile.mjs'
22
+ import { nugetAssets } from '../locks.mjs'
23
+ import { compareVersion } from '../versions.mjs'
24
+ import { upFrom } from '../workspace.mjs'
25
+ import { startOverrides } from './k8s.mjs'
26
+
27
+ const DOCS = STACKS.find((s) => s.id === 'dotnet')
28
+ const { package: PACKAGE, version: VERSION, minNet, redis: REDIS } = PINS.dotnet
29
+ const USE_NET8 = DOCS.notes.find((n) => /\.NET 8/.test(n))
30
+ const ENTRYPOINT = DOCS.dockerfile.split('\n').find((l) => l.startsWith('ENTRYPOINT'))
31
+ const SCRIPT_NOTE = DOCS.notes.find((n) => /instrument\.sh/.test(n))
32
+ const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
33
+ const dirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
34
+ const base = (path) => path.split('/').pop()
35
+ const above = (path) => path.startsWith('../')
36
+ const read = (view, path) => { try { return view.read(path) } catch { return null } }
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'
41
+
42
+ /** @type {import('../types.mjs').Applier} */
43
+ export const dotnet = {
44
+ id: 'dotnet',
45
+ plan(service, ctx) {
46
+ const { ws } = ctx
47
+ const plan = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
48
+ const csproj = webProject(service, ws)
49
+ if (!csproj) {
50
+ plan.manual.push({ title: 'Add OpenTelemetry to your .NET app', lines: ['This tool did not find the .csproj file of the web app.',
51
+ `Run dotnet add package ${PACKAGE} in the folder of the web app.`, 'Then start the app with instrument.sh.', SCRIPT_NOTE] })
52
+ return plan
53
+ }
54
+ const text = ws.read(csproj)
55
+ const view = ctx.repo ?? ws
56
+ const folders = upFrom(dirOf(csproj), view.top ?? '.')
57
+ const props = (name) => folders.map((dir) => at(dir, name)).map((file) => ({ file, text: read(view, file) })).filter((p) => p.text !== null)
58
+ const framework = targetFramework(text, props('Directory.Build.props'))
59
+ const major = /^net(?:coreapp)?(\d+)\./.exec(framework ?? '')?.[1] ?? (/^net\d+$/.test(framework ?? '') ? '4' : null)
60
+ if (major !== null && Number(major) < minNet) {
61
+ plan.manual.push({ title: USE_NET8.replace(/\.$/, ''), lines: [`${csproj} targets ${framework}.`,
62
+ `${PACKAGE} ${VERSION} needs .NET ${minNet} or later.`, `Change the target framework to net${minNet}.0 or later. Then run npx @uknows/setup again.`] })
63
+ return plan
64
+ }
65
+ if (major === null) plan.notes.push(`This tool could not read the target framework of ${csproj}. ${USE_NET8}`)
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)
92
+ if (added !== true) {
93
+ plan.stop = added === 'outside'
94
+ return plan
95
+ }
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
+ }
106
+ // The docs settings for .NET add a log folder that the app can write. Without it, the app can stop at start.
107
+ for (const line of DOCS.extraSettings?.lines ?? []) {
108
+ const at = line.indexOf('=')
109
+ plan.extraSettings.push({ name: line.slice(0, at), value: line.slice(at + 1) })
110
+ }
111
+ addStart(ws, service, dll, plan, { csproj, text })
112
+ return plan
113
+ },
114
+ }
115
+
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) {
380
+ for (const source of [text, ...buildProps.map((p) => p.text)]) {
381
+ const found = source && (tag(source, 'TargetFramework') ?? tag(source, 'TargetFrameworks')?.split(';')[0])
382
+ if (found) return found.includes('$(') ? null : found
383
+ }
384
+ return null
385
+ }
386
+
387
+ /**
388
+ * Add the package references. Returns true when they are there after this run. Else the start must not change:
389
+ * 'outside' when the versions live in a file above the --path folder (run from the repo root), else false.
390
+ */
391
+ function addPackages(csproj, text, findProps, plan, wanted, redis) {
392
+ const { file: propsFile = null, text: props = null } = findProps('Directory.Packages.props')[0] ?? {}
393
+ const central = [text, props, ...findProps('Directory.Build.props').map((p) => p.text)]
394
+ .some((t) => /<ManagePackageVersionsCentrally>\s*true\s*</i.test(t ?? ''))
395
+ const add = wanted.filter(([name]) => !hasReference(text, name))
396
+ if (!add.length) return true
397
+ const reference = ([name, version]) => `<PackageReference Include="${name}"${central ? '' : ` Version="${version}"`} />`
398
+ const version = ([name, v]) => `<PackageVersion Include="${name}" Version="${v}" />`
399
+ if (central && (!propsFile || above(propsFile))) {
400
+ plan.manual.push({ title: `Add ${PACKAGE} to ${csproj}`, lines: [
401
+ propsFile ? `This repo sets package versions in ${propsFile}, outside the folder this tool works in, so this tool cannot change it.`
402
+ : 'This project sets package versions centrally, but this tool did not find Directory.Packages.props.',
403
+ 'Run npx @uknows/setup in the repo root without --path, and pick this service. Or add these lines by hand. Then run it again for the start command.'],
404
+ snippets: [{ caption: propsFile ?? 'Directory.Packages.props', language: 'xml', code: add.map(version).join('\n') },
405
+ { caption: csproj, language: 'xml', code: add.map(reference).join('\n') }] })
406
+ return propsFile ? 'outside' : false
407
+ }
408
+ const after = addItems(text, add.map(reference), /<PackageReference\s/)
409
+ if (after === null) {
410
+ plan.manual.push({ title: `Add ${PACKAGE} to ${csproj}`, lines: [`Run dotnet add package ${PACKAGE} in ${dirOf(csproj)}.`] })
411
+ return false
412
+ }
413
+ plan.edits.push({ file: csproj, before: text, after, why: `Add the ${PACKAGE} package.` })
414
+ if (central) {
415
+ const versions = add.filter(([name]) => !new RegExp(`<PackageVersion\\s+Include="${name}"`, 'i').test(props))
416
+ if (versions.length) {
417
+ plan.edits.push({ file: propsFile, before: props, why: `Set the version of the ${PACKAGE} package.`,
418
+ after: addItems(props, versions.map(version), /<PackageVersion\s/) ?? props })
419
+ }
420
+ }
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}`}.`))
423
+ return true
424
+ }
425
+
426
+ /** Add item lines after the last single-line item like them (not in a conditional ItemGroup), else in a new ItemGroup before </Project>. */
427
+ export function addItems(text, items, like) {
428
+ const doc = new Doc(text)
429
+ const lines = doc.lines
430
+ const lastItem = lines.findLastIndex((l) => like.test(l) && /\/>\s*$/.test(l))
431
+ const group = lines.slice(0, lastItem + 1).findLast((l) => /<ItemGroup\b/.test(l)) ?? ''
432
+ if (lastItem >= 0 && !/Condition/.test(group)) {
433
+ const indent = /^\s*/.exec(lines[lastItem])[0]
434
+ doc.insert(lastItem + 1, ...items.map((item) => indent + item))
435
+ return doc.toString()
436
+ }
437
+ const end = lines.findLastIndex((l) => /^\s*<\/Project>\s*$/.test(l))
438
+ if (end < 0) return null
439
+ const unit = lines.map((l) => /^(\s+)</.exec(l)?.[1]).find(Boolean) ?? ' '
440
+ const block = [`${unit}<ItemGroup>`, ...items.map((item) => unit + unit + item), `${unit}</ItemGroup>`]
441
+ if (end > 0 && !lines[end - 1].trim()) doc.insert(end, ...block, '')
442
+ else doc.insert(end, '', ...block)
443
+ return doc.toString()
444
+ }
445
+
446
+ function addStart(ws, service, dll, plan, project = {}) {
447
+ const entrypoint = ENTRYPOINT.replace('OrdersApi.dll', dll)
448
+ const manual = (lines) => plan.manual.push({ title: 'Start the app with instrument.sh', lines: [...lines, SCRIPT_NOTE],
449
+ snippets: [{ caption: service.dockerfile ? 'Dockerfile' : 'Start', language: service.dockerfile ? 'dockerfile' : 'bash',
450
+ code: service.dockerfile ? entrypoint : DOCS.start.replace('OrdersApi.dll', dll) }] })
451
+ const text = service.dockerfile && ws.read(service.dockerfile)
452
+ if (!text) return manual(['This app does not start from a Dockerfile. Start it with the command below.'])
453
+ const info = parseDockerfile(text)
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.`,
457
+ 'Use a final image with a shell, such as mcr.microsoft.com/dotnet/aspnet. Then start the app like this.'])
458
+ }
459
+ const argv = dockerStart(info)?.argv ?? []
460
+ const target = argv.find((a) => /\.dll$/i.test(a)) ?? (/^\.{0,2}\//.test(argv[0] ?? '') ? argv[0] : null)
461
+ // instrument.sh sits next to the dll in the publish output.
462
+ const script = argv[1]?.endsWith('instrument.sh') ? argv[1]
463
+ : target && (target.includes('/') ? `${dirOf(target)}/instrument.sh` : './instrument.sh')
464
+ const after = script && prefixStart(text, ['sh', script])
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.'])
466
+ const libc = libcAdvice(service, text, info, project)
467
+ if (libc) plan.manual.push(libc)
468
+ plan.manual.push(...startOverrides(service, info.entrypoint ? 'ENTRYPOINT' : 'CMD', `sh ${script}`))
469
+ if (after === text) return
470
+ plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Start the app with instrument.sh.' })
471
+ plan.summary.push(`Start the app with instrument.sh (${info.entrypoint ? 'ENTRYPOINT' : 'CMD'} in ${service.dockerfile}).`)
472
+ }