@uknows/setup 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +99 -32
- package/lib/automate.mjs +1299 -0
- package/lib/check.mjs +149 -33
- package/lib/ci.mjs +21 -0
- package/lib/cli.mjs +114 -29
- package/lib/cloudid.mjs +456 -0
- package/lib/code.mjs +147 -0
- package/lib/context.mjs +126 -0
- package/lib/contract-docs.mjs +1780 -198
- package/lib/contract.mjs +93 -10
- package/lib/detect.mjs +96 -44
- package/lib/dockerfile.mjs +83 -0
- package/lib/endpoint.mjs +36 -5
- package/lib/engine.mjs +1272 -126
- package/lib/existing.mjs +448 -54
- package/lib/git.mjs +53 -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 +521 -16
- package/lib/locks.mjs +353 -0
- package/lib/manifest.mjs +27 -7
- package/lib/members.mjs +269 -0
- package/lib/plan.mjs +130 -0
- package/lib/settings.mjs +216 -28
- 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 +336 -29
- package/lib/stacks/functions-hooks.mjs +228 -0
- package/lib/stacks/functions.mjs +148 -0
- package/lib/stacks/guided.mjs +153 -30
- package/lib/stacks/index.mjs +10 -2
- package/lib/stacks/java.mjs +14 -13
- package/lib/stacks/node.mjs +142 -19
- package/lib/stacks/otel.mjs +56 -4
- package/lib/stacks/python-bootstrap.mjs +27 -7
- package/lib/stacks/python-lock.mjs +105 -0
- package/lib/stacks/python-sets.mjs +143 -0
- package/lib/stacks/python.mjs +261 -63
- package/lib/toml.mjs +64 -0
- package/lib/types.mjs +52 -5
- package/lib/ui.mjs +139 -20
- package/lib/updates.mjs +398 -0
- package/lib/versions.mjs +115 -0
- package/lib/workspace.mjs +4 -1
- package/package.json +3 -8
package/lib/check.mjs
CHANGED
|
@@ -12,7 +12,7 @@ import { randomBytes } from 'node:crypto'
|
|
|
12
12
|
import { readFileSync } from 'node:fs'
|
|
13
13
|
import http from 'node:http'
|
|
14
14
|
import https from 'node:https'
|
|
15
|
-
import {
|
|
15
|
+
import { CHECK_NETWORK, CLI, KEY_CHECK, SECRET, SETUP_NEXT, TROUBLESHOOTING, keyCheckLine } from './contract.mjs'
|
|
16
16
|
import { ingestEndpoint, sameUknows, siteUrl } from './endpoint.mjs'
|
|
17
17
|
|
|
18
18
|
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
@@ -22,18 +22,66 @@ const ACCEPTED = 'UKNOWS accepted 1 test span.'
|
|
|
22
22
|
/** What each refusal of the ping route means, from the docs list ("ingest_key_invalid" and so on). */
|
|
23
23
|
const MEANING = new Map(KEY_CHECK.answers.map((a) => [/^[a-z_]+$/.test(a.answer) ? a.answer : null, a.meaning]).filter(([code]) => code))
|
|
24
24
|
|
|
25
|
+
/** The key variable of apps that keep their own metrics and logs. check reads it when OTEL_EXPORTER_OTLP_HEADERS is not set. */
|
|
26
|
+
const TRACES_HEADERS = 'OTEL_EXPORTER_OTLP_TRACES_HEADERS'
|
|
27
|
+
|
|
28
|
+
/** A timeout gets 1 more try, for this many times as long: UKNOWS can take about 16 seconds to answer after a quiet time. */
|
|
29
|
+
const RETRY_FACTOR = 3
|
|
30
|
+
|
|
25
31
|
/**
|
|
26
|
-
*
|
|
27
|
-
*
|
|
32
|
+
* A new key: one that UKNOWS made a moment ago. That is the key of the sign-in of this run, or a key that the site says
|
|
33
|
+
* is under 5 minutes old (createdAt in its answer). The site knows a new key at once. The data intake (the ingest
|
|
34
|
+
* address) learns new keys by itself, about every 30 seconds (the key sync of its gateway; measured 26 Oct 2026: about
|
|
35
|
+
* 30 seconds). So for a new key, a 401 means "not ready yet": the check asks again, every 5 seconds for the first 30
|
|
36
|
+
* seconds and then every 10 seconds, for up to 2 minutes, with 1 line that says so. A pasted or older key gets 1 try.
|
|
37
|
+
*/
|
|
38
|
+
export const NEW_KEY = {
|
|
39
|
+
waitMs: 120000, fastMs: 30000, fastEveryMs: 5000, everyMs: 10000, youngMs: 300000,
|
|
40
|
+
/** The line while the check waits. On a terminal, the time since the wait began follows it, for example 0:35. */
|
|
41
|
+
waiting: 'UKNOWS is getting your new key ready on the data intake...',
|
|
42
|
+
/** The same in a pipe or a log, printed once. */
|
|
43
|
+
waitingPlain: 'UKNOWS is getting your new key ready on the data intake. This can take up to 2 minutes.',
|
|
44
|
+
ready: (took) => `Your new key is ready on the data intake. It took ${took}.`,
|
|
45
|
+
/** After 2 minutes. where: " for Your Company, environment Production" when the site named them, else "". */
|
|
46
|
+
late: (where, siteKnows) => (siteKnows
|
|
47
|
+
? `UKNOWS made your new key${where}, but its data intake did not take the key within 2 minutes. This is on the UKNOWS side, not in your app.`
|
|
48
|
+
: 'UKNOWS did not get your new key ready within 2 minutes. This is on the UKNOWS side, not in your app.'),
|
|
49
|
+
/** The last line after that: of a run with 1 Yes, of the key command, and of a run that changed the files already. */
|
|
50
|
+
again: 'Run npx @uknows/setup again in a few minutes. Nothing changed.',
|
|
51
|
+
againKey: 'Run npx @uknows/setup key again in a few minutes. Nothing changed.',
|
|
52
|
+
againFiles: 'Next: in a few minutes, run npx @uknows/setup key. It signs you in again, checks the new key and stores it.',
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Time as the person reads it: 35 seconds, 1 minute 5 seconds, 2 minutes. */
|
|
56
|
+
export function duration(ms) {
|
|
57
|
+
const s = Math.max(0, Math.round(ms / 1000)), m = Math.floor(s / 60), rest = s % 60
|
|
58
|
+
const part = (n, word) => `${n} ${word}${n === 1 ? '' : 's'}`
|
|
59
|
+
return m ? `${part(m, 'minute')}${rest ? ` ${part(rest, 'second')}` : ''}` : part(rest, 'second')
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The clock of a real check. Tests pass one whose time moves only when something sleeps. */
|
|
63
|
+
const SYSTEM_CLOCK = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, Math.max(0, ms))) }
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Check the key in OTEL_EXPORTER_OTLP_HEADERS (or OTEL_EXPORTER_OTLP_TRACES_HEADERS) from env, or options.headers,
|
|
67
|
+
* then send 1 test span. serviceName: the name of the service that setup found, for the test span. A request with no
|
|
68
|
+
* answer in timeoutMs is tried once more, for up to 3 times as long. onRetry(line) gets 1 line that says so, before it.
|
|
69
|
+
* newKey: the key came from the sign-in of this run, so a 401 waits for it (NEW_KEY), with clock (now and sleep).
|
|
70
|
+
* onWait() is called once, when that wait begins. It returns end(line): line is NEW_KEY.ready when the key works after
|
|
71
|
+
* the wait, else null. A new key that still gets 401 after 2 minutes gives notReady: true and the NEW_KEY.late line.
|
|
72
|
+
* @param {{env?: Record<string, string|undefined>, headers?: string, timeoutMs?: number, serviceName?: string|null,
|
|
73
|
+
* onRetry?: (line: string) => void, newKey?: boolean, clock?: {now: () => number, sleep: (ms: number) => Promise<void>},
|
|
74
|
+
* onWait?: () => ((line: string|null) => void)}} [options]
|
|
28
75
|
* @returns {Promise<import('./types.mjs').CheckResult>}
|
|
29
76
|
*/
|
|
30
77
|
export async function runCheck(options = {}) {
|
|
31
|
-
const { env = process.env, timeoutMs = 10000 } = options
|
|
32
|
-
const
|
|
78
|
+
const { env = process.env, timeoutMs = 10000, onRetry = () => {}, clock = SYSTEM_CLOCK, onWait = () => () => {} } = options
|
|
79
|
+
const ask = (url, method, headers, body) => requestWithRetry(url, method, headers, body, timeoutMs, onRetry)
|
|
80
|
+
const raw = options.headers ?? (env[SECRET.name]?.trim() ? env[SECRET.name] : env[TRACES_HEADERS])
|
|
33
81
|
if (!raw || !raw.trim()) {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
82
|
+
// 1 line, safe to paste at once: read takes the key from the next line, and nothing stays set in the terminal.
|
|
83
|
+
const vars = [CLI.siteEnv, CLI.endpointEnv].filter((name) => env[name]).map((name) => `${name}=${env[name]}`)
|
|
84
|
+
return result(2, null, [`${SECRET.name} is not set in this terminal.`, SETUP_NEXT.skipped, ` ${keyCheckLine(vars)}`, CHECK_NETWORK.where])
|
|
37
85
|
}
|
|
38
86
|
const headers = parseHeaders(raw)
|
|
39
87
|
// Hide each header value as written and as decoded. A part without '=' may be a bare key: hide it whole.
|
|
@@ -47,22 +95,51 @@ export async function runCheck(options = {}) {
|
|
|
47
95
|
const site = siteUrl(env)
|
|
48
96
|
if (!site) return result(1, null, [`${CLI.siteEnv} must be an https address with no path, for example https://uknows.example.com.`])
|
|
49
97
|
|
|
50
|
-
// 1. Which workspace and environment the key sends to.
|
|
51
98
|
let ping = null
|
|
52
99
|
const lines = [], after = []
|
|
100
|
+
// A new key waits while UKNOWS gets it ready (NEW_KEY): 1 wait for both steps, with 1 line.
|
|
101
|
+
const wait = waiter(clock, onWait)
|
|
102
|
+
let fresh = Boolean(options.newKey)
|
|
103
|
+
const late = (siteKnows) => {
|
|
104
|
+
wait.end(null)
|
|
105
|
+
const where = ping?.workspace ? ` for ${ping.workspace}${ping.environment ? `, environment ${ping.environment}` : ''}` : ''
|
|
106
|
+
return { ...result(1, 401, [NEW_KEY.late(where, siteKnows)]), ...named(ping), notReady: true }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// 1. Which workspace and environment the key sends to.
|
|
53
110
|
if (sameUknows(env)) {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
|
|
111
|
+
const pingUrl = new URL(`${site}${CLI.pingPath}`)
|
|
112
|
+
const askPing = () => ask(pingUrl, 'GET', { [SECRET.header]: headers[keyName] }, null)
|
|
113
|
+
let answer = await askPing()
|
|
114
|
+
// A key the site does not know yet. Revoked, paused and the other answers are no wait.
|
|
115
|
+
const unknown = (a) => a.status === 401 && parse(a.body)?.error === 'ingest_key_invalid'
|
|
116
|
+
while (fresh && unknown(answer) && await wait.more()) answer = await askPing()
|
|
117
|
+
if (fresh && unknown(answer)) return late(false)
|
|
118
|
+
ping = explainPing(answer, site, redact, clock.now())
|
|
119
|
+
if (ping.stop) {
|
|
120
|
+
wait.end(null)
|
|
121
|
+
return { ...result(1, answer.status ?? null, ping.lines), keyState: ping.keyState ?? null }
|
|
122
|
+
}
|
|
57
123
|
lines.push(...ping.lines)
|
|
124
|
+
fresh ||= typeof ping.ageMs === 'number' && ping.ageMs < NEW_KEY.youngMs
|
|
58
125
|
} else {
|
|
59
126
|
after.push(`To also see which workspace the key sends to, set ${CLI.siteEnv} with ${CLI.endpointEnv}.`)
|
|
60
127
|
}
|
|
61
128
|
|
|
62
|
-
// 2. 1 test span to the ingest address.
|
|
129
|
+
// 2. 1 test span to the ingest address. A new key that the data intake does not know yet gets 401 there.
|
|
63
130
|
const url = new URL(`${endpoint}/v1/traces`)
|
|
64
|
-
const
|
|
65
|
-
|
|
131
|
+
const send = () => ask(url, 'POST', { ...headers, 'content-type': 'application/json' }, testSpan(env, options.serviceName))
|
|
132
|
+
let response = await send()
|
|
133
|
+
while (fresh && response.status === 401 && await wait.more()) response = await send()
|
|
134
|
+
if (fresh && response.status === 401) return late(true)
|
|
135
|
+
wait.end(wait.started && response.status >= 200 && response.status < 300 ? NEW_KEY.ready(duration(wait.elapsed())) : null)
|
|
136
|
+
if (response.error) {
|
|
137
|
+
// The site said the key works, but the upload to the ingest address failed: say both plainly, with the next step.
|
|
138
|
+
// After a second timeout, the advice names the longer wait.
|
|
139
|
+
const keyFine = Boolean(ping?.workspace) && !CERT.test(response.error?.code ?? '')
|
|
140
|
+
const waited = timedOut(response.error) ? timeoutMs * RETRY_FACTOR : timeoutMs
|
|
141
|
+
return { ...result(1, null, [...lines, ...(keyFine ? [CHECK_NETWORK.keyFine] : []), ...explainNetworkError(response.error, url, waited, env).map(redact), ...after]), ...named(ping) }
|
|
142
|
+
}
|
|
66
143
|
const sent = explainResponse(response, redact)
|
|
67
144
|
// Without the site's answer, the accepted span is what shows that the key works.
|
|
68
145
|
const spanLines = sent.lines.map((line) => (line === ACCEPTED && !ping?.workspace ? `${ACCEPTED} Your key works.` : line))
|
|
@@ -74,8 +151,11 @@ function named(ping, sent = {}) {
|
|
|
74
151
|
return { workspace: sent.workspace ?? ping?.workspace ?? null, environment: sent.environment ?? ping?.environment ?? null, keyState: ping?.keyState ?? null }
|
|
75
152
|
}
|
|
76
153
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
154
|
+
/**
|
|
155
|
+
* What the ping route said. stop: true when the key is refused, so no span is sent. ageMs: how old the key is, when
|
|
156
|
+
* the answer says when it was made (createdAt), measured at now. null when it does not say.
|
|
157
|
+
*/
|
|
158
|
+
export function explainPing({ status, error, body }, site, redact = (x) => x, now = Date.now()) {
|
|
79
159
|
const host = new URL(site).hostname
|
|
80
160
|
if (error) return { stop: false, lines: [`Could not ask ${host} which workspace the key sends to (${error.code || 'network error'}). Sending the test span anyway.`] }
|
|
81
161
|
const data = parse(body)
|
|
@@ -85,7 +165,8 @@ export function explainPing({ status, error, body }, site, redact = (x) => x) {
|
|
|
85
165
|
const lines = [`Your key works. It sends data to ${workspace}${environment ? `, environment ${environment}` : ''}.`]
|
|
86
166
|
if (data.keyState === 'retiring') lines.push(`A new key replaced this key. It stops working${data.retiresAt ? ` at ${clean(String(data.retiresAt), redact)}` : ' soon'}. Set the new key.`)
|
|
87
167
|
if (data.status === 'paused') lines.push('Your workspace is paused.')
|
|
88
|
-
|
|
168
|
+
const made = typeof data.createdAt === 'string' ? Date.parse(data.createdAt) : NaN
|
|
169
|
+
return { stop: false, workspace, environment, keyState: clean(data.keyState, redact), ageMs: Number.isFinite(made) ? Math.max(0, now - made) : null, lines }
|
|
89
170
|
}
|
|
90
171
|
const code = typeof data?.error === 'string' ? data.error : null
|
|
91
172
|
if ((status === 401 || status === 403) && code) {
|
|
@@ -108,30 +189,45 @@ export function parseHeaders(value) {
|
|
|
108
189
|
return out
|
|
109
190
|
}
|
|
110
191
|
|
|
111
|
-
/**
|
|
192
|
+
/**
|
|
193
|
+
* What to print for a network error: what failed, with the host and port, and what to do. The same words as the
|
|
194
|
+
* answers in "Check your key" (CHECK_NETWORK). Exported for tests: DNS and TLS failures are hard to cause offline.
|
|
195
|
+
*/
|
|
112
196
|
export function explainNetworkError(error, url, timeoutMs, env = {}) {
|
|
113
197
|
const code = error?.code ?? ''
|
|
114
198
|
const port = url.port || (url.protocol === 'https:' ? '443' : '80')
|
|
115
199
|
const host = url.hostname
|
|
116
200
|
const proxy = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'].some((k) => env[k])
|
|
117
201
|
if (CERT.test(code)) return [`Cannot check the TLS certificate of ${host} (${code}).`, 'A proxy may be in the way. This tool does not use HTTPS_PROXY.']
|
|
202
|
+
if (/^ERR_INVALID_(?:CHAR|HTTP_TOKEN)/.test(code)) return [`${SECRET.name} has a character that is not allowed in a header.`]
|
|
118
203
|
const seconds = Math.round(timeoutMs / 100) / 10
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
: code === '
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
if (proxy) lines.push('This tool does not use HTTPS_PROXY. Run the check on a network that can reach UKNOWS directly.')
|
|
204
|
+
const values = { host, port, seconds: seconds === 1 ? '1' : String(seconds), code: code || 'unknown error' }
|
|
205
|
+
const fill = (t) => t.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
206
|
+
const dns = code === 'ENOTFOUND' || code === 'EAI_AGAIN'
|
|
207
|
+
const what = dns ? CHECK_NETWORK.dns : code === 'ECONNREFUSED' ? CHECK_NETWORK.refused
|
|
208
|
+
: code === 'UKNOWS_TIMEOUT' || code === 'ETIMEDOUT' ? CHECK_NETWORK.timeout.replace('{seconds} seconds', seconds === 1 ? '1 second' : '{seconds} seconds')
|
|
209
|
+
: code === 'ECONNRESET' || code === 'EPIPE' ? CHECK_NETWORK.reset
|
|
210
|
+
: code === 'EHOSTUNREACH' || code === 'ENETUNREACH' ? CHECK_NETWORK.unreachable : CHECK_NETWORK.other
|
|
211
|
+
const lines = [fill(what), ...(dns ? [] : [fill(CHECK_NETWORK.ask)]), CHECK_NETWORK.where]
|
|
212
|
+
if (proxy) lines.push(CHECK_NETWORK.proxy)
|
|
129
213
|
return lines
|
|
130
214
|
}
|
|
131
215
|
|
|
132
216
|
// --- request and response ---------------------------------------------------------
|
|
133
217
|
|
|
134
|
-
|
|
218
|
+
const timedOut = (error) => error?.code === 'UKNOWS_TIMEOUT' || error?.code === 'ETIMEDOUT'
|
|
219
|
+
const secondsOf = (ms) => { const s = Math.round(ms / 100) / 10; return s === 1 ? '1 second' : `${s} seconds` }
|
|
220
|
+
|
|
221
|
+
/** 1 request. With no answer in timeoutMs, 1 more try for up to RETRY_FACTOR times as long, after 1 line to onRetry. */
|
|
222
|
+
async function requestWithRetry(url, method, headers, body, timeoutMs, onRetry) {
|
|
223
|
+
const first = await request(url, method, headers, body, timeoutMs)
|
|
224
|
+
if (!timedOut(first.error)) return first
|
|
225
|
+
onRetry(`No answer from ${url.hostname} in ${secondsOf(timeoutMs)}. Trying again, for up to ${secondsOf(timeoutMs * RETRY_FACTOR)}.`)
|
|
226
|
+
return request(url, method, headers, body, timeoutMs * RETRY_FACTOR)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 1 HTTP request with node:http or node:https. Resolves {status, headers, body} or {error}. Never rejects. */
|
|
230
|
+
export function request(url, method, headers, body, timeoutMs) {
|
|
135
231
|
return new Promise((resolve) => {
|
|
136
232
|
let done = false, timer = null, req
|
|
137
233
|
const finish = (value) => { if (!done) { done = true; clearTimeout(timer); resolve(value) } }
|
|
@@ -165,7 +261,7 @@ function explainResponse({ status, headers, body }, redact) {
|
|
|
165
261
|
}
|
|
166
262
|
const steps = (id) => TROUBLESHOOTING.find((t) => t.id === id)?.steps ?? []
|
|
167
263
|
if (status === 401) return result(1, status, [`UKNOWS did not accept the key (status 401).`, ...steps('unauthorized')])
|
|
168
|
-
if (status === 403) return result(1, status, [`UKNOWS refused the
|
|
264
|
+
if (status === 403) return result(1, status, [`UKNOWS refused the data (status 403). ${TROUBLESHOOTING.find((t) => t.id === 'forbidden')?.cause ?? ''}`.trim(), ...steps('forbidden')])
|
|
169
265
|
const textual = !data && /json|text/i.test(String(headers['content-type'] ?? ''))
|
|
170
266
|
const message = clean(typeof data?.message === 'string' ? data.message : textual ? body : null, redact)
|
|
171
267
|
const lines = [`UKNOWS answered with status ${status}.`]
|
|
@@ -175,11 +271,11 @@ function explainResponse({ status, headers, body }, redact) {
|
|
|
175
271
|
}
|
|
176
272
|
|
|
177
273
|
/** 1 span in OTLP/JSON: hex ids, integer enums, int64 values as decimal strings. */
|
|
178
|
-
function testSpan(env) {
|
|
274
|
+
function testSpan(env, serviceName = null) {
|
|
179
275
|
const hex = (bytes) => { let id; do id = randomBytes(bytes).toString('hex'); while (/^0+$/.test(id)); return id }
|
|
180
276
|
const start = BigInt(Date.now()) * 1000000n
|
|
181
277
|
return JSON.stringify({ resourceSpans: [{
|
|
182
|
-
resource: { attributes: [{ key: 'service.name', value: { stringValue: env.OTEL_SERVICE_NAME || 'uknows-setup-check' } }] },
|
|
278
|
+
resource: { attributes: [{ key: 'service.name', value: { stringValue: env.OTEL_SERVICE_NAME || serviceName || 'uknows-setup-check' } }] },
|
|
183
279
|
scopeSpans: [{ scope: { name: '@uknows/setup', version: VERSION }, spans: [{
|
|
184
280
|
traceId: hex(16), spanId: hex(8), name: 'uknows setup check', kind: 1,
|
|
185
281
|
startTimeUnixNano: String(start), endTimeUnixNano: String(start + 1000000n),
|
|
@@ -190,6 +286,26 @@ function testSpan(env) {
|
|
|
190
286
|
|
|
191
287
|
// --- helpers ------------------------------------------------------------------------
|
|
192
288
|
|
|
289
|
+
/**
|
|
290
|
+
* The wait for a new key (NEW_KEY). more() starts it at the first call (onWait() shows its line), sleeps until the next
|
|
291
|
+
* try, and is false once 2 minutes have passed since the start. end(line) ends the line once, with line or nothing.
|
|
292
|
+
*/
|
|
293
|
+
function waiter(clock, onWait) {
|
|
294
|
+
let started = null, finish = null
|
|
295
|
+
return {
|
|
296
|
+
get started() { return started !== null },
|
|
297
|
+
elapsed: () => (started === null ? 0 : clock.now() - started),
|
|
298
|
+
async more() {
|
|
299
|
+
if (started === null) { started = clock.now(); finish = onWait() ?? (() => {}) }
|
|
300
|
+
const waited = clock.now() - started
|
|
301
|
+
if (waited >= NEW_KEY.waitMs) return false
|
|
302
|
+
await clock.sleep(Math.min(waited < NEW_KEY.fastMs ? NEW_KEY.fastEveryMs : NEW_KEY.everyMs, NEW_KEY.waitMs - waited))
|
|
303
|
+
return true
|
|
304
|
+
},
|
|
305
|
+
end(line) { if (finish) { const done = finish; finish = null; done(line) } },
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
193
309
|
function result(exit, status, lines) {
|
|
194
310
|
return { ok: false, status, workspace: null, environment: null, lines, exit }
|
|
195
311
|
}
|
package/lib/ci.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Does this run in a pipeline? Setup and remove change a repo once, on a
|
|
3
|
+
* computer, so in a pipeline they stop with exit 2 unless --allow-ci is set.
|
|
4
|
+
* The variables come from contract.mjs CLI.ciEnv.
|
|
5
|
+
*/
|
|
6
|
+
import { CLI } from './contract.mjs'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The name of the first CI variable that is set, or null. CI counts when it is not empty, 0 or false.
|
|
10
|
+
* @param {Record<string, string|undefined>} [env]
|
|
11
|
+
* @returns {string|null}
|
|
12
|
+
*/
|
|
13
|
+
export function ciVariable(env = {}) {
|
|
14
|
+
for (const name of CLI.ciEnv) {
|
|
15
|
+
const value = String(env[name] ?? '').trim()
|
|
16
|
+
if (!value) continue
|
|
17
|
+
if (name === 'CI' && /^(?:0|false)$/i.test(value)) continue
|
|
18
|
+
return name
|
|
19
|
+
}
|
|
20
|
+
return null
|
|
21
|
+
}
|
package/lib/cli.mjs
CHANGED
|
@@ -5,59 +5,117 @@
|
|
|
5
5
|
import { statSync } from 'node:fs'
|
|
6
6
|
import { resolve } from 'node:path'
|
|
7
7
|
import { runCheck } from './check.mjs'
|
|
8
|
-
import { CLI,
|
|
8
|
+
import { CI_CD, CLI, DEFAULT_AGENT_REGISTRY, DOCS_URL, SECRET, SETUP_NEEDS } from './contract.mjs'
|
|
9
9
|
import { toServiceName } from './detect.mjs'
|
|
10
|
-
import { ingestEndpoint, siteUrl } from './endpoint.mjs'
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
10
|
+
import { ingestEndpoint, siteUrl, validAppUrl } from './endpoint.mjs'
|
|
11
|
+
import { CHANNELS, validRegistry } from './stacks/channel.mjs'
|
|
12
|
+
import { UPDATE_BOTS } from './updates.mjs'
|
|
13
|
+
import { runKey, runRemove, runSetup } from './engine.mjs'
|
|
14
|
+
import { TOOL, VERSION, readManifest } from './manifest.mjs'
|
|
13
15
|
import { UsageError, createUI } from './ui.mjs'
|
|
14
16
|
|
|
15
|
-
const FLAGS = { '--dry-run': 'dryRun', '--yes': 'yes', '-y': 'yes', '--all': 'all', '--json': 'json', '--force': 'force',
|
|
16
|
-
'--check': 'check', '--help': 'help', '-h': 'help', '--version': 'version'
|
|
17
|
-
|
|
18
|
-
const
|
|
17
|
+
const FLAGS = { '--dry-run': 'dryRun', '--yes': 'yes', '-y': 'yes', '--all': 'all', '--json': 'json', '--force': 'force', '--updates': 'updates',
|
|
18
|
+
'--allow-ci': 'allowCi', '--check': 'check', '--help': 'help', '-h': 'help', '--version': 'version', '--step-by-step': 'stepByStep', '--paste-key': 'pasteKey',
|
|
19
|
+
'--functions-opentelemetry': 'functionsOpenTelemetry' }
|
|
20
|
+
const VALUES = { '--path': 'path', '--service-name': 'serviceName', '--channel': 'channel', '--update-bot': 'updateBot' }
|
|
21
|
+
const COMMANDS = { setup: ['dryRun', 'yes', 'path', 'all', 'json', 'serviceName', 'force', 'allowCi', 'stepByStep', 'pasteKey', 'channel', 'updates', 'updateBot', 'functionsOpenTelemetry'], key: ['path', 'pasteKey'],
|
|
22
|
+
remove: ['yes', 'path', 'json', 'force', 'allowCi'], check: ['json'] }
|
|
23
|
+
/** Flags that only mean something when setup asks questions in a terminal. */
|
|
24
|
+
const ASKS = { stepByStep: '--step-by-step', pasteKey: '--paste-key' }
|
|
19
25
|
|
|
20
|
-
export const HELP = `uknows-setup adds OpenTelemetry to your app, so it sends traces to UKNOWS.
|
|
26
|
+
export const HELP = `uknows-setup adds OpenTelemetry to your app, so it sends traces and logs to UKNOWS.
|
|
21
27
|
|
|
22
28
|
Usage:
|
|
23
29
|
npx @uknows/setup [setup] [options] Find your app, show the plan, then apply it.
|
|
30
|
+
npx @uknows/setup key [options] Check and store the key of another environment, for example Production.
|
|
24
31
|
npx @uknows/setup remove [options] Undo what setup changed.
|
|
25
32
|
npx @uknows/setup check Send 1 test span with the key in ${SECRET.name}.
|
|
26
33
|
|
|
27
34
|
Options:
|
|
28
35
|
--dry-run Show the plan. Change nothing.
|
|
29
36
|
--yes, -y Apply without asking. Never asks any question.
|
|
30
|
-
--
|
|
37
|
+
--step-by-step Ask before each step, instead of 1 question for the whole plan.
|
|
38
|
+
--paste-key Paste your key instead of signing in to UKNOWS in your browser.
|
|
39
|
+
--path <folder> The folder to set up. The default is this folder. key takes only this option and --paste-key.
|
|
31
40
|
--all Set up every service folder. Each one gets its own OTEL_SERVICE_NAME.
|
|
32
41
|
--service-name <name> The name of the service in UKNOWS.
|
|
42
|
+
--channel <name> Java, .NET and Node.js apps with a Dockerfile: copy the UKNOWS agent from the
|
|
43
|
+
agent image of a channel, stable or early, at each build. Then a new agent
|
|
44
|
+
reaches the app at its next build, with no change to your repo.
|
|
45
|
+
--update-bot <name> How the OpenTelemetry packages of your app stay up to date: renovate,
|
|
46
|
+
dependabot (GitHub only, with a workflow that merges the updates and starts
|
|
47
|
+
the deploy) or none. Without it, setup picks, as below.
|
|
48
|
+
--functions-opentelemetry
|
|
49
|
+
Azure Functions apps: turn on OpenTelemetry in host.json, instead of the UKNOWS hooks
|
|
50
|
+
that setup adds when the app sends to Application Insights or it cannot look.
|
|
33
51
|
--json Print a JSON report. Without --yes, setup only plans.
|
|
34
52
|
--force Run even when the repo has changes that are not committed.
|
|
53
|
+
--allow-ci Apply in a pipeline. Only for a bot or an AI agent that opens a pull request.
|
|
35
54
|
--check The same as the check command.
|
|
36
55
|
--help, -h Show this help.
|
|
37
56
|
--version Show the version.
|
|
38
57
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
2
|
|
43
|
-
3
|
|
58
|
+
Setup keeps the UKNOWS agent up to date by itself, and the plan says how and why:
|
|
59
|
+
1. Java, .NET and Node.js apps that build from a Dockerfile get the agent channel: each build
|
|
60
|
+
copies the newest stable agent, so no update pull request is needed.
|
|
61
|
+
2. Other apps get Renovate when your repo uses Renovate already.
|
|
62
|
+
3. Else Dependabot on GitHub. On GitLab, Azure DevOps or Bitbucket, setup adds renovate.json
|
|
63
|
+
and says how to add Renovate in 1 step.
|
|
64
|
+
--update-bot and --channel choose instead.
|
|
65
|
+
|
|
66
|
+
Run setup once on your computer, not in your pipeline. In a pipeline, setup and remove
|
|
67
|
+
change nothing and exit 2, or exit 0 when the repo is already set up. --dry-run, a --json plan,
|
|
68
|
+
check, --help and --version still work.
|
|
69
|
+
To check in a pipeline that a repo is set up ("status": "nothing-to-change", "exitCode": 0):
|
|
70
|
+
${CI_CD.snippet.code}
|
|
71
|
+
|
|
72
|
+
In a terminal, setup opens your browser so you sign in to UKNOWS and pick the workspace and
|
|
73
|
+
the environment. Your key then comes to setup by itself. Setup checks it, then shows the whole
|
|
74
|
+
plan and asks 1 question: Do all of this? (Y/n). On a yes, it changes the files and also:
|
|
75
|
+
Stores the key on your platform with az, gh or kubectl, for the environment of the key.
|
|
76
|
+
Adds the key to a deploy file that would remove it on the next deploy.
|
|
77
|
+
Creates a branch, commits the files that setup changed, pushes it and opens a pull request.
|
|
78
|
+
To show this plan, it reads your Azure subscriptions, your GitHub repo or your kubectl
|
|
79
|
+
contexts. It changes nothing there until you say yes. With --step-by-step, it asks before each
|
|
80
|
+
of these steps instead, after the change to the files.
|
|
81
|
+
Then it opens the page where you watch your app connect. With --yes or --json, in a pipeline
|
|
82
|
+
or without a terminal, it does none of these and prints the steps for you.
|
|
83
|
+
|
|
84
|
+
Exit codes of setup and remove (they describe your repo):
|
|
85
|
+
0 Done, already set up, or nothing to do. Only the key steps on your platform are left.
|
|
86
|
+
Merging the pull request and deploying are left too.
|
|
87
|
+
1 Stopped or failed. Nothing changed. For example, no app found or another APM agent.
|
|
88
|
+
In a terminal, also when UKNOWS refuses the key. With --step-by-step, the changes to your repo stay.
|
|
89
|
+
2 Needs your input. Nothing changed. For example, pick a service, or a pipeline without --allow-ci.
|
|
90
|
+
3 Done, but you must do 1 or more steps in your repo before traces flow.
|
|
91
|
+
Ctrl+C while setup waits for your sign-in stops it with exit code 130.
|
|
92
|
+
check exits 0 when UKNOWS takes the test span, 1 when UKNOWS refuses the key or cannot be
|
|
93
|
+
reached, and 2 when ${SECRET.name} is not set.
|
|
94
|
+
key exits 0 when it stored the key or printed its steps, 1 when the folder is not set up or UKNOWS
|
|
95
|
+
refuses the key or cannot be reached, and 2 without a terminal or a key.
|
|
96
|
+
|
|
97
|
+
You need on this computer (setup updates the lock file of your app with its own tools):
|
|
98
|
+
${SETUP_NEEDS.map((line) => ` ${line}`).join('\n')}
|
|
44
99
|
|
|
45
100
|
Environment:
|
|
46
101
|
${SECRET.name}
|
|
47
102
|
Your key, for check, as ${SECRET.value}. Set it only in your terminal.
|
|
48
|
-
${CLI.
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
A test ingest address, for setup and check. The default is ${INGEST_ENDPOINT}.
|
|
103
|
+
${CLI.agentRegistryEnv}
|
|
104
|
+
Another store of the UKNOWS agent images for the agent channel, as a registry and path, for example
|
|
105
|
+
registry.example.com/uknows.
|
|
52
106
|
|
|
53
|
-
|
|
54
|
-
|
|
107
|
+
In a terminal, the tool gets your key when you sign in, or uses the key in ${SECRET.name}.
|
|
108
|
+
With --paste-key, it asks you to paste it. The key does not show on the screen.
|
|
109
|
+
With --yes in a terminal, it checks the key in ${SECRET.name} if it is set.
|
|
110
|
+
This tool never writes your key to a file. To store it, az, gh and kubectl read it from
|
|
111
|
+
a pipe, so it is in no command line.
|
|
55
112
|
Docs: ${DOCS_URL}
|
|
56
113
|
`
|
|
57
114
|
|
|
58
|
-
/** @returns {{command: 'setup'|'remove'|'check', dryRun: boolean, yes: boolean, all: boolean, json: boolean, force: boolean, help: boolean, version: boolean, path: string|null, serviceName: string|null}} */
|
|
115
|
+
/** @returns {{command: 'setup'|'key'|'remove'|'check', dryRun: boolean, yes: boolean, all: boolean, json: boolean, force: boolean, allowCi: boolean, help: boolean, version: boolean, path: string|null, serviceName: string|null, stepByStep: boolean, pasteKey: boolean, channel: string|null, updates: boolean, updateBot: string|null}} */
|
|
59
116
|
export function parseArgs(argv) {
|
|
60
|
-
const opts = { command: null, dryRun: false, yes: false, all: false, json: false, force: false, help: false, version: false, path: null, serviceName: null
|
|
117
|
+
const opts = { command: null, dryRun: false, yes: false, all: false, json: false, force: false, allowCi: false, help: false, version: false, path: null, serviceName: null,
|
|
118
|
+
stepByStep: false, pasteKey: false, channel: null, updates: false, updateBot: null, functionsOpenTelemetry: false }
|
|
61
119
|
const given = []
|
|
62
120
|
let check = false
|
|
63
121
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -87,17 +145,27 @@ export function parseArgs(argv) {
|
|
|
87
145
|
} else throw new UsageError(`Unknown option: ${flag}.`)
|
|
88
146
|
}
|
|
89
147
|
if (check && opts.command && opts.command !== 'check') throw new UsageError(`--check does not work with ${opts.command}.`)
|
|
148
|
+
if (opts.channel !== null && !CHANNELS.includes(opts.channel)) throw new UsageError(`--channel is ${CHANNELS.join(' or ')}.`)
|
|
149
|
+
if (opts.updateBot !== null) {
|
|
150
|
+
if (!UPDATE_BOTS.includes(opts.updateBot)) throw new UsageError(`--update-bot is ${UPDATE_BOTS.slice(0, -1).join(', ')} or ${UPDATE_BOTS.at(-1)}.`)
|
|
151
|
+
opts.updates = true
|
|
152
|
+
}
|
|
90
153
|
opts.command = check ? 'check' : opts.command ?? 'setup'
|
|
91
154
|
for (const [key, flag] of given) {
|
|
92
155
|
if (!COMMANDS[opts.command].includes(key)) throw new UsageError(`${flag} does not work with ${opts.command}.`)
|
|
93
156
|
}
|
|
157
|
+
// --yes and --json never ask, and a dry run asks for no key: the flags that change the questions do not fit them.
|
|
158
|
+
for (const [key, flag] of Object.entries(ASKS)) {
|
|
159
|
+
const other = opts.yes ? '--yes' : opts.json ? '--json' : opts.dryRun && key === 'pasteKey' ? '--dry-run' : null
|
|
160
|
+
if (opts[key] && other) throw new UsageError(`${flag} does not work with ${other}.`)
|
|
161
|
+
}
|
|
94
162
|
return opts
|
|
95
163
|
}
|
|
96
164
|
|
|
97
165
|
/**
|
|
98
166
|
* Run the CLI. Returns the exit code.
|
|
99
167
|
* @param {string[]} argv
|
|
100
|
-
* @param {{stdin?: any, stdout?: any, stderr?: any, cwd?: string, env?: Record<string, string|undefined>, interactive?: boolean, deps?: object}} [io]
|
|
168
|
+
* @param {{stdin?: any, stdout?: any, stderr?: any, cwd?: string, env?: Record<string, string|undefined>, interactive?: boolean, terminal?: boolean, deps?: object}} [io]
|
|
101
169
|
*/
|
|
102
170
|
export async function main(argv, io = {}) {
|
|
103
171
|
const { stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, cwd = process.cwd(), env = process.env, deps = {} } = io
|
|
@@ -116,12 +184,25 @@ export async function main(argv, io = {}) {
|
|
|
116
184
|
}
|
|
117
185
|
if (args.help) { stdout.write(HELP); return 0 }
|
|
118
186
|
if (args.version) { stdout.write(`${VERSION}\n`); return 0 }
|
|
119
|
-
if (args.command === 'check') return check(args, { stdout, stderr, env }, deps.runCheck ?? runCheck)
|
|
187
|
+
if (args.command === 'check') return check(args, { stdout, stderr, env, cwd }, deps.runCheck ?? runCheck)
|
|
188
|
+
const run = { setup: runSetup, key: runKey, remove: runRemove }[args.command]
|
|
120
189
|
|
|
121
190
|
// setup writes the same ingest address that check sends to.
|
|
122
191
|
const endpoint = ingestEndpoint(env)
|
|
123
192
|
if (!endpoint) return usage(`${CLI.endpointEnv} must be an https address.`)
|
|
124
193
|
if (!siteUrl(env)) return usage(`${CLI.siteEnv} must be an https address with no path, for example https://uknows.example.com.`)
|
|
194
|
+
if (!validAppUrl(env)) return usage(`${CLI.appEnv} must be an https address with no path, for example https://app.uknows.example.com.`)
|
|
195
|
+
// The agent channel: the store of the UKNOWS agent images, UKNOWS_AGENT_REGISTRY or the one of this release.
|
|
196
|
+
const given = String(env[CLI.agentRegistryEnv] ?? '').trim().replace(/\/+$/, '')
|
|
197
|
+
if (given && !validRegistry(given)) return usage(`${CLI.agentRegistryEnv} must be the registry and path of the UKNOWS agent images, for example registry.example.com/uknows.`)
|
|
198
|
+
const registry = given || (validRegistry(DEFAULT_AGENT_REGISTRY) ? DEFAULT_AGENT_REGISTRY : null)
|
|
199
|
+
let channel = null
|
|
200
|
+
if (args.channel) {
|
|
201
|
+
if (!registry) return usage(`--channel needs ${CLI.agentRegistryEnv}: the registry and path of the UKNOWS agent images, for example registry.example.com/uknows.`)
|
|
202
|
+
channel = { name: args.channel, registry }
|
|
203
|
+
}
|
|
204
|
+
// The default (updates.mjs, step 1): the stable channel for the apps that fit, unless --update-bot or --channel chose.
|
|
205
|
+
const defaultChannel = !args.channel && !args.updateBot && registry ? { name: 'stable', registry } : null
|
|
125
206
|
const root = resolve(cwd, args.path ?? '.')
|
|
126
207
|
let folder = false
|
|
127
208
|
try { folder = statSync(root).isDirectory() } catch { /* missing */ }
|
|
@@ -129,9 +210,9 @@ export async function main(argv, io = {}) {
|
|
|
129
210
|
if (args.serviceName !== null && (deps.toServiceName ?? toServiceName)(args.serviceName) !== args.serviceName) {
|
|
130
211
|
return usage(`${args.serviceName} is not a valid service name. Use lowercase letters, digits and -, up to 63 characters.`)
|
|
131
212
|
}
|
|
132
|
-
const ui = createUI({ stdin, stdout, stderr, json: args.json, interactive: io.interactive })
|
|
213
|
+
const ui = createUI({ stdin, stdout, stderr, json: args.json, interactive: io.interactive, terminal: io.terminal, env })
|
|
133
214
|
try {
|
|
134
|
-
const { code, report } = await (
|
|
215
|
+
const { code, report } = await run({ ...args, channel, defaultChannel, root, endpoint, env }, { ...deps, ui })
|
|
135
216
|
if (args.json) stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
136
217
|
return code
|
|
137
218
|
} finally {
|
|
@@ -139,8 +220,12 @@ export async function main(argv, io = {}) {
|
|
|
139
220
|
}
|
|
140
221
|
}
|
|
141
222
|
|
|
142
|
-
async function check(args, { stdout, stderr, env }, run) {
|
|
143
|
-
|
|
223
|
+
async function check(args, { stdout, stderr, env, cwd }, run) {
|
|
224
|
+
// The test span carries the service name that setup recorded here, so the workspace never lists a made-up app.
|
|
225
|
+
let serviceName = null
|
|
226
|
+
try { serviceName = readManifest(cwd)?.services?.[0]?.name ?? null } catch { /* not valid: no name */ }
|
|
227
|
+
// The retry line goes to stderr, so --json stays 1 JSON document on stdout.
|
|
228
|
+
const result = await run({ env, serviceName, onRetry: (line) => stderr.write(`${line}\n`) })
|
|
144
229
|
const status = result.exit === 2 ? 'no-key' : result.ok ? 'reached' : result.status === null ? 'network-error' : 'rejected'
|
|
145
230
|
const lines = redact(result.lines, env[SECRET.name])
|
|
146
231
|
if (args.json) {
|