@uknows/setup 0.0.0-stage → 0.1.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.
@@ -0,0 +1,280 @@
1
+ // Where the 5 UKNOWS settings go for 1 service, and the edits that put them there: the final stage of
2
+ // the Dockerfile, else the env of the Kubernetes container, else <dir>/otel.env. Never the key.
3
+ // Settings that would keep traces away from UKNOWS (another address, a traces-only override, a Kubernetes
4
+ // env that replaces the image value) become manual steps, never silent successes.
5
+ import { INGEST_ENDPOINT, SECRET, SETTINGS, SETTINGS_FILE, STACKS } from './contract.mjs'
6
+ import { Doc, indentOf } from './doc.mjs'
7
+ import { parseDockerfile, setEnv } from './dockerfile.mjs'
8
+ import { list } from './ui.mjs'
9
+
10
+ const NAMES = SETTINGS.map((s) => s.name)
11
+ const [SERVICE, ENDPOINT] = NAMES
12
+ const COLLECTOR = STACKS.find((s) => s.id === 'otel').notes.find((n) => /Collector/.test(n))
13
+ const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
14
+ const blank = (line) => !line.trim() || /^\s*#/.test(line)
15
+ const quote = (value) => `"${value.replace(/["\\]/g, '\\$&')}"`
16
+ const unquote = (raw) => /^\s*(["'])(.*)\1\s*(#.*)?$/.exec(raw)?.[2] ?? raw.trim().replace(/\s+#.*$/, '')
17
+ const KEY_NAME = new RegExp(`name:\\s*["']?${SECRET.name}["']?\\s*(#.*)?$`)
18
+ const KEY_LINE = new RegExp(`${SECRET.name}["']?\\s*[=:]\\s*(.*)$|^\\s*ENV\\s+${SECRET.name}\\s+(.*)$`, 'i')
19
+ /** Flow style: - {name: OTEL_EXPORTER_OTLP_HEADERS, value: "..."} */
20
+ const KEY_FLOW = new RegExp(`name:\\s*["']?${SECRET.name}["']?\\s*,\\s*value:\\s*(["']?)([^"'},]*)\\1`)
21
+ /** The key header anywhere, for any other way to write it. */
22
+ const KEY_HEADER = new RegExp(`${SECRET.header}\\s*=\\s*([^\\s"',}\\]]+)`, 'gi')
23
+ const PLACEHOLDER = /[<>$]|\{\{|secretref:|secretKeyRef|=$|^\*+$/
24
+ const sameAs = (endpoint) => (value) => value.replace(/\/+$/, '') === endpoint
25
+ const hideUser = (url) => url.replace(/\/\/[^/@]*@/, '//')
26
+ /**
27
+ * OpenTelemetry settings that win over the 5 for traces, or turn the SDK off: [name, bad(value), fix].
28
+ * Values are never printed for headers, which can hold a key.
29
+ */
30
+ const overrideRules = (endpoint) => [
31
+ ['OTEL_EXPORTER_OTLP_TRACES_ENDPOINT', (v) => !sameAs(endpoint)(v.replace(/\/v1\/traces\/?$/, '')), `Remove it, or set it to ${endpoint}/v1/traces.`],
32
+ ['OTEL_EXPORTER_OTLP_TRACES_PROTOCOL', (v) => v !== 'http/protobuf', 'Remove it, or set it to http/protobuf.'],
33
+ ['OTEL_EXPORTER_OTLP_TRACES_HEADERS', () => true, `Remove it, so traces use the key in ${SECRET.name}.`],
34
+ ['OTEL_TRACES_EXPORTER', (v) => !v.split(',').map((x) => x.trim()).includes('otlp'), 'Remove it, or set it to otlp.'],
35
+ ['OTEL_SDK_DISABLED', (v) => v.trim().toLowerCase() === 'true', 'Remove it, or set it to false.'],
36
+ ]
37
+
38
+ /**
39
+ * @param {import('./types.mjs').Service} service
40
+ * @param {import('./types.mjs').Ctx} ctx
41
+ * @param {import('./types.mjs').Setting[]} [extra]
42
+ * @returns {import('./types.mjs').SettingsPlan}
43
+ */
44
+ export function settingsPlan(service, ctx, extra = []) {
45
+ const { ws } = ctx
46
+ const ingest = ctx.endpoint ?? INGEST_ENDPOINT
47
+ const sameEndpoint = sameAs(ingest)
48
+ const pairs = [...SETTINGS.map((s) => [s.name, s.name === SERVICE ? ctx.name : s.name === ENDPOINT ? ingest : s.value]),
49
+ ...extra.filter((s) => s.name !== SECRET.name && !NAMES.includes(s.name)).map((s) => [s.name, String(s.value)])]
50
+ const target = (service.dockerfile && ws.read(service.dockerfile) !== null && dockerTarget(ws, service.dockerfile))
51
+ || (service.k8s ?? []).map((k) => k8sTarget(ws, k)).find(Boolean)
52
+ || envTarget(ws, at(service.dir, SETTINGS_FILE.path))
53
+ const plan = { target: { kind: target.kind, file: target.file }, edits: [], summary: [], manual: [], notes: [] }
54
+
55
+ const keyFiles = [...new Set([service.dockerfile, ...(service.k8s ?? []).map((k) => k.file),
56
+ at(service.dir, SETTINGS_FILE.path), at(service.dir, '.env.example')])].filter((f) => f && hasKey(ws.read(f)))
57
+ // A file that holds a key is never edited: the diff and the manifest hunks must not show it.
58
+ const keyInTarget = keyFiles.includes(target.file)
59
+ if (keyFiles.length) {
60
+ plan.manual.push({ title: 'Remove the key from your repo', lines: [...keyFiles.map((f) => `Remove ${SECRET.name} from ${f}.`),
61
+ 'Get a new key. The old key stays in your git history.', SECRET.summary,
62
+ ...(keyInTarget ? [`Then run npx @uknows/setup again. It adds the settings to ${target.file}.`] : [])] })
63
+ }
64
+ if (keyInTarget) return plan
65
+ if (target.flow) {
66
+ plan.manual.push({ title: `Add the UKNOWS settings to ${target.file}`,
67
+ lines: [`The env of ${target.where} is on 1 line. This tool does not change that format.`, 'Add these settings to the env of the container.'],
68
+ snippets: [{ caption: target.file, language: 'yaml', code: ['env:', ...envItems(pairs, 2, 4)].join('\n') }] })
69
+ return plan
70
+ }
71
+
72
+ // Another address is kept. Then its protocol and exporters belong to that pipeline: keep them, add none.
73
+ const endpoint = target.current.get(ENDPOINT)
74
+ const kept = endpoint !== undefined && !(endpoint !== null && sameEndpoint(endpoint))
75
+ if (kept) {
76
+ plan.manual.push({ title: 'Check where this app sends data', lines: [
77
+ endpoint === null ? `${target.where} sets ${ENDPOINT} from another source.` : `${target.where} sends data to ${hideUser(endpoint)}.`,
78
+ 'This tool keeps that address and the other OpenTelemetry settings of the app.', COLLECTOR,
79
+ 'To send data to UKNOWS directly, set these settings instead.'],
80
+ snippets: [{ caption: 'Settings for UKNOWS', language: 'text', code: pairs.filter(([name]) => NAMES.includes(name) && name !== SERVICE)
81
+ .map(([name, value]) => `${name}=${value}`).join('\n') }] })
82
+ }
83
+ const set = []
84
+ for (const [name, value] of pairs) {
85
+ const current = target.current.get(name)
86
+ if (current === undefined) { if (!kept || !NAMES.includes(name) || name === SERVICE) set.push([name, value]) }
87
+ else if (current === value || name === ENDPOINT) continue
88
+ else if (current === null) plan.notes.push(`${target.where} sets ${name} from another source. This tool keeps it.`)
89
+ else if (!NAMES.includes(name) || kept) plan.notes.push(`${target.where} already sets ${name}. This tool keeps that value.`)
90
+ else {
91
+ if (name !== SERVICE) plan.notes.push(`${target.where} sets ${name} to ${current}. This tool changes it to ${value}.`)
92
+ set.push([name, value])
93
+ }
94
+ }
95
+ plan.manual.push(...overrides(service, target, pairs, ingest))
96
+ if (!set.length) return plan
97
+ const added = set.every(([name]) => target.current.get(name) === undefined) && NAMES.every((n) => set.some(([name]) => name === n))
98
+ const extras = set.map(([name]) => name).filter((name) => !NAMES.includes(name))
99
+ plan.summary.push(added ? `Add the 5 UKNOWS settings${extras.length ? ` and ${list(extras)}` : ''} to ${target.where}.`
100
+ : `Set ${list(set.map(([name]) => name))} in ${target.where}.`)
101
+ plan.edits.push({ file: target.file, before: target.before, after: target.write(set), why: added ? 'Add the UKNOWS settings.' : 'Set the UKNOWS settings.' })
102
+ return plan
103
+ }
104
+
105
+ /**
106
+ * True when the text sets OTEL_EXPORTER_OTLP_HEADERS to something that is not a placeholder (in any env format,
107
+ * Kubernetes flow style too), or holds x-uknows-key=<a real value> anywhere. Never returns the value.
108
+ */
109
+ export function hasKey(text) {
110
+ const lines = text?.includes(SECRET.name) || text?.toLowerCase().includes(SECRET.header) ? text.split(/\r?\n/) : []
111
+ return lines.some((line, i) => {
112
+ if (/^\s*#/.test(line)) return false
113
+ if ([...line.matchAll(KEY_HEADER)].some((m) => !PLACEHOLDER.test(m[1]))) return true
114
+ let m = KEY_LINE.exec(line), value = null
115
+ if (KEY_FLOW.test(line)) value = KEY_FLOW.exec(line)[2].trim()
116
+ else if (!m && KEY_NAME.test(line)) {
117
+ for (const next of lines.slice(i + 1, i + 4)) if ((m = /^\s*value:\s*(.*)$/.exec(next)) || /^\s*-/.test(next)) break
118
+ }
119
+ value ??= m ? unquote((m[1] ?? m[2]).replace(/\s*\\$/, '')) : ''
120
+ return Boolean(value) && !PLACEHOLDER.test(value)
121
+ })
122
+ }
123
+
124
+ /** Take the key value out of a line, for anything this tool prints from a repo file. */
125
+ export const redactKey = (line) => line.replace(KEY_HEADER, (m, value) => m.slice(0, m.length - value.length) + '***')
126
+
127
+ /**
128
+ * Manual steps for settings that keep traces from UKNOWS: an overrideRules() value where the settings go, and a
129
+ * Kubernetes container env that replaces the image values with something else.
130
+ */
131
+ function overrides(service, target, pairs, endpoint) {
132
+ const sameEndpoint = sameAs(endpoint)
133
+ const sources = [{ where: target.where, file: target.file, env: target.current }]
134
+ if (target.kind !== 'k8s') {
135
+ for (const k of (service.k8s ?? []).filter((x) => x.env)) {
136
+ sources.push({ where: `${k.file} (container ${k.container})`, file: k.file, env: new Map(Object.entries(k.env)), k8s: true })
137
+ }
138
+ }
139
+ const blocks = []
140
+ for (const { where, file, env, k8s } of sources) {
141
+ for (const [name, bad, fix] of overrideRules(endpoint)) {
142
+ if (!env.has(name) || (env.get(name) !== null && !bad(env.get(name)))) continue
143
+ const value = env.get(name)
144
+ const shown = value === null ? ' from another source' : /HEADERS/.test(name) ? '' : ` to ${hideUser(value)}`
145
+ blocks.push({ title: `Check ${name}`, lines: [`${where} sets ${name}${shown}.`,
146
+ name === 'OTEL_SDK_DISABLED' ? 'That turns OpenTelemetry off.' : 'It wins over the UKNOWS settings, so traces would not reach UKNOWS.', fix] })
147
+ }
148
+ if (!k8s) continue
149
+ // The container env replaces the ENV of the image for the same names.
150
+ const differs = (name, value, current) => current === null || (current !== value && !(name === ENDPOINT && sameEndpoint(current)))
151
+ const differ = pairs.filter(([name, value]) => NAMES.includes(name) && env.has(name) && differs(name, value, env.get(name)))
152
+ if (differ.length) {
153
+ blocks.push({ title: `Check the UKNOWS settings in ${file}`, lines: [
154
+ `${where} sets ${list(differ.map(([name]) => name))}. The container env replaces the values in ${target.file}.`,
155
+ 'Set them to the values below there, or remove them.'],
156
+ snippets: [{ caption: file, language: 'text', code: differ.map(([name, value]) => `${name}=${value}`).join('\n') }] })
157
+ }
158
+ }
159
+ return blocks
160
+ }
161
+
162
+ function dockerTarget(ws, file) {
163
+ const before = ws.read(file)
164
+ const info = parseDockerfile(before)
165
+ // Values from the stages the final stage builds on count too: the image has them.
166
+ const current = new Map([...info.inherited, ...info.env].map(([name, entry]) => [name, entry.value]))
167
+ return { kind: 'dockerfile', file, where: file, before, current, write: (set) => setEnv(before, set) }
168
+ }
169
+
170
+ function envTarget(ws, file) {
171
+ const before = ws.read(file)
172
+ const doc = new Doc(before ?? '')
173
+ const lines = doc.lines.map((line) => /^\s*(?:export\s+)?([A-Za-z_]\w*)\s*=(.*)$/.exec(line)).filter(Boolean)
174
+ const current = new Map(lines.map((m) => [m[1], unquote(m[2])]))
175
+ return { kind: 'env-file', file, where: file, before, current, write(set) {
176
+ for (const [name, value] of set) {
177
+ const i = doc.find(new RegExp(`^\\s*(export\\s+)?${name}\\s*=`))
178
+ if (i >= 0) doc.set(i, doc.get(i).replace(/=.*$/, `=${value}`))
179
+ else doc.insert(doc.length, `${name}=${value}`)
180
+ }
181
+ return doc.toString()
182
+ } }
183
+ }
184
+
185
+ /** The env list of 1 container in a plain Kubernetes manifest. null when the container is not there. */
186
+ function k8sTarget(ws, k) {
187
+ const before = ws.read(k.file)
188
+ if (before === null || before.includes('{{')) return null
189
+ const doc = new Doc(before)
190
+ const item = findContainer(doc, k)
191
+ if (!item) return null
192
+ const target = { kind: 'k8s', file: k.file, where: `${k.file} (container ${k.container})`, before, current: new Map() }
193
+ const envAt = doc.find((line, i) => i > item.start && i <= item.end && new RegExp(`^ {${item.key}}env:`).test(line))
194
+ let dash = item.key + 2, itemKey = item.key + 4, last = envAt
195
+ const entries = []
196
+ if (envAt >= 0) {
197
+ if (doc.get(envAt).slice(item.key + 4).replace(/(^|\s)#.*$/, '').trim()) return { ...target, flow: true }
198
+ for (let i = envAt + 1; i <= item.end; i++) {
199
+ const line = doc.get(i)
200
+ if (blank(line)) continue
201
+ const indent = indentOf(line).length, isDash = /^\s*-\s/.test(line)
202
+ if (last === envAt) {
203
+ if (!isDash) break
204
+ dash = indent
205
+ itemKey = indent + /^-\s+/.exec(line.trimStart())[0].length
206
+ }
207
+ if (indent < dash || (indent === dash && !isDash)) break
208
+ last = i
209
+ if (indent === dash) entries.push({ name: null, value: -1, raw: null })
210
+ const entry = entries.at(-1), keyed = indent === dash ? line.slice(itemKey) : indent === itemKey ? line.trim() : ''
211
+ const m = /^(name|value|valueFrom):\s*(.*)$/.exec(keyed)
212
+ if (!m) continue
213
+ if (m[1] === 'name') entry.name = unquote(m[2])
214
+ else if (m[1] === 'value') Object.assign(entry, { value: i, raw: unquote(m[2]) })
215
+ }
216
+ for (const e of entries) if (e.name) target.current.set(e.name, e.value >= 0 ? e.raw : null)
217
+ } else {
218
+ const image = doc.find((line, i) => i >= item.start && i <= item.end && new RegExp(`^ {${item.key}}image:\\s*\\S`).test(line))
219
+ last = image >= 0 ? image : item.nameLine
220
+ }
221
+ target.write = (set) => {
222
+ const missing = []
223
+ for (const [name, value] of set) {
224
+ const entry = entries.find((e) => e.name === name && e.value >= 0)
225
+ if (!entry) { missing.push([name, value]); continue }
226
+ const line = doc.get(entry.value)
227
+ doc.set(entry.value, `${line.slice(0, line.indexOf('value:') + 6)} ${quote(value)}`)
228
+ }
229
+ const lines = envItems(missing, dash, itemKey)
230
+ doc.insert(last + 1, ...(envAt >= 0 ? lines : [`${' '.repeat(item.key)}env:`, ...lines]))
231
+ return doc.toString()
232
+ }
233
+ return target
234
+ }
235
+
236
+ function envItems(pairs, dash, key) {
237
+ return pairs.flatMap(([name, value]) => [`${' '.repeat(dash)}- name: ${name}`, `${' '.repeat(key)}value: ${quote(value)}`])
238
+ }
239
+
240
+ /** The list item of the container named k.container under a containers: key. */
241
+ function findContainer(doc, k) {
242
+ const name = k.container.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
243
+ const re = new RegExp(`^(\\s*)(-\\s+)?name:\\s*(["']?)${name}\\3\\s*(#.*)?$`)
244
+ const found = []
245
+ let docNo = 0
246
+ for (let i = 0; i < doc.length; i++) {
247
+ if (/^---/.test(doc.get(i))) { docNo++; continue }
248
+ const m = re.exec(doc.get(i))
249
+ if (!m) continue
250
+ const key = m[1].length + (m[2]?.length ?? 0)
251
+ let start = m[2] ? i : -1
252
+ for (let j = i - 1; start < 0 && j >= 0; j--) {
253
+ const line = doc.get(j)
254
+ if (blank(line)) continue
255
+ if (/^\s*-\s+/.exec(line)?.[0].length === key) start = j
256
+ else if (indentOf(line).length < key) break
257
+ }
258
+ if (start < 0 || !underContainers(doc, start)) continue
259
+ let end = start
260
+ for (let j = start + 1; j < doc.length && !/^---/.test(doc.get(j)); j++) {
261
+ if (blank(doc.get(j))) continue
262
+ if (indentOf(doc.get(j)).length < key) break
263
+ end = j
264
+ }
265
+ found.push({ start, end, key, nameLine: i, doc: docNo })
266
+ }
267
+ return found.length === 1 ? found[0] : found.find((f) => f.doc === k.doc) ?? null
268
+ }
269
+
270
+ function underContainers(doc, start) {
271
+ const dash = indentOf(doc.get(start)).length
272
+ for (let j = start - 1; j >= 0; j--) {
273
+ const line = doc.get(j)
274
+ if (blank(line)) continue
275
+ const indent = indentOf(line).length
276
+ if (indent > dash || (indent === dash && /^\s*-\s/.test(line))) continue
277
+ return /^\s*containers:\s*(#.*)?$/.test(line)
278
+ }
279
+ return false
280
+ }
@@ -0,0 +1,165 @@
1
+ // .NET: add the OpenTelemetry.AutoInstrumentation package to the web project and start the app with instrument.sh.
2
+ // The csproj is edited as text (no dotnet add), so its BOM, line endings and layout stay. Directory.*.props files
3
+ // above the --path folder count too: with central package versions there, the step is manual.
4
+ import { PINS, STACKS } from '../contract.mjs'
5
+ import { Doc } from '../doc.mjs'
6
+ import { dockerStart, parseDockerfile, prefixStart } from '../dockerfile.mjs'
7
+ import { upFrom } from '../workspace.mjs'
8
+ import { startOverrides } from './k8s.mjs'
9
+
10
+ const DOCS = STACKS.find((s) => s.id === 'dotnet')
11
+ const { package: PACKAGE, version: VERSION, minNet, redis: REDIS } = PINS.dotnet
12
+ const USE_NET8 = DOCS.notes.find((n) => /\.NET 8/.test(n))
13
+ const ENTRYPOINT = DOCS.dockerfile.split('\n').find((l) => l.startsWith('ENTRYPOINT'))
14
+ const SCRIPT_NOTE = DOCS.notes.find((n) => /instrument\.sh/.test(n))
15
+ const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
16
+ const dirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
17
+ const above = (path) => path.startsWith('../')
18
+ const read = (view, path) => { try { return view.read(path) } catch { return null } }
19
+ const tag = (text, name) => new RegExp(`<${name}>\\s*([^<]*?)\\s*</${name}>`, 'i').exec(text)?.[1] ?? null
20
+
21
+ /** @type {import('../types.mjs').Applier} */
22
+ export const dotnet = {
23
+ id: 'dotnet',
24
+ plan(service, ctx) {
25
+ const { ws } = ctx
26
+ const plan = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
27
+ const projects = [...service.manifests, ...ws.list(service.dir).map((n) => at(service.dir, n))].filter((p) => p.endsWith('.csproj'))
28
+ const csproj = projects.find((p) => /Microsoft\.NET\.Sdk\.Web/.test(ws.read(p) ?? '')) ?? projects[0]
29
+ if (!csproj) {
30
+ plan.manual.push({ title: 'Add OpenTelemetry to your .NET app', lines: ['This tool did not find the .csproj file of the web app.',
31
+ `Run dotnet add package ${PACKAGE} in the folder of the web app.`, 'Then start the app with instrument.sh.', SCRIPT_NOTE] })
32
+ return plan
33
+ }
34
+ const text = ws.read(csproj)
35
+ const view = ctx.repo ?? ws
36
+ const folders = upFrom(dirOf(csproj), view.top ?? '.')
37
+ const props = (name) => folders.map((dir) => at(dir, name)).map((file) => ({ file, text: read(view, file) })).filter((p) => p.text !== null)
38
+ const framework = targetFramework(text, props('Directory.Build.props'))
39
+ const major = /^net(?:coreapp)?(\d+)\./.exec(framework ?? '')?.[1] ?? (/^net\d+$/.test(framework ?? '') ? '4' : null)
40
+ if (major !== null && Number(major) < minNet) {
41
+ plan.manual.push({ title: USE_NET8.replace(/\.$/, ''), lines: [`${csproj} targets ${framework}.`,
42
+ `${PACKAGE} ${VERSION} needs .NET ${minNet} or later.`, `Change the target framework to net${minNet}.0 or later. Then run npx @uknows/setup again.`] })
43
+ return plan
44
+ }
45
+ if (major === null) plan.notes.push(`This tool could not read the target framework of ${csproj}. ${USE_NET8}`)
46
+ const added = addPackages(csproj, text, props, plan)
47
+ if (added !== true) {
48
+ plan.stop = added === 'outside'
49
+ return plan
50
+ }
51
+ const dll = `${tag(text, 'AssemblyName') ?? csproj.split('/').pop().replace(/\.csproj$/, '')}.dll`
52
+ // The docs settings for .NET add a log folder that the app can write. Without it, the app can stop at start.
53
+ for (const line of DOCS.extraSettings?.lines ?? []) {
54
+ const at = line.indexOf('=')
55
+ plan.extraSettings.push({ name: line.slice(0, at), value: line.slice(at + 1) })
56
+ }
57
+ addStart(ws, service, dll, plan)
58
+ return plan
59
+ },
60
+ }
61
+
62
+ function targetFramework(text, buildProps) {
63
+ for (const source of [text, ...buildProps.map((p) => p.text)]) {
64
+ const found = source && (tag(source, 'TargetFramework') ?? tag(source, 'TargetFrameworks')?.split(';')[0])
65
+ if (found) return found.includes('$(') ? null : found
66
+ }
67
+ return null
68
+ }
69
+
70
+ /**
71
+ * Add the package references. Returns true when they are there after this run. Else the start must not change:
72
+ * 'outside' when the versions live in a file above the --path folder (run from the repo root), else false.
73
+ */
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())
77
+ const { file: propsFile = null, text: props = null } = findProps('Directory.Packages.props')[0] ?? {}
78
+ const central = [text, props, ...findProps('Directory.Build.props').map((p) => p.text)]
79
+ .some((t) => /<ManagePackageVersionsCentrally>\s*true\s*</i.test(t ?? ''))
80
+ const add = has(PACKAGE) ? [] : [[PACKAGE, VERSION]]
81
+ const redis = refs.find((m) => m[1].toLowerCase() === REDIS.library.toLowerCase())
82
+ if (redis && !has(REDIS.package)) {
83
+ const version = /Version\s*=\s*"([^"]+)"/i.exec(redis[0])?.[1] ?? tag(redis[2] ?? '', 'Version')
84
+ ?? new RegExp(`<PackageVersion\\s+Include="${REDIS.library}"\\s+Version="([^"]+)"`, 'i').exec(props ?? '')?.[1]
85
+ const v = /^(\d+)\.(\d+)\.(\d+)/.exec(version ?? '')?.slice(1).map(Number)
86
+ if (!v) plan.notes.push(`This app uses ${REDIS.library}. If the build fails, add ${REDIS.package} ${REDIS.version}.`)
87
+ else if (v[0] < 4 && (v[0] > 2 || (v[0] === 2 && (v[1] > 6 || (v[1] === 6 && v[2] >= 122))))) add.push([REDIS.package, REDIS.version])
88
+ }
89
+ if (!add.length) return true
90
+ const reference = ([name, version]) => `<PackageReference Include="${name}"${central ? '' : ` Version="${version}"`} />`
91
+ const version = ([name, v]) => `<PackageVersion Include="${name}" Version="${v}" />`
92
+ if (central && (!propsFile || above(propsFile))) {
93
+ plan.manual.push({ title: `Add ${PACKAGE} to ${csproj}`, lines: [
94
+ propsFile ? `This repo sets package versions in ${propsFile}, outside the folder this tool works in, so this tool cannot change it.`
95
+ : 'This project sets package versions centrally, but this tool did not find Directory.Packages.props.',
96
+ 'Run npx @uknows/setup in the repo root without --path, and pick this service. Or add these lines by hand. Then run it again for the start command.'],
97
+ snippets: [{ caption: propsFile ?? 'Directory.Packages.props', language: 'xml', code: add.map(version).join('\n') },
98
+ { caption: csproj, language: 'xml', code: add.map(reference).join('\n') }] })
99
+ return propsFile ? 'outside' : false
100
+ }
101
+ const after = addItems(text, add.map(reference), /<PackageReference\s/)
102
+ if (after === null) {
103
+ plan.manual.push({ title: `Add ${PACKAGE} to ${csproj}`, lines: [`Run dotnet add package ${PACKAGE} in ${dirOf(csproj)}.`] })
104
+ return false
105
+ }
106
+ plan.edits.push({ file: csproj, before: text, after, why: `Add the ${PACKAGE} package.` })
107
+ if (central) {
108
+ const versions = add.filter(([name]) => !new RegExp(`<PackageVersion\\s+Include="${name}"`, 'i').test(props))
109
+ if (versions.length) {
110
+ plan.edits.push({ file: propsFile, before: props, why: `Set the version of the ${PACKAGE} package.`,
111
+ after: addItems(props, versions.map(version), /<PackageVersion\s/) ?? props })
112
+ }
113
+ }
114
+ add.forEach(([name, version]) => plan.summary.push(`Add ${name} ${version} to ${csproj}${name === PACKAGE ? '' : `, because the app uses ${REDIS.library}`}.`))
115
+ if (findProps('packages.lock.json')[0]?.file === at(dirOf(csproj), 'packages.lock.json')) {
116
+ plan.manual.push({ title: 'Update packages.lock.json', lines: ['Run dotnet restore --force-evaluate.', 'Then commit packages.lock.json.'] })
117
+ }
118
+ return true
119
+ }
120
+
121
+ /** 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) {
123
+ const doc = new Doc(text)
124
+ const lines = doc.lines
125
+ const lastItem = lines.findLastIndex((l) => like.test(l) && /\/>\s*$/.test(l))
126
+ const group = lines.slice(0, lastItem + 1).findLast((l) => /<ItemGroup\b/.test(l)) ?? ''
127
+ if (lastItem >= 0 && !/Condition/.test(group)) {
128
+ const indent = /^\s*/.exec(lines[lastItem])[0]
129
+ doc.insert(lastItem + 1, ...items.map((item) => indent + item))
130
+ return doc.toString()
131
+ }
132
+ const end = lines.findLastIndex((l) => /^\s*<\/Project>\s*$/.test(l))
133
+ if (end < 0) return null
134
+ const unit = lines.map((l) => /^(\s+)</.exec(l)?.[1]).find(Boolean) ?? ' '
135
+ const block = [`${unit}<ItemGroup>`, ...items.map((item) => unit + unit + item), `${unit}</ItemGroup>`]
136
+ if (end > 0 && !lines[end - 1].trim()) doc.insert(end, ...block, '')
137
+ else doc.insert(end, '', ...block)
138
+ return doc.toString()
139
+ }
140
+
141
+ function addStart(ws, service, dll, plan) {
142
+ const entrypoint = ENTRYPOINT.replace('OrdersApi.dll', dll)
143
+ const manual = (lines) => plan.manual.push({ title: 'Start the app with instrument.sh', lines: [...lines, SCRIPT_NOTE],
144
+ snippets: [{ caption: service.dockerfile ? 'Dockerfile' : 'Start', language: service.dockerfile ? 'dockerfile' : 'bash',
145
+ code: service.dockerfile ? entrypoint : DOCS.start.replace('OrdersApi.dll', dll) }] })
146
+ const text = service.dockerfile && ws.read(service.dockerfile)
147
+ if (!text) return manual(['This app does not start from a Dockerfile. Start it with the command below.'])
148
+ const info = parseDockerfile(text)
149
+ if (/chiseled|distroless|nanoserver|windowsservercore/i.test(info.image ?? '')) {
150
+ return manual([`The final image ${info.image} has no /bin/sh. instrument.sh needs it.`,
151
+ 'Use a final image with a shell, such as mcr.microsoft.com/dotnet/aspnet. Then start the app like this.'])
152
+ }
153
+ const argv = dockerStart(info)?.argv ?? []
154
+ const target = argv.find((a) => /\.dll$/i.test(a)) ?? (/^\.{0,2}\//.test(argv[0] ?? '') ? argv[0] : null)
155
+ // instrument.sh sits next to the dll in the publish output.
156
+ const script = argv[1]?.endsWith('instrument.sh') ? argv[1]
157
+ : target && (target.includes('/') ? `${dirOf(target)}/instrument.sh` : './instrument.sh')
158
+ const after = script && prefixStart(text, ['sh', script])
159
+ if (!after) return manual([`This tool cannot change the start command in ${service.dockerfile}.`, 'Put sh ./instrument.sh in front of the command that starts your app.'])
160
+ if (/alpine/i.test(info.image ?? '')) plan.notes.push('The final image uses Alpine. If monitoring does not start, publish with -r linux-musl-x64.')
161
+ plan.manual.push(...startOverrides(service, info.entrypoint ? 'ENTRYPOINT' : 'CMD', `sh ${script}`))
162
+ if (after === text) return
163
+ plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Start the app with instrument.sh.' })
164
+ plan.summary.push(`Start the app with instrument.sh (${info.entrypoint ? 'ENTRYPOINT' : 'CMD'} in ${service.dockerfile}).`)
165
+ }
@@ -0,0 +1,104 @@
1
+ // Go, PHP and Ruby: guided in v1. settings.mjs adds the settings as usual, but this tool does not edit
2
+ // go.mod, composer.json, Gemfile or code: without the lock or sum updates the build breaks. It prints
3
+ // the docs steps instead, and none when the SDK is already a dependency.
4
+ import { GO_INIT, GO_MAIN, PINS, RUBY_INITIALIZER, STACKS } from '../contract.mjs'
5
+ import { parseDockerfile } from '../dockerfile.mjs'
6
+
7
+ const docs = (id) => STACKS.find((s) => s.id === id)
8
+ const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
9
+ const runIn = (dir) => `Run these commands in ${dir === '.' ? 'the root folder of the repo' : dir}.`
10
+ const empty = () => ({ summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] })
11
+
12
+ /** @type {import('../types.mjs').Applier} */
13
+ export const go = {
14
+ id: 'go',
15
+ plan(service, { ws }) {
16
+ const plan = empty()
17
+ if (/go\.opentelemetry\.io\/otel\/sdk\s/.test(ws.read(at(service.dir, 'go.mod')) ?? '')) return plan
18
+ const { install, files, notes, label } = docs('go')
19
+ // go get raises the go line of go.mod to what the modules need. An older Go builder then fails.
20
+ const version = /^(\d+)\.(\d+)/.exec(service.runtimeVersion ?? '')
21
+ const [major, minor] = PINS.go.minGo
22
+ if (version && Number(version[1]) * 1000 + Number(version[2]) < major * 1000 + minor) {
23
+ plan.manual.push({ title: `Use Go ${major}.${minor} or later`, lines: [`This app builds with Go ${version[0]}.`,
24
+ `The OpenTelemetry Go packages that go get adds need Go ${major}.${minor} or later.`,
25
+ `Update the go line in go.mod and the Go version of the build image to ${major}.${minor} or later. Do this first.`] })
26
+ }
27
+ // The base go get, then only the '# Gin only' or '# Echo v4 only' part for a detected router.
28
+ const sections = install.split('\n').reduce((all, line) => {
29
+ if (line.startsWith('# ')) all.push([line]); else all.at(-1).push(line)
30
+ return all
31
+ }, [[]])
32
+ const code = sections.filter((s, k) => k === 0 || service.frameworks.some((f) => s[0].toLowerCase().includes(f))).flat().join('\n')
33
+ plan.summary.push(`Show the steps to add OpenTelemetry to your ${label} code. You do these steps yourself.`)
34
+ plan.manual.push(
35
+ { title: 'Add the OpenTelemetry Go packages', lines: [runIn(service.dir)], snippets: [{ caption: 'go get', language: 'bash', code }] },
36
+ { title: `Add ${at(service.dir, files[0].path)}`, lines: [files[0].summary], snippets: [{ caption: files[0].path, language: 'go', code: GO_INIT }] },
37
+ { title: 'Start OpenTelemetry in main', lines: [files[1].summary, ...notes], snippets: [{ caption: files[1].path, language: 'go', code: GO_MAIN }] })
38
+ return plan
39
+ },
40
+ }
41
+
42
+ /** The docs RUN lines that install the PHP extension in official php images. */
43
+ const PHP_RUN = (() => {
44
+ const lines = docs('php').dockerfile.split('\n')
45
+ const start = lines.findIndex((l) => l.startsWith('RUN '))
46
+ let end = start
47
+ while (lines[end].endsWith('\\')) end++
48
+ return lines.slice(start, end + 1).join('\n')
49
+ })()
50
+
51
+ /** @type {import('../types.mjs').Applier} */
52
+ export const php = {
53
+ id: 'php',
54
+ plan(service, { ws }) {
55
+ const plan = empty()
56
+ plan.extraSettings.push({ ...PINS.php.autoload })
57
+ let composer = {}
58
+ try { composer = JSON.parse(ws.read(at(service.dir, 'composer.json')) ?? '{}') } catch { /* not JSON: print the steps */ }
59
+ if (composer.require?.['open-telemetry/sdk']) return plan
60
+ const { install, files, notes, label } = docs('php')
61
+ const [pecl, allowPlugins, require] = install.split('\n')
62
+ const note = (re) => notes.filter((n) => re.test(n))
63
+ // Laravel keeps the docs line. Symfony and Slim swap the Laravel package, as the docs notes say. Plain PHP drops it.
64
+ const swap = notes.map((n) => /^For (\w+), use (\S+) instead of the Laravel package/.exec(n))
65
+ .find((m) => m && service.frameworks.includes(m[1].toLowerCase()))
66
+ const laravel = service.frameworks.includes('laravel')
67
+ const packages = laravel ? require : require.replace(/\S*opentelemetry-auto-laravel/, swap?.[2] ?? '').trimEnd()
68
+ const dockerText = (service.dockerfile && ws.read(service.dockerfile)) || ''
69
+ const image = parseDockerfile(dockerText).image ?? ''
70
+ // composer install in a build stage without the extension stops on the platform check.
71
+ const platform = /composer\s+install\b/.test(dockerText) && !/ignore-platform-req/.test(dockerText) ? note(/--ignore-platform-req option/) : []
72
+ const extension = service.dockerfile
73
+ ? { lines: [`Add these lines to the final stage of ${service.dockerfile}, after its FROM line.`, ...(/alpine/i.test(image) ? note(/Alpine/) : [])],
74
+ snippets: [{ caption: service.dockerfile, language: 'dockerfile', code: PHP_RUN }] }
75
+ : { lines: ['Install the extension on each server.', files[0].summary],
76
+ snippets: [{ caption: 'Install', language: 'bash', code: pecl }, { caption: files[0].path, language: 'ini', code: files[0].content }] }
77
+ plan.summary.push(`Show the steps to add OpenTelemetry to your ${label} app. You do these steps yourself.`)
78
+ plan.manual.push({ title: 'Add the OpenTelemetry PHP extension', ...extension }, {
79
+ title: 'Add the OpenTelemetry Composer packages',
80
+ lines: [runIn(service.dir), 'Commit composer.json and composer.lock.', ...(laravel ? [] : note(/PSR-18/)), ...platform, ...(/fpm/i.test(image) ? note(/PHP-FPM/) : [])],
81
+ snippets: [{ caption: 'composer', language: 'bash', code: [allowPlugins, packages].join('\n') }] })
82
+ return plan
83
+ },
84
+ }
85
+
86
+ /** @type {import('../types.mjs').Applier} */
87
+ export const ruby = {
88
+ id: 'ruby',
89
+ plan(service, { ws }) {
90
+ const plan = empty()
91
+ if (/^\s*gem\s*\(?\s*["']opentelemetry-sdk["']/m.test(ws.read(at(service.dir, 'Gemfile')) ?? '')) return plan
92
+ const { install, files, notes, label } = docs('ruby')
93
+ const rails = service.frameworks.includes('rails') || ws.read(at(service.dir, 'config/application.rb')) !== null
94
+ plan.summary.push(`Show the steps to add OpenTelemetry to your ${label} app. You do these steps yourself.`)
95
+ plan.manual.push(
96
+ { title: 'Add the OpenTelemetry gems', lines: [runIn(service.dir), notes.find((n) => /Gemfile\.lock/.test(n))],
97
+ snippets: [{ caption: 'bundle', language: 'bash', code: install }] },
98
+ rails ? { title: `Add ${at(service.dir, files[0].path)}`, lines: [files[0].summary],
99
+ snippets: [{ caption: files[0].path, language: 'ruby', code: RUBY_INITIALIZER }] }
100
+ : { title: `Add OpenTelemetry to ${at(service.dir, 'config.ru')}`, lines: [notes.find((n) => /config\.ru/.test(n))],
101
+ snippets: [{ caption: 'config.ru', language: 'ruby', code: RUBY_INITIALIZER }] })
102
+ return plan
103
+ },
104
+ }
@@ -0,0 +1,10 @@
1
+ // Registry: stack id -> applier. The engine looks appliers up here.
2
+ import { node } from './node.mjs'
3
+ import { python } from './python.mjs'
4
+ import { dotnet } from './dotnet.mjs'
5
+ import { java } from './java.mjs'
6
+ import { go, php, ruby } from './guided.mjs'
7
+ import { otel } from './otel.mjs'
8
+
9
+ /** @type {Record<import('../types.mjs').StackId, import('../types.mjs').Applier>} */
10
+ export const APPLIERS = { node, python, dotnet, java, go, php, ruby, otel }
@@ -0,0 +1,56 @@
1
+ // Java: add the pinned OpenTelemetry Java agent to the final image with the docs ADD line (Docker checks
2
+ // the checksum) and load it with JAVA_TOOL_OPTIONS. Build files are never touched. Both lines go after
3
+ // the last RUN, so build steps (mvn, gradle) never load the agent.
4
+ import { JAVA_AGENT, STACKS } from '../contract.mjs'
5
+ import { addOption, insertBeforeStart, parseDockerfile } from '../dockerfile.mjs'
6
+ import { envOverrides } from './k8s.mjs'
7
+
8
+ const DOCS = STACKS.find((s) => s.id === 'java')
9
+ const ADD_LINE = DOCS.dockerfile.split('\n').find((l) => l.startsWith('ADD '))
10
+ // Earlier /docs gave --chmod=644. BuildKit also gives that mode to the /otel folder it creates, so a
11
+ // non-root USER cannot load the agent and the JVM stops. A Dockerfile with that exact line gets the new one.
12
+ const OLD_ADD_LINE = ADD_LINE.replace('--chmod=755', '--chmod=644')
13
+ const AGENT_OPTION = /JAVA_TOOL_OPTIONS="([^"]+)"/.exec(DOCS.dockerfile)[1]
14
+ const AGENT_PATH = AGENT_OPTION.replace(/^-javaagent:/, '')
15
+ const note = (re) => DOCS.notes.find((n) => re.test(n))
16
+
17
+ /** @type {import('../types.mjs').Applier} */
18
+ export const java = {
19
+ id: 'java',
20
+ plan(service, ctx) {
21
+ const plan = { summary: [], edits: [], commands: [], extraSettings: [], manual: [], notes: [] }
22
+ const text = service.dockerfile && ctx.ws.read(service.dockerfile)
23
+ const info = text ? parseDockerfile(text) : null
24
+ const image = info?.image ?? ''
25
+ if (!text || /windows|nanoserver|servercore/i.test(image)) {
26
+ plan.manual.push({ title: `Add the OpenTelemetry Java agent ${JAVA_AGENT.version}`, lines: [
27
+ text ? `The final image ${image} is a Windows image. This tool adds the agent to Linux images only.`
28
+ : 'This app does not build from a Dockerfile, so this tool cannot add the agent.',
29
+ 'Download the agent and check its checksum with the commands below.', note(/macOS/),
30
+ `Start the JVM with ${AGENT_OPTION.replace(AGENT_PATH, `<path to ${JAVA_AGENT.file}>`)}, or set it in JAVA_TOOL_OPTIONS.`,
31
+ note(/Put -javaagent before -jar/)],
32
+ snippets: [{ caption: 'Download and check the agent', language: 'bash', code: DOCS.install },
33
+ { caption: 'Start', language: 'bash', code: DOCS.start }] })
34
+ return plan
35
+ }
36
+ const has = (value) => value.includes(AGENT_OPTION)
37
+ plan.manual.push(...envOverrides(service, 'JAVA_TOOL_OPTIONS', AGENT_OPTION, has))
38
+ const fixed = text.split(OLD_ADD_LINE).join(ADD_LINE)
39
+ const added = fixed.includes(JAVA_AGENT.url) || fixed.includes(AGENT_PATH) ? fixed : insertBeforeStart(fixed, [ADD_LINE])
40
+ const after = addOption(added, 'JAVA_TOOL_OPTIONS', AGENT_OPTION, has)
41
+ if (after === text) return plan
42
+ if (after === fixed) {
43
+ plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Let a non-root user load the OpenTelemetry Java agent.' })
44
+ plan.summary.push(`Give the agent folder in ${service.dockerfile} mode 755, so an app that runs as a non-root user can load the agent.`)
45
+ return plan
46
+ }
47
+ plan.edits.push({ file: service.dockerfile, before: text, after, why: 'Add the OpenTelemetry Java agent and load it when the JVM starts.' })
48
+ plan.summary.push(`Add the OpenTelemetry Java agent ${JAVA_AGENT.version} to ${service.dockerfile}. Docker checks its checksum.`,
49
+ `Load the agent when the JVM starts (JAVA_TOOL_OPTIONS in ${service.dockerfile}).`)
50
+ plan.notes.push(note(/Docker 25/))
51
+ if (info.env.has('JAVA_TOOL_OPTIONS') || info.inherited.has('JAVA_TOOL_OPTIONS')) {
52
+ plan.notes.push(`${service.dockerfile} already sets JAVA_TOOL_OPTIONS. A new ENV line before the start keeps that value and adds the agent.`)
53
+ }
54
+ return plan
55
+ },
56
+ }
@@ -0,0 +1,31 @@
1
+ // Kubernetes containers that replace what this tool sets in the image: an env value, or the start command.
2
+ // The image edit stays (it works with docker run). These steps say what to change in the manifest too.
3
+
4
+ /**
5
+ * Manual steps for containers whose env sets `name`, which replaces the image value that loads OpenTelemetry.
6
+ * @param {import('../types.mjs').Service} service
7
+ * @param {string} name e.g. NODE_OPTIONS
8
+ * @param {string} option what the value needs, e.g. --require ...
9
+ * @param {(value: string) => boolean} has true when a value already has it
10
+ * @returns {import('../types.mjs').Block[]}
11
+ */
12
+ export function envOverrides(service, name, option, has) {
13
+ return (service.k8s ?? []).filter((k) => k.env && Object.prototype.hasOwnProperty.call(k.env, name) && !(k.env[name] !== null && has(k.env[name])))
14
+ .map((k) => ({ title: `Add the option to ${name} in ${k.file}`, lines: [
15
+ `${k.file} sets ${name} for the container ${k.container}. That value replaces the one in the image, so OpenTelemetry would not load.`,
16
+ `Add ${option} to the end of that value.`] }))
17
+ }
18
+
19
+ /**
20
+ * Manual steps for containers whose command (or args, for a CMD) replaces the image start that this tool changed.
21
+ * @param {import('../types.mjs').Service} service
22
+ * @param {'ENTRYPOINT'|'CMD'} instruction the instruction that got the words
23
+ * @param {string} words e.g. opentelemetry-instrument
24
+ * @returns {import('../types.mjs').Block[]}
25
+ */
26
+ export function startOverrides(service, instruction, words) {
27
+ return (service.k8s ?? []).filter((k) => k.command || (k.args && instruction === 'CMD')).map((k) => ({
28
+ title: `Start the app with ${words} in ${k.file}`, lines: [
29
+ `${k.file} sets ${k.command ? 'command' : 'args'} for the container ${k.container}. That replaces the ${instruction} of the image.`,
30
+ `Put ${words} in front of the command there.`] }))
31
+ }