@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/dotnet.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
115
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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.' })
|