@uknows/setup 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +190 -0
- package/README.md +209 -2
- package/bin/uknows-setup.mjs +14 -0
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +327 -0
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +251 -0
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +2736 -0
- package/lib/contract.mjs +149 -0
- package/lib/detect.mjs +627 -0
- package/lib/diff.mjs +147 -0
- package/lib/doc.mjs +98 -0
- package/lib/dockerfile.mjs +455 -0
- package/lib/endpoint.mjs +81 -0
- package/lib/engine.mjs +1753 -0
- package/lib/existing.mjs +562 -0
- package/lib/git.mjs +100 -0
- package/lib/hooks/UknowsOpenTelemetry.cs +118 -0
- package/lib/hooks/pins.mjs +28 -0
- package/lib/hooks/uknows-otel.cjs +82 -0
- package/lib/hooks/uknows_otel.py +179 -0
- package/lib/hosts.mjs +650 -0
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +132 -0
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +468 -0
- package/lib/signin.mjs +259 -0
- package/lib/stacks/azure.mjs +345 -0
- package/lib/stacks/channel.mjs +125 -0
- package/lib/stacks/dotnet.mjs +472 -0
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +227 -0
- package/lib/stacks/index.mjs +18 -0
- package/lib/stacks/java.mjs +57 -0
- package/lib/stacks/k8s.mjs +31 -0
- package/lib/stacks/node.mjs +358 -0
- package/lib/stacks/otel.mjs +63 -0
- package/lib/stacks/python-bootstrap.mjs +71 -0
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +632 -0
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +208 -0
- package/lib/ui.mjs +225 -0
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +201 -0
- package/package.json +37 -4
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The lock refresh of uv and Poetry projects (uv lock in the workspace root, poetry lock), shared by the Python and
|
|
3
|
+
* the Azure Monitor appliers. The tool on this computer writes the lock, and the image reads it, so the run stops
|
|
4
|
+
* with a required step when:
|
|
5
|
+
* - the lock is above the --path folder (the command would write outside it),
|
|
6
|
+
* - the tool is missing (the engine probed it: ctx.tools[tool] === null),
|
|
7
|
+
* - it does not meet [tool.uv] required-version (or uv.toml), [tool.poetry] requires-poetry, or the version that
|
|
8
|
+
* the Dockerfile pins (COPY --from=ghcr.io/astral-sh/uv:0.11.29, pip install uv==X, the astral.sh/uv/<version>/install.sh
|
|
9
|
+
* installer with ENV UV_VERSION, POETRY_VERSION).
|
|
10
|
+
* After the run, the engine rolls back when the lockfile format line changed (keep): version = N in uv.lock,
|
|
11
|
+
* lock-version in poetry.lock. An older tool in the image cannot read a newer format.
|
|
12
|
+
*/
|
|
13
|
+
import { posix } from 'node:path'
|
|
14
|
+
import { RESOLVE } from '../contract.mjs'
|
|
15
|
+
import { parseDockerfile } from '../dockerfile.mjs'
|
|
16
|
+
import { tomlString, tomlTable } from '../toml.mjs'
|
|
17
|
+
import { satisfies } from '../versions.mjs'
|
|
18
|
+
|
|
19
|
+
/** Lockfile name -> tool. PDM has no automatic refresh. */
|
|
20
|
+
export const LOCK_TOOLS = { 'uv.lock': 'uv', 'poetry.lock': 'poetry' }
|
|
21
|
+
const KEEP = { 'uv.lock': '^version = \\d+$', 'poetry.lock': '^lock-version = "[^"]*"$' }
|
|
22
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
23
|
+
const read = (view, path) => { try { return view.read(path) } catch { return null } }
|
|
24
|
+
const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The lock command for lockFile, or the required step that replaces it.
|
|
28
|
+
* @param {{view: any, lockFile: string, tools?: Record<string, string|null>, dockerfile?: string|null, docker?: string|null, packages: string}} opts
|
|
29
|
+
* packages: what the lock adds, for the required step ('the OpenTelemetry packages').
|
|
30
|
+
* @returns {{command: import('../types.mjs').Command}|{block: import('../types.mjs').Block}|null} null for a lockfile with no tool (pdm.lock).
|
|
31
|
+
*/
|
|
32
|
+
export function lockStep({ view, lockFile, tools, dockerfile = null, docker = null, packages }) {
|
|
33
|
+
const name = posix.basename(lockFile), tool = LOCK_TOOLS[name]
|
|
34
|
+
if (!tool) return null
|
|
35
|
+
const dir = posix.dirname(lockFile)
|
|
36
|
+
const version = tools && Object.prototype.hasOwnProperty.call(tools, tool) ? tools[tool] : undefined
|
|
37
|
+
const poetry1 = tool === 'poetry' && /^1\./.test(version ?? '')
|
|
38
|
+
const args = poetry1 ? ['lock', '--no-update'] : ['lock']
|
|
39
|
+
const command = [tool, ...args].join(' ')
|
|
40
|
+
if (lockFile.startsWith('../')) {
|
|
41
|
+
return { block: { title: RESOLVE.outside.title, lines: RESOLVE.outside.lines.map((l) => fill(l, { lock: lockFile, command, dir })) } }
|
|
42
|
+
}
|
|
43
|
+
const needs = toolNeeds(view, dir, tool, dockerfile, docker)
|
|
44
|
+
const pinned = needs.find((n) => n.exact)
|
|
45
|
+
const block = (reason) => ({ title: fill(RESOLVE.tool.title, { tool, version: pinned ? ` ${pinned.version}` : '' }),
|
|
46
|
+
lines: [fill(RESOLVE.tool.why, { lock: lockFile, tool, packages }), reason,
|
|
47
|
+
fill(RESOLVE.tool.install, { tool, version: pinned ? ` ${pinned.version}` : '' }), RESOLVE.again] })
|
|
48
|
+
if (version === null) return { block: block(fill(RESOLVE.tool.missing, { tool })) }
|
|
49
|
+
if (version) {
|
|
50
|
+
const unmet = needs.find((n) => !satisfies(version, n.spec))
|
|
51
|
+
if (unmet) {
|
|
52
|
+
return { block: block(fill(unmet.line ? RESOLVE.tool.image : RESOLVE.tool.version,
|
|
53
|
+
{ file: unmet.file, line: unmet.line, tool, need: unmet.version ?? unmet.spec, have: version })) }
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { command: { cwd: dir, cmd: tool, args, touches: [lockFile], added: [], kind: 'lock', undo: null,
|
|
57
|
+
keep: [{ file: lockFile, pattern: KEEP[name] }], why: fill(RESOLVE.update, { lock: name, command }) } }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The versions of the tool that the project asks for: required-version (uv) or requires-poetry in the pyproject.toml
|
|
62
|
+
* next to the lock, uv.toml, and the version the Dockerfile installs. exact: a pinned image or package version.
|
|
63
|
+
* @returns {{spec: string, version?: string, file: string, line?: number, exact: boolean}[]}
|
|
64
|
+
*/
|
|
65
|
+
export function toolNeeds(view, dir, tool, dockerfile, docker) {
|
|
66
|
+
const out = []
|
|
67
|
+
const pyproject = read(view, at(dir, 'pyproject.toml'))
|
|
68
|
+
if (tool === 'uv') {
|
|
69
|
+
const spec = tomlString(tomlTable(pyproject ?? '', 'tool.uv'), 'required-version')
|
|
70
|
+
if (spec) out.push({ spec, file: at(dir, 'pyproject.toml'), exact: false })
|
|
71
|
+
const own = tomlString(read(view, at(dir, 'uv.toml')) ?? '', 'required-version')
|
|
72
|
+
if (own) out.push({ spec: own, file: at(dir, 'uv.toml'), exact: false })
|
|
73
|
+
} else {
|
|
74
|
+
const spec = tomlString(tomlTable(pyproject ?? '', 'tool.poetry'), 'requires-poetry')
|
|
75
|
+
if (spec) out.push({ spec, file: at(dir, 'pyproject.toml'), exact: false })
|
|
76
|
+
}
|
|
77
|
+
if (docker && dockerfile) {
|
|
78
|
+
const image = tool === 'uv' ? /\bghcr\.io\/astral-sh\/uv:(\d+(?:\.\d+){0,2})(?=[\s@-]|$)/ : null
|
|
79
|
+
const pip = new RegExp(`\\bpip3?\\s+install\\b[^\\n&|;]*?["']?\\b${tool}==(\\d+(?:\\.\\d+){1,2})\\b`)
|
|
80
|
+
const env = tool === 'poetry' ? /\bPOETRY_VERSION=["']?(\d+(?:\.\d+){1,2})\b/ : null
|
|
81
|
+
// The versioned installer script: astral.sh/uv/0.11.29/install.sh, or astral.sh/uv/${UV_VERSION}/install.sh with an
|
|
82
|
+
// ENV or ARG UV_VERSION line before it.
|
|
83
|
+
const script = tool === 'uv' ? /astral\.sh\/uv\/(?:\$\{?UV_VERSION\}?|(\d+(?:\.\d+){1,2}))\/install\.sh/ : null
|
|
84
|
+
const instructions = parseDockerfile(docker).instructions
|
|
85
|
+
const uvVersion = (upTo) => instructions.slice(0, upTo).filter((y) => y.keyword === 'ENV' || y.keyword === 'ARG')
|
|
86
|
+
.map((y) => ({ y, m: /\bUV_VERSION=["']?(\d+(?:\.\d+){1,2})\b/.exec(y.args) })).filter((e) => e.m).at(-1) ?? null
|
|
87
|
+
for (const [k, x] of instructions.entries()) {
|
|
88
|
+
const text = x.argv ? x.argv.join(' ') : x.args
|
|
89
|
+
let m = (image && (x.keyword === 'FROM' || x.keyword === 'COPY') && image.exec(text)) || (x.keyword === 'RUN' && pip.exec(text))
|
|
90
|
+
|| (env && (x.keyword === 'ENV' || x.keyword === 'ARG') && env.exec(text))
|
|
91
|
+
let at = x
|
|
92
|
+
const run = !m && script && x.keyword === 'RUN' ? script.exec(text) : null
|
|
93
|
+
if (run) {
|
|
94
|
+
const found = run[1] ? null : uvVersion(k)
|
|
95
|
+
m = run[1] ? run : found?.m ?? null
|
|
96
|
+
at = found?.y ?? x
|
|
97
|
+
}
|
|
98
|
+
if (!m) continue
|
|
99
|
+
const v = m[1]
|
|
100
|
+
out.push({ spec: v.split('.').length < 3 ? `==${v}.*` : `==${v}`, version: v, file: dockerfile, line: at.start + 1, exact: true })
|
|
101
|
+
break
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return out
|
|
105
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Which OpenTelemetry set (PINS.python.sets, newest first) a Python app gets. The first set that fits the direct
|
|
3
|
+
* limits of the app (protobuf and opentelemetry-* in its dependency files) and its Python version wins. pip apps get
|
|
4
|
+
* exact pins from that set. uv and Poetry apps get floors, and the set only decides which instrumentation packages
|
|
5
|
+
* exist for them (bootstrap SINCE). The plan says why when it is not the newest set.
|
|
6
|
+
*/
|
|
7
|
+
import { PINS, RESOLVE } from '../contract.mjs'
|
|
8
|
+
import { tomlArray, tomlEntries, tomlTable } from '../toml.mjs'
|
|
9
|
+
import { clauses, compareVersion, lowestRelease, overlaps, satisfies } from '../versions.mjs'
|
|
10
|
+
import { normalize } from './python-bootstrap.mjs'
|
|
11
|
+
|
|
12
|
+
/** opentelemetry-* packages on the core release line (1.x), and on the contrib line (0.xb). Other names are ignored. */
|
|
13
|
+
const CORE = /^opentelemetry-(?:api|sdk|proto|exporter-otlp(?:-proto-(?:http|grpc|common))?)$/
|
|
14
|
+
const CONTRIB = /^opentelemetry-(?:distro|instrumentation(?:-[\w-]+)?|semantic-conventions|util-http)$/
|
|
15
|
+
const fill = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
16
|
+
const read = (ws, path) => { try { return ws.read(path) } catch { return null } }
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @typedef {{name: string, spec: string, style: 'python'|'poetry', file: string, line: number, value: string}} Limit
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The direct limits that decide the set: protobuf and opentelemetry-* entries with a version limit, in requirements
|
|
24
|
+
* files (1 level of -r), [project] dependencies and [tool.poetry.dependencies] of these pyproject.toml files.
|
|
25
|
+
* @param {any} ws
|
|
26
|
+
* @param {string[]} files dependency files: requirements*.txt or pyproject.toml
|
|
27
|
+
* @returns {Limit[]}
|
|
28
|
+
*/
|
|
29
|
+
export function directLimits(ws, files) {
|
|
30
|
+
const out = []
|
|
31
|
+
const relevant = (name) => name === 'protobuf' || CORE.test(name) || CONTRIB.test(name)
|
|
32
|
+
const seen = new Set()
|
|
33
|
+
const fromRequirements = (file, depth) => {
|
|
34
|
+
if (seen.has(file)) return
|
|
35
|
+
seen.add(file)
|
|
36
|
+
const text = read(ws, file)
|
|
37
|
+
if (text === null) return
|
|
38
|
+
text.split(/\r?\n/).forEach((raw, i) => {
|
|
39
|
+
const line = raw.replace(/(^|\s)#.*$/, '').trim()
|
|
40
|
+
const include = /^(?:-r|--requirement)(?:\s+|=)(\S+)/.exec(line)
|
|
41
|
+
if (include && depth === 0) return fromRequirements(join(dirOf(file), include[1]), 1)
|
|
42
|
+
const m = /^([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*([<>=!~][^;]*)?/.exec(line)
|
|
43
|
+
if (m?.[2] && relevant(normalize(m[1]))) out.push({ name: normalize(m[1]), spec: m[2].trim(), style: 'python', file, line: i + 1, value: line.split(';')[0].trim() })
|
|
44
|
+
})
|
|
45
|
+
}
|
|
46
|
+
for (const file of files) {
|
|
47
|
+
if (/\.txt$/.test(file)) { fromRequirements(file, 0); continue }
|
|
48
|
+
const text = read(ws, file)
|
|
49
|
+
if (text === null) continue
|
|
50
|
+
const lines = text.split(/\r?\n/)
|
|
51
|
+
for (const req of tomlArray(tomlTable(text, 'project'), 'dependencies')) {
|
|
52
|
+
const m = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[^\]]*\])?\s*([<>=!~][^;]*)?/.exec(req)
|
|
53
|
+
if (!m?.[2] || !relevant(normalize(m[1]))) continue
|
|
54
|
+
const line = lines.findIndex((l) => l.includes(req)) + 1
|
|
55
|
+
out.push({ name: normalize(m[1]), spec: m[2].trim(), style: 'python', file, line, value: req.split(';')[0].trim() })
|
|
56
|
+
}
|
|
57
|
+
for (const { key, value, line } of tomlEntries(text, 'tool.poetry.dependencies')) {
|
|
58
|
+
const name = normalize(key)
|
|
59
|
+
const spec = /^["']([^"']*)["']/.exec(value)?.[1] ?? /\bversion\s*=\s*["']([^"']*)["']/.exec(value)?.[1]
|
|
60
|
+
if (spec && spec !== '*' && relevant(name)) out.push({ name, spec, style: 'poetry', file, line, value: `${key} = ${value}` })
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return out
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The Python versions a uv or Poetry project must support: the lowest of requires-python, or python in Poetry. */
|
|
67
|
+
export function projectPython(ws, files) {
|
|
68
|
+
for (const file of files.filter((f) => /pyproject\.toml$/.test(f))) {
|
|
69
|
+
const text = read(ws, file) ?? ''
|
|
70
|
+
const requires = /^\s*requires-python\s*=\s*["']([^"']+)["']/m.exec(tomlTable(text, 'project'))?.[1]
|
|
71
|
+
const low = requires ? lowestRelease(requires) : null
|
|
72
|
+
if (low) return low
|
|
73
|
+
const poetry = tomlEntries(text, 'tool.poetry.dependencies').find((e) => e.key === 'python')
|
|
74
|
+
const spec = poetry && /^["']([^"']*)["']/.exec(poetry.value)?.[1]
|
|
75
|
+
const lowPoetry = spec ? lowestRelease(spec, 'poetry') : null
|
|
76
|
+
if (lowPoetry) return lowPoetry
|
|
77
|
+
}
|
|
78
|
+
return null
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** True when the set allows the limit. */
|
|
82
|
+
function fits(set, limit) {
|
|
83
|
+
if (limit.name === 'protobuf') return overlaps(limit.spec, set.protobuf, limit.style)
|
|
84
|
+
if (CORE.test(limit.name)) return satisfies(set.core, limit.spec, limit.style)
|
|
85
|
+
if (CONTRIB.test(limit.name)) return satisfies(set.contrib, limit.spec, limit.style)
|
|
86
|
+
return true
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The first set that fits. why: the plan sentence when it is not the newest set. When none fits: the required step.
|
|
91
|
+
* @param {{limits: Limit[], python: [number, number]|null, tool?: string|null}} opts tool: uv or poetry for floors, else pins.
|
|
92
|
+
* @returns {{set: typeof PINS.python.sets[number], why: string|null}|{block: import('../types.mjs').Block}}
|
|
93
|
+
*/
|
|
94
|
+
export function pickSet({ limits, python, tool = null }) {
|
|
95
|
+
const sets = PINS.python.sets
|
|
96
|
+
const pyOk = (set) => !python || python[0] * 1000 + python[1] >= set.minPython[0] * 1000 + set.minPython[1]
|
|
97
|
+
const index = sets.findIndex((set) => pyOk(set) && limits.every((l) => fits(set, l)))
|
|
98
|
+
if (index < 0) {
|
|
99
|
+
const limit = limits.find((l) => sets.filter(pyOk).every((set) => !fits(set, l)))
|
|
100
|
+
if (!limit) return { block: oldPython(python) }
|
|
101
|
+
return { block: { title: RESOLVE.python.noSet.title, lines: RESOLVE.python.noSet.lines.map((l) => fill(l, limit)) } }
|
|
102
|
+
}
|
|
103
|
+
const set = sets[index]
|
|
104
|
+
if (index === 0) return { set, why: null }
|
|
105
|
+
const newest = sets[0]
|
|
106
|
+
const limit = limits.find((l) => !fits(newest, l))
|
|
107
|
+
const values = { ...limit, python: python?.join('.'), newest: newest.core, need: newest.minPython.join('.'), core: set.core, contrib: set.contrib, tool }
|
|
108
|
+
const text = limit ? (tool ? RESOLVE.python.floorLimit : RESOLVE.python.olderLimit) : tool ? RESOLVE.python.floorPython : RESOLVE.python.olderPython
|
|
109
|
+
return { set, why: fill(text, values) }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** The step for a Python version below every set. */
|
|
113
|
+
function oldPython(python) {
|
|
114
|
+
const oldest = PINS.python.sets.at(-1)
|
|
115
|
+
const need = oldest.minPython.join('.')
|
|
116
|
+
return { title: `Use Python ${need} or later`, lines: [`This app uses Python ${python.join('.')}.`,
|
|
117
|
+
`opentelemetry-distro ${oldest.contrib} needs Python ${need} or later.`, 'Update the Python version of the app. Then run npx @uknows/setup again.'] }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Requirement text of 1 package: name==version or name>=floor. */
|
|
121
|
+
export const requirement = (name, version, floor = null) => (floor ? `${name}>=${floor}` : `${name}==${version}`)
|
|
122
|
+
|
|
123
|
+
/** The package name of a requirement text. */
|
|
124
|
+
export const requirementName = (req) => normalize(/^[A-Za-z0-9][A-Za-z0-9._-]*/.exec(req)?.[0] ?? req)
|
|
125
|
+
|
|
126
|
+
/** The lowest protobuf release a lock has, as a limit, so the set fits a lock that keeps protobuf below 5. */
|
|
127
|
+
export function lockedLimit(installed, name) {
|
|
128
|
+
const item = installed?.packages?.get(name)
|
|
129
|
+
if (!item?.version || !clauses(`==${item.version}`).length) return null
|
|
130
|
+
return { name, spec: `==${item.version}`, style: 'python', file: item.file, line: item.line ?? 0, value: `${item.name} ${item.version}` }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export { compareVersion }
|
|
134
|
+
|
|
135
|
+
const dirOf = (path) => (path.includes('/') ? path.slice(0, path.lastIndexOf('/')) : '.')
|
|
136
|
+
function join(dir, rel) {
|
|
137
|
+
const parts = dir === '.' ? [] : dir.split('/')
|
|
138
|
+
for (const part of rel.split('/')) {
|
|
139
|
+
if (part === '..') parts.pop()
|
|
140
|
+
else if (part && part !== '.') parts.push(part)
|
|
141
|
+
}
|
|
142
|
+
return parts.join('/') || '.'
|
|
143
|
+
}
|