@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.
- package/LICENSE +190 -0
- package/README.md +209 -2
- package/bin/uknows-setup.mjs +14 -0
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +327 -0
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +251 -0
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +2736 -0
- package/lib/contract.mjs +149 -0
- package/lib/detect.mjs +627 -0
- package/lib/diff.mjs +147 -0
- package/lib/doc.mjs +98 -0
- package/lib/dockerfile.mjs +455 -0
- package/lib/endpoint.mjs +81 -0
- package/lib/engine.mjs +1753 -0
- package/lib/existing.mjs +562 -0
- package/lib/git.mjs +100 -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 +650 -0
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +132 -0
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +468 -0
- 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 +472 -0
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +227 -0
- package/lib/stacks/index.mjs +18 -0
- package/lib/stacks/java.mjs +57 -0
- package/lib/stacks/k8s.mjs +31 -0
- package/lib/stacks/node.mjs +358 -0
- package/lib/stacks/otel.mjs +63 -0
- package/lib/stacks/python-bootstrap.mjs +71 -0
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +632 -0
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +208 -0
- package/lib/ui.mjs +225 -0
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +201 -0
- package/package.json +37 -4
package/lib/signin.mjs
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Sign in from the terminal. When setup runs in a terminal and has no key, it asks UKNOWS for a sign-in link, opens
|
|
3
|
+
* the browser, and waits while the person signs in and picks the workspace and the environment. The key then comes
|
|
4
|
+
* to setup by itself: nobody creates, shows, copies or pastes it.
|
|
5
|
+
*
|
|
6
|
+
* 1. POST {app}/api/v1/cli/links {client, version, secretHash, app?}: 201 {linkId, userCode, verificationUrl,
|
|
7
|
+
* expiresIn, interval}. secretHash is the base64url SHA-256 of a device secret: 32 random bytes that only this
|
|
8
|
+
* process holds, in memory. So only this process can collect the key.
|
|
9
|
+
* 2. POST {app}/api/v1/cli/links/{linkId}/token {deviceSecret}, every interval seconds: 202 waiting, 200 the key,
|
|
10
|
+
* 403 cancelled in the browser, 410 expired, 429 slow down (Retry-After).
|
|
11
|
+
*
|
|
12
|
+
* A UKNOWS without these routes answers the links request with 404 or 405, or with 401 when its sign-in check runs
|
|
13
|
+
* before its routes (a test copy of UKNOWS older than this sign-in does). The links route itself needs no sign-in, so
|
|
14
|
+
* all 3 mean the same: this UKNOWS cannot make a sign-in link, and setup asks for the key instead. 5xx answers and
|
|
15
|
+
* network errors are failures: setup tries again, then says what went wrong.
|
|
16
|
+
*
|
|
17
|
+
* The key and the device secret never print and are never written anywhere. A pipeline, --yes, --json and a run
|
|
18
|
+
* without a terminal never start a sign-in (engine.mjs decides, before it calls signIn).
|
|
19
|
+
*/
|
|
20
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
21
|
+
import { request } from './check.mjs'
|
|
22
|
+
import { CLI } from './contract.mjs'
|
|
23
|
+
import { VERSION } from './manifest.mjs'
|
|
24
|
+
import { list } from './ui.mjs'
|
|
25
|
+
|
|
26
|
+
export const LINKS_PATH = '/api/v1/cli/links'
|
|
27
|
+
export const CLIENT = '@uknows/setup'
|
|
28
|
+
/** The answers of a UKNOWS that has no links route: it cannot make a sign-in link, so the person pastes the key. */
|
|
29
|
+
export const NO_LINKS_STATUS = new Set([401, 404, 405])
|
|
30
|
+
/** Network errors and 5xx answers in a row before setup says that it cannot reach UKNOWS. */
|
|
31
|
+
const MAX_FAILURES = 5
|
|
32
|
+
const LOCAL = new Set(['localhost', '127.0.0.1', '[::1]'])
|
|
33
|
+
|
|
34
|
+
/** The clock of a real run. Tests pass one whose time moves only when something sleeps. */
|
|
35
|
+
export const REAL_CLOCK = {
|
|
36
|
+
now: () => Date.now(),
|
|
37
|
+
sleep(ms) {
|
|
38
|
+
let timer
|
|
39
|
+
const done = new Promise((resolve) => { timer = setTimeout(resolve, Math.max(0, ms)) })
|
|
40
|
+
done.cancel = () => clearTimeout(timer)
|
|
41
|
+
return done
|
|
42
|
+
},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** base64url of the SHA-256 of the device secret: what UKNOWS keeps, so only this process can collect the key. */
|
|
46
|
+
export const secretHash = (secret) => createHash('sha256').update(secret).digest('base64url')
|
|
47
|
+
|
|
48
|
+
/** The words of the sign-in. Exported for the tests and the README. */
|
|
49
|
+
export const SIGN_IN = {
|
|
50
|
+
opening: 'Sign in to UKNOWS to connect this app. Opening your browser...',
|
|
51
|
+
noBrowser: 'Sign in to UKNOWS to connect this app. Open this link in a browser:',
|
|
52
|
+
code: (code) => `Your code: ${code} (check the browser shows the same code)`,
|
|
53
|
+
keys: 'Press Enter to open the browser again, p to paste a key instead, or Ctrl+C to stop.',
|
|
54
|
+
waitingPlain: 'Waiting for you to sign in in the browser. The link works for {minutes}.',
|
|
55
|
+
waiting: 'Waiting for you to sign in... {left} left',
|
|
56
|
+
slow: ' (UKNOWS is slow to answer, still trying)',
|
|
57
|
+
signedIn: 'Signed in.',
|
|
58
|
+
denied: 'The sign-in was cancelled in the browser.',
|
|
59
|
+
expired: 'The sign-in link expired.',
|
|
60
|
+
noLinks: (host) => `${host} cannot make a sign-in link, so paste your key instead.`,
|
|
61
|
+
/** Sign-in is off: a test address is set (names: the settings that are set), but the app address of that UKNOWS is not. */
|
|
62
|
+
noApp: (names) => [`Setup cannot sign you in from this terminal: ${list(names)} ${names.length > 1 ? 'are' : 'is'} set, but ${CLI.appEnv} is not.`,
|
|
63
|
+
`To sign in, set ${CLI.appEnv} to the app address of your test copy of UKNOWS, for example ${CLI.appEnv}=https://app-test.example.com, and run setup again. For now, paste your key.`],
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Sign in, and come back with the key. Returns one of:
|
|
68
|
+
* {approved: true, key, workspace: {id, name}, environment, environmentLabel, keyId, ingestEndpoint, siteUrl}
|
|
69
|
+
* {paste: true} the person chose to paste a key instead (p, or after a failed sign-in). pressed: p while waiting.
|
|
70
|
+
* {stopped: true} q after a failed sign-in, or the end of the input. interrupt: true for Ctrl+C.
|
|
71
|
+
* ctx: {base: the app address, app?: {name, repo, platform}, env, openUrl(url, env, say) => boolean, request?, clock?}
|
|
72
|
+
*/
|
|
73
|
+
export async function signIn(ui, ctx) {
|
|
74
|
+
for (let round = 0; ; round++) {
|
|
75
|
+
const made = await makeLink(ui, ctx)
|
|
76
|
+
let result = made
|
|
77
|
+
if (made.link) result = await waitForSignIn(ui, ctx, made.link, made.deviceSecret)
|
|
78
|
+
if (result.approved || result.paste || result.stopped) return result
|
|
79
|
+
ui.print(result.why)
|
|
80
|
+
const next = await whatNext(ui, result.again)
|
|
81
|
+
if (next !== 'again') return next === 'paste' ? { paste: true } : { stopped: true }
|
|
82
|
+
ui.print()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* POST /api/v1/cli/links. {link, deviceSecret}, or {why, again} when UKNOWS cannot be reached, or {paste} when this
|
|
88
|
+
* UKNOWS has no links route (401, 404 or 405).
|
|
89
|
+
*/
|
|
90
|
+
async function makeLink(ui, { base, app, request: send = request, clock = REAL_CLOCK }) {
|
|
91
|
+
const deviceSecret = randomBytes(32).toString('base64url')
|
|
92
|
+
const body = { client: CLIENT, version: VERSION, secretHash: secretHash(deviceSecret), ...(app ? { app: clean(app) } : {}) }
|
|
93
|
+
const url = new URL(`${base}${LINKS_PATH}`)
|
|
94
|
+
for (let failures = 0, tries = 0; tries < 20; tries++) {
|
|
95
|
+
const r = await post(send, url, body)
|
|
96
|
+
if (r.status === 201 || r.status === 200) {
|
|
97
|
+
const link = readLink(r.json)
|
|
98
|
+
return link ? { link, deviceSecret } : { why: `${url.hostname} sent a sign-in link that setup cannot read.`, again: 'try again' }
|
|
99
|
+
}
|
|
100
|
+
if (NO_LINKS_STATUS.has(r.status)) {
|
|
101
|
+
ui.print(SIGN_IN.noLinks(url.hostname))
|
|
102
|
+
return { paste: true }
|
|
103
|
+
}
|
|
104
|
+
if (r.status === 429) { await clock.sleep(retryAfter(r.headers, clock) ?? 5000); continue }
|
|
105
|
+
if (r.error || r.status >= 500) {
|
|
106
|
+
if (++failures >= 3) return { why: unreachable(url, r), again: 'try again' }
|
|
107
|
+
await clock.sleep(1000 * 2 ** (failures - 1))
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
return { why: `${url.hostname} did not make a sign-in link (status ${r.status}).`, again: 'try again' }
|
|
111
|
+
}
|
|
112
|
+
return { why: `${url.hostname} is busy. It asked setup to wait too many times.`, again: 'try again' }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Show the link and the code, open the browser, and poll until UKNOWS answers. Enter opens the browser again, p goes to
|
|
117
|
+
* the paste question, Ctrl+C stops. Returns {approved, ...}, {paste}, {stopped}, or {why, again} (cancelled in the
|
|
118
|
+
* browser, expired, or no answer from UKNOWS).
|
|
119
|
+
*/
|
|
120
|
+
async function waitForSignIn(ui, ctx, link, deviceSecret) {
|
|
121
|
+
const { env, openUrl, request: send = request, clock = REAL_CLOCK } = ctx
|
|
122
|
+
const open = () => openUrl(link.verificationUrl, env, () => {})
|
|
123
|
+
const opened = open()
|
|
124
|
+
ui.print(opened ? SIGN_IN.opening : SIGN_IN.noBrowser)
|
|
125
|
+
ui.print(` ${ui.cyan(link.verificationUrl)}`)
|
|
126
|
+
ui.print(` ${SIGN_IN.code(ui.bold(link.userCode))}`)
|
|
127
|
+
ui.print()
|
|
128
|
+
const keys = ui.keys()
|
|
129
|
+
if (keys) {
|
|
130
|
+
ui.print(ui.dim(` ${SIGN_IN.keys}`))
|
|
131
|
+
ui.print()
|
|
132
|
+
} else ui.print(SIGN_IN.waitingPlain.replace('{minutes}', minutes(link.expiresIn)))
|
|
133
|
+
const expires = clock.now() + link.expiresIn * 1000
|
|
134
|
+
const url = new URL(`${new URL(ctx.base).origin}${LINKS_PATH}/${encodeURIComponent(link.linkId)}/token`)
|
|
135
|
+
let interval = link.interval * 1000, failures = 0, note = ''
|
|
136
|
+
let nextPoll = clock.now() + interval
|
|
137
|
+
const left = () => {
|
|
138
|
+
const s = Math.max(0, Math.ceil((expires - clock.now()) / 1000))
|
|
139
|
+
return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}`
|
|
140
|
+
}
|
|
141
|
+
const spin = ui.progress(() => `${SIGN_IN.waiting.replace('{left}', left())}${note}`)
|
|
142
|
+
let keyWait = null
|
|
143
|
+
const end = (result, line = null) => { spin.end(line); keys?.close(); return result }
|
|
144
|
+
try {
|
|
145
|
+
for (;;) {
|
|
146
|
+
const now = clock.now()
|
|
147
|
+
if (now >= expires) return end({ why: SIGN_IN.expired, again: 'start a new sign-in' })
|
|
148
|
+
if (now >= nextPoll) {
|
|
149
|
+
const r = await post(send, url, { deviceSecret })
|
|
150
|
+
if (r.status === 200) {
|
|
151
|
+
const data = readToken(r.json)
|
|
152
|
+
if (data) return end({ approved: true, ...data }, `${ui.cyan(ui.marks.done)} ${SIGN_IN.signedIn}`)
|
|
153
|
+
return end({ why: `${url.hostname} sent an answer that setup cannot read.`, again: 'try again' })
|
|
154
|
+
}
|
|
155
|
+
if (r.status === 403) return end({ why: SIGN_IN.denied, again: 'sign in again' })
|
|
156
|
+
if (r.status === 410 || r.status === 404) return end({ why: SIGN_IN.expired, again: 'start a new sign-in' })
|
|
157
|
+
if (r.status === 202) { failures = 0; note = ''; nextPoll = clock.now() + interval; continue }
|
|
158
|
+
if (r.status === 429) {
|
|
159
|
+
// Slow down: wait as long as UKNOWS asks, and poll less often from now on.
|
|
160
|
+
const wait = retryAfter(r.headers, clock)
|
|
161
|
+
interval = Math.min(Math.max(interval, wait ?? interval * 2), 60000)
|
|
162
|
+
nextPoll = clock.now() + Math.max(interval, wait ?? 0)
|
|
163
|
+
continue
|
|
164
|
+
}
|
|
165
|
+
if (r.error || r.status >= 500) {
|
|
166
|
+
if (++failures >= MAX_FAILURES) return end({ why: unreachable(url, r), again: 'try again' })
|
|
167
|
+
note = SIGN_IN.slow
|
|
168
|
+
nextPoll = clock.now() + Math.min(interval * 2 ** failures, 30000)
|
|
169
|
+
continue
|
|
170
|
+
}
|
|
171
|
+
return end({ why: `${url.hostname} answered the sign-in with status ${r.status}.`, again: 'try again' })
|
|
172
|
+
}
|
|
173
|
+
const sleep = clock.sleep(Math.min(nextPoll, expires) - now)
|
|
174
|
+
if (!keys) { await sleep; continue }
|
|
175
|
+
keyWait ??= keys.next()
|
|
176
|
+
const key = await Promise.race([sleep.then(() => undefined), keyWait])
|
|
177
|
+
sleep.cancel?.()
|
|
178
|
+
if (key === undefined) continue
|
|
179
|
+
keyWait = null
|
|
180
|
+
if (key === 'ctrl-c' || key === null) return end({ stopped: true, interrupt: true }, null)
|
|
181
|
+
if (key === 'p') {
|
|
182
|
+
// People often press Enter after p by habit. That Enter belongs to p, not to the paste question.
|
|
183
|
+
await Promise.race([keys.next(), new Promise((resolve) => setTimeout(resolve, 400))])
|
|
184
|
+
return end({ paste: true, pressed: true }, null)
|
|
185
|
+
}
|
|
186
|
+
if (key === 'enter' && !open()) ui.print(` ${ui.cyan(link.verificationUrl)}`)
|
|
187
|
+
}
|
|
188
|
+
} finally {
|
|
189
|
+
spin.stop()
|
|
190
|
+
keys?.close()
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** After a failed sign-in: again (Enter), paste (p) or stop (q, or the end of the input). */
|
|
195
|
+
async function whatNext(ui, again) {
|
|
196
|
+
for (let tries = 0; tries < 3; tries++) {
|
|
197
|
+
const answer = await ui.line(`Press Enter to ${again}, type p to paste a key instead, or type q to stop:`)
|
|
198
|
+
if (answer === null) return 'stop'
|
|
199
|
+
const a = answer.toLowerCase()
|
|
200
|
+
if (a === '') return 'again'
|
|
201
|
+
if (a === 'p' || a === 'paste') return 'paste'
|
|
202
|
+
if (a === 'q' || a === 'quit' || a === 'stop') return 'stop'
|
|
203
|
+
ui.print('Press Enter, or type p or q.')
|
|
204
|
+
}
|
|
205
|
+
return 'stop'
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** The link of a 201 answer, checked and tidied, or null. */
|
|
209
|
+
function readLink(json) {
|
|
210
|
+
if (!json || typeof json !== 'object') return null
|
|
211
|
+
const { linkId, userCode, verificationUrl } = json
|
|
212
|
+
if (typeof linkId !== 'string' || !/^[A-Za-z0-9._~-]{1,200}$/.test(linkId)) return null
|
|
213
|
+
if (typeof userCode !== 'string' || !/^[A-Za-z0-9-]{4,20}$/.test(userCode)) return null
|
|
214
|
+
let url
|
|
215
|
+
try { url = new URL(String(verificationUrl)) } catch { return null }
|
|
216
|
+
const safe = url.protocol === 'https:' || (url.protocol === 'http:' && LOCAL.has(url.hostname))
|
|
217
|
+
if (!safe || url.username || url.password) return null
|
|
218
|
+
const number = (value, fallback, min, max) => (Number.isFinite(value) && value > 0 ? Math.min(Math.max(value, min), max) : fallback)
|
|
219
|
+
return { linkId, userCode, verificationUrl: url.href, expiresIn: number(json.expiresIn, 600, 30, 3600), interval: number(json.interval, 3, 0.05, 60) }
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** The key of a 200 answer, checked, or null. A key fits in a header: printable, no spaces, no commas. */
|
|
223
|
+
function readToken(json) {
|
|
224
|
+
if (!json || typeof json !== 'object' || typeof json.key !== 'string' || !/^[\x21-\x7e]{8,512}$/.test(json.key) || json.key.includes(',')) return null
|
|
225
|
+
const text = (value) => (typeof value === 'string' && value.trim() ? value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim().slice(0, 200) : null)
|
|
226
|
+
const workspace = json.workspace && typeof json.workspace === 'object' ? { id: text(json.workspace.id), name: text(json.workspace.name) } : { id: null, name: null }
|
|
227
|
+
return { key: json.key, workspace, environment: text(json.environment), environmentLabel: text(json.environmentLabel),
|
|
228
|
+
keyId: text(json.keyId), ingestEndpoint: text(json.ingestEndpoint), siteUrl: text(json.siteUrl) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Retry-After in ms: seconds, or an HTTP date. null when missing. */
|
|
232
|
+
function retryAfter(headers = {}, clock = REAL_CLOCK) {
|
|
233
|
+
const raw = String(headers?.['retry-after'] ?? '').trim()
|
|
234
|
+
if (!raw) return null
|
|
235
|
+
if (/^\d+$/.test(raw)) return Math.min(Number(raw), 120) * 1000
|
|
236
|
+
const at = Date.parse(raw)
|
|
237
|
+
return Number.isFinite(at) ? Math.min(Math.max(at - clock.now(), 0), 120000) : null
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function unreachable(url, r) {
|
|
241
|
+
return r.error ? `Could not reach ${url.hostname} (${r.error.code || 'network error'}). Check your internet connection.`
|
|
242
|
+
: `${url.hostname} did not answer the sign-in (status ${r.status}).`
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async function post(send, url, body) {
|
|
246
|
+
const r = await send(url, 'POST', { 'content-type': 'application/json', accept: 'application/json' }, JSON.stringify(body), 10000)
|
|
247
|
+
let json = null
|
|
248
|
+
try { json = r.body ? JSON.parse(r.body) : null } catch { json = null }
|
|
249
|
+
return { ...r, json }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The app as UKNOWS shows it in the browser: only text fields that are set. */
|
|
253
|
+
function clean(app) {
|
|
254
|
+
const out = {}
|
|
255
|
+
for (const key of ['name', 'repo', 'platform']) if (typeof app[key] === 'string' && app[key].trim()) out[key] = app[key].trim().slice(0, 200)
|
|
256
|
+
return out
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const minutes = (seconds) => (seconds % 60 === 0 ? `${seconds / 60} ${seconds === 60 ? 'minute' : 'minutes'}` : `${Math.round(seconds)} seconds`)
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Azure Monitor (App Insights) apps: keep it, and add 1 OTLP exporter to the same tracer provider, so nothing is
|
|
3
|
+
* traced twice. This mode never adds opentelemetry-instrument, NODE_OPTIONS or instrument.sh.
|
|
4
|
+
* Python: the exporter package at the locked opentelemetry-sdk version, a lock refresh (uv lock or poetry lock),
|
|
5
|
+
* and the docs block after the 1 configure_azure_monitor(...) statement.
|
|
6
|
+
* .NET: the exporter package at the resolved OpenTelemetry version, the using line and 1 line after CreateBuilder.
|
|
7
|
+
* Node.js: printed code and packages (option objects vary, and OpenTelemetry JS 2 cannot add a processor later).
|
|
8
|
+
* Anything that is not plain becomes a printed step. The text comes from AZURE_MONITOR in the docs file.
|
|
9
|
+
*/
|
|
10
|
+
import { posix } from 'node:path'
|
|
11
|
+
import { AZURE_MONITOR, PINS, RESOLVE } from '../contract.mjs'
|
|
12
|
+
import { Doc } from '../doc.mjs'
|
|
13
|
+
import { addUsing, csharpBuilder, insertPythonBlock, maskCsharp, pythonCalls, pythonStatement } from '../code.mjs'
|
|
14
|
+
import { dependencies, findExisting } from '../existing.mjs'
|
|
15
|
+
import { nearest, packageKey } from '../locks.mjs'
|
|
16
|
+
import { pythonProjectName } from '../members.mjs'
|
|
17
|
+
import { pyName, tomlString, tomlTable } from '../toml.mjs'
|
|
18
|
+
import { upFrom } from '../workspace.mjs'
|
|
19
|
+
import { addItems, offlineRestore, restoreStep } from './dotnet.mjs'
|
|
20
|
+
import { appendLines, compiledRequirements, dockerInstalls, editPyproject, parsePyproject } from './python.mjs'
|
|
21
|
+
import { lockStep } from './python-lock.mjs'
|
|
22
|
+
|
|
23
|
+
const AM = AZURE_MONITOR
|
|
24
|
+
const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
|
|
25
|
+
const dirOf = (path) => posix.dirname(path)
|
|
26
|
+
const read = (view, path) => { try { return view.read(path) } catch { return null } }
|
|
27
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
28
|
+
const LOCK_CMD = { 'uv.lock': 'uv', 'poetry.lock': 'poetry', 'pdm.lock': 'pdm' }
|
|
29
|
+
|
|
30
|
+
/** @type {import('../types.mjs').Applier} */
|
|
31
|
+
export const azure = {
|
|
32
|
+
id: 'azure',
|
|
33
|
+
plan(service, ctx) {
|
|
34
|
+
// What this mode is, and what it does not add for this stack.
|
|
35
|
+
const start = AM.plan.start[service.lang]
|
|
36
|
+
const out = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [AM.plan.mode, ...(start ? [fill(AM.plan.keep, { start })] : [])] }
|
|
37
|
+
const view = ctx.repo ?? ctx.ws
|
|
38
|
+
const existing = ctx.existing ?? findExisting(service, view)
|
|
39
|
+
if (service.lang === 'python') {
|
|
40
|
+
python(service, ctx, existing, out)
|
|
41
|
+
if (!out.stop) out.manual.push(...untracedServer(service, view, existing))
|
|
42
|
+
}
|
|
43
|
+
else if (service.lang === 'dotnet') dotnet(service, ctx, existing, out)
|
|
44
|
+
else if (service.lang === 'node') node(service, ctx, existing, out)
|
|
45
|
+
else out.manual.push(printedBlock('Add UKNOWS beside Azure Monitor', ['This tool has no automatic steps for Azure Monitor in this language.'], []))
|
|
46
|
+
out.manual.push(samplingAdvice(existing, service.lang))
|
|
47
|
+
return out
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The sampling that applies, as advice: code arguments, OTEL_TRACES_SAMPLER, or the default of 5 a second. Then why it
|
|
53
|
+
* matters, the option names of this stack and how to send every trace, each said once.
|
|
54
|
+
*/
|
|
55
|
+
function samplingAdvice(existing, lang) {
|
|
56
|
+
const anchors = existing.anchors ?? []
|
|
57
|
+
const code = anchors.find((a) => a.kind === 'sampling')
|
|
58
|
+
const env = anchors.find((a) => a.kind === 'sampler-env')
|
|
59
|
+
const line = code ? fill(AM.plan.samplingCode, { file: code.file, line: code.line, value: code.what })
|
|
60
|
+
: env ? fill(AM.plan.samplingEnv, { file: env.file, value: env.value ?? env.what }) : AM.plan.samplingDefault
|
|
61
|
+
const options = AM.plan.samplingOptions[lang] ?? AM.plan.samplingOptions.python
|
|
62
|
+
return { title: 'Check the sampling of Azure Monitor', lines: [line, ...AM.plan.sampling.map((l) => fill(l, { options }))], required: false }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** A required step with the code to add by hand. The Keep line names only what this stack must not add. */
|
|
66
|
+
function printedBlock(title, reasons, snippets, lang = null) {
|
|
67
|
+
const start = AM.plan.start[lang]
|
|
68
|
+
const steps = AM.steps.map((step) => (start && /^Keep APPLICATIONINSIGHTS_CONNECTION_STRING/.test(step) ? fill(AM.plan.keep, { start }) : step))
|
|
69
|
+
return { title, lines: [...reasons, ...steps], snippets }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// --- Python ----------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
function python(service, ctx, existing, out) {
|
|
75
|
+
const { ws } = ctx
|
|
76
|
+
const view = ctx.repo ?? ws
|
|
77
|
+
const exporterName = AM.python.package
|
|
78
|
+
const sdk = existing.installed?.packages.get('opentelemetry-sdk')
|
|
79
|
+
let requirement = sdk?.version ? `${exporterName}==${sdk.version}` : exporterName
|
|
80
|
+
const printed = (reasons) => {
|
|
81
|
+
out.manual.push(printedBlock('Add UKNOWS beside Azure Monitor', reasons, [
|
|
82
|
+
{ caption: 'Package to add', language: 'text', code: requirement },
|
|
83
|
+
{ caption: `Python: after the ${AM.python.call}...) statement`, language: 'python', code: AM.python.code }], 'python'))
|
|
84
|
+
}
|
|
85
|
+
// The manifest that lists azure-monitor-opentelemetry: in a uv workspace, the member that declares it.
|
|
86
|
+
const calls = pythonCallSites(existing, view)
|
|
87
|
+
const declared = (existing.azure ?? []).filter((e) => /(?:^|\/)(pyproject\.toml|requirements[\w.-]*\.txt)$/.test(e.file))
|
|
88
|
+
const near = calls[0] && declared.find((e) => calls[0].file === e.file || calls[0].file.startsWith(`${dirOf(e.file)}/`) || dirOf(e.file) === '.')
|
|
89
|
+
const manifest = (near ?? declared[0])?.file
|
|
90
|
+
if (!manifest) return printed(['This tool did not find the file that lists azure-monitor-opentelemetry.'])
|
|
91
|
+
|
|
92
|
+
// The image must install the file that gets the package, or the import in the block would fail at start.
|
|
93
|
+
const docker = service.dockerfile ? read(ws, service.dockerfile) : null
|
|
94
|
+
if (docker !== null && !imageInstalls(service, docker, manifest, view, existing)) {
|
|
95
|
+
return printed([`${service.dockerfile} does not install ${manifest}, so the image would not have ${exporterName}. This tool changed nothing.`])
|
|
96
|
+
}
|
|
97
|
+
// The code goes in automatically only when it is safe: 1 call, a whole statement, no pylint config.
|
|
98
|
+
const reasons = []
|
|
99
|
+
if (calls.length !== 1) reasons.push(calls.length ? `The app calls ${AM.python.call}...) ${calls.length} times.` : `This tool did not find the ${AM.python.call}...) call.`)
|
|
100
|
+
const call = calls.length === 1 ? calls[0] : null
|
|
101
|
+
const statement = call ? pythonStatement(read(ws, call.file), call) : null
|
|
102
|
+
if (statement && !statement.ok) reasons.push(`This tool cannot add code after the ${AM.python.call}...) call in ${call.file} line ${call.line + 1}: ${statement.reason}.`)
|
|
103
|
+
const pylint = pylintConfig(view, call?.file ?? manifest)
|
|
104
|
+
if (pylint) reasons.push(`${pylint} configures pylint, which can reject the added code.`)
|
|
105
|
+
|
|
106
|
+
// The package, then the lock.
|
|
107
|
+
const text = read(ws, manifest)
|
|
108
|
+
const listed = dependencies(manifest, text ?? '').some((d) => d.name === exporterName)
|
|
109
|
+
const lockFile = /pyproject\.toml$/.test(manifest) ? lockFor(view, dirOf(manifest)) : null
|
|
110
|
+
const lockName = lockFile?.split('/').pop()
|
|
111
|
+
const member = pythonProjectName(view, dirOf(manifest))
|
|
112
|
+
const locked = lockFile && existing.installed?.packages.get(member ?? '')?.requires.some((r) => pyName(r.name) === pyName(exporterName))
|
|
113
|
+
const needsLock = lockFile && (!listed || !locked)
|
|
114
|
+
// A uv or Poetry project gets a lower limit: its resolver keeps the exporter at the SDK version, now and after a
|
|
115
|
+
// later Azure Monitor upgrade. An exact pin would hold the SDK at today's version (gate open issue 7).
|
|
116
|
+
const tool = lockName === 'uv.lock' ? 'uv lock' : lockName === 'poetry.lock' ? 'poetry lock' : null
|
|
117
|
+
if (tool && sdk?.version) requirement = `${exporterName}>=${sdk.version}`
|
|
118
|
+
if (needsLock && lockName === 'pdm.lock') return printed([`${manifest} is locked with PDM. Add the package, then run pdm lock.`])
|
|
119
|
+
// The tool on this computer writes the lock that the image reads: it must be there, and the version the project asks for.
|
|
120
|
+
const step = needsLock ? lockStep({ view, lockFile, tools: ctx.tools, dockerfile: service.dockerfile, docker, packages: exporterName }) : null
|
|
121
|
+
if (step?.block) {
|
|
122
|
+
out.manual.push(step.block)
|
|
123
|
+
out.stop = true
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
// A requirements file that pip-compile or uv writes, or that has hashes, takes no lines from this tool. Then the
|
|
127
|
+
// code block stays out too: its import would fail at start without the package.
|
|
128
|
+
const compiled = !listed && /\.txt$/.test(manifest) ? compiledRequirements(ws, manifest) : null
|
|
129
|
+
if (compiled) return printed([...compiled, 'This tool changed nothing.'])
|
|
130
|
+
if (!listed) {
|
|
131
|
+
const deps = parsePyproject(text ?? '')
|
|
132
|
+
const after = /\.txt$/.test(manifest) ? appendLines(text, [requirement])
|
|
133
|
+
: deps?.kind === 'poetry' ? editPyproject(text, deps, [sdk?.version ? `${exporterName}>=${sdk.version}` : `${exporterName}==*`]) : deps ? editPyproject(text, deps, [requirement]) : null
|
|
134
|
+
if (after === null) return printed([`This tool cannot change the dependency list in ${manifest}.`])
|
|
135
|
+
out.edits.push({ file: manifest, before: text, after, why: `Add ${exporterName}, so Azure Monitor traces also go to UKNOWS.` })
|
|
136
|
+
out.summary.push(`Add ${requirement} to ${manifest}${sdk?.version ? `, at the opentelemetry-sdk version in ${sdk.file}` : ''}.`)
|
|
137
|
+
if (sdk?.version) out.notes.push(fill(tool ? AM.plan.floor : AM.plan.pin, { file: manifest, package: exporterName, version: sdk.version, tool }))
|
|
138
|
+
}
|
|
139
|
+
if (step?.command) out.commands.push(step.command)
|
|
140
|
+
if (reasons.length) return printed(reasons)
|
|
141
|
+
const source = read(ws, call.file)
|
|
142
|
+
if (/\bdef\s+_send_traces_to_uknows\s*\(/.test(source)) return
|
|
143
|
+
const after = insertPythonBlock(source, statement, AM.python.code, quoteStyle(view, call.file))
|
|
144
|
+
out.edits.push({ file: call.file, before: source, after, why: 'Also send the Azure Monitor traces to UKNOWS.' })
|
|
145
|
+
const count = after.split('\n').length - source.split('\n').length
|
|
146
|
+
out.summary.push(fill(AM.plan.pythonCode, { count, call: AM.python.call, file: call.file, line: statement.end + 1 }))
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A required step when the app serves HTTP with a framework that Azure Monitor does not trace (Starlette, FastMCP,
|
|
151
|
+
* aiohttp, Tornado, Falcon, Pyramid) and with none that it traces (Django, FastAPI, Flask). Without it, App Insights
|
|
152
|
+
* and UKNOWS get no request spans. Nothing when the app lists the instrumentation package already.
|
|
153
|
+
*/
|
|
154
|
+
function untracedServer(service, view, existing) {
|
|
155
|
+
const files = [...new Set([...service.manifests, ...(existing.members ?? []).flatMap((m) => m.manifests ?? []),
|
|
156
|
+
...(existing.azure ?? []).map((e) => e.file)])].filter((f) => /(?:^|\/)(pyproject\.toml|requirements[\w.-]*\.txt|Pipfile)$/.test(f))
|
|
157
|
+
const listed = files.flatMap((file) => dependencies(file, read(view, file) ?? '').map((d) => ({ ...d, name: pyName(d.name), file })))
|
|
158
|
+
const has = (name) => listed.find((d) => d.name === name)
|
|
159
|
+
if (AM.python.traced.some(has)) return []
|
|
160
|
+
const anchors = existing.anchors ?? []
|
|
161
|
+
const serves = (name) => (name === 'aiohttp' ? anchors.some((a) => a.kind === 'aiohttp-server') : name === 'mcp' ? anchors.some((a) => a.kind === 'mcp-server') : true)
|
|
162
|
+
const installed = existing.installed?.packages
|
|
163
|
+
const contrib = installed?.get('opentelemetry-instrumentation')?.version ?? contribOf(installed?.get('opentelemetry-sdk')?.version)
|
|
164
|
+
const T = AM.python.untraced
|
|
165
|
+
for (const server of AM.python.servers) {
|
|
166
|
+
if (has(server.package)) return []
|
|
167
|
+
const found = server.names.map(has).find((d) => d && serves(d.name))
|
|
168
|
+
if (!found) continue
|
|
169
|
+
const values = { ...server, name: found.name, file: found.file, line: found.line, manifest: found.file, version: contrib ? `, here ${contrib}` : '' }
|
|
170
|
+
return [{ title: fill(T.title, values), verify: true, lines: [fill(T.found, values), ...(found.name !== server.label.toLowerCase() ? [fill(T.builtOn, values)] : []),
|
|
171
|
+
T.why, fill(T.add, values), fill(T.call, values)],
|
|
172
|
+
snippets: [{ caption: 'Package to add', language: 'text', code: contrib ? `${server.package}==${contrib}` : server.package },
|
|
173
|
+
{ caption: T.caption, language: 'python', code: `from ${server.module} import ${server.instrumentor}\n\n${server.instrumentor}().instrument()` }] }]
|
|
174
|
+
}
|
|
175
|
+
return []
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The contrib release (opentelemetry-instrumentation-*) of an opentelemetry-sdk release: 1.36.0 -> 0.57b0. */
|
|
179
|
+
const contribOf = (core) => { const m = /^1\.(\d+)\.(\d+)$/.exec(core ?? ''); return m ? `0.${Number(m[1]) + 21}b${m[2]}` : null }
|
|
180
|
+
|
|
181
|
+
/** Real configure_azure_monitor( calls in the files where the anchor scan found the name. */
|
|
182
|
+
function pythonCallSites(existing, view) {
|
|
183
|
+
const files = [...new Set((existing.anchors ?? []).filter((a) => a.kind === 'azure-call' && a.file.endsWith('.py')).map((a) => a.file))]
|
|
184
|
+
return files.flatMap((file) => pythonCalls(read(view, file) ?? '', 'configure_azure_monitor').map((c) => ({ ...c, file })))
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* The image installs this manifest: uv sync, poetry install or pip install . for a pyproject.toml, pip install -r for
|
|
189
|
+
* a requirements file. uv sync --package <name> installs that member and the members it depends on, not the others.
|
|
190
|
+
*/
|
|
191
|
+
function imageInstalls(service, docker, manifest, view, existing) {
|
|
192
|
+
const installs = dockerInstalls(service, docker)
|
|
193
|
+
if (!/pyproject\.toml$/.test(manifest)) return installs.files.includes(manifest)
|
|
194
|
+
if (!installs.project || installs.packages === null) return installs.project
|
|
195
|
+
const name = pythonProjectName(view, dirOf(manifest))
|
|
196
|
+
return (name !== null && installs.packages.includes(name)) || (existing.members ?? []).some((m) => m.dir === dirOf(manifest))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The uv, Poetry or PDM lockfile that covers a folder. */
|
|
200
|
+
function lockFor(view, dir) {
|
|
201
|
+
for (const name of Object.keys(LOCK_CMD)) { const file = nearest(view, dir, name); if (file) return file }
|
|
202
|
+
return null
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** A pylint config next to the code or above it: .pylintrc, pylintrc, [tool.pylint] or [pylint] in setup.cfg or tox.ini. */
|
|
206
|
+
function pylintConfig(view, file) {
|
|
207
|
+
for (const dir of upFrom(dirOf(file), view.top ?? '.')) {
|
|
208
|
+
for (const name of ['.pylintrc', 'pylintrc']) if (read(view, at(dir, name)) !== null) return at(dir, name)
|
|
209
|
+
if (/^\s*\[tool\.pylint/m.test(read(view, at(dir, 'pyproject.toml')) ?? '')) return at(dir, 'pyproject.toml')
|
|
210
|
+
for (const name of ['setup.cfg', 'tox.ini']) if (/^\s*\[(?:tool:)?pylint/m.test(read(view, at(dir, name)) ?? '')) return at(dir, name)
|
|
211
|
+
}
|
|
212
|
+
return null
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Single quotes when ruff format says so: [tool.ruff.format] quote-style, or [format] in ruff.toml. Else double, as black. */
|
|
216
|
+
function quoteStyle(view, file) {
|
|
217
|
+
for (const dir of upFrom(dirOf(file), view.top ?? '.')) {
|
|
218
|
+
for (const [name, table] of [['ruff.toml', 'format'], ['.ruff.toml', 'format'], ['pyproject.toml', 'tool.ruff.format']]) {
|
|
219
|
+
const style = tomlString(tomlTable(read(view, at(dir, name)) ?? '', table), 'quote-style')
|
|
220
|
+
if (style) return style === 'single' ? "'" : '"'
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return '"'
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- .NET ------------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
function dotnet(service, ctx, existing, out) {
|
|
229
|
+
const { ws } = ctx
|
|
230
|
+
const view = ctx.repo ?? ws
|
|
231
|
+
const { package: exporter } = PINS.dotnet.otlpExporter
|
|
232
|
+
const projects = service.manifests.filter((m) => m.endsWith('.csproj'))
|
|
233
|
+
const csproj = projects.find((p) => /Microsoft\.NET\.Sdk\.Web/.test(read(ws, p) ?? '')) ?? projects[0]
|
|
234
|
+
const resolved = existing.installed?.packages.get('opentelemetry')?.version
|
|
235
|
+
const listed = csproj ? dependencies(csproj, read(ws, csproj) ?? '') : []
|
|
236
|
+
const direct = listed.find((d) => /^OpenTelemetry(\.Extensions\.Hosting|\.Exporter\.[\w.]+)?$/i.test(d.name) && /^\d+\.\d+\.\d+$/.test(d.spec ?? ''))?.spec
|
|
237
|
+
const version = resolved ?? direct ?? PINS.dotnet.otlpExporter.version
|
|
238
|
+
const code = [AM.dotnet.using, '', AM.dotnet.after, AM.dotnet.line].join('\n')
|
|
239
|
+
const printed = (reasons) => out.manual.push(printedBlock('Add UKNOWS beside Azure Monitor', reasons, [
|
|
240
|
+
{ caption: 'Terminal', language: 'bash', code: `dotnet add package ${exporter} --version ${version}` },
|
|
241
|
+
{ caption: '.NET: Program.cs', language: 'csharp', code }], 'dotnet'))
|
|
242
|
+
if (!csproj) return printed(['This tool did not find the .csproj file of the web app.'])
|
|
243
|
+
|
|
244
|
+
// Program.cs: exactly 1 WebApplication.CreateBuilder in the project that gets the package.
|
|
245
|
+
const builders = projectSources(ws, csproj).map((file) => ({ file, text: read(ws, file) ?? '' })).filter((f) => /WebApplication\s*\.\s*Create(?:Slim)?Builder\s*\(/.test(maskCsharp(f.text)))
|
|
246
|
+
const done = builders.some((f) => /ConfigureOpenTelemetryTracerProvider\s*\([^;]*AddOtlpExporter/.test(maskCsharp(f.text)))
|
|
247
|
+
const program = builders.length === 1 ? builders[0] : null
|
|
248
|
+
const statement = program ? csharpBuilder(program.text) : null
|
|
249
|
+
const reasons = []
|
|
250
|
+
if (!done && builders.length !== 1) reasons.push(builders.length ? `${builders.length} files call WebApplication.CreateBuilder.` : 'This app does not call WebApplication.CreateBuilder, for example because it uses a Startup class.')
|
|
251
|
+
if (!done && statement && !statement.ok) reasons.push(`This tool cannot add the line in ${program.file}: ${statement.reason}.`)
|
|
252
|
+
|
|
253
|
+
// The package, at the OpenTelemetry version the app resolves now.
|
|
254
|
+
const text = read(ws, csproj)
|
|
255
|
+
if (!listed.some((d) => d.name.toLowerCase() === exporter.toLowerCase())) {
|
|
256
|
+
// A locked restore refreshes packages.lock.json after the edit. When it cannot run, nothing changes.
|
|
257
|
+
const docker = service.dockerfile ? read(ws, service.dockerfile) : null
|
|
258
|
+
const offline = docker === null ? null : offlineRestore(docker)
|
|
259
|
+
const framework = /<TargetFrameworks?>\s*net(\d+)\./i.exec(text ?? '')?.[1] ?? null
|
|
260
|
+
const restore = restoreStep(service, ctx, csproj, framework)
|
|
261
|
+
if (offline || restore?.block) {
|
|
262
|
+
const block = offline ? { title: RESOLVE.dotnet.offline.title, lines: [fill(RESOLVE.dotnet.offline.lines[0], { file: service.dockerfile, line: offline.line })] }
|
|
263
|
+
: restore.block
|
|
264
|
+
out.manual.push({ ...block, snippets: [{ caption: 'Terminal', language: 'bash', code: `dotnet add package ${exporter} --version ${version}` },
|
|
265
|
+
{ caption: '.NET: Program.cs', language: 'csharp', code }] })
|
|
266
|
+
out.stop = true
|
|
267
|
+
return
|
|
268
|
+
}
|
|
269
|
+
const central = upFrom(dirOf(csproj), view.top ?? '.').map((d) => at(d, 'Directory.Packages.props')).find((f) => read(view, f) !== null)
|
|
270
|
+
const centrally = central && /<ManagePackageVersionsCentrally>\s*true\s*</i.test(read(view, central))
|
|
271
|
+
if (centrally) return printed([`This repo sets package versions in ${central}. Add ${exporter} ${version} there and in ${csproj}.`, ...reasons])
|
|
272
|
+
const after = addItems(text, [`<PackageReference Include="${exporter}" Version="${version}" />`], /<PackageReference\s/)
|
|
273
|
+
if (after === null) return printed([`This tool cannot add a package to ${csproj}.`, ...reasons])
|
|
274
|
+
out.edits.push({ file: csproj, before: text, after, why: `Add the ${exporter} package, so Azure Monitor traces also go to UKNOWS.` })
|
|
275
|
+
out.summary.push(`Add ${exporter} ${version} to ${csproj}${resolved ? ', at the OpenTelemetry version the app resolves now' : ''}.`)
|
|
276
|
+
if (!resolved) out.notes.push(`${exporter} ${version} needs OpenTelemetry ${version} or later. If the app uses an older OpenTelemetry, this raises it to ${version}.`)
|
|
277
|
+
if (restore?.command) out.commands.push(...restore.before, restore.command)
|
|
278
|
+
if (restore?.command) out.notes.push(...restore.notes)
|
|
279
|
+
if (restore?.note) out.notes.push(restore.note)
|
|
280
|
+
}
|
|
281
|
+
if (done) return
|
|
282
|
+
if (reasons.length) return printed(reasons)
|
|
283
|
+
const withUsing = addUsing(program.text, AM.dotnet.using)
|
|
284
|
+
// The using line can move the builder line down by 1 or 2 lines.
|
|
285
|
+
const moved = csharpBuilder(withUsing)
|
|
286
|
+
const doc = new Doc(withUsing)
|
|
287
|
+
doc.insert(moved.end + 1, `${moved.indent}${AM.dotnet.line.replace(/\bbuilder\.Services\b/, `${moved.name}.Services`)}`)
|
|
288
|
+
out.edits.push({ file: program.file, before: program.text, after: doc.toString(), why: 'Also send the Azure Monitor traces to UKNOWS.' })
|
|
289
|
+
out.summary.push(`Also send the Azure Monitor traces to UKNOWS: add ${AM.dotnet.using} and 1 line after the builder in ${program.file}.`)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* The .cs files that 1 project compiles: the files under its folder, without obj/ and bin/, without the folders of
|
|
294
|
+
* other projects below it, and without the <Compile Remove="..."> globs of the project.
|
|
295
|
+
*/
|
|
296
|
+
export function projectSources(ws, csproj) {
|
|
297
|
+
const dir = dirOf(csproj)
|
|
298
|
+
const under = (file) => dir === '.' || file.startsWith(`${dir}/`)
|
|
299
|
+
const rel = (file) => (dir === '.' ? file : file.slice(dir.length + 1))
|
|
300
|
+
const all = ws.files(dir).filter(under)
|
|
301
|
+
const nested = all.filter((f) => f.endsWith('.csproj') && dirOf(f) !== dir).map(dirOf)
|
|
302
|
+
const removed = [...(read(ws, csproj) ?? '').matchAll(/<Compile\s[^>]*?Remove\s*=\s*"([^"]+)"/gi)]
|
|
303
|
+
.flatMap((m) => m[1].split(';')).map((g) => g.trim().replace(/\\/g, '/').replace(/^\.\//, '')).filter(Boolean).map(globRegex)
|
|
304
|
+
return all.filter((f) => f.endsWith('.cs') && !/(?:^|\/)(?:obj|bin)\//.test(rel(f))
|
|
305
|
+
&& !nested.some((d) => f.startsWith(`${d}/`)) && !removed.some((re) => re.test(rel(f))))
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** An MSBuild item glob as a regex on a path relative to the project: ** any folders, * and ? within 1 name. */
|
|
309
|
+
function globRegex(glob) {
|
|
310
|
+
let out = ''
|
|
311
|
+
for (let i = 0; i < glob.length; i++) {
|
|
312
|
+
const c = glob[i]
|
|
313
|
+
if (c === '*' && glob[i + 1] === '*') { out += glob[i + 2] === '/' ? '(?:.*/)?' : '.*'; i += glob[i + 2] === '/' ? 2 : 1 }
|
|
314
|
+
else if (c === '*') out += '[^/]*'
|
|
315
|
+
else if (c === '?') out += '[^/]'
|
|
316
|
+
else out += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
317
|
+
}
|
|
318
|
+
return new RegExp(`^${out}$`, 'i')
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// --- Node.js ---------------------------------------------------------------------------
|
|
322
|
+
|
|
323
|
+
function node(service, ctx, existing, out) {
|
|
324
|
+
const view = ctx.repo ?? ctx.ws
|
|
325
|
+
const packages = existing.installed?.packages ?? new Map()
|
|
326
|
+
const versionOf = (name) => packages.get(packageKey('node', name))?.version ?? null
|
|
327
|
+
// The experimental packages share 1 version line: take the exporter version from one the distro already has.
|
|
328
|
+
const experimental = versionOf(AM.node.packages[0]) ?? versionOf('@opentelemetry/instrumentation') ?? versionOf('@opentelemetry/sdk-node')
|
|
329
|
+
const stable = versionOf(AM.node.packages[1])
|
|
330
|
+
const wanted = [[AM.node.packages[0], experimental], [AM.node.packages[1], stable]]
|
|
331
|
+
const pkg = (() => { try { return JSON.parse(read(view, at(service.dir, 'package.json')) ?? '') } catch { return null } })()
|
|
332
|
+
const has = (name) => ['dependencies', 'devDependencies'].some((g) => pkg?.[g]?.[name])
|
|
333
|
+
const call = (existing.anchors ?? []).find((a) => a.kind === 'azure-call' && /\.(m?[jt]sx?|c[jt]s)$/.test(a.file))
|
|
334
|
+
const shim = !call && (existing.azure ?? []).some((e) => e.what === 'applicationinsights')
|
|
335
|
+
const added = call && /OTLPTraceExporter/.test(read(view, call.file) ?? '')
|
|
336
|
+
if (added && wanted.every(([name]) => has(name))) return
|
|
337
|
+
const pm = nearest(view, service.dir, 'pnpm-lock.yaml') ? 'pnpm add' : nearest(view, service.dir, 'yarn.lock') ? 'yarn add' : 'npm install --save'
|
|
338
|
+
const install = `${pm} ${wanted.map(([name, v]) => (v ? `${name}@${v}` : name)).join(' ')}`
|
|
339
|
+
const where = call ? `Found ${AM.node.call}...) in ${call.file} line ${call.line}.` : null
|
|
340
|
+
out.manual.push(printedBlock('Add the UKNOWS exporter to Azure Monitor', [where, shim ? AM.node.shim : null,
|
|
341
|
+
'Node.js apps pass their Azure Monitor options in many ways, so this tool does not change the code.',
|
|
342
|
+
...AM.notes.filter((n) => /Node\.js/.test(n))].filter(Boolean), [
|
|
343
|
+
{ caption: 'Terminal', language: 'bash', code: install },
|
|
344
|
+
{ caption: `Node.js: where your app calls ${AM.node.call.slice(0, -1)}`, language: 'javascript', code: AM.node.code }], 'node'))
|
|
345
|
+
}
|