@uknows/setup 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/README.md +99 -32
  2. package/lib/automate.mjs +1299 -0
  3. package/lib/check.mjs +149 -33
  4. package/lib/ci.mjs +21 -0
  5. package/lib/cli.mjs +114 -29
  6. package/lib/cloudid.mjs +456 -0
  7. package/lib/code.mjs +147 -0
  8. package/lib/context.mjs +126 -0
  9. package/lib/contract-docs.mjs +1780 -198
  10. package/lib/contract.mjs +93 -10
  11. package/lib/detect.mjs +96 -44
  12. package/lib/dockerfile.mjs +83 -0
  13. package/lib/endpoint.mjs +36 -5
  14. package/lib/engine.mjs +1272 -126
  15. package/lib/existing.mjs +448 -54
  16. package/lib/git.mjs +53 -0
  17. package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
  18. package/lib/hooks/pins.mjs +28 -0
  19. package/lib/hooks/uknows-otel.cjs +82 -0
  20. package/lib/hooks/uknows_otel.py +179 -0
  21. package/lib/hosts.mjs +521 -16
  22. package/lib/locks.mjs +353 -0
  23. package/lib/manifest.mjs +27 -7
  24. package/lib/members.mjs +269 -0
  25. package/lib/plan.mjs +130 -0
  26. package/lib/settings.mjs +216 -28
  27. package/lib/signin.mjs +259 -0
  28. package/lib/stacks/azure.mjs +345 -0
  29. package/lib/stacks/channel.mjs +125 -0
  30. package/lib/stacks/dotnet.mjs +336 -29
  31. package/lib/stacks/functions-hooks.mjs +228 -0
  32. package/lib/stacks/functions.mjs +148 -0
  33. package/lib/stacks/guided.mjs +153 -30
  34. package/lib/stacks/index.mjs +10 -2
  35. package/lib/stacks/java.mjs +14 -13
  36. package/lib/stacks/node.mjs +142 -19
  37. package/lib/stacks/otel.mjs +56 -4
  38. package/lib/stacks/python-bootstrap.mjs +27 -7
  39. package/lib/stacks/python-lock.mjs +105 -0
  40. package/lib/stacks/python-sets.mjs +143 -0
  41. package/lib/stacks/python.mjs +261 -63
  42. package/lib/toml.mjs +64 -0
  43. package/lib/types.mjs +52 -5
  44. package/lib/ui.mjs +139 -20
  45. package/lib/updates.mjs +398 -0
  46. package/lib/versions.mjs +115 -0
  47. package/lib/workspace.mjs +4 -1
  48. package/package.json +3 -8
@@ -0,0 +1,456 @@
1
+ /*
2
+ * The cloud id of a container app, in the command that deploys it. Each deploy then sets OTEL_RESOURCE_ATTRIBUTES on the
3
+ * app: cloud.provider, cloud.platform, cloud.region (when known) and cloud.resource_id. With cloud.resource_id UKNOWS
4
+ * links the data of the app to that exact resource, and the gateway keeps only a keyed hash of it. The replica host
5
+ * name (host.name) still tells the revisions apart.
6
+ *
7
+ * Only where setup knows the exact id: the subscription, resource group and app that the deploy command names itself,
8
+ * written as the command writes them (a value, a shell variable or a workflow expression), so at deploy time the id is
9
+ * the one of the app that the command deploys. Never a guess, and never over a value the customer set:
10
+ * - the command sets OTEL_RESOURCE_ATTRIBUTES itself: the attributes it lacks go at the end of its value, and its own
11
+ * ones stay as they are;
12
+ * - --replace-env-vars, or environmentVariables of the deploy action: the pair goes on that list. The list holds every
13
+ * variable of the app, so nothing set elsewhere is lost;
14
+ * - --set-env-vars or no env option: only when az shows that no app the command can deploy has
15
+ * OTEL_RESOURCE_ATTRIBUTES (names only, never values), with 1 container or the one that the command names. The pair
16
+ * goes on the --set-env-vars list, never in a second --set-env-vars: az keeps only the last list (az 2.87.0, measured
17
+ * 22 Oct 2026).
18
+ * Nothing when a Dockerfile of the services sets OTEL_RESOURCE_ATTRIBUTES (the setting on the app would hide it), and
19
+ * nothing when the subscription is only in a secret: setup never copies a secret into the settings of an app.
20
+ * cloud.region only when az found the app and the deploy file names its subscription as a value.
21
+ */
22
+ import { Doc } from './doc.mjs'
23
+ import { unique } from './ui.mjs'
24
+
25
+ export const RESOURCE_ATTRIBUTES = 'OTEL_RESOURCE_ATTRIBUTES'
26
+ /** cloud.platform of Container Apps in the OpenTelemetry semantic conventions (1.44.0). */
27
+ export const ACA_PLATFORM = 'azure.container_apps'
28
+ const ACA_TYPE = 'Microsoft.App/containerApps'
29
+ const UPDATE = /\baz\s+containerapp\s+update\b/
30
+ /** A shell word as written: workflow expressions, quoted parts, escapes and plain characters. */
31
+ const WORD = /(?:\$\{\{[^}]*\}\}|"(?:[^"\\]|\\.)*"|'[^']*'|\\.|[^\s"'\\])+/g
32
+ const OPERATOR = /^(?:&&|\|\||;|\||&|[0-9]?>.*|<.*|#.*)$/
33
+ const OPTION = /^--?[A-Za-z]/
34
+ const GUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
35
+ const WHY = 'Tell UKNOWS which app in Azure the data comes from.'
36
+ /** facts for a dry run in a terminal: the plan of a real run, as if az showed no OTEL_RESOURCE_ATTRIBUTES on the app. */
37
+ export const PREDICT = 'predict'
38
+
39
+ /** The attributes for an app: [key, value] pairs, the region only when known. */
40
+ export function cloudAttributes({ subscription, group, app, region = null }) {
41
+ return [['cloud.provider', 'azure'], ['cloud.platform', ACA_PLATFORM], ...(region ? [['cloud.region', region]] : []),
42
+ ['cloud.resource_id', `/subscriptions/${subscription}/resourceGroups/${group}/providers/${ACA_TYPE}/${app}`]]
43
+ }
44
+
45
+ /**
46
+ * value with the attributes of ours that it lacks at its end. Its own keys and values stay as they are. null when the
47
+ * value is not a plain list of key=value pairs (a variable, an empty pair), so setup cannot add to it safely.
48
+ * '' when nothing is missing.
49
+ */
50
+ export function mergeAttributes(value, ours) {
51
+ const items = value.replace(/,+\s*$/, '').split(',')
52
+ if (!items.every((item) => /^\s*[\w.-]+\s*=\s*\S/.test(item))) return null
53
+ const keys = new Set(items.map((item) => item.slice(0, item.indexOf('=')).trim()))
54
+ return ours.filter(([key]) => !keys.has(key)).map(([key, v]) => `${key}=${v}`).join(',')
55
+ }
56
+
57
+ /** The text between matching quotes, or the word itself when it has no quotes. null when the quotes are mixed. */
58
+ function unquote(text) {
59
+ const m = /^(["'])(.*)\1$/.exec(text)
60
+ if (m) return { bare: m[2], quote: m[1] }
61
+ return /["']/.test(text) ? null : { bare: text, quote: '' }
62
+ }
63
+
64
+ /**
65
+ * A name as the command writes it, as text to put inside "...": a value, a shell variable ($NAME or ${NAME}, not in a
66
+ * workflow input) or a workflow expression that is not a secret. null for anything else.
67
+ */
68
+ function nameText(word, { shell = true } = {}) {
69
+ const u = unquote(word)
70
+ if (!u) return null
71
+ if (/^[A-Za-z0-9][\w.()-]*$/.test(u.bare)) return { text: u.bare, literal: u.bare }
72
+ const variable = /^\$(?:\{([A-Za-z_]\w*)\}|([A-Za-z_]\w*))$/.exec(u.bare)
73
+ if (variable && shell && u.quote !== "'") return { text: `\${${variable[1] ?? variable[2]}}`, ref: variable[1] ?? variable[2] }
74
+ const expression = /^\$\{\{\s*([A-Za-z_][\w.-]*)\s*\}\}$/.exec(u.bare)
75
+ if (expression && !/^secrets\./.test(expression[1])) return { text: `\${{ ${expression[1]} }}`, ref: /^env\.(\w+)$/.exec(expression[1])?.[1] ?? null }
76
+ if (expression) return { secret: true }
77
+ return null
78
+ }
79
+
80
+ /** The variables that a file sets for itself (KEY: value in YAML, export KEY=value or KEY=value in a script). */
81
+ function fileVariables(doc) {
82
+ const vars = new Map()
83
+ for (const line of doc.lines) {
84
+ const m = /^\s*([A-Z][A-Z0-9_]*)\s*:\s*(.+?)\s*$/.exec(line) ?? /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)=(\S+)\s*$/.exec(line)
85
+ if (m && !vars.has(m[1])) vars.set(m[1], m[2].replace(/\s+#.*$/, '').replace(/^["']|["']$/g, ''))
86
+ }
87
+ return vars
88
+ }
89
+
90
+ /** The value that a name has in the file: itself, or the value of the variable it names (3 hops at most). null if unknown. */
91
+ function literalOf(name, vars) {
92
+ if (!name || name.secret) return null
93
+ if (name.literal) return name.literal
94
+ let ref = name.ref
95
+ for (let hop = 0; hop < 3 && ref; hop++) {
96
+ const v = vars.get(ref)
97
+ if (v === undefined) return null
98
+ if (/^[A-Za-z0-9][\w.()-]*$/.test(v)) return v
99
+ ref = /^\$\{\{\s*env\.(\w+)\s*\}\}$/.exec(v)?.[1] ?? /^\$\{?([A-Za-z_]\w*)\}?$/.exec(v)?.[1] ?? null
100
+ }
101
+ return null
102
+ }
103
+
104
+ /** The rows of the job that row i is in (a GitHub workflow), as text. null outside a job. */
105
+ function jobText(doc, i) {
106
+ const indent = (l) => /^\s*/.exec(l)[0].length
107
+ let top = i
108
+ while (top > 0 && !/^\s*steps\s*:/.test(doc.get(top))) top--
109
+ if (!/^\s*steps\s*:/.test(doc.get(top) ?? '')) return null
110
+ let job = top
111
+ while (job > 0 && !(doc.get(job).trim() && indent(doc.get(job)) < indent(doc.get(top)))) job--
112
+ let end = top + 1
113
+ while (end < doc.length && (!doc.get(end).trim() || indent(doc.get(end)) > indent(doc.get(job)))) end++
114
+ return doc.lines.slice(job, end).join('\n')
115
+ }
116
+
117
+ /** The subscription that azure/login signs in to in the job of row i: its subscription-id input. */
118
+ function loginSubscription(doc, i) {
119
+ const job = jobText(doc, i)
120
+ const raw = job ? /^\s*subscription-id\s*:\s*(.+?)\s*(?:#.*)?$/m.exec(job)?.[1] : null
121
+ return raw ? nameText(raw, { shell: false }) : null
122
+ }
123
+
124
+ /** Each az containerapp update command of a file: its first and last row and its words, with their rows and columns. */
125
+ export function updateCommands(doc) {
126
+ const out = []
127
+ for (let i = 0; i < doc.length; i++) {
128
+ if (/^\s*#/.test(doc.get(i)) || !UPDATE.test(doc.get(i))) continue
129
+ let end = i
130
+ while (end + 1 < doc.length && /\\\s*$/.test(doc.get(end))) end++
131
+ const words = []
132
+ for (let row = i; row <= end; row++) {
133
+ const text = doc.get(row).replace(/\\\s*$/, '')
134
+ for (const m of text.matchAll(WORD)) words.push({ text: m[0], row, start: m.index, end: m.index + m[0].length })
135
+ }
136
+ const at = words.findIndex((w, k) => w.text === 'az' && words[k + 1]?.text === 'containerapp' && words[k + 2]?.text === 'update')
137
+ if (at >= 0) {
138
+ const stop = words.findIndex((w, k) => k > at && OPERATOR.test(w.text))
139
+ out.push({ first: i, last: end, rows: doc.lines.slice(i, end + 1), words: words.slice(at, stop < 0 ? words.length : stop) })
140
+ }
141
+ i = end
142
+ }
143
+ return out
144
+ }
145
+
146
+ /** The options of a command: name -> {word, values}. --name=value counts too. */
147
+ function optionsOf(words) {
148
+ const options = new Map()
149
+ for (let k = 3; k < words.length; k++) {
150
+ const w = words[k]
151
+ const eq = /^(--?[A-Za-z][\w-]*)=(.*)$/.exec(w.text)
152
+ if (eq) {
153
+ const start = w.start + eq[1].length + 1
154
+ options.set(eq[1], { word: w, values: [{ ...w, text: eq[2], start }] })
155
+ continue
156
+ }
157
+ if (!OPTION.test(w.text)) continue
158
+ const values = []
159
+ let j = k + 1
160
+ while (j < words.length && !OPTION.test(words[j].text)) values.push(words[j++])
161
+ options.set(w.text, { word: w, values })
162
+ k = j - 1
163
+ }
164
+ return options
165
+ }
166
+
167
+ /**
168
+ * The deploy steps of container apps in the deploy files that the hosts name: each az containerapp update command, and
169
+ * each deploy action with environmentVariables on 1 line. Each spot: {file, row, kind ('az' or 'action'), names
170
+ * ({subscription, group, app, container} as nameText), list ('replace', 'set' or null), why (when setup cannot add the
171
+ * id there)}. Read only.
172
+ */
173
+ export function cloudIdSpots(view, hosts) {
174
+ const files = unique((hosts ?? []).filter((h) => h.id === 'azure-container-apps').flatMap((h) => h.evidence ?? [])
175
+ .filter((e) => e.what === 'Deploy step runs az containerapp' || e.what === 'Azure Container Apps deploy action').map((e) => e.file))
176
+ .filter((f) => !f.startsWith('../') && !/\.ps1$/i.test(f))
177
+ const spots = []
178
+ for (const file of files) {
179
+ let text = null
180
+ try { text = view.read(file) } catch { text = null }
181
+ if (text === null) continue
182
+ const doc = new Doc(text)
183
+ const vars = fileVariables(doc)
184
+ for (const c of updateCommands(doc)) {
185
+ const options = optionsOf(c.words)
186
+ const get = (...names) => names.map((n) => options.get(n)).find(Boolean) ?? null
187
+ const one = (option) => (option && option.values.length === 1 ? nameText(option.values[0].text) : null)
188
+ const names = { subscription: one(get('--subscription')) ?? loginSubscription(doc, c.first), group: one(get('--resource-group', '-g')),
189
+ app: one(get('--name', '-n')), container: one(get('--container-name')) }
190
+ const replace = get('--replace-env-vars'), set = get('--set-env-vars')
191
+ const why = get('--ids') ? 'ids' : replace && set ? 'both-lists' : names.subscription?.secret ? 'secret-subscription'
192
+ : !names.subscription ? 'no-subscription' : !names.group || names.group.secret ? 'no-group' : !names.app || names.app.secret ? 'no-app' : null
193
+ spots.push({ file, row: c.first, kind: 'az', command: c, names, vars, list: replace ? 'replace' : set ? 'set' : null, why,
194
+ app: literalOf(names.app, vars), group: literalOf(names.group, vars), subscription: literalOf(names.subscription, vars) })
195
+ }
196
+ for (let i = 0; i < doc.length; i++) {
197
+ const m = /^(\s*environmentVariables\s*:\s*)(.*?)((?:\s+#.*)?)\s*$/.exec(doc.get(i))
198
+ if (!m || /^\s*#/.test(doc.get(i))) continue
199
+ const step = stepOf(doc, i)
200
+ if (!step.some((l) => /^\s*(?:-\s+)?uses\s*:\s*['"]?azure\/container-apps-deploy-action@/.test(l))) continue
201
+ const input = (key) => { const raw = step.map((l) => new RegExp(`^\\s*${key}\\s*:\\s*(.+?)\\s*(?:#.*)?$`).exec(l)?.[1]).find(Boolean); return raw ? nameText(raw, { shell: false }) : null }
202
+ const names = { subscription: loginSubscription(doc, i), group: input('resourceGroup'), app: input('containerAppName'), container: null }
203
+ const value = m[2]
204
+ const why = !value || /^[|>]/.test(value) ? 'value-form' : names.subscription?.secret ? 'secret-subscription' : !names.subscription ? 'no-subscription'
205
+ : !names.group || names.group.secret ? 'no-group' : !names.app || names.app.secret ? 'no-app' : null
206
+ spots.push({ file, row: i, kind: 'action', names, vars, list: 'replace', why,
207
+ app: literalOf(names.app, vars), group: literalOf(names.group, vars), subscription: literalOf(names.subscription, vars) })
208
+ }
209
+ }
210
+ return spots
211
+ }
212
+
213
+ /** The rows of the workflow step that row i is in. */
214
+ function stepOf(doc, i) {
215
+ const indent = (l) => /^\s*/.exec(l)[0].length
216
+ let start = i
217
+ while (start >= 0 && !/^\s*-\s/.test(doc.get(start))) start--
218
+ if (start < 0) return []
219
+ const pad = indent(doc.get(start))
220
+ const rows = [doc.get(start)]
221
+ for (let j = start + 1; j < doc.length && (!doc.get(j).trim() || indent(doc.get(j)) > pad); j++) rows.push(doc.get(j))
222
+ return rows
223
+ }
224
+
225
+ /** True when the spot needs az to show that the app has no OTEL_RESOURCE_ATTRIBUTES of its own. */
226
+ const needsAzure = (spot) => !spot.why && spot.kind === 'az' && spot.list !== 'replace' && !setsAttributes(spot)
227
+
228
+ /** The value word of OTEL_RESOURCE_ATTRIBUTES on the env list of an az spot, or null. */
229
+ function attributesWord(spot) {
230
+ if (spot.kind !== 'az' || !spot.list) return null
231
+ const options = optionsOf(spot.command.words)
232
+ const list = options.get(spot.list === 'replace' ? '--replace-env-vars' : '--set-env-vars')
233
+ return list?.values.find((w) => (unquote(w.text)?.bare ?? w.text.replace(/["']/g, '')).startsWith(`${RESOURCE_ATTRIBUTES}=`)) ?? null
234
+ }
235
+ const setsAttributes = (spot) => Boolean(attributesWord(spot))
236
+
237
+ /**
238
+ * The apps that az must look up for the spots, as {app, group, subscription}: group and subscription null when the
239
+ * deploy file does not name them as values.
240
+ */
241
+ export function cloudIdLookups(spots) {
242
+ const out = []
243
+ for (const spot of spots.filter((s) => !s.why && s.app && (needsAzure(s) || s.subscription))) {
244
+ if (!out.some((x) => x.app === spot.app && x.group === spot.group && x.subscription === spot.subscription)) {
245
+ out.push({ app: spot.app, group: spot.group, subscription: spot.subscription })
246
+ }
247
+ }
248
+ return out
249
+ }
250
+ const factKey = (x) => `${x.app}|${x.group}|${x.subscription}`
251
+
252
+ /** fn for each item, at most n at a time, in order. */
253
+ async function inTurn(items, n, fn) {
254
+ const out = new Array(items.length)
255
+ let next = 0
256
+ const worker = async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i]) } }
257
+ await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker))
258
+ return out
259
+ }
260
+
261
+ const parse = (text) => { try { return JSON.parse(text) } catch { return null } }
262
+
263
+ /**
264
+ * What az shows of the container apps that the deploy commands name, read only. For each lookup: the apps of that name
265
+ * (in that group and subscription when the file names them) in the subscriptions that az can see. Only when that is 1
266
+ * app, az shows its containers with the names of their settings (never values), in the subscription of that app, as
267
+ * the key store does. Map of lookup -> {found: [{sub, group, name, id, location}], app: the 1 app with containers, or
268
+ * null}. null when az is not installed or not signed in. Every call but az account list names the subscription.
269
+ */
270
+ export async function cloudIdFacts(cli, { root, env }, lookups) {
271
+ if (!lookups.length) return null
272
+ const az = (args) => cli('az', [...args, '--only-show-errors'], { cwd: root, env, timeoutMs: 60000 })
273
+ const accounts = await az(['account', 'list', '--query', "[?state=='Enabled'].{id:id, name:name}", '-o', 'json'])
274
+ if (accounts.error === 'missing' || accounts.code !== 0) return null
275
+ const subs = (parse(accounts.stdout) ?? []).filter((s) => typeof s?.id === 'string' && GUID.test(s.id))
276
+ if (!subs.length) return null
277
+ const facts = new Map()
278
+ for (const lookup of lookups) {
279
+ const { app, group, subscription } = lookup
280
+ const where = subscription ? subs.filter((s) => s.id.toLowerCase() === subscription.toLowerCase()) : subs
281
+ const answers = await inTurn(where, 4, (sub) => az(['resource', 'list', '--subscription', sub.id, '--name', app, '--resource-type', ACA_TYPE,
282
+ '--query', '[].{id:id, name:name, resourceGroup:resourceGroup, location:location}', '-o', 'json']))
283
+ if (answers.some((a) => a.code !== 0)) { facts.set(factKey(lookup), null); continue }
284
+ const found = []
285
+ where.forEach((sub, i) => {
286
+ for (const row of parse(answers[i].stdout) ?? []) {
287
+ if (typeof row?.name !== 'string' || row.name.toLowerCase() !== app.toLowerCase() || typeof row.resourceGroup !== 'string') continue
288
+ if (group && row.resourceGroup.toLowerCase() !== group.toLowerCase()) continue
289
+ found.push({ sub: sub.id, group: row.resourceGroup, name: row.name, id: row.id, location: typeof row.location === 'string' ? row.location : null })
290
+ }
291
+ })
292
+ let one = null
293
+ if (found.length === 1) {
294
+ const f = found[0]
295
+ const shown = await az(['containerapp', 'show', '--subscription', f.sub, '-g', f.group, '-n', f.name,
296
+ '--query', 'properties.template.containers[].{name:name, env:env[].name}', '-o', 'json'])
297
+ const containers = shown.code === 0 ? parse(shown.stdout) : null
298
+ if (Array.isArray(containers)) one = { ...f, containers: containers.map((c) => ({ name: String(c?.name ?? ''), env: Array.isArray(c?.env) ? c.env.map(String) : [] })) }
299
+ }
300
+ facts.set(factKey(lookup), { found, app: one })
301
+ }
302
+ return facts
303
+ }
304
+
305
+ /**
306
+ * Whether setup may set the variable with --set-env-vars on the apps that az found for a spot. Each app must lack it in
307
+ * the container that the command updates. {ok, apps} or {ok: false, why}.
308
+ */
309
+ function azureAllows(spot, facts) {
310
+ if (facts === PREDICT) return spot.app ? { ok: true, predicted: true } : { ok: false, why: 'not-checked' }
311
+ const fact = facts?.get?.(factKey(spot))
312
+ if (!fact) return { ok: false, why: 'not-checked' }
313
+ if (!fact.found.length) return { ok: false, why: 'not-found' }
314
+ if (fact.found.length > 1) return { ok: false, why: 'not-unique' }
315
+ if (!fact.app) return { ok: false, why: 'not-checked' }
316
+ const container = literalOf(spot.names.container, spot.vars)
317
+ if (spot.names.container && !container) return { ok: false, why: 'container' }
318
+ const c = container ? fact.app.containers.find((x) => x.name === container) : fact.app.containers.length === 1 ? fact.app.containers[0] : null
319
+ if (!c) return { ok: false, why: 'container' }
320
+ if (c.env.includes(RESOURCE_ATTRIBUTES)) return { ok: false, why: 'has-own' }
321
+ return { ok: true }
322
+ }
323
+
324
+ /** The region of the app, when the deploy file names its subscription as a value and az found that 1 app. */
325
+ function regionOf(spot, facts) {
326
+ if (!spot.subscription || !spot.app || facts === PREDICT) return null
327
+ const found = facts?.get?.(factKey(spot))?.found ?? []
328
+ return found.length === 1 && /^[a-z0-9]+$/.test(found[0].location ?? '') ? found[0].location : null
329
+ }
330
+
331
+ /** The az containerapp update command whose first row is row, or null. */
332
+ export function updateCommandAt(doc, row) {
333
+ return updateCommands(doc).find((c) => c.first === row) ?? null
334
+ }
335
+
336
+ /**
337
+ * The change that puts pair (its text as the shell reads it) on the --set-env-vars list of a command: after the last
338
+ * value of the list, never in a second --set-env-vars (az keeps only the last list). A command with no list gets one:
339
+ * on a row of its own after the first row when the command goes over several rows, else after update.
340
+ */
341
+ export function setEnvChange(command, pair) {
342
+ const list = optionsOf(command.words).get('--set-env-vars')
343
+ if (list) {
344
+ const after = list.values.at(-1) ?? list.word
345
+ return { row: after.row, start: after.end, end: after.end, text: ` ${pair}` }
346
+ }
347
+ if (command.last > command.first && command.rows[1] !== undefined) {
348
+ return { insertAfter: command.first, text: `${/^\s*/.exec(command.rows[1])[0]}--set-env-vars ${pair} \\` }
349
+ }
350
+ const update = command.words[2]
351
+ return { row: update.row, start: update.end, end: update.end, text: ` --set-env-vars ${pair}` }
352
+ }
353
+
354
+ /** Apply changes to a Doc, from the end, so the rows and columns of the ones before stay right. */
355
+ export function applyChanges(doc, changes) {
356
+ const rowOf = (c) => (c.insertAfter ?? c.row)
357
+ for (const c of [...changes].sort((a, b) => rowOf(b) - rowOf(a) || (b.start ?? 0) - (a.start ?? 0))) {
358
+ if (c.insertAfter !== undefined) { doc.insert(c.insertAfter + 1, c.text); continue }
359
+ const line = doc.get(c.row)
360
+ doc.set(c.row, `${line.slice(0, c.start)}${c.text}${line.slice(c.end)}`)
361
+ }
362
+ }
363
+
364
+ /** A pair for the shell: "OTEL_RESOURCE_ATTRIBUTES=...". */
365
+ const shellPair = (attributes) => `"${RESOURCE_ATTRIBUTES}=${attributes.map(([k, v]) => `${k}=${v}`).join(',')}"`
366
+
367
+ /** The word with the missing attributes added inside it, keeping its quotes. null when setup cannot add them safely. */
368
+ function grownWord(text, missing) {
369
+ if (/^[^"']*"[^"]*"$/.test(text)) return `${text.slice(0, -1)},${missing}"`
370
+ if (!/["'\\]/.test(text)) return `"${text},${missing}"`
371
+ if (/^[^"']*'[^']*'$/.test(text)) return `${text}",${missing}"`
372
+ return null
373
+ }
374
+
375
+ /**
376
+ * The edits that add the cloud id to the deploy steps of container apps, and 1 summary line each. dockerfiles: the
377
+ * Dockerfiles of the services; when one sets OTEL_RESOURCE_ATTRIBUTES, nothing changes. Returns {edits, summary, skipped}:
378
+ * skipped lists {file, row, why} for each deploy step that keeps its text.
379
+ */
380
+ export function cloudIdPlan(view, spots, facts, { dockerfiles = [] } = {}) {
381
+ const out = { edits: [], summary: [], skipped: [] }
382
+ const own = dockerfiles.some((file) => { try { return new RegExp(`\\b${RESOURCE_ATTRIBUTES}\\b`).test(view.read(file) ?? '') } catch { return false } })
383
+ const byFile = new Map()
384
+ for (const spot of spots) {
385
+ const skip = (why) => out.skipped.push({ file: spot.file, row: spot.row + 1, why })
386
+ if (own) { skip('dockerfile-sets-it'); continue }
387
+ if (spot.why) { skip(spot.why); continue }
388
+ const attributes = cloudAttributes({ subscription: spot.names.subscription.text, group: spot.names.group.text, app: spot.names.app.text, region: regionOf(spot, facts) })
389
+ let change = null
390
+ if (spot.kind === 'action') {
391
+ change = actionChange(view, spot, attributes)
392
+ if (change?.none) continue
393
+ }
394
+ else {
395
+ const word = attributesWord(spot)
396
+ if (word) {
397
+ const value = (unquote(word.text)?.bare ?? word.text.replace(/["']/g, '')).slice(RESOURCE_ATTRIBUTES.length + 1)
398
+ const missing = mergeAttributes(value, attributes)
399
+ if (missing === null) { skip('own-value'); continue }
400
+ if (missing === '') continue
401
+ const grown = grownWord(word.text, missing)
402
+ if (!grown) { skip('own-value'); continue }
403
+ change = { row: word.row, start: word.start, end: word.end, text: grown }
404
+ } else if (spot.list === 'replace') {
405
+ const list = optionsOf(spot.command.words).get('--replace-env-vars')
406
+ const after = list.values.at(-1) ?? list.word
407
+ change = { row: after.row, start: after.end, end: after.end, text: ` ${shellPair(attributes)}` }
408
+ } else {
409
+ const allowed = azureAllows(spot, facts)
410
+ if (!allowed.ok) { skip(allowed.why); continue }
411
+ change = { ...setEnvChange(spot.command, shellPair(attributes)), predicted: Boolean(allowed.predicted) }
412
+ }
413
+ }
414
+ if (!change) { skip('value-form'); continue }
415
+ if (!byFile.has(spot.file)) byFile.set(spot.file, [])
416
+ byFile.get(spot.file).push({ ...change, spot })
417
+ }
418
+ for (const [file, changes] of byFile) {
419
+ const before = view.read(file)
420
+ const doc = new Doc(before)
421
+ applyChanges(doc, changes)
422
+ const after = doc.toString()
423
+ if (after === before) continue
424
+ out.edits.push({ file, before, after, why: WHY })
425
+ for (const c of changes) {
426
+ const app = c.spot.app ? `the container app ${c.spot.app}` : 'the container app that it deploys'
427
+ const first = c.predicted ? ` A real run first checks in Azure that the app has no ${RESOURCE_ATTRIBUTES} of its own.` : ''
428
+ out.summary.push(`${file} line ${c.spot.row + 1}: each deploy sets ${RESOURCE_ATTRIBUTES} with the Azure resource id of ${app}, so UKNOWS knows which app in Azure the data comes from.${first}`)
429
+ }
430
+ }
431
+ return out
432
+ }
433
+
434
+ /** The change for environmentVariables of the deploy action: the pair at the end of its list, or the missing attributes in it. */
435
+ function actionChange(view, spot, attributes) {
436
+ const doc = new Doc(view.read(spot.file))
437
+ const line = doc.get(spot.row)
438
+ const m = /^(\s*environmentVariables\s*:\s*)(.*?)((?:\s+#.*)?)\s*$/.exec(line)
439
+ if (!m) return null
440
+ const quoted = /^(["'])(.*)\1$/.exec(m[2])
441
+ if (!quoted && /["']/.test(m[2])) return null
442
+ const inner = quoted ? quoted[2] : m[2]
443
+ const begin = m[1].length + (quoted ? 1 : 0)
444
+ // The pairs of the list, with their columns in the line.
445
+ const pairs = [...inner.matchAll(/\S+/g)].map((p) => ({ text: p[0], start: begin + p.index, end: begin + p.index + p[0].length }))
446
+ const own = pairs.find((p) => p.text.startsWith(`${RESOURCE_ATTRIBUTES}=`))
447
+ const plain = attributes.map(([k, v]) => `${k}=${v}`).join(',')
448
+ if (own) {
449
+ const missing = mergeAttributes(own.text.slice(RESOURCE_ATTRIBUTES.length + 1), attributes)
450
+ if (missing === null) return null
451
+ if (missing === '') return { none: true }
452
+ return { row: spot.row, start: own.end, end: own.end, text: `,${missing}` }
453
+ }
454
+ const end = begin + inner.length
455
+ return { row: spot.row, start: end, end, text: `${inner ? ' ' : ''}${RESOURCE_ATTRIBUTES}=${plain}` }
456
+ }
package/lib/code.mjs ADDED
@@ -0,0 +1,147 @@
1
+ /*
2
+ * Read and edit app source code as text, for the few lines the Azure Monitor mode adds. Strings and comments are
3
+ * masked first, so a search finds code only. Every edit keeps the other lines byte for byte (doc.mjs), and the
4
+ * caller refuses anything that is not plain: then the code goes into a printed step instead.
5
+ */
6
+ import { Doc } from './doc.mjs'
7
+
8
+ /** Python comments and string literals (any prefix, single, double and triple quotes). */
9
+ const PY_TOKEN = /#[^\r\n]*|(?<![\w])[rRbBuUfFtT]{0,2}(?:'''[\s\S]*?'''|"""[\s\S]*?"""|'(?:\\[\s\S]|[^'\\\r\n])*'|"(?:\\[\s\S]|[^"\\\r\n])*")/g
10
+ /** C# comments, string and char literals: raw """...""", verbatim @"...", interpolated $"..." and plain. */
11
+ const CS_TOKEN = /\/\/[^\r\n]*|\/\*[\s\S]*?\*\/|\$*"""[\s\S]*?"""|(?:@\$?|\$@)"(?:""|[^"])*"|\$?"(?:\\.|[^"\\\r\n])*"|'(?:\\.|[^'\\\r\n])+'/g
12
+
13
+ /** The text with every comment and string replaced by spaces. Line breaks stay, so offsets and lines still match. */
14
+ export const maskPython = (text) => mask(text, PY_TOKEN)
15
+ export const maskCsharp = (text) => mask(text, CS_TOKEN)
16
+
17
+ function mask(text, re) {
18
+ return text.replace(new RegExp(re.source, 'g'), (m) => m.replace(/[^\r\n]/g, ' '))
19
+ }
20
+
21
+ const lineAt = (text, index) => text.slice(0, index).split('\n').length - 1
22
+
23
+ /** The offset of the bracket that closes the one at open, in masked text. -1 when there is none. */
24
+ function closing(masked, open) {
25
+ let depth = 0
26
+ for (let i = open; i < masked.length; i++) {
27
+ const c = masked[i]
28
+ if (c === '(' || c === '[' || c === '{') depth++
29
+ else if (c === ')' || c === ']' || c === '}') { if (--depth === 0) return i }
30
+ }
31
+ return -1
32
+ }
33
+
34
+ /** Bracket depth at an offset of masked text. */
35
+ function depthAt(masked, end) {
36
+ let depth = 0
37
+ for (let i = 0; i < end; i++) {
38
+ const c = masked[i]
39
+ if (c === '(' || c === '[' || c === '{') depth++
40
+ else if (c === ')' || c === ']' || c === '}') depth = Math.max(0, depth - 1)
41
+ }
42
+ return depth
43
+ }
44
+
45
+ /**
46
+ * Calls of a Python function in code, not its def or a mention in a string or comment.
47
+ * @returns {{index: number, line: number}[]} index of the first character of the call (with its module prefix), 0-based line
48
+ */
49
+ export function pythonCalls(text, name) {
50
+ const masked = maskPython(text)
51
+ const re = new RegExp(`(^|[^\\w.])((?:[A-Za-z_]\\w*\\s*\\.\\s*)*)${name}\\s*\\(`, 'g')
52
+ const out = []
53
+ for (const m of masked.matchAll(re)) {
54
+ const index = m.index + m[1].length
55
+ if (/\bdef\s+$/.test(masked.slice(Math.max(0, index - 20), index))) continue
56
+ out.push({ index, line: lineAt(text, index) })
57
+ }
58
+ return out
59
+ }
60
+
61
+ /**
62
+ * The statement of 1 call, when it is a whole statement on its own: only indentation (and an optional
63
+ * `name =`) before it, nothing but a comment after its closing parenthesis, no `;`, no `\` and no open
64
+ * bracket around it.
65
+ * @returns {{ok: true, start: number, end: number, indent: string} | {ok: false, reason: string}} 0-based lines.
66
+ */
67
+ export function pythonStatement(text, call) {
68
+ const masked = maskPython(text)
69
+ const lineStart = masked.lastIndexOf('\n', call.index - 1) + 1
70
+ const before = masked.slice(lineStart, call.index)
71
+ if (!/^[ \t]*(?:[A-Za-z_]\w*(?:\s*:\s*[\w.[\], ]+)?\s*=\s*)?$/.test(before)) {
72
+ return { ok: false, reason: before.includes(';') ? 'a ; is on its line' : /:\s*$/.test(before.trim()) || /^\s*(if|elif|else|for|while|with|try|except|finally)\b/.test(before) ? 'it is on the same line as an if or another statement' : 'it is part of a longer statement' }
73
+ }
74
+ const prev = masked.slice(0, Math.max(0, lineStart - 1)).replace(/[ \t\r]+$/, '')
75
+ if (prev.endsWith('\\')) return { ok: false, reason: 'the line before it ends with \\' }
76
+ if (depthAt(masked, lineStart) > 0) return { ok: false, reason: 'it is inside brackets' }
77
+ const open = masked.indexOf('(', call.index)
78
+ const close = closing(masked, open)
79
+ if (close < 0) return { ok: false, reason: 'its closing parenthesis is missing' }
80
+ const lineEnd = masked.indexOf('\n', close)
81
+ const after = masked.slice(close + 1, lineEnd < 0 ? masked.length : lineEnd)
82
+ if (after.trim()) return { ok: false, reason: after.includes(';') ? 'a ; follows it' : after.includes('\\') ? 'its line ends with \\' : 'more code follows it on the same line' }
83
+ return { ok: true, start: lineAt(text, call.index), end: lineAt(text, close), indent: /^[ \t]*/.exec(text.slice(lineStart))[0] }
84
+ }
85
+
86
+ /**
87
+ * Insert a Python block after a statement, indented to it. At module level it gets 2 blank lines before it and
88
+ * before its last line (the call), as PEP 8 and the formatters want. In a function or block it gets 1.
89
+ * block: the docs text, whose last line is the call. quote: '"' or "'".
90
+ */
91
+ export function insertPythonBlock(text, statement, block, quote = '"') {
92
+ const doc = new Doc(text)
93
+ const lines = block.split('\n')
94
+ const call = lines.pop()
95
+ while (lines.length && !lines.at(-1).trim()) lines.pop()
96
+ const gap = statement.indent ? 1 : 2
97
+ const fix = (line) => (quote === "'" ? line.replace(/"/g, "'") : line)
98
+ const indented = (line) => (line.trim() ? statement.indent + fix(line) : '')
99
+ doc.insert(statement.end + 1, ...Array(gap).fill(''), ...lines.map(indented), ...Array(gap).fill(''), indented(call))
100
+ return doc.toString()
101
+ }
102
+
103
+ /**
104
+ * The 1 statement `var builder = WebApplication.CreateBuilder(...);` of a C# file, with the variable name.
105
+ * @returns {{ok: true, end: number, indent: string, name: string} | {ok: false, reason: string}}
106
+ */
107
+ export function csharpBuilder(text) {
108
+ const masked = maskCsharp(text)
109
+ const hits = [...masked.matchAll(/WebApplication\s*\.\s*Create(?:Slim)?Builder\s*\(/g)]
110
+ if (hits.length !== 1) return { ok: false, reason: hits.length ? `it calls WebApplication.CreateBuilder ${hits.length} times` : 'it does not call WebApplication.CreateBuilder' }
111
+ const lineStart = masked.lastIndexOf('\n', hits[0].index - 1) + 1
112
+ const head = /^([ \t]*)(?:var|WebApplicationBuilder)\s+([A-Za-z_]\w*)\s*=\s*$/.exec(masked.slice(lineStart, hits[0].index))
113
+ if (!head) return { ok: false, reason: 'the builder is not set in a plain statement' }
114
+ const close = closing(masked, masked.indexOf('(', hits[0].index))
115
+ const semi = close < 0 ? -1 : masked.indexOf(';', close)
116
+ if (semi < 0 || masked.slice(close + 1, semi).trim()) return { ok: false, reason: 'the statement does not end after the call' }
117
+ const lineEnd = masked.indexOf('\n', semi)
118
+ if (masked.slice(semi + 1, lineEnd < 0 ? masked.length : lineEnd).trim()) return { ok: false, reason: 'more code follows it on the same line' }
119
+ return { ok: true, end: lineAt(text, semi), indent: head[1], name: head[2] }
120
+ }
121
+
122
+ /**
123
+ * Add a using directive in sorted order (System namespaces first, as dotnet format does), or at the top when the
124
+ * file has none. Returns the text unchanged when the directive is there.
125
+ */
126
+ export function addUsing(text, directive) {
127
+ const ns = /^using\s+([\w.]+)\s*;$/.exec(directive)?.[1]
128
+ const doc = new Doc(text)
129
+ const usings = []
130
+ for (let i = 0; i < doc.length; i++) {
131
+ const m = /^using\s+([\w.]+)\s*;\s*$/.exec(doc.get(i))
132
+ if (m) usings.push({ i, ns: m[1] })
133
+ else if (doc.get(i).trim() && !/^\s*(\/\/|#|global\s+using\b|using\s+(static\s|\w+\s*=))/.test(doc.get(i))) break
134
+ }
135
+ if (usings.some((u) => u.ns === ns) || new RegExp(`^\\s*global\\s+using\\s+${ns.replace(/\./g, '\\.')}\\s*;`, 'm').test(text)) return text
136
+ if (!usings.length) {
137
+ // At the first line of code, after a file header comment.
138
+ const first = doc.find((line) => line.trim() && !/^\s*\/\//.test(line))
139
+ doc.insert(Math.max(0, first), directive, '')
140
+ return doc.toString()
141
+ }
142
+ const key = (name) => [/^System(\.|$)/.test(name) ? 0 : 1, name]
143
+ const less = (a, b) => { const [x, y] = [key(a), key(b)]; return x[0] !== y[0] ? x[0] < y[0] : x[1].localeCompare(y[1], 'en', { sensitivity: 'base' }) < 0 }
144
+ const next = usings.find((u) => less(ns, u.ns))
145
+ doc.insert(next ? next.i : usings.at(-1).i + 1, directive)
146
+ return doc.toString()
147
+ }