@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.
- package/LICENSE +190 -0
- package/README.md +142 -2
- package/bin/uknows-setup.mjs +14 -0
- package/lib/check.mjs +211 -0
- package/lib/cli.mjs +166 -0
- package/lib/contract-docs.mjs +1154 -0
- package/lib/contract.mjs +66 -0
- package/lib/detect.mjs +575 -0
- package/lib/diff.mjs +147 -0
- package/lib/doc.mjs +98 -0
- package/lib/dockerfile.mjs +372 -0
- package/lib/endpoint.mjs +50 -0
- package/lib/engine.mjs +607 -0
- package/lib/existing.mjs +168 -0
- package/lib/git.mjs +47 -0
- package/lib/hosts.mjs +145 -0
- package/lib/manifest.mjs +112 -0
- package/lib/settings.mjs +280 -0
- package/lib/stacks/dotnet.mjs +165 -0
- package/lib/stacks/guided.mjs +104 -0
- package/lib/stacks/index.mjs +10 -0
- package/lib/stacks/java.mjs +56 -0
- package/lib/stacks/k8s.mjs +31 -0
- package/lib/stacks/node.mjs +235 -0
- package/lib/stacks/otel.mjs +11 -0
- package/lib/stacks/python-bootstrap.mjs +51 -0
- package/lib/stacks/python.mjs +434 -0
- package/lib/types.mjs +161 -0
- package/lib/ui.mjs +106 -0
- package/lib/workspace.mjs +198 -0
- package/package.json +42 -4
package/lib/settings.mjs
ADDED
|
@@ -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
|
+
}
|