@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/existing.mjs
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// OpenTelemetry and other APM agents already in 1 service, from its manifests and Dockerfile.
|
|
2
|
+
// Sentry counts only when its tracing is on, so for Sentry this also reads the source files.
|
|
3
|
+
import { TROUBLESHOOTING } from './contract.mjs'
|
|
4
|
+
|
|
5
|
+
const at = (dir, name) => (dir === '.' ? name : `${dir}/${name}`)
|
|
6
|
+
|
|
7
|
+
/** Manifest name -> ecosystem. */
|
|
8
|
+
const ECOSYSTEM = [
|
|
9
|
+
[/^package\.json$/, 'node'], [/^(requirements[\w.-]*\.txt|pyproject\.toml|Pipfile)$/, 'python'], [/\.csproj$/, 'dotnet'],
|
|
10
|
+
[/^(pom\.xml|build\.gradle(\.kts)?)$/, 'java'], [/^go\.mod$/, 'go'], [/^composer\.json$/, 'php'], [/^Gemfile$/, 'ruby'],
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
/** Per ecosystem: OpenTelemetry at SDK level (never the API package alone), other APM agents, and Sentry. */
|
|
14
|
+
const RULES = {
|
|
15
|
+
node: { otel: /^(@opentelemetry\/(sdk-node|sdk-trace-.+|auto-instrumentations-node|exporter-trace-otlp-.+)|@vercel\/otel)$/,
|
|
16
|
+
apm: { Datadog: /^dd-trace$/, 'New Relic': /^newrelic$/, 'Elastic APM': /^elastic-apm-node$/,
|
|
17
|
+
'Azure Monitor': /^(applicationinsights|@azure\/monitor-opentelemetry.*)$/, Dynatrace: /^@dynatrace\//, Instana: /^@instana\/collector$/ },
|
|
18
|
+
sentry: /^@sentry\// },
|
|
19
|
+
python: { otel: /^opentelemetry-(sdk|distro|instrumentation.*)$/,
|
|
20
|
+
apm: { Datadog: /^ddtrace$/, 'New Relic': /^newrelic$/, 'Elastic APM': /^elastic-apm$/,
|
|
21
|
+
'Azure Monitor': /^(azure-monitor-opentelemetry.*|opencensus-ext-azure)$/ },
|
|
22
|
+
sentry: /^sentry-sdk$/ },
|
|
23
|
+
dotnet: { otel: /^OpenTelemetry(\.Extensions\.Hosting|\.AutoInstrumentation|\.Exporter\..+)?$/i,
|
|
24
|
+
apm: { Datadog: /^Datadog\.Trace/i, 'New Relic': /^NewRelic\./i, 'Elastic APM': /^Elastic\.Apm/i,
|
|
25
|
+
'Azure Monitor': /^(Microsoft\.ApplicationInsights|Azure\.Monitor\.OpenTelemetry)/i },
|
|
26
|
+
sentry: /^Sentry(\.|$)/ },
|
|
27
|
+
java: { otel: /^io\.opentelemetry[\w.]*:opentelemetry-[\w-]*(sdk|starter|exporter|javaagent)/,
|
|
28
|
+
apm: { Datadog: /^com\.datadoghq:dd-/, 'New Relic': /^com\.newrelic\./, 'Elastic APM': /^co\.elastic\.apm:/,
|
|
29
|
+
'Azure Monitor': /^(com\.microsoft\.azure:applicationinsights-|com\.azure:azure-monitor-opentelemetry)/ },
|
|
30
|
+
sentry: /^io\.sentry:/ },
|
|
31
|
+
go: { otel: /^go\.opentelemetry\.io\/otel\/sdk(\/|$)/,
|
|
32
|
+
apm: { Datadog: /dd-trace-go/, 'New Relic': /^github\.com\/newrelic\/go-agent/, 'Elastic APM': /^go\.elastic\.co\/apm/ },
|
|
33
|
+
sentry: /^github\.com\/getsentry\/sentry-go/ },
|
|
34
|
+
php: { otel: /^open-telemetry\/sdk$/,
|
|
35
|
+
apm: { Datadog: /^datadog\//, 'New Relic': /newrelic/, 'Elastic APM': /^elastic\/apm-agent/ }, sentry: /^sentry\// },
|
|
36
|
+
ruby: { otel: /^opentelemetry-sdk$/,
|
|
37
|
+
apm: { Datadog: /^(ddtrace|datadog)$/, 'New Relic': /^newrelic_rpm$/, 'Elastic APM': /^elastic-apm$/,
|
|
38
|
+
'Scout APM': /^scout_apm$/, Skylight: /^skylight$/ },
|
|
39
|
+
sentry: /^sentry-(ruby|rails|raven)$/ },
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Agents that a Dockerfile loads (comment lines are ignored). */
|
|
43
|
+
const DOCKER_APM = { Datadog: /\bdd-trace\b|\bddtrace(-run)?\b|dd-java-agent|datadog-dotnet-apm|datadog-setup\.php/,
|
|
44
|
+
'New Relic': /\bnewrelic(-admin|\.jar|-php5?)?\b/i, 'Elastic APM': /elastic-apm-agent|elastic-apm-node|\belasticapm\b/i,
|
|
45
|
+
'Azure Monitor': /applicationinsights-agent/i, Dynatrace: /\bdynatrace\b|\boneagent\b/i, Instana: /\binstana\b/i }
|
|
46
|
+
const DOCKER_OTEL = /-javaagent:\S*opentelemetry/
|
|
47
|
+
const TRACING = /tracesSampleRate|traces_sample_rate|TracesSampleRate|enableTracing|traces-sample-rate|tracesSampler|traces_sampler/
|
|
48
|
+
const SOURCE = /\.(m?[jt]sx?|c[jt]s|py|cs|rb|go|php|java|kt|properties|ya?ml|json|exs?)$/
|
|
49
|
+
const LOCKS = /(^|\/)(package-lock|npm-shrinkwrap|composer\.lock|packages\.lock)\.json$/
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* @param {import('./types.mjs').Service} service
|
|
53
|
+
* @param {import('./workspace.mjs').Workspace} ws
|
|
54
|
+
* @returns {import('./types.mjs').Existing}
|
|
55
|
+
*/
|
|
56
|
+
export function findExisting(service, ws) {
|
|
57
|
+
const otel = [], apm = new Map(), sentry = []
|
|
58
|
+
const add = (vendor, evidence) => {
|
|
59
|
+
const list = apm.get(vendor) ?? apm.set(vendor, []).get(vendor)
|
|
60
|
+
if (!list.some((e) => e.file === evidence.file && e.what === evidence.what)) list.push(evidence)
|
|
61
|
+
}
|
|
62
|
+
const manifests = new Set(service.manifests)
|
|
63
|
+
for (const name of ws.list(service.dir)) if (ECOSYSTEM.some(([re]) => re.test(name))) manifests.add(at(service.dir, name))
|
|
64
|
+
for (const file of [...manifests].sort()) {
|
|
65
|
+
const text = ws.read(file)
|
|
66
|
+
const eco = ECOSYSTEM.find(([re]) => re.test(file.split('/').pop()))?.[1]
|
|
67
|
+
if (text === null || !eco) continue
|
|
68
|
+
const rules = RULES[eco]
|
|
69
|
+
for (const { name, line } of dependencies(file, text)) {
|
|
70
|
+
const evidence = { file, line, what: name }
|
|
71
|
+
if (rules.otel.test(name)) otel.push(evidence)
|
|
72
|
+
for (const [vendor, re] of Object.entries(rules.apm)) if (re.test(name)) add(vendor, evidence)
|
|
73
|
+
if (rules.sentry.test(name)) sentry.push(evidence)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const dockerfile = (service.dockerfile && ws.read(service.dockerfile)) || ''
|
|
77
|
+
dockerfile.split('\n').forEach((text, i) => {
|
|
78
|
+
const found = (re) => (/^\s*#/.test(text) || !re.test(text) ? null : { file: service.dockerfile, line: i + 1, what: re.exec(text)[0] })
|
|
79
|
+
for (const [vendor, re] of Object.entries(DOCKER_APM)) if (found(re)) add(vendor, found(re))
|
|
80
|
+
if (found(DOCKER_OTEL)) otel.push(found(DOCKER_OTEL))
|
|
81
|
+
})
|
|
82
|
+
if (sentry.length) {
|
|
83
|
+
const hit = tracingIn(service, ws)
|
|
84
|
+
if (hit) for (const evidence of [...sentry, hit]) add('Sentry', evidence)
|
|
85
|
+
}
|
|
86
|
+
return { otel, apm: [...apm].map(([vendor, evidence]) => ({ vendor, evidence })) }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Why the run stops for a service with another APM agent, and the options.
|
|
91
|
+
* @param {import('./types.mjs').Service} service
|
|
92
|
+
* @param {import('./types.mjs').Apm[]} apm
|
|
93
|
+
* @returns {import('./types.mjs').Block}
|
|
94
|
+
*/
|
|
95
|
+
export function explainApm(service, apm) {
|
|
96
|
+
const vendors = [...new Set(apm.map((a) => a.vendor))]
|
|
97
|
+
const names = vendors.length > 1 ? `${vendors.slice(0, -1).join(', ')} and ${vendors.at(-1)}` : vendors[0]
|
|
98
|
+
const steps = TROUBLESHOOTING.find((t) => t.id === 'other-apm')?.steps ?? []
|
|
99
|
+
return {
|
|
100
|
+
title: `${service.dir === '.' ? 'This app' : service.dir} already uses ${names}`,
|
|
101
|
+
lines: [...apm.flatMap((a) => a.evidence.map((e) => `Found ${e.what} in ${e.file}${e.line ? ` line ${e.line}` : ''}.`)),
|
|
102
|
+
...steps, 'Then run npx @uknows/setup again.'],
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Package names a manifest lists, with 1-based lines. */
|
|
107
|
+
export function dependencies(file, text) {
|
|
108
|
+
const base = file.split('/').pop()
|
|
109
|
+
const lines = text.split('\n')
|
|
110
|
+
const lineOf = (needle) => lines.findIndex((l) => l.includes(needle)) + 1 || undefined
|
|
111
|
+
const out = []
|
|
112
|
+
const push = (name, line) => { if (name && !out.some((d) => d.name === name)) out.push({ name, line }) }
|
|
113
|
+
if (base === 'package.json' || base === 'composer.json') {
|
|
114
|
+
let json
|
|
115
|
+
try { json = JSON.parse(text) } catch { return out }
|
|
116
|
+
const groups = base === 'package.json' ? ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] : ['require', 'require-dev']
|
|
117
|
+
for (const group of groups) {
|
|
118
|
+
for (const name of Object.keys(json?.[group] ?? {})) push(name, lineOf(`${JSON.stringify(name)}`))
|
|
119
|
+
}
|
|
120
|
+
} else if (/\.csproj$/.test(base)) {
|
|
121
|
+
for (const m of text.matchAll(/<PackageReference\s[^>]*?Include\s*=\s*"([^"]+)"/gi)) push(m[1], lineOf(m[0]))
|
|
122
|
+
} else if (base === 'pom.xml') {
|
|
123
|
+
for (const m of text.matchAll(/<dependency>([\s\S]*?)<\/dependency>/g)) {
|
|
124
|
+
const group = /<groupId>\s*([^<\s]+)/.exec(m[1])?.[1], artifact = /<artifactId>\s*([^<\s]+)/.exec(m[1])?.[1]
|
|
125
|
+
if (group && artifact) push(`${group}:${artifact}`, lineOf(`<artifactId>${artifact}`))
|
|
126
|
+
}
|
|
127
|
+
} else if (/^build\.gradle/.test(base)) {
|
|
128
|
+
for (const m of text.matchAll(/['"]([\w.-]+):([\w.-]+)(?::[^'"]*)?['"]/g)) push(`${m[1]}:${m[2]}`, lineOf(m[0]))
|
|
129
|
+
} else if (base === 'go.mod') {
|
|
130
|
+
lines.forEach((l, i) => { const m = /^\s*(?:require\s+)?([\w.-]+\.[\w-]+\/\S+)\s+v\d/.exec(l); if (m) push(m[1], i + 1) })
|
|
131
|
+
} else if (base === 'Gemfile') {
|
|
132
|
+
lines.forEach((l, i) => { const m = /^\s*gem\s*\(?\s*["']([^"']+)["']/.exec(l); if (m) push(m[1], i + 1) })
|
|
133
|
+
} else if (/\.txt$/.test(base)) {
|
|
134
|
+
lines.forEach((l, i) => { const m = /^\s*([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(l.replace(/#.*/, '')); if (m) push(pyName(m[1]), i + 1) })
|
|
135
|
+
} else {
|
|
136
|
+
// pyproject.toml and Pipfile: dependency arrays and dependency tables.
|
|
137
|
+
let table = '', array = false
|
|
138
|
+
lines.forEach((raw, i) => {
|
|
139
|
+
const l = raw.replace(/\s#.*$/, '')
|
|
140
|
+
const closes = () => l.replace(/"[^"]*"|'[^']*'/g, '').includes(']')
|
|
141
|
+
const quoted = () => { for (const m of l.matchAll(/["']([A-Za-z0-9][A-Za-z0-9._-]*)[^"']*["']/g)) push(pyName(m[1]), i + 1) }
|
|
142
|
+
if (array) { quoted(); array = !closes(); return }
|
|
143
|
+
const header = /^\s*\[+([^\]]+)\]/.exec(l)
|
|
144
|
+
if (header) { table = header[1]; return }
|
|
145
|
+
const kv = /^\s*["']?([A-Za-z0-9_.-]+)["']?\s*=\s*(.*)$/.exec(l)
|
|
146
|
+
if (!kv) return
|
|
147
|
+
if (kv[2].startsWith('[')) {
|
|
148
|
+
if (/dependencies|requires/.test(kv[1]) || /dependencies|dependency-groups/.test(table)) { quoted(); array = !closes() }
|
|
149
|
+
} else if (/dependencies|packages/.test(table) && kv[1] !== 'python') push(pyName(kv[1]), i + 1)
|
|
150
|
+
})
|
|
151
|
+
}
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const pyName = (name) => name.toLowerCase().replace(/[-_.]+/g, '-')
|
|
156
|
+
|
|
157
|
+
/** The first source line that turns Sentry tracing on, as evidence. */
|
|
158
|
+
function tracingIn(service, ws) {
|
|
159
|
+
let count = 0
|
|
160
|
+
for (const file of ws.files(service.dir)) {
|
|
161
|
+
if (!SOURCE.test(file) || LOCKS.test(file) || ++count > 5000) continue
|
|
162
|
+
if ((ws.stat(file)?.size ?? 0) > 1_000_000) continue
|
|
163
|
+
const text = ws.read(file) ?? ''
|
|
164
|
+
const m = TRACING.exec(text)
|
|
165
|
+
if (m) return { file, line: text.slice(0, m.index).split('\n').length, what: `Sentry tracing (${m[0]})` }
|
|
166
|
+
}
|
|
167
|
+
return null
|
|
168
|
+
}
|
package/lib/git.mjs
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The dirty-tree check. Setup refuses to edit a repo with uncommitted
|
|
3
|
+
* changes (untracked files count), so the user can review exactly what this
|
|
4
|
+
* tool changed with git diff. Paths that an earlier setup run changed are
|
|
5
|
+
* ignored, so running setup again before a commit works.
|
|
6
|
+
*/
|
|
7
|
+
import { spawnSync } from 'node:child_process'
|
|
8
|
+
import { readdirSync } from 'node:fs'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @param {string} root absolute path of the folder setup works in
|
|
13
|
+
* @param {Set<string>} ignore repo-relative paths to leave out
|
|
14
|
+
* @returns {{git: boolean, repo: boolean, paths: string[]}} paths are relative to root, '/' separated
|
|
15
|
+
*/
|
|
16
|
+
export function gitStatus(root, ignore = new Set()) {
|
|
17
|
+
const run = (...args) => spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 })
|
|
18
|
+
const prefix = run('rev-parse', '--show-prefix')
|
|
19
|
+
if (prefix.error) return { git: false, repo: false, paths: [] }
|
|
20
|
+
if (prefix.status !== 0) return { git: true, repo: false, paths: [] }
|
|
21
|
+
const status = run('status', '--porcelain=v1', '-z', '--untracked-files=normal', '--', '.')
|
|
22
|
+
if (status.status !== 0) return { git: true, repo: false, paths: [] }
|
|
23
|
+
const base = prefix.stdout.trim()
|
|
24
|
+
const tokens = status.stdout.split('\0')
|
|
25
|
+
const paths = []
|
|
26
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
27
|
+
const entry = tokens[i]
|
|
28
|
+
if (entry.length < 4) continue
|
|
29
|
+
if (entry[0] === 'R' || entry[0] === 'C') i++ // the next token is the old path
|
|
30
|
+
const path = entry.slice(3).startsWith(base) ? entry.slice(3 + base.length) : entry.slice(3)
|
|
31
|
+
if (ignore.has(path) || (path.endsWith('/') && onlyIgnored(root, path.slice(0, -1), ignore))) continue
|
|
32
|
+
paths.push(path)
|
|
33
|
+
}
|
|
34
|
+
return { git: true, repo: true, paths }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** True when every file under an untracked folder is in ignore. */
|
|
38
|
+
function onlyIgnored(root, dir, ignore, budget = { left: 2000 }) {
|
|
39
|
+
let entries
|
|
40
|
+
try { entries = readdirSync(join(root, ...dir.split('/')), { withFileTypes: true }) } catch { return false }
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (--budget.left < 0) return false
|
|
43
|
+
const path = `${dir}/${entry.name}`
|
|
44
|
+
if (entry.isDirectory() ? !onlyIgnored(root, path, ignore, budget) : !ignore.has(path)) return false
|
|
45
|
+
}
|
|
46
|
+
return true
|
|
47
|
+
}
|
package/lib/hosts.mjs
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Where the app runs, read from deploy files in the repo, and the "set your
|
|
3
|
+
* key once" steps for those hosts. Source cues and precedence come from
|
|
4
|
+
* instrumentation/agents/setup-cli/lib/hosting.mjs. Environment variables of
|
|
5
|
+
* this computer are never used: the CLI runs on a laptop, not on the host.
|
|
6
|
+
*/
|
|
7
|
+
import { DOCS_URL, HOSTS, SETTINGS_FILE, SETTINGS_FILE_LOADERS } from './contract.mjs'
|
|
8
|
+
|
|
9
|
+
const MAX_BYTES = 262144
|
|
10
|
+
const RUNTIME = ['azure-container-apps', 'azure-app-service', 'aws-ecs', 'kubernetes', 'alibaba-sae', 'vm-docker']
|
|
11
|
+
/** hosting.mjs adapter ids -> docs host ids. */
|
|
12
|
+
const DOCS_ID = { 'azure-aca': 'azure-container-apps', 'azure-app-service': 'azure-app-service', 'aws-ecs': 'aws-ecs',
|
|
13
|
+
kubernetes: 'kubernetes', 'alibaba-ack': 'kubernetes',
|
|
14
|
+
'alibaba-sae': 'alibaba-sae', 'alibaba-ecs': 'vm-docker', vm: 'vm-docker', docker: 'vm-docker' }
|
|
15
|
+
|
|
16
|
+
const DOCKERFILE = /(?:^|\/)(?:Dockerfile(?:\.[^/]+)?|[^/]+\.[Dd]ockerfile)$/
|
|
17
|
+
const COMPOSE = /(?:^|\/)(?:docker-)?compose(?:\.[^/]+)?\.ya?ml$/
|
|
18
|
+
/** Compose files for local work, by file name (compose.dev.yaml) or folder (local/compose.yaml). They are not deploy targets. */
|
|
19
|
+
const COMPOSE_DEV = /(?:^|[./-])(?:dev|development|local|test|tests|ci|override|sandbox|example|examples)(?:\.ya?ml$|\/)/
|
|
20
|
+
const YAML = /\.ya?ml$/
|
|
21
|
+
const SCRIPT = /\.(?:ya?ml|sh|ps1)$/
|
|
22
|
+
const TF = /\.tf$/
|
|
23
|
+
const API = /^[ \t]*apiVersion[ \t]*:/m
|
|
24
|
+
const WORKFLOW = /^(?:\.\.\/)*\.github\/workflows\/[^/]+\.ya?ml$/
|
|
25
|
+
const DEPLOY_CUE = /deploy(?![._-]?tests?\b)|kubectl|containerapp|webapp|docker push|aliyun|aws ecs|helm upgrade/i
|
|
26
|
+
/** An ECS task definition file: a JSON file with containerDefinitions. */
|
|
27
|
+
const TASK_DEF = /(?:^|\/)[^/]*task[-_.]?def(?:inition)?[^/]*\.json$/i
|
|
28
|
+
|
|
29
|
+
/** [adapter, evidence text, path test or null for any file, regexes that must all match]. The first regex gives the line. */
|
|
30
|
+
const RULES = [
|
|
31
|
+
['docker', 'Dockerfile builds an image', DOCKERFILE, /^[ \t]*FROM[ \t]+\S+/im],
|
|
32
|
+
['docker', 'Docker Compose services', COMPOSE, /^services[ \t]*:/m],
|
|
33
|
+
['kubernetes', 'Kubernetes workload', YAML, /^[ \t]*kind[ \t]*:[ \t]*['"]?(?:Deployment|StatefulSet|DaemonSet|Pod|Job|CronJob)['"]?[ \t]*$/m, API],
|
|
34
|
+
['kubernetes', 'Helm chart', /(?:^|\/)Chart\.ya?ml$/, API, /^[ \t]*name[ \t]*:/m],
|
|
35
|
+
['kubernetes', 'Deploy step runs kubectl or helm', SCRIPT, /\bkubectl[ \t]+(?:apply|set[ \t]+image|rollout)\b|\bhelm[ \t]+(?:upgrade|install)\b/],
|
|
36
|
+
['vm', 'systemd service', /\.service$/, /^[ \t]*ExecStart[ \t]*=/m, /^[ \t]*\[Service\][ \t]*$/m],
|
|
37
|
+
['vm', 'cloud-init config', null, /^#cloud-config\b/m, /^[ \t]*(?:runcmd|bootcmd|write_files)[ \t]*:/m],
|
|
38
|
+
['azure-aca', 'Azure Container App in Terraform', TF, /\bresource\s+"azurerm_container_app"\s+"[^"\r\n]+"/],
|
|
39
|
+
['azure-aca', 'Azure Container App in Bicep or ARM', null, /Microsoft\.App\/containerApps(?:@|["'\s])/],
|
|
40
|
+
['azure-aca', 'Azure Container Apps deploy action', null, /azure\/container-apps-deploy-action@/],
|
|
41
|
+
['azure-aca', 'azure.yaml deploys to Container Apps', /(?:^|\/)azure\.ya?ml$/, /^[ \t]*host[ \t]*:[ \t]*['"]?containerapp\b/m],
|
|
42
|
+
['azure-aca', 'Deploy step runs az containerapp', SCRIPT, /\baz[ \t]+containerapp[ \t]+(?:create|update|up|revision)\b/],
|
|
43
|
+
['azure-app-service', 'Azure App Service in Terraform', TF, /\bresource\s+"azurerm_(?:linux_web_app|windows_web_app|app_service)"\s+"[^"\r\n]+"/],
|
|
44
|
+
['azure-app-service', 'Azure App Service in Bicep or ARM', null, /Microsoft\.Web\/sites(?:@|["'\s])/],
|
|
45
|
+
['azure-app-service', 'Azure App Service deploy action', null, /azure\/webapps-deploy@/],
|
|
46
|
+
['azure-app-service', 'azure.yaml deploys to App Service', /(?:^|\/)azure\.ya?ml$/, /^[ \t]*host[ \t]*:[ \t]*['"]?appservice\b/m],
|
|
47
|
+
['azure-app-service', 'Deploy step runs az webapp', SCRIPT, /\baz[ \t]+webapp[ \t]+(?:create|up|deploy|config[ \t]+(?:container|appsettings))\b/],
|
|
48
|
+
['aws-ecs', 'AWS ECS in Terraform', TF, /\bresource\s+"aws_ecs_(?:service|task_definition)"\s+"[^"\r\n]+"/],
|
|
49
|
+
['aws-ecs', 'AWS ECS in CloudFormation or CDK output', null, /\bAWS::ECS::(?:TaskDefinition|Service)\b/],
|
|
50
|
+
['aws-ecs', 'ECS task definition', TASK_DEF, /"containerDefinitions"\s*:/],
|
|
51
|
+
['aws-ecs', 'ECS deploy action', null, /aws-actions\/amazon-ecs-deploy-task-definition@/],
|
|
52
|
+
['aws-ecs', 'Deploy step runs aws ecs', SCRIPT, /\baws[ \t]+ecs[ \t]+(?:update-service|create-service|register-task-definition|deploy)\b/],
|
|
53
|
+
['aws-ecs', 'AWS Copilot service manifest', /(?:^|\/)copilot\/[^/]+\/manifest\.ya?ml$/, /^[ \t]*type[ \t]*:[ \t]*['"]?(?:Load Balanced Web Service|Backend Service|Worker Service)/m],
|
|
54
|
+
['alibaba-ecs', 'Alibaba ECS instance in Terraform', TF, /\bresource\s+"alicloud_instance"\s+"[^"\r\n]+"/],
|
|
55
|
+
['alibaba-ack', 'Alibaba ACK cluster in Terraform', TF, /\bresource\s+"alicloud_cs_(?:managed_kubernetes|kubernetes)"\s+"[^"\r\n]+"/],
|
|
56
|
+
['alibaba-ack', 'Alibaba Cloud settings in a Kubernetes file', YAML, /(?:alibabacloud\.com|aliyun\.com)\//, API],
|
|
57
|
+
['alibaba-sae', 'Alibaba SAE application in Terraform', TF, /\bresource\s+"alicloud_sae_application"/],
|
|
58
|
+
['alibaba-sae', 'Alibaba SAE address', null, /\bsae\.(?:[a-z0-9-]+\.)?aliyuncs\.com\b/],
|
|
59
|
+
['alibaba-sae', 'Deploy step runs aliyun sae', SCRIPT, /\baliyun[ \t]+sae\b/],
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
const deployFile = (path) => DOCKERFILE.test(path) || /\.(?:ya?ml|tf|bicep|service|sh|ps1)$/.test(path) || TASK_DEF.test(path)
|
|
63
|
+
|| /(?:^|\/)(?:containerapp|azure-container-app|azuredeploy|template|ecs|ack|sae|webapp|appservice)\.json$/.test(path)
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Where the app runs, from deploy files in the repo.
|
|
67
|
+
* @param {Pick<import('./workspace.mjs').Workspace, 'files'|'read'|'stat'>} ws Paths may start with '../' (files above a --path folder).
|
|
68
|
+
* @returns {import('./types.mjs').Host[]}
|
|
69
|
+
*/
|
|
70
|
+
export function detectHosts(ws) {
|
|
71
|
+
/** @type {Map<string, {evidence: import('./types.mjs').Evidence[], buildOnly: boolean}>} */
|
|
72
|
+
const seen = new Map()
|
|
73
|
+
const cue = (adapter, file, line, what) => {
|
|
74
|
+
if (!seen.has(adapter)) seen.set(adapter, { evidence: [], buildOnly: true })
|
|
75
|
+
const entry = seen.get(adapter)
|
|
76
|
+
if (!entry.evidence.some((e) => e.file === file && e.what === what)) entry.evidence.push({ file, line, what })
|
|
77
|
+
if (what !== RULES[0][1]) entry.buildOnly = false
|
|
78
|
+
}
|
|
79
|
+
for (const file of ws.files()) {
|
|
80
|
+
if (!deployFile(file) || /(?:^|\/)pnpm-lock\.ya?ml$/.test(file)) continue
|
|
81
|
+
let body = null
|
|
82
|
+
try { body = (ws.stat(file)?.size ?? 0) <= MAX_BYTES ? ws.read(file) : null } catch { body = null }
|
|
83
|
+
if (!body) continue
|
|
84
|
+
// Comment lines never count, so a commented-out resource is not a host. Line numbers stay the same.
|
|
85
|
+
const code = body.replace(/^[ \t]*(?:#(?!cloud-config\b)|\/\/).*$/gm, '')
|
|
86
|
+
for (const [adapter, what, path, ...need] of RULES) {
|
|
87
|
+
if ((path && !path.test(file)) || (adapter === 'docker' && COMPOSE.test(file) && COMPOSE_DEV.test(file))) continue
|
|
88
|
+
if (need.every((re) => re.test(code))) cue(adapter, file, lineOf(code, need[0]), what)
|
|
89
|
+
}
|
|
90
|
+
if (WORKFLOW.test(file) && /^jobs[ \t]*:/m.test(code) && DEPLOY_CUE.test(code)) {
|
|
91
|
+
cue('github-actions', file, lineOf(code, DEPLOY_CUE), 'GitHub Actions workflow that deploys')
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
// A Dockerfile is a build, not a host of its own: it does not compete with a real deploy target.
|
|
95
|
+
const runtime = [...seen.keys()].filter((id) => id !== 'github-actions')
|
|
96
|
+
if (seen.get('docker')?.buildOnly && runtime.some((id) => id !== 'docker')) seen.delete('docker')
|
|
97
|
+
const hosts = []
|
|
98
|
+
for (const id of RUNTIME) {
|
|
99
|
+
const evidence = [...seen].filter(([adapter]) => DOCS_ID[adapter] === id).flatMap(([, entry]) => entry.evidence)
|
|
100
|
+
if (evidence.length) hosts.push({ id, evidence: evidence.sort(byStrength) })
|
|
101
|
+
}
|
|
102
|
+
if (!hosts.length) hosts.push({ id: 'vm-docker', evidence: [] })
|
|
103
|
+
if (seen.has('github-actions')) hosts.push({ id: 'github-actions', evidence: seen.get('github-actions').evidence })
|
|
104
|
+
return hosts
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The 'set your key once' steps for the detected hosts, from contract HOSTS.
|
|
109
|
+
* envFile: true when a service got otel.env, so the steps also say how to load it.
|
|
110
|
+
* Fields are read defensively: the docs file keeps changing.
|
|
111
|
+
* @param {import('./types.mjs').Host[]} hosts
|
|
112
|
+
* @param {{envFile?: boolean}} [options]
|
|
113
|
+
* @returns {import('./types.mjs').Block[]}
|
|
114
|
+
*/
|
|
115
|
+
export function keySteps(hosts, options = {}) {
|
|
116
|
+
const ids = [...new Set((Array.isArray(hosts) ? hosts : []).map((h) => h?.id))]
|
|
117
|
+
const blocks = []
|
|
118
|
+
for (const id of ids) {
|
|
119
|
+
const host = (Array.isArray(HOSTS) ? HOSTS : []).find((h) => h?.id === id)
|
|
120
|
+
if (!host) continue
|
|
121
|
+
const lines = [...strings(host.steps), ...strings(host.notes)]
|
|
122
|
+
if (typeof host.stricter?.title === 'string') lines.push(`${host.stricter.title}. See ${DOCS_URL}.`)
|
|
123
|
+
blocks.push({ title: `Set your key once: ${host.label ?? id}`, lines, snippets: snippets(host.snippets) })
|
|
124
|
+
}
|
|
125
|
+
if (options.envFile) {
|
|
126
|
+
const loaders = (Array.isArray(SETTINGS_FILE_LOADERS) ? SETTINGS_FILE_LOADERS : [])
|
|
127
|
+
.filter((l) => l && (l.id !== 'alibaba-sae' || ids.includes('alibaba-sae')))
|
|
128
|
+
blocks.push({ title: `Load ${SETTINGS_FILE?.path ?? 'otel.env'} when your app starts`,
|
|
129
|
+
lines: loaders.filter((l) => l.summary).map((l) => `${l.label ?? l.id}: ${l.summary}`),
|
|
130
|
+
snippets: snippets(loaders.map((l) => l.snippet)) })
|
|
131
|
+
}
|
|
132
|
+
return blocks
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Deploy files before deploy steps (RULES order), then by path and line. */
|
|
136
|
+
const rank = (e) => RULES.findIndex((rule) => rule[1] === e.what)
|
|
137
|
+
const byStrength = (a, b) => rank(a) - rank(b) || (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line)
|
|
138
|
+
const strings = (list) => (Array.isArray(list) ? list.filter((s) => typeof s === 'string') : [])
|
|
139
|
+
const snippets = (list) => (Array.isArray(list) ? list : [])
|
|
140
|
+
.filter((s) => s && typeof s.code === 'string')
|
|
141
|
+
.map((s) => ({ caption: String(s.caption ?? ''), language: String(s.language ?? 'text'), code: s.code }))
|
|
142
|
+
const lineOf = (body, re) => {
|
|
143
|
+
const m = new RegExp(re.source, re.flags.replace('g', '')).exec(body)
|
|
144
|
+
return m ? body.slice(0, m.index).split('\n').length : 1
|
|
145
|
+
}
|
package/lib/manifest.mjs
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* .uknows/setup.json: what setup changed, so remove can undo it exactly.
|
|
3
|
+
* It keeps hashes and the changed lines only, never whole files, so it
|
|
4
|
+
* cannot copy a secret that is already in the repo.
|
|
5
|
+
*/
|
|
6
|
+
import { createHash } from 'node:crypto'
|
|
7
|
+
import { existsSync, readFileSync } from 'node:fs'
|
|
8
|
+
import { join } from 'node:path'
|
|
9
|
+
import { CLI, PINS } from './contract.mjs'
|
|
10
|
+
import { hunks, reverseAt, reverseByContent, splitLines } from './diff.mjs'
|
|
11
|
+
import { checkPath } from './workspace.mjs'
|
|
12
|
+
|
|
13
|
+
export const TOOL = '@uknows/setup'
|
|
14
|
+
export const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
15
|
+
export const BOM = '\uFEFF'
|
|
16
|
+
const NOTE = 'Written by npx @uknows/setup. To undo these changes, run npx @uknows/setup remove. This file has no secrets.'
|
|
17
|
+
const STACKS = ['node', 'python', 'dotnet', 'java', 'go', 'php', 'ruby', 'otel']
|
|
18
|
+
const UNDO = { npm: 'uninstall', pnpm: 'remove', yarn: 'remove' }
|
|
19
|
+
/** The only flags an undo command may have: the ones setup itself writes. Everything else must be a package setup adds. */
|
|
20
|
+
const UNDO_FLAGS = new Set(['-w', '--no-package-lock', '--package-lock-only', '--lockfile-only', '--ignore-scripts', '--mode=skip-build'])
|
|
21
|
+
const PACKAGES = new Set([...PINS.node.packages, PINS.node.esmPackage])
|
|
22
|
+
const SHA = /^[0-9a-f]{64}$/
|
|
23
|
+
|
|
24
|
+
/** @param {Buffer|null} bytes */
|
|
25
|
+
export const sha256 = (bytes) => (bytes === null ? null : createHash('sha256').update(bytes).digest('hex'))
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The manifest of the repo at root, or null. Throws when it is not valid.
|
|
29
|
+
* The file comes from the repo, so it is checked before remove trusts it:
|
|
30
|
+
* paths stay inside the repo and undo commands only remove packages.
|
|
31
|
+
*/
|
|
32
|
+
export function readManifest(root) {
|
|
33
|
+
const file = join(root, ...CLI.manifest.split('/'))
|
|
34
|
+
if (!existsSync(file)) return null
|
|
35
|
+
let data = null
|
|
36
|
+
try { data = JSON.parse(readFileSync(file, 'utf8')) } catch { /* reported below */ }
|
|
37
|
+
if (!valid(data)) throw new Error(`${CLI.manifest} is not valid. Fix it or delete it.`)
|
|
38
|
+
return data
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function valid(m) {
|
|
42
|
+
try {
|
|
43
|
+
if (m?.schema !== 1 || ![m.services, m.changes, m.packages].every(Array.isArray)) return false
|
|
44
|
+
for (const s of m.services) { checkPath(s.dir); if (!STACKS.includes(s.stack)) return false }
|
|
45
|
+
for (const c of m.changes) {
|
|
46
|
+
checkPath(c.file)
|
|
47
|
+
if (!c.hunks.every((h) => Number.isInteger(h.at) && [...h.before, ...h.after].every((l) => typeof l === 'string'))) return false
|
|
48
|
+
}
|
|
49
|
+
for (const p of m.packages) {
|
|
50
|
+
checkPath(p.cwd)
|
|
51
|
+
p.touches.forEach(checkPath)
|
|
52
|
+
// Only the exact packages that setup added, never other flags (-g, --prefix) or other packages.
|
|
53
|
+
const [cmd, verb, ...rest] = p.undo
|
|
54
|
+
const names = rest.filter((arg) => !UNDO_FLAGS.has(arg))
|
|
55
|
+
if (UNDO[cmd] !== verb || p.cmd !== cmd || !Array.isArray(p.added) || !names.length || new Set(names).size !== names.length
|
|
56
|
+
|| !names.every((name) => PACKAGES.has(name)) || names.length !== p.added.length || !p.added.every((name) => names.includes(name))) return false
|
|
57
|
+
// before: the sha256 of each file the package manager touched, as it was before that run. Optional.
|
|
58
|
+
for (const [file, sha] of Object.entries(p.before ?? {})) { checkPath(file); if (sha !== null && !SHA.test(sha)) return false }
|
|
59
|
+
}
|
|
60
|
+
return true
|
|
61
|
+
} catch {
|
|
62
|
+
return false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const manifestText = (manifest) => `${JSON.stringify(manifest, null, 2)}\n`
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* The record of 1 text edit. Hashes cover the bytes on disk, BOM included.
|
|
70
|
+
* Hunks are on the text without BOM.
|
|
71
|
+
*/
|
|
72
|
+
export function changeEntry({ file, before, after, bom }, beforeBytes, afterBytes) {
|
|
73
|
+
return { file, created: before === null, bom, beforeSha256: sha256(beforeBytes), afterSha256: sha256(afterBytes),
|
|
74
|
+
hunks: before === null ? [] : hunks(before, after) }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Add 1 run to a manifest (or start one). Services are merged by dir. */
|
|
78
|
+
export function mergeManifest(old, { services, changes, packages }) {
|
|
79
|
+
const kept = (old?.services ?? []).filter((s) => !services.some((n) => n.dir === s.dir))
|
|
80
|
+
return { schema: 1, tool: TOOL, version: VERSION, note: NOTE,
|
|
81
|
+
services: [...kept, ...services].sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0)),
|
|
82
|
+
changes: [...(old?.changes ?? []), ...changes], packages: [...(old?.packages ?? []), ...packages] }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Paths the dirty-tree check ignores: the manifest and every path it lists. */
|
|
86
|
+
export function manifestPaths(manifest) {
|
|
87
|
+
const paths = new Set([CLI.manifest])
|
|
88
|
+
for (const c of manifest?.changes ?? []) paths.add(c.file)
|
|
89
|
+
for (const p of manifest?.packages ?? []) for (const t of p.touches ?? []) paths.add(t)
|
|
90
|
+
return paths
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* How to undo 1 change, given the bytes on disk now (null = missing).
|
|
95
|
+
* Returns {bytes} to write, {bytes: null} to delete, or {reason} to skip.
|
|
96
|
+
*/
|
|
97
|
+
export function undoChange(entry, current) {
|
|
98
|
+
if (current === null) return { reason: 'The file is gone.' }
|
|
99
|
+
const sha = sha256(current)
|
|
100
|
+
if (entry.created) return sha === entry.afterSha256 ? { bytes: null } : { reason: 'The file changed after setup.' }
|
|
101
|
+
let text = current.toString('utf8')
|
|
102
|
+
const bom = text.startsWith(BOM)
|
|
103
|
+
if (bom) text = text.slice(1)
|
|
104
|
+
const lines = splitLines(text)
|
|
105
|
+
const pack = (out) => Buffer.from((bom ? BOM : '') + out.join(''), 'utf8')
|
|
106
|
+
if (sha === entry.afterSha256) {
|
|
107
|
+
const out = reverseAt(lines, entry.hunks)
|
|
108
|
+
if (out && sha256(pack(out)) === entry.beforeSha256) return { bytes: pack(out) }
|
|
109
|
+
}
|
|
110
|
+
const out = reverseByContent(lines, entry.hunks)
|
|
111
|
+
return out ? { bytes: pack(out) } : { reason: 'The lines that setup added changed, so they cannot be found exactly once.' }
|
|
112
|
+
}
|