@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
package/lib/check.mjs
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The check command: check the key from OTEL_EXPORTER_OTLP_HEADERS (or the
|
|
3
|
+
* key pasted after setup), and say what happened. 2 steps, the only network
|
|
4
|
+
* calls the CLI makes:
|
|
5
|
+
* 1. Ask the site which workspace and environment the key sends to (the
|
|
6
|
+
* ping route that "Check your key" on /docs uses). A refused key stops here.
|
|
7
|
+
* 2. Send 1 test span to the ingest address, as the app will.
|
|
8
|
+
* It uses node:http and node:https (fetch prints a warning on Node 18). The key
|
|
9
|
+
* never appears in the output.
|
|
10
|
+
*/
|
|
11
|
+
import { randomBytes } from 'node:crypto'
|
|
12
|
+
import { readFileSync } from 'node:fs'
|
|
13
|
+
import http from 'node:http'
|
|
14
|
+
import https from 'node:https'
|
|
15
|
+
import { CHECK_NETWORK, CLI, KEY_CHECK, SECRET, SETUP_NEXT, TROUBLESHOOTING, keyCheckLine } from './contract.mjs'
|
|
16
|
+
import { ingestEndpoint, sameUknows, siteUrl } from './endpoint.mjs'
|
|
17
|
+
|
|
18
|
+
const VERSION = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version
|
|
19
|
+
const CERT = /^(?:CERT_|ERR_TLS_CERT|UNABLE_TO_(?:VERIFY_LEAF_SIGNATURE|GET_ISSUER_CERT)|SELF_SIGNED_CERT_IN_CHAIN|DEPTH_ZERO_SELF_SIGNED_CERT|ERR_SSL_)/
|
|
20
|
+
const MAX_BODY = 65536
|
|
21
|
+
const ACCEPTED = 'UKNOWS accepted 1 test span.'
|
|
22
|
+
/** What each refusal of the ping route means, from the docs list ("ingest_key_invalid" and so on). */
|
|
23
|
+
const MEANING = new Map(KEY_CHECK.answers.map((a) => [/^[a-z_]+$/.test(a.answer) ? a.answer : null, a.meaning]).filter(([code]) => code))
|
|
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
|
+
|
|
31
|
+
/**
|
|
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]
|
|
75
|
+
* @returns {Promise<import('./types.mjs').CheckResult>}
|
|
76
|
+
*/
|
|
77
|
+
export async function runCheck(options = {}) {
|
|
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])
|
|
81
|
+
if (!raw || !raw.trim()) {
|
|
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])
|
|
85
|
+
}
|
|
86
|
+
const headers = parseHeaders(raw)
|
|
87
|
+
// Hide each header value as written and as decoded. A part without '=' may be a bare key: hide it whole.
|
|
88
|
+
const redact = redactor([...raw.split(',').map((part) => part.slice(part.indexOf('=') + 1)), ...Object.values(headers)])
|
|
89
|
+
const keyName = Object.keys(headers).find((name) => name.toLowerCase() === SECRET.header)
|
|
90
|
+
if (!keyName) {
|
|
91
|
+
return result(2, null, [`${SECRET.name} has no ${SECRET.header}. Use the format ${SECRET.value}. Remove spaces and quotes.`])
|
|
92
|
+
}
|
|
93
|
+
const endpoint = ingestEndpoint(env)
|
|
94
|
+
if (!endpoint) return result(1, null, [`${CLI.endpointEnv} must be an https address.`])
|
|
95
|
+
const site = siteUrl(env)
|
|
96
|
+
if (!site) return result(1, null, [`${CLI.siteEnv} must be an https address with no path, for example https://uknows.example.com.`])
|
|
97
|
+
|
|
98
|
+
let ping = null
|
|
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.
|
|
110
|
+
if (sameUknows(env)) {
|
|
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
|
+
}
|
|
123
|
+
lines.push(...ping.lines)
|
|
124
|
+
fresh ||= typeof ping.ageMs === 'number' && ping.ageMs < NEW_KEY.youngMs
|
|
125
|
+
} else {
|
|
126
|
+
after.push(`To also see which workspace the key sends to, set ${CLI.siteEnv} with ${CLI.endpointEnv}.`)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 2. 1 test span to the ingest address. A new key that the data intake does not know yet gets 401 there.
|
|
130
|
+
const url = new URL(`${endpoint}/v1/traces`)
|
|
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
|
+
}
|
|
143
|
+
const sent = explainResponse(response, redact)
|
|
144
|
+
// Without the site's answer, the accepted span is what shows that the key works.
|
|
145
|
+
const spanLines = sent.lines.map((line) => (line === ACCEPTED && !ping?.workspace ? `${ACCEPTED} Your key works.` : line))
|
|
146
|
+
return { ...sent, ...named(ping, sent), lines: [...lines, ...spanLines, ...(sent.ok ? after : [])] }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The workspace and environment from the ping, when the ingest answer did not name them. */
|
|
150
|
+
function named(ping, sent = {}) {
|
|
151
|
+
return { workspace: sent.workspace ?? ping?.workspace ?? null, environment: sent.environment ?? ping?.environment ?? null, keyState: ping?.keyState ?? null }
|
|
152
|
+
}
|
|
153
|
+
|
|
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()) {
|
|
159
|
+
const host = new URL(site).hostname
|
|
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.`] }
|
|
161
|
+
const data = parse(body)
|
|
162
|
+
if (status === 200 && data?.workspace) {
|
|
163
|
+
const workspace = clean(data.workspace.name ?? data.workspace.id, redact)
|
|
164
|
+
const environment = clean(data.environmentLabel ?? data.environment, redact)
|
|
165
|
+
const lines = [`Your key works. It sends data to ${workspace}${environment ? `, environment ${environment}` : ''}.`]
|
|
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.`)
|
|
167
|
+
if (data.status === 'paused') lines.push('Your workspace is paused.')
|
|
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 }
|
|
170
|
+
}
|
|
171
|
+
const code = typeof data?.error === 'string' ? data.error : null
|
|
172
|
+
if ((status === 401 || status === 403) && code) {
|
|
173
|
+
const why = code === 'ingest_key_missing' ? 'The key is empty.' : MEANING.get(code) ?? `UKNOWS refused the key (${code}).`
|
|
174
|
+
return { stop: true, keyState: code === 'ingest_key_revoked' ? 'revoked' : null, lines: [`UKNOWS did not accept the key (${code}).`, why] }
|
|
175
|
+
}
|
|
176
|
+
if (status === 429) return { stop: false, lines: [`${host} says: too many checks from your address. Sending the test span anyway.`] }
|
|
177
|
+
return { stop: false, lines: [`${host} did not say which workspace the key sends to (status ${status}). Sending the test span anyway.`] }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Header pairs from 'k=v,k2=v2'. Each part splits on its first '=' and is percent-decoded, as the OTLP spec says. */
|
|
181
|
+
export function parseHeaders(value) {
|
|
182
|
+
const out = {}
|
|
183
|
+
for (const part of String(value).split(',')) {
|
|
184
|
+
const at = part.indexOf('=')
|
|
185
|
+
if (at < 1) continue
|
|
186
|
+
const name = decode(part.slice(0, at).trim())
|
|
187
|
+
if (name) out[name] = decode(part.slice(at + 1).trim())
|
|
188
|
+
}
|
|
189
|
+
return out
|
|
190
|
+
}
|
|
191
|
+
|
|
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
|
+
*/
|
|
196
|
+
export function explainNetworkError(error, url, timeoutMs, env = {}) {
|
|
197
|
+
const code = error?.code ?? ''
|
|
198
|
+
const port = url.port || (url.protocol === 'https:' ? '443' : '80')
|
|
199
|
+
const host = url.hostname
|
|
200
|
+
const proxy = ['HTTPS_PROXY', 'https_proxy', 'HTTP_PROXY', 'http_proxy'].some((k) => env[k])
|
|
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.`]
|
|
203
|
+
const seconds = Math.round(timeoutMs / 100) / 10
|
|
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)
|
|
213
|
+
return lines
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// --- request and response ---------------------------------------------------------
|
|
217
|
+
|
|
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) {
|
|
231
|
+
return new Promise((resolve) => {
|
|
232
|
+
let done = false, timer = null, req
|
|
233
|
+
const finish = (value) => { if (!done) { done = true; clearTimeout(timer); resolve(value) } }
|
|
234
|
+
try {
|
|
235
|
+
req = (url.protocol === 'https:' ? https : http).request(url, {
|
|
236
|
+
method, agent: false,
|
|
237
|
+
headers: { ...headers, ...(body === null ? {} : { 'content-length': Buffer.byteLength(body) }), 'user-agent': `uknows-setup/${VERSION}` },
|
|
238
|
+
}, (res) => {
|
|
239
|
+
const chunks = []
|
|
240
|
+
let size = 0
|
|
241
|
+
res.on('data', (chunk) => { if (size < MAX_BODY) { chunks.push(chunk); size += chunk.length } })
|
|
242
|
+
res.on('end', () => finish({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks).toString('utf8').slice(0, MAX_BODY) }))
|
|
243
|
+
res.on('error', (error) => finish({ error }))
|
|
244
|
+
})
|
|
245
|
+
} catch (error) { resolve({ error }); return }
|
|
246
|
+
timer = setTimeout(() => { finish({ error: { code: 'UKNOWS_TIMEOUT' } }); req.destroy() }, timeoutMs)
|
|
247
|
+
req.on('error', (error) => finish({ error }))
|
|
248
|
+
req.end(body ?? undefined)
|
|
249
|
+
})
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function explainResponse({ status, headers, body }, redact) {
|
|
253
|
+
const data = parse(body)
|
|
254
|
+
if (status >= 200 && status < 300) {
|
|
255
|
+
const workspace = clean(data?.workspace, redact) ?? clean(headers['x-uknows-workspace'], redact)
|
|
256
|
+
const environment = clean(data?.environment, redact) ?? clean(headers['x-uknows-environment'], redact)
|
|
257
|
+
const lines = [workspace && environment ? `Reached ${workspace} / ${environment}.` : workspace ? `Reached ${workspace}.` : ACCEPTED]
|
|
258
|
+
const rejected = Number(data?.partialSuccess?.rejectedSpans ?? 0)
|
|
259
|
+
if (rejected > 0) lines.push(`UKNOWS did not keep the test span: ${clean(data.partialSuccess.errorMessage, redact) ?? 'no reason given'}.`)
|
|
260
|
+
return { ok: true, status, workspace, environment, lines, exit: 0 }
|
|
261
|
+
}
|
|
262
|
+
const steps = (id) => TROUBLESHOOTING.find((t) => t.id === id)?.steps ?? []
|
|
263
|
+
if (status === 401) return result(1, status, [`UKNOWS did not accept the key (status 401).`, ...steps('unauthorized')])
|
|
264
|
+
if (status === 403) return result(1, status, [`UKNOWS refused the data (status 403). ${TROUBLESHOOTING.find((t) => t.id === 'forbidden')?.cause ?? ''}`.trim(), ...steps('forbidden')])
|
|
265
|
+
const textual = !data && /json|text/i.test(String(headers['content-type'] ?? ''))
|
|
266
|
+
const message = clean(typeof data?.message === 'string' ? data.message : textual ? body : null, redact)
|
|
267
|
+
const lines = [`UKNOWS answered with status ${status}.`]
|
|
268
|
+
if (message) lines.push(message.length > 200 ? `${message.slice(0, 197)}...` : message)
|
|
269
|
+
if ([429, 502, 503, 504].includes(status)) lines.push('Try again in 1 minute.')
|
|
270
|
+
return result(1, status, lines)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 1 span in OTLP/JSON: hex ids, integer enums, int64 values as decimal strings. */
|
|
274
|
+
function testSpan(env, serviceName = null) {
|
|
275
|
+
const hex = (bytes) => { let id; do id = randomBytes(bytes).toString('hex'); while (/^0+$/.test(id)); return id }
|
|
276
|
+
const start = BigInt(Date.now()) * 1000000n
|
|
277
|
+
return JSON.stringify({ resourceSpans: [{
|
|
278
|
+
resource: { attributes: [{ key: 'service.name', value: { stringValue: env.OTEL_SERVICE_NAME || serviceName || 'uknows-setup-check' } }] },
|
|
279
|
+
scopeSpans: [{ scope: { name: '@uknows/setup', version: VERSION }, spans: [{
|
|
280
|
+
traceId: hex(16), spanId: hex(8), name: 'uknows setup check', kind: 1,
|
|
281
|
+
startTimeUnixNano: String(start), endTimeUnixNano: String(start + 1000000n),
|
|
282
|
+
attributes: [{ key: 'uknows.check', value: { boolValue: true } }],
|
|
283
|
+
}] }],
|
|
284
|
+
}] })
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// --- helpers ------------------------------------------------------------------------
|
|
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
|
+
|
|
309
|
+
function result(exit, status, lines) {
|
|
310
|
+
return { ok: false, status, workspace: null, environment: null, lines, exit }
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Replaces every form of the key with <key>, longest first, so a server that echoes it back cannot leak it. */
|
|
314
|
+
function redactor(values) {
|
|
315
|
+
const secrets = [...new Set(values.map((v) => String(v ?? '').trim()))].filter((v) => v.length >= 4).sort((a, b) => b.length - a.length)
|
|
316
|
+
return (line) => secrets.reduce((out, secret) => out.split(secret).join(SECRET.placeholder), line)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** A short single-line text from the server, or null. */
|
|
320
|
+
function clean(value, redact) {
|
|
321
|
+
if (typeof value !== 'string') return null
|
|
322
|
+
const out = redact(value.replace(/[\u0000-\u001f\u007f]+/g, ' ').trim())
|
|
323
|
+
return out ? out.slice(0, 200) : null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
function decode(text) { try { return decodeURIComponent(text) } catch { return text } }
|
|
327
|
+
function parse(body) { try { return JSON.parse(body) } catch { return null } }
|
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
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Command line: parse the arguments, then run setup, remove or check.
|
|
3
|
+
* A hand-written parser, because util.parseArgs is experimental on Node 18.
|
|
4
|
+
*/
|
|
5
|
+
import { statSync } from 'node:fs'
|
|
6
|
+
import { resolve } from 'node:path'
|
|
7
|
+
import { runCheck } from './check.mjs'
|
|
8
|
+
import { CI_CD, CLI, DEFAULT_AGENT_REGISTRY, DOCS_URL, SECRET, SETUP_NEEDS } from './contract.mjs'
|
|
9
|
+
import { toServiceName } from './detect.mjs'
|
|
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'
|
|
15
|
+
import { UsageError, createUI } from './ui.mjs'
|
|
16
|
+
|
|
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' }
|
|
25
|
+
|
|
26
|
+
export const HELP = `uknows-setup adds OpenTelemetry to your app, so it sends traces and logs to UKNOWS.
|
|
27
|
+
|
|
28
|
+
Usage:
|
|
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.
|
|
31
|
+
npx @uknows/setup remove [options] Undo what setup changed.
|
|
32
|
+
npx @uknows/setup check Send 1 test span with the key in ${SECRET.name}.
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--dry-run Show the plan. Change nothing.
|
|
36
|
+
--yes, -y Apply without asking. Never asks any question.
|
|
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.
|
|
40
|
+
--all Set up every service folder. Each one gets its own OTEL_SERVICE_NAME.
|
|
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.
|
|
51
|
+
--json Print a JSON report. Without --yes, setup only plans.
|
|
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.
|
|
54
|
+
--check The same as the check command.
|
|
55
|
+
--help, -h Show this help.
|
|
56
|
+
--version Show the version.
|
|
57
|
+
|
|
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')}
|
|
99
|
+
|
|
100
|
+
Environment:
|
|
101
|
+
${SECRET.name}
|
|
102
|
+
Your key, for check, as ${SECRET.value}. Set it only in your terminal.
|
|
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.
|
|
106
|
+
|
|
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.
|
|
112
|
+
Docs: ${DOCS_URL}
|
|
113
|
+
`
|
|
114
|
+
|
|
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}} */
|
|
116
|
+
export function parseArgs(argv) {
|
|
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 }
|
|
119
|
+
const given = []
|
|
120
|
+
let check = false
|
|
121
|
+
for (let i = 0; i < argv.length; i++) {
|
|
122
|
+
const arg = argv[i]
|
|
123
|
+
if (!arg.startsWith('-') || arg === '-') {
|
|
124
|
+
if (opts.command) throw new UsageError(`Unexpected argument: ${arg}.`)
|
|
125
|
+
if (!(arg in COMMANDS)) throw new UsageError(`Unknown command: ${arg}.`)
|
|
126
|
+
opts.command = arg
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
const eq = arg.startsWith('--') ? arg.indexOf('=') : -1
|
|
130
|
+
const flag = eq > 0 ? arg.slice(0, eq) : arg
|
|
131
|
+
if (flag in VALUES) {
|
|
132
|
+
let value = eq > 0 ? arg.slice(eq + 1) : argv[i + 1]
|
|
133
|
+
if (eq < 0) {
|
|
134
|
+
if (value === undefined || value.startsWith('-')) throw new UsageError(`${flag} needs a value.`)
|
|
135
|
+
i++
|
|
136
|
+
}
|
|
137
|
+
if (!value) throw new UsageError(`${flag} needs a value.`)
|
|
138
|
+
opts[VALUES[flag]] = value
|
|
139
|
+
given.push([VALUES[flag], flag])
|
|
140
|
+
} else if (flag in FLAGS) {
|
|
141
|
+
if (eq > 0) throw new UsageError(`${flag} does not take a value.`)
|
|
142
|
+
if (FLAGS[flag] === 'check') check = true
|
|
143
|
+
else opts[FLAGS[flag]] = true
|
|
144
|
+
if (!['help', 'version', 'check'].includes(FLAGS[flag])) given.push([FLAGS[flag], flag])
|
|
145
|
+
} else throw new UsageError(`Unknown option: ${flag}.`)
|
|
146
|
+
}
|
|
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
|
+
}
|
|
153
|
+
opts.command = check ? 'check' : opts.command ?? 'setup'
|
|
154
|
+
for (const [key, flag] of given) {
|
|
155
|
+
if (!COMMANDS[opts.command].includes(key)) throw new UsageError(`${flag} does not work with ${opts.command}.`)
|
|
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
|
+
}
|
|
162
|
+
return opts
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Run the CLI. Returns the exit code.
|
|
167
|
+
* @param {string[]} argv
|
|
168
|
+
* @param {{stdin?: any, stdout?: any, stderr?: any, cwd?: string, env?: Record<string, string|undefined>, interactive?: boolean, terminal?: boolean, deps?: object}} [io]
|
|
169
|
+
*/
|
|
170
|
+
export async function main(argv, io = {}) {
|
|
171
|
+
const { stdin = process.stdin, stdout = process.stdout, stderr = process.stderr, cwd = process.cwd(), env = process.env, deps = {} } = io
|
|
172
|
+
const json = argv.includes('--json')
|
|
173
|
+
const usage = (message) => {
|
|
174
|
+
stderr.write(`${message}\nRun uknows-setup --help.\n`)
|
|
175
|
+
if (json) stdout.write(`${JSON.stringify({ schema: 1, tool: TOOL, version: VERSION, command: null, status: 'usage-error', exitCode: 2, problems: [{ code: 'usage', message }] }, null, 2)}\n`)
|
|
176
|
+
return 2
|
|
177
|
+
}
|
|
178
|
+
let args
|
|
179
|
+
try {
|
|
180
|
+
args = parseArgs(argv)
|
|
181
|
+
} catch (error) {
|
|
182
|
+
if (error instanceof UsageError) return usage(error.message)
|
|
183
|
+
throw error
|
|
184
|
+
}
|
|
185
|
+
if (args.help) { stdout.write(HELP); return 0 }
|
|
186
|
+
if (args.version) { stdout.write(`${VERSION}\n`); return 0 }
|
|
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]
|
|
189
|
+
|
|
190
|
+
// setup writes the same ingest address that check sends to.
|
|
191
|
+
const endpoint = ingestEndpoint(env)
|
|
192
|
+
if (!endpoint) return usage(`${CLI.endpointEnv} must be an https address.`)
|
|
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
|
|
206
|
+
const root = resolve(cwd, args.path ?? '.')
|
|
207
|
+
let folder = false
|
|
208
|
+
try { folder = statSync(root).isDirectory() } catch { /* missing */ }
|
|
209
|
+
if (!folder) return usage(`${args.path ?? root} is not a folder.`)
|
|
210
|
+
if (args.serviceName !== null && (deps.toServiceName ?? toServiceName)(args.serviceName) !== args.serviceName) {
|
|
211
|
+
return usage(`${args.serviceName} is not a valid service name. Use lowercase letters, digits and -, up to 63 characters.`)
|
|
212
|
+
}
|
|
213
|
+
const ui = createUI({ stdin, stdout, stderr, json: args.json, interactive: io.interactive, terminal: io.terminal, env })
|
|
214
|
+
try {
|
|
215
|
+
const { code, report } = await run({ ...args, channel, defaultChannel, root, endpoint, env }, { ...deps, ui })
|
|
216
|
+
if (args.json) stdout.write(`${JSON.stringify(report, null, 2)}\n`)
|
|
217
|
+
return code
|
|
218
|
+
} finally {
|
|
219
|
+
ui.close()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
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`) })
|
|
229
|
+
const status = result.exit === 2 ? 'no-key' : result.ok ? 'reached' : result.status === null ? 'network-error' : 'rejected'
|
|
230
|
+
const lines = redact(result.lines, env[SECRET.name])
|
|
231
|
+
if (args.json) {
|
|
232
|
+
stdout.write(`${JSON.stringify({ schema: 1, tool: TOOL, version: VERSION, command: 'check', status, exitCode: result.exit,
|
|
233
|
+
httpStatus: result.status, workspace: result.workspace, environment: result.environment, lines }, null, 2)}\n`)
|
|
234
|
+
} else {
|
|
235
|
+
(result.exit === 1 ? stderr : stdout).write(lines.map((line) => `${line}\n`).join(''))
|
|
236
|
+
}
|
|
237
|
+
return result.exit
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Belt and braces: take every header value out of the lines, even if the server echoed it. */
|
|
241
|
+
function redact(lines, headers) {
|
|
242
|
+
const secrets = []
|
|
243
|
+
for (const pair of String(headers ?? '').split(',')) {
|
|
244
|
+
const value = pair.slice(pair.indexOf('=') + 1).trim()
|
|
245
|
+
if (pair.includes('=') && value.length >= 4) {
|
|
246
|
+
secrets.push(value)
|
|
247
|
+
try { secrets.push(decodeURIComponent(value)) } catch { /* not encoded */ }
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return lines.map((line) => secrets.reduce((out, secret) => out.split(secret).join('***'), String(line)))
|
|
251
|
+
}
|