@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/engine.mjs
CHANGED
|
@@ -2,48 +2,229 @@
|
|
|
2
2
|
* The setup and remove flows. Everything that reads the repo, runs a
|
|
3
3
|
* program or asks the user comes in through deps, so tests can use fakes.
|
|
4
4
|
* Each flow returns {code, report}: the exit code and the --json report.
|
|
5
|
+
*
|
|
6
|
+
* Exit codes describe the repo, never the key (README "Exit codes"):
|
|
7
|
+
* 0 applied, already set up, or nothing to do. Only the key steps on the platform are left. A repo whose manifest
|
|
8
|
+
* lists every service and that has nothing to change is set up (status nothing-to-change), also with steps left
|
|
9
|
+
* that the repo cannot show. With no manifest and only required steps, the status is needs-steps (exit 3).
|
|
10
|
+
* 1 stopped or failed, and nothing changed: dirty tree, key in a file, other APM, no app found, a failed command.
|
|
11
|
+
* 2 needs input, and nothing changed: usage, a service to pick, no terminal and no --yes, a pipeline without --allow-ci.
|
|
12
|
+
* 3 applied or planned, but required repo steps remain. Only blocks with required !== false count.
|
|
13
|
+
*
|
|
14
|
+
* Each service gets a mode before anything is planned (existing.mjs classify): standard, otel, azure-monitor
|
|
15
|
+
* or stop. With --channel (or a channel recorded in .uknows/setup.json), a standard Java, .NET or Node.js service
|
|
16
|
+
* with a Dockerfile gets the channel mode instead: the agent comes from the UKNOWS agent image (stacks/channel.mjs).
|
|
17
|
+
* A stop in any service stops the whole run. Lock refreshes (uv lock, poetry lock, dotnet restore
|
|
18
|
+
* --force-evaluate) are commands of kind 'lock': the manifest records the lockfile they rewrite as an ordinary text
|
|
19
|
+
* change, so remove gives back every byte without running the tool again. They run after the edits, and any failure
|
|
20
|
+
* puts every file back: a resolver that finds no solution, a lockfile format line that changed (keep), or a restore
|
|
21
|
+
* whose obj/project.assets.json needs a package the plan did not add (verify).
|
|
5
22
|
*/
|
|
6
|
-
import { spawn as spawnChild } from 'node:child_process'
|
|
23
|
+
import { spawn as spawnChild, spawnSync } from 'node:child_process'
|
|
7
24
|
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
8
25
|
import { dirname, sep } from 'node:path'
|
|
9
|
-
import {
|
|
10
|
-
|
|
26
|
+
import { PUT, automates, changedFiles, connectUrl, deployEdits, deployFilesInTerminal, githubSecret, githubStepBlock, githubStepLines, k8sLoadsKey, keyStepsInTerminal,
|
|
27
|
+
keyTargets, lastLine, notCommitted, openBrowser, openChecks, pickApp, planKeyStores, plannedQuestions, prBody, prFacts, prPlan, prRun, pullRequest, runProgram,
|
|
28
|
+
storeName, storeRows, watchLine } from './automate.mjs'
|
|
29
|
+
import { NEW_KEY, parseHeaders, request, runCheck } from './check.mjs'
|
|
30
|
+
import { ciVariable } from './ci.mjs'
|
|
31
|
+
import { PREDICT, cloudIdFacts, cloudIdLookups, cloudIdPlan, cloudIdSpots } from './cloudid.mjs'
|
|
32
|
+
import { APP_INSIGHTS_SETTINGS, appInsightsSigns, functionAppSettingNames, hostJsonEdit } from './stacks/functions.mjs'
|
|
33
|
+
import { APP_SERVICE_CODE, CI_CD, CLI, FUNCTIONS, HOSTS, INGEST_ENDPOINT, RESOLVE, SECRET, SETUP_NEXT, SETUP_STATUS, STACKS, keyCheckLine, settingsSnippet } from './contract.mjs'
|
|
11
34
|
import { detect } from './detect.mjs'
|
|
12
|
-
import { docsUrl, llmsUrl } from './endpoint.mjs'
|
|
35
|
+
import { appUrl, docsUrl, llmsUrl, nextData } from './endpoint.mjs'
|
|
36
|
+
import { askOnce, fileRows, printPlan as printOnePlan, step } from './plan.mjs'
|
|
37
|
+
import { REAL_CLOCK, SIGN_IN, signIn } from './signin.mjs'
|
|
13
38
|
import { countLines, unified } from './diff.mjs'
|
|
14
39
|
import { shellWords } from './doc.mjs'
|
|
15
|
-
import { explainApm, findExisting } from './existing.mjs'
|
|
16
|
-
import { gitStatus } from './git.mjs'
|
|
17
|
-
import { keySteps } from './hosts.mjs'
|
|
40
|
+
import { classify, existingNotes, explainApm, explainStop, findExisting } from './existing.mjs'
|
|
41
|
+
import { gitHost, gitIgnored, gitRemote, gitStatus } from './git.mjs'
|
|
42
|
+
import { deployNames, keySteps } from './hosts.mjs'
|
|
18
43
|
import { BOM, TOOL, VERSION, changeEntry, manifestPaths, manifestText, mergeManifest, readManifest, sha256, undoChange } from './manifest.mjs'
|
|
19
44
|
import { hasKey, redactKey, settingsPlan } from './settings.mjs'
|
|
45
|
+
import { lockedMode, verifyRestore, webProject } from './stacks/dotnet.mjs'
|
|
46
|
+
import { channelMisfit } from './stacks/channel.mjs'
|
|
47
|
+
import { updatesPlan } from './updates.mjs'
|
|
20
48
|
import { APPLIERS } from './stacks/index.mjs'
|
|
49
|
+
import { otelNotes } from './stacks/otel.mjs'
|
|
21
50
|
import { UsageError, frameworkLabel, hostLabel, list, plural, stackLabel, unique } from './ui.mjs'
|
|
22
51
|
import { createWorkspace, repoView } from './workspace.mjs'
|
|
23
52
|
|
|
24
|
-
const DEFAULTS = { detect, findExisting, explainApm, settingsPlan, keySteps, appliers: APPLIERS, spawn: runCommand,
|
|
53
|
+
const DEFAULTS = { detect, findExisting, classify, explainApm, explainStop, settingsPlan, keySteps, deployNames, appliers: APPLIERS, spawn: runCommand,
|
|
54
|
+
probe: probeTool, verify: verifyRestore, gitStatus, gitIgnored, gitRemote, gitHost, createWorkspace, runCheck, cli: runProgram, openUrl: openBrowser,
|
|
55
|
+
signIn, request, clock: REAL_CLOCK }
|
|
25
56
|
/** The language menu, in this order, then 'Something else'. */
|
|
26
57
|
const MENU = ['node', 'python', 'java', 'dotnet', 'go', 'php', 'ruby', 'otel']
|
|
27
|
-
const LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock'])
|
|
58
|
+
const LOCKFILES = new Set(['package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock', 'uv.lock', 'poetry.lock', 'pdm.lock', 'packages.lock.json'])
|
|
59
|
+
/** Local tools the plan can need, probed once before planning: name -> files in the repo that need it. */
|
|
60
|
+
const TOOLS = { uv: /(?:^|\/)uv\.lock$/, poetry: /(?:^|\/)poetry\.lock$/ }
|
|
28
61
|
const DIFF_LIMIT = 200
|
|
29
62
|
/** On Windows, package managers run through a shell (.cmd files), so every argument must be plain. */
|
|
30
63
|
const SAFE_ARG = /^[@A-Za-z0-9._/=:~+-]+$/
|
|
64
|
+
/** The output of a command that the engine keeps, to say why a resolver failed. */
|
|
65
|
+
const OUTPUT_LIMIT = 65536
|
|
31
66
|
|
|
32
67
|
/**
|
|
33
|
-
* @param {{root: string, dryRun?: boolean, yes?: boolean, all?: boolean, json?: boolean, serviceName?: string|null, force?: boolean
|
|
68
|
+
* @param {{root: string, dryRun?: boolean, yes?: boolean, all?: boolean, json?: boolean, serviceName?: string|null, force?: boolean,
|
|
69
|
+
* allowCi?: boolean, env?: Record<string, string|undefined>, endpoint?: string}} opts
|
|
34
70
|
* @param {Partial<typeof DEFAULTS> & {ui: ReturnType<typeof import('./ui.mjs').createUI>}} deps
|
|
35
71
|
*/
|
|
36
72
|
export async function runSetup(opts, deps) {
|
|
37
73
|
const d = { ...DEFAULTS, ...deps }
|
|
38
74
|
const report = { schema: 1, tool: TOOL, version: VERSION, command: 'setup', status: 'failed', exitCode: 1,
|
|
39
|
-
services: [], files: [], hosts: [], keySteps: [], check: null, problems: [], next:
|
|
75
|
+
services: [], files: [], hosts: [], keySteps: [], check: null, problems: [], next: nextData(opts.env), ci: Boolean(ciVariable(opts.env)) }
|
|
40
76
|
return guard(d.ui, report, () => setup(opts, d, report))
|
|
41
77
|
}
|
|
42
78
|
|
|
43
|
-
/**
|
|
79
|
+
/**
|
|
80
|
+
* npx @uknows/setup key: paste the key of another environment (for example Production), check it, and store it on the
|
|
81
|
+
* platform of an app that setup set up, as the key steps of a setup run do. Only in a terminal. It changes no file.
|
|
82
|
+
* Exit codes: 0 stored, or its steps printed; 1 not set up yet, a refused key or a key that could not be checked;
|
|
83
|
+
* 2 no terminal (a pipeline, --json, a pipe) or no key given.
|
|
84
|
+
* @param {{root: string, env?: Record<string, string|undefined>}} opts
|
|
85
|
+
*/
|
|
86
|
+
export async function runKey(opts, deps) {
|
|
87
|
+
const d = { ...DEFAULTS, ...deps }
|
|
88
|
+
const report = { schema: 1, tool: TOOL, version: VERSION, command: 'key', status: 'failed', exitCode: 1, check: null, stored: [], problems: [],
|
|
89
|
+
ci: Boolean(ciVariable(opts.env)) }
|
|
90
|
+
return guard(d.ui, report, () => key(opts, d, report))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function key(opts, d, report) {
|
|
94
|
+
const { ui } = d, root = opts.root, env = opts.env ?? {}
|
|
95
|
+
ui.print('UKNOWS key')
|
|
96
|
+
ui.print()
|
|
97
|
+
if (!automates({}, ui, env)) {
|
|
98
|
+
ui.print('Run npx @uknows/setup key in a terminal. It asks for your key, so it does not run in a pipeline or without a terminal. Nothing changed.')
|
|
99
|
+
return ['needs-terminal', 2]
|
|
100
|
+
}
|
|
101
|
+
const manifest = readManifest(root)
|
|
102
|
+
if (!manifest?.services.length) {
|
|
103
|
+
ui.print(`This folder is not set up yet: it has no ${CLI.manifest}. Run npx @uknows/setup first. It sets up your app and stores the key of your first environment.`)
|
|
104
|
+
return ['not-set-up', 1]
|
|
105
|
+
}
|
|
106
|
+
ui.print('This checks the key of 1 environment, for example Production, and stores it where your app runs. It changes no file in your repo.')
|
|
107
|
+
const detection = d.detect(root)
|
|
108
|
+
const repo = repoView(d.createWorkspace(root))
|
|
109
|
+
const names = d.deployNames(repo, detection.hosts)
|
|
110
|
+
const slug = d.gitRemote(root)
|
|
111
|
+
const plans = manifest.services.map((m) => ({ name: m.name, s: detection.services.find((s) => s.dir === m.dir) ?? { dir: m.dir, k8s: [] } }))
|
|
112
|
+
// Apps that keep their own metrics and logs got the traces-only settings: their key goes in the traces-only variable.
|
|
113
|
+
const tracesOnly = manifest.changes.some((c) => c.hunks.some((h) => h.after.some((l) => l.includes('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT')))
|
|
114
|
+
|| (c.created && (repo.read(c.file) ?? '').includes('OTEL_EXPORTER_OTLP_TRACES_ENDPOINT')))
|
|
115
|
+
const secretName = tracesOnly ? TRACES_HEADERS : SECRET.name
|
|
116
|
+
// The key of this terminal is often the one of the first environment: a no to it goes on to the paste question.
|
|
117
|
+
const app = { name: plans.map((p) => p.name).join(', '), repo: slug ?? undefined, platform: platformOf(detection.hosts) }
|
|
118
|
+
const checked = await checkKey(ui, d, env, false, plans[0].name, { paste: true, pasteKey: opts.pasteKey, app })
|
|
119
|
+
if (checked?.stopped && checked.interrupt) { ui.print('Stopped. Nothing changed.'); return ['cancelled', 130] }
|
|
120
|
+
report.check = checked?.entry ?? null
|
|
121
|
+
const line = keyCheckLine(checkVars(env))
|
|
122
|
+
if (!checked || checked.stopped) {
|
|
123
|
+
ui.print('No key given. Nothing changed.')
|
|
124
|
+
return ['no-key', 2]
|
|
125
|
+
}
|
|
126
|
+
if (checked.notReady) {
|
|
127
|
+
ui.print(NEW_KEY.againKey)
|
|
128
|
+
return ['key-refused', 1]
|
|
129
|
+
}
|
|
130
|
+
if (report.check.status !== 'reached') {
|
|
131
|
+
ui.print(report.check.status === 'rejected' ? SETUP_NEXT.rejected : SETUP_NEXT.network)
|
|
132
|
+
ui.print(` ${line}`)
|
|
133
|
+
ui.print('Then run npx @uknows/setup key again.')
|
|
134
|
+
return [report.check.status === 'rejected' ? 'key-refused' : 'network-error', 1]
|
|
135
|
+
}
|
|
136
|
+
const blocks = d.keySteps(detection.hosts, { view: repo, names, repo: slug, service: plans.length === 1 ? plans[0].name : undefined, secretName })
|
|
137
|
+
report.keySteps = blocks
|
|
138
|
+
const targets = keyTargets({ hosts: detection.hosts, names, view: repo, plans, repo: slug })
|
|
139
|
+
const environment = report.check.environment ?? null
|
|
140
|
+
const steps = await keyStepsInTerminal(ui, d.cli, blocks, targets, { key: bareKey(checked.headers), environment, env, root, secretName,
|
|
141
|
+
service: plans.length === 1 ? plans[0].name : null, services: plans.map((p) => p.name) })
|
|
142
|
+
report.stored = steps.stored.map((s) => s.done)
|
|
143
|
+
// The deploy files decide if the key stays: the checks that are open, the key step of a GitHub secret, and envFrom
|
|
144
|
+
// for a Kubernetes secret. key changes no file, so it prints them.
|
|
145
|
+
const rest = openChecks(blocks, { stored: steps.stored.map((s) => s.host), skipped: steps.skipped, secretName })
|
|
146
|
+
if (steps.github) rest.unshift(githubStepBlock(steps.github))
|
|
147
|
+
const k8s = steps.stored.some((s) => s.host === 'kubernetes') && !k8sLoadsKey(repo, plans)
|
|
148
|
+
if (k8s) rest.unshift(loadKeyBlock(blocks))
|
|
149
|
+
for (const block of rest) ui.block(block)
|
|
150
|
+
ui.print('Never put the key in a file in your repo.')
|
|
151
|
+
ui.print()
|
|
152
|
+
// A deploy file that drops the key (--replace-env-vars, environmentVariables) or a deployment without envFrom.
|
|
153
|
+
const drops = steps.github || k8s || deployEdits(repo, detection.hosts, plans, secretName).length > 0
|
|
154
|
+
const urls = plans.map((p) => connectUrl(p.name, environment, appUrl(env)))
|
|
155
|
+
const next = !steps.stored.length ? 'Do the steps above, then deploy as usual.'
|
|
156
|
+
: drops ? 'Your deploy files do not keep the key yet. Do the steps above, then deploy as usual.' : 'Your app gets the key when it starts again or with your next deploy.'
|
|
157
|
+
ui.print(`${next} ${watchLine(urls, environment)}`)
|
|
158
|
+
if (ui.terminal && urls[0]) d.openUrl(urls[0], env, () => ui.print(OPENING))
|
|
159
|
+
return [steps.stored.length ? 'stored' : 'steps', 0]
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* The app settings step of an Azure Functions app (settings.mjs), with the function app and resource group that the
|
|
164
|
+
* deploy files name. With more than 1 function app, the first one, as in the key script.
|
|
165
|
+
*/
|
|
166
|
+
function functionsSettingsNames(block, p, names) {
|
|
167
|
+
if (block.title !== FUNCTIONS.settings.title) return block
|
|
168
|
+
const fn = names.functions ?? { apps: [], groups: [] }
|
|
169
|
+
const group = new Set(fn.groups.map((g) => g.name)).size === 1 ? fn.groups[0] : null
|
|
170
|
+
const scriptNames = { ...(fn.apps[0] ? { app: fn.apps[0].name } : {}), ...(group ? { resourceGroup: group.name } : {}),
|
|
171
|
+
from: { ...(fn.apps[0] ? { app: fn.apps[0].where } : {}), ...(group ? { resourceGroup: group.where } : {}) } }
|
|
172
|
+
return { ...block, snippets: [settingsSnippet('azure-functions', p.settings.pairs ?? [], p.name, scriptNames)] }
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* How setup turns on OpenTelemetry for an Azure Functions app (stacks/functions.mjs): {route, signs, unknown,
|
|
177
|
+
* predicted}. route 'host': the line in host.json, when setup knows that the app does not send to Application Insights
|
|
178
|
+
* (a run in a terminal reads the names of the app settings of each function app that the deploy files name, and no
|
|
179
|
+
* deploy file sets the connection string), or with --functions-opentelemetry. route 'hooks': the UKNOWS worker hooks,
|
|
180
|
+
* which keep Application Insights exactly as it is, when the app sends to it or setup cannot tell. A dry run in a
|
|
181
|
+
* terminal shows the plan of a real run that finds no Application Insights (predicted). host.json that has the line
|
|
182
|
+
* already needs no decision.
|
|
183
|
+
*/
|
|
184
|
+
async function functionsDecision(s, it, { d, opts, ui, env, repo, detection, root }) {
|
|
185
|
+
if (!hostJsonEdit(repo.read(s.functions.hostJson)).after) return { route: 'host', signs: [] }
|
|
186
|
+
if (opts.functionsOpenTelemetry) return { route: 'host', signs: [] }
|
|
187
|
+
const signs = appInsightsSigns(s, repo, it.existing)
|
|
188
|
+
const deploys = signs.filter((x) => x.deploy)
|
|
189
|
+
if (automates(opts, ui, env)) {
|
|
190
|
+
const names = d.deployNames(repo, detection.hosts).functions ?? { apps: [], groups: [] }
|
|
191
|
+
const group = new Set(names.groups.map((g) => g.name)).size === 1 ? names.groups[0].name : null
|
|
192
|
+
const lookups = [...new Set(names.apps.map((a) => a.name))].map((app) => ({ app, group }))
|
|
193
|
+
const look = ui.progress('Looking at the app settings of your function app in Azure')
|
|
194
|
+
const facts = await functionAppSettingNames(d.cli, { root, env }, lookups)
|
|
195
|
+
look.end(null)
|
|
196
|
+
const platform = [...(facts ?? new Map())].flatMap(([app, list]) => (list ?? []).filter((n) => APP_INSIGHTS_SETTINGS.includes(n))
|
|
197
|
+
.map((n) => ({ file: `the app settings of ${app}`, what: n, deploy: false })))
|
|
198
|
+
const found = [...platform, ...deploys]
|
|
199
|
+
if (found.length) return { route: 'hooks', signs: found }
|
|
200
|
+
const known = Boolean(facts) && lookups.length > 0 && [...facts.values()].every((list) => Array.isArray(list))
|
|
201
|
+
return known ? { route: 'host', signs: [] } : { route: 'hooks', signs, unknown: true }
|
|
202
|
+
}
|
|
203
|
+
if (opts.dryRun && ui.interactive && !opts.json && !ciVariable(env) && !deploys.length) return { route: 'host', signs: [], predicted: true }
|
|
204
|
+
return deploys.length ? { route: 'hooks', signs: deploys } : { route: 'hooks', signs, unknown: true }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** The platform of the app for the sign-in page: where it runs, not the pipeline that deploys it. */
|
|
208
|
+
const platformOf = (hosts) => hosts.find((h) => h.id !== 'github-actions')?.id ?? hosts[0]?.id
|
|
209
|
+
|
|
210
|
+
/** The step that loads a Kubernetes secret: envFrom in each deployment, from the snippets of the Kubernetes block. */
|
|
211
|
+
/**
|
|
212
|
+
* True when every service of the run sends its logs to UKNOWS: the standard settings and the agent channel set
|
|
213
|
+
* OTEL_LOGS_EXPORTER=otlp. An app that keeps its own OpenTelemetry or Azure Monitor keeps its logs where they go now.
|
|
214
|
+
*/
|
|
215
|
+
const sendsLogs = (plans) => plans.length > 0 && plans.every((p) => p.mode === 'standard' || p.mode === 'channel')
|
|
216
|
+
|
|
217
|
+
function loadKeyBlock(blocks) {
|
|
218
|
+
const k8s = blocks.find((b) => b.title === `Set your key once: ${HOSTS.find((h) => h.id === 'kubernetes').label}`)
|
|
219
|
+
return { title: 'Load the key in your deployment', lines: ['Add envFrom to each deployment, so new pods get the key. Commit this change.'],
|
|
220
|
+
snippets: (k8s?.snippets ?? []).filter((x) => /envFrom/.test(x.code) && !/kubectl/.test(x.code)) }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** @param {{root: string, yes?: boolean, json?: boolean, force?: boolean, allowCi?: boolean, env?: Record<string, string|undefined>}} opts */
|
|
44
224
|
export async function runRemove(opts, deps) {
|
|
45
225
|
const d = { ...DEFAULTS, ...deps }
|
|
46
|
-
const report = { schema: 1, tool: TOOL, version: VERSION, command: 'remove', status: 'failed', exitCode: 1, files: [], commands: [], problems: []
|
|
226
|
+
const report = { schema: 1, tool: TOOL, version: VERSION, command: 'remove', status: 'failed', exitCode: 1, files: [], commands: [], problems: [],
|
|
227
|
+
ci: Boolean(ciVariable(opts.env)) }
|
|
47
228
|
return guard(d.ui, report, () => remove(opts, d, report))
|
|
48
229
|
}
|
|
49
230
|
|
|
@@ -91,7 +272,14 @@ async function setup(opts, d, report) {
|
|
|
91
272
|
ui.print(`Found ${services.length} services:`)
|
|
92
273
|
for (const s of services) ui.print(` ${s.dir}: ${describe(s)}`)
|
|
93
274
|
ui.print()
|
|
94
|
-
|
|
275
|
+
// A pipeline check (a plan with no terminal) of a repo that setup set up plans the services that it lists.
|
|
276
|
+
const listed = manifest ? services.filter((s) => manifest.services.some((m) => m.dir === s.dir)) : []
|
|
277
|
+
const planOnly = opts.dryRun || (opts.json && !opts.yes)
|
|
278
|
+
if (!opts.all && !ask && planOnly && listed.length) {
|
|
279
|
+
services = listed
|
|
280
|
+
ui.print(fillIn(SETUP_STATUS.recorded, { count: plural(listed.length, 'service'), manifest: CLI.manifest }))
|
|
281
|
+
ui.print()
|
|
282
|
+
} else if (!opts.all) {
|
|
95
283
|
if (!ask) {
|
|
96
284
|
report.services = services.map((s) => serviceReport(s))
|
|
97
285
|
ui.print(`Pick the services to set up. Use --all to set up all of them, or --path <folder> to set up 1. Or run this in a terminal${opts.yes ? ' without --yes' : ''} to pick from a list.`)
|
|
@@ -115,9 +303,10 @@ async function setup(opts, d, report) {
|
|
|
115
303
|
}
|
|
116
304
|
services = services.filter((s) => !frontend(s))
|
|
117
305
|
if (!services.length) {
|
|
306
|
+
// A frontend needs nothing from UKNOWS, so this is not a failure.
|
|
118
307
|
report.services = detection.services.map((x) => serviceReport(x))
|
|
119
308
|
ui.print(`Run this tool in the folder of your server app, or use --path <folder>. To set up UKNOWS by hand, see ${DOCS_URL}`)
|
|
120
|
-
return ['
|
|
309
|
+
return ['nothing-to-change', 0]
|
|
121
310
|
}
|
|
122
311
|
ui.print()
|
|
123
312
|
}
|
|
@@ -129,66 +318,149 @@ async function setup(opts, d, report) {
|
|
|
129
318
|
ui.print(`No app found in ${where(s.dir)}.`)
|
|
130
319
|
ui.print(`Run this tool in the folder of your app, or use --path <folder>. To set up UKNOWS by hand, see ${DOCS_URL}`)
|
|
131
320
|
report.services = services.map((x) => serviceReport(x))
|
|
132
|
-
return ['not-found',
|
|
321
|
+
return ['not-found', 1]
|
|
133
322
|
}
|
|
134
323
|
const pick = await ui.choose(`No app found in ${where(s.dir)}. What does your app use?`, [...MENU.map(stackLabel), 'Something else'])
|
|
135
324
|
if (pick === MENU.length) {
|
|
136
325
|
ui.print(`To set up UKNOWS by hand, see ${DOCS_URL}`)
|
|
137
326
|
ui.print(`AI coding tools can read the same steps at ${LLMS_URL}`)
|
|
138
327
|
report.services = services.map((x) => serviceReport(x))
|
|
139
|
-
return ['not-found',
|
|
328
|
+
return ['not-found', 1]
|
|
140
329
|
}
|
|
141
330
|
ui.print()
|
|
142
331
|
chosen.set(s.dir, MENU[pick])
|
|
143
332
|
if (MENU[pick] !== 'otel') s.lang = MENU[pick]
|
|
144
333
|
}
|
|
145
334
|
|
|
146
|
-
//
|
|
147
|
-
const items = services.map((s) =>
|
|
148
|
-
|
|
149
|
-
|
|
335
|
+
// A mode for each service before anything is planned. A stop in any service stops the whole run.
|
|
336
|
+
const items = services.map((s) => {
|
|
337
|
+
const existing = d.findExisting(s, repo)
|
|
338
|
+
const cls = d.classify(existing, { lang: s.lang, hosts: detection.hosts })
|
|
339
|
+
const recorded = manifest?.services.find((m) => m.dir === s.dir)
|
|
340
|
+
// A service that setup already did keeps its mode: its own packages must not turn it into an otel app.
|
|
341
|
+
let mode = cls.mode === 'stop' ? 'stop' : recorded ? recorded.mode ?? (recorded.stack === 'otel' ? 'otel' : 'standard') : s.mode ?? cls.mode
|
|
342
|
+
// The agent channel: asked for in this run, recorded by an earlier one, or the default (updates.mjs, step 1) for a
|
|
343
|
+
// service that setup did not set up before. Only for a standard service that fits; never for an Azure Functions app.
|
|
344
|
+
const fallback = !recorded && !s.functions ? opts.defaultChannel ?? null : null
|
|
345
|
+
const channel = (recorded?.mode === 'channel' ? recorded.channel : null) ?? opts.channel ?? fallback
|
|
346
|
+
const misfit = channel && mode === 'standard' ? channelMisfit(s) : null
|
|
347
|
+
if (channel && mode === 'standard' && !misfit) mode = 'channel'
|
|
348
|
+
return { s, name: opts.serviceName || s.name, existing, cls, mode, channel: mode === 'channel' ? channel : null,
|
|
349
|
+
channelDefault: mode === 'channel' && channel === fallback && !opts.channel,
|
|
350
|
+
channelNote: misfit && opts.channel ? `${misfit} So ${s.dir === '.' ? 'this service' : s.dir} gets the packages instead.` : null }
|
|
351
|
+
})
|
|
352
|
+
report.services = items.map((it) => serviceReport(it.s, { name: it.name, mode: it.mode, otel: it.existing.otel, apm: it.existing.apm ?? [] }))
|
|
353
|
+
const blocked = items.filter((it) => it.mode === 'stop')
|
|
150
354
|
if (blocked.length) {
|
|
151
|
-
for (const it of blocked) ui.block(d.explainApm(it.s, it.existing.apm))
|
|
355
|
+
for (const it of blocked) ui.block(it.cls.reason === 'apm' ? d.explainApm(it.s, it.existing.apm) : d.explainStop(it.s, it.existing, it.cls))
|
|
152
356
|
ui.print('Nothing changed.')
|
|
153
357
|
return ['refused', 1]
|
|
154
358
|
}
|
|
359
|
+
const tools = probeTools(d, repo, items)
|
|
155
360
|
|
|
156
361
|
// Plan each service. All edits go into 1 workspace, so each step reads the edits before it.
|
|
157
362
|
const plans = []
|
|
158
363
|
for (const it of items) {
|
|
159
364
|
const { s } = it
|
|
160
|
-
const stack = known(s) ?? chosen.get(s.dir) ?? (it.
|
|
161
|
-
|
|
365
|
+
const stack = known(s) ?? chosen.get(s.dir) ?? (it.mode === 'otel' ? 'otel' : s.lang)
|
|
366
|
+
// How the tool sets up this service: the classified mode, or otel for a stack picked as 'otel' from the menu.
|
|
367
|
+
const mode = stack === 'otel' ? 'otel' : it.mode
|
|
368
|
+
// An Azure Functions app (standard, or with App Insights in its code): the Functions host sends the data, or setup
|
|
369
|
+
// stands down (stacks/functions.mjs).
|
|
370
|
+
const fnApp = s.functions && (mode === 'standard' || mode === 'azure-monitor')
|
|
371
|
+
const id = fnApp ? 'functions' : mode === 'azure-monitor' ? 'azure' : mode === 'channel' ? 'channel' : stack
|
|
372
|
+
const applier = Object.prototype.hasOwnProperty.call(d.appliers, id) ? d.appliers[id] : null
|
|
162
373
|
if (!applier) throw new Error(`This tool has no steps for ${stack}.`)
|
|
163
374
|
report.problems.push(...s.problems)
|
|
164
375
|
const ambiguous = s.problems.find((x) => x.code === 'dockerfile-ambiguous')
|
|
165
376
|
if (ambiguous) {
|
|
166
377
|
// Editing the wrong Dockerfile changes nothing that ships: say so, and change nothing for this service.
|
|
167
|
-
const manual = [{ title: 'Pick the Dockerfile you deploy', lines: [ambiguous.message, 'Then run npx @uknows/setup again.'] }]
|
|
168
|
-
plans.push({ ...it, stack, plan: { summary: [], edits: [], commands: [], extraSettings: [], manual, notes: [] },
|
|
169
|
-
settings: { target: null, edits: [], summary: [], manual: [], notes: [] }, manual, notes: [] })
|
|
378
|
+
const manual = [{ title: 'Pick the Dockerfile you deploy', verify: true, lines: [ambiguous.message, 'Then run npx @uknows/setup again.'] }]
|
|
379
|
+
plans.push({ ...it, stack, mode, plan: { summary: [], edits: [], commands: [], extraSettings: [], manual: manual.map(tidy), notes: [] },
|
|
380
|
+
settings: { target: null, edits: [], summary: [], manual: [], notes: [] }, manual: manual.map(tidy), notes: [] })
|
|
170
381
|
continue
|
|
171
382
|
}
|
|
172
|
-
|
|
383
|
+
// The Functions host starts a function app: no start command to ask for.
|
|
384
|
+
if ((stack === 'node' || stack === 'python') && !s.start && !s.dockerfile && !s.functions && ask) {
|
|
173
385
|
const raw = await ui.ask('What command starts this app now? For example: node server.js. Press Enter to skip.')
|
|
174
386
|
if (raw) s.start = { kind: 'answer', file: '', line: 0, form: 'shell', raw, argv: shellWords(raw) }
|
|
175
387
|
}
|
|
176
|
-
const
|
|
388
|
+
const functions = fnApp ? await functionsDecision(s, it, { d, opts, ui, env, repo, detection, root }) : undefined
|
|
389
|
+
const ctx = { ws, repo, name: it.name, detection, endpoint, mode, channel: it.channel, existing: it.existing, installed: it.existing.installed, tools, manifest,
|
|
390
|
+
ignored: (file) => d.gitIgnored(root, file), functions }
|
|
177
391
|
const plan = applier.plan(s, ctx)
|
|
178
392
|
for (const edit of plan.edits) ws.stage(edit)
|
|
179
393
|
const settings = plan.stop ? { target: null, edits: [], summary: [], manual: [], notes: [] } : d.settingsPlan(s, ctx, plan.extraSettings ?? [])
|
|
180
394
|
for (const edit of settings.edits) ws.stage(edit)
|
|
181
|
-
|
|
182
|
-
|
|
395
|
+
const extra = existingNotes(it.existing, { hosts: detection.hosts, mode })
|
|
396
|
+
// otel mode: only the notes of the docs page that fit the language and what the repo uses.
|
|
397
|
+
const own = mode === 'otel' ? otelNotes(plan.notes, { lang: s.lang, existing: it.existing, tracesOnly: settings.tracesOnly, endpoint, docs: DOCS_URL }) : plan.notes
|
|
398
|
+
plans.push({ ...it, stack, mode, plan, settings, functions, manual: [...plan.manual, ...settings.manual, ...extra.advice].map(tidy),
|
|
399
|
+
notes: unique([...own, ...settings.notes, ...extra.notes, it.channelNote].filter(Boolean)) })
|
|
400
|
+
}
|
|
401
|
+
// How the agent stays up to date (updates.mjs): on by default; --update-bot and --channel choose. Each line shows with
|
|
402
|
+
// the first service that it is about (the channel's with the services on the channel, the update bot's with the
|
|
403
|
+
// services on the package route), and the pull request says which method was chosen and why.
|
|
404
|
+
let updates = { pr: [], methods: [] }
|
|
405
|
+
if (plans.length) {
|
|
406
|
+
updates = updatesPlan(plans, { ws, repo, bot: opts.updateBot ?? null, recorded: manifest?.updates ?? null, host: d.gitHost(root) })
|
|
407
|
+
for (const edit of updates.edits) ws.stage(edit)
|
|
408
|
+
const home = (names) => plans.find((p) => names.includes(p.name)) ?? plans[0]
|
|
409
|
+
for (const line of updates.summary) home(line === updates.channelLine ? updates.homes.channel : updates.homes.packages).plan.summary.push(line)
|
|
410
|
+
home(updates.homes.packages).manual.push(...updates.manual.map(tidy))
|
|
411
|
+
home(updates.homes.packages).notes.push(...updates.notes)
|
|
412
|
+
}
|
|
413
|
+
// The cloud id of each container app, in the command that deploys it (cloudid.mjs). az only reads, and only in a run
|
|
414
|
+
// in a terminal that asks (automates): never with --yes, --json or --dry-run, never in a pipeline. The run with 1 Yes
|
|
415
|
+
// reads after the key check, with the other reads (runOnce); --step-by-step reads here, before Apply. A dry run in a
|
|
416
|
+
// terminal shows the plan of a real run, as if az showed nothing in the way. Other runs change only the deploy steps
|
|
417
|
+
// that need no look in Azure.
|
|
418
|
+
const spots = cloudIdSpots(repo, detection.hosts)
|
|
419
|
+
const lookups = cloudIdLookups(spots)
|
|
420
|
+
const terminalRun = automates(opts, ui, env)
|
|
421
|
+
const dockerfiles = plans.map((p) => p.s.dockerfile).filter(Boolean)
|
|
422
|
+
const cloudLater = terminalRun && !opts.stepByStep && lookups.length > 0 && plans.length > 0 ? { dockerfiles } : null
|
|
423
|
+
let azureFacts = opts.dryRun && !opts.json && !ciVariable(env) ? PREDICT : null
|
|
424
|
+
if (terminalRun && opts.stepByStep && lookups.length > 0 && plans.length > 0) {
|
|
425
|
+
const look = ui.progress('Looking at your container apps in Azure')
|
|
426
|
+
azureFacts = await cloudIdFacts(d.cli, { root, env }, lookups)
|
|
427
|
+
look.end(null)
|
|
183
428
|
}
|
|
184
|
-
|
|
429
|
+
const cloudIds = plans.length ? cloudIdPlan(repo, spots, azureFacts, { dockerfiles }) : { edits: [], summary: [] }
|
|
430
|
+
for (const edit of cloudIds.edits) ws.stage(edit)
|
|
431
|
+
// After the settings of the service: it is 1 more setting, set by the deploy.
|
|
432
|
+
if (plans.length) plans[0].settings.summary = [...plans[0].settings.summary, ...cloudIds.summary]
|
|
433
|
+
const cloudId = cloudIds.edits.length > 0
|
|
434
|
+
// App Service that deploys the code, not an image, never runs the Dockerfile: the start and the settings go on App Service.
|
|
435
|
+
const names = d.deployNames(repo, detection.hosts)
|
|
436
|
+
for (const p of plans) p.manual.push(...appServiceCode(p, names, repo).map(tidy))
|
|
437
|
+
for (const p of plans.filter((x) => x.s.functions)) p.manual = p.manual.map((b) => functionsSettingsNames(b, p, names))
|
|
438
|
+
report.services = plans.map((p) => serviceReport(p.s, { stack: p.stack, mode: p.mode, name: p.name, settings: p.settings.target ?? null,
|
|
185
439
|
summary: steps(p), commands: p.plan.commands.map((c) => ({ cwd: c.cwd, argv: [c.cmd, ...c.args] })),
|
|
186
440
|
manual: p.manual, notes: p.notes, otel: p.existing.otel }))
|
|
187
|
-
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
441
|
+
// The key steps of each host, fitted to this repo: the names in its deploy files, how its apps start, the settings of
|
|
442
|
+
// this run, and the key variable of apps that keep their own metrics and logs (traces only).
|
|
443
|
+
const one = plans.length === 1 ? plans[0] : null
|
|
444
|
+
const tracesOnly = plans.length > 0 && plans.every((p) => p.settings.tracesOnly)
|
|
445
|
+
if (!tracesOnly) for (const p of plans.filter((x) => x.settings.tracesOnly)) p.notes.push(`For ${p.name}, use ${TRACES_HEADERS} instead of ${SECRET.name} in the key steps below.`)
|
|
446
|
+
const slug = d.gitRemote(root)
|
|
447
|
+
const secretName = tracesOnly ? TRACES_HEADERS : SECRET.name
|
|
448
|
+
const keyBlocks = d.keySteps(detection.hosts, {
|
|
449
|
+
view: repo, names, repo: slug, service: one?.name,
|
|
450
|
+
// How to load otel.env only when no repo file loads it. otel.env and systemd only when a service needs them.
|
|
451
|
+
envFile: plans.some((p) => p.settings.target?.kind === 'env-file' && !p.settings.target.loader),
|
|
452
|
+
otelEnv: plans.some((p) => p.settings.target?.kind === 'env-file'),
|
|
453
|
+
docker: plans.length > 0 && plans.every((p) => p.settings.target?.kind === 'dockerfile'),
|
|
454
|
+
settings: one?.settings.pairs?.length ? one.settings.pairs : null,
|
|
455
|
+
startLine: plans.some((p) => p.mode === 'standard' && START_PREFIX[p.stack]),
|
|
456
|
+
secretName,
|
|
457
|
+
})
|
|
458
|
+
// A function app where OpenTelemetry stays off (functionsDecision) gets no key: the key would only restart the app.
|
|
459
|
+
const fnPlans = plans.filter((p) => p.s.functions)
|
|
460
|
+
const fnOff = fnPlans.length > 0 && fnPlans.every((p) => p.plan.stop)
|
|
461
|
+
report.keySteps = !fnOff ? keyBlocks : fnPlans.length === plans.length ? [] : keyBlocks.filter((b) => b.title !== FUNCTIONS.key.title)
|
|
462
|
+
// Where a run in a terminal can store the key itself (automate.mjs). Only read from the repo here.
|
|
463
|
+
const targets = keyTargets({ hosts: detection.hosts, names, view: repo, plans, repo: slug })
|
|
192
464
|
const changes = ws.changes()
|
|
193
465
|
// A file that holds a key is never changed, so the key cannot reach the diff or the manifest.
|
|
194
466
|
const keyed = changes.filter((c) => hasKey(c.before) || hasKey(c.after)).map((c) => c.file)
|
|
@@ -203,36 +475,121 @@ async function setup(opts, d, report) {
|
|
|
203
475
|
}
|
|
204
476
|
const commands = plans.flatMap((p) => p.plan.commands)
|
|
205
477
|
const manual = plans.flatMap((p) => p.manual)
|
|
206
|
-
const
|
|
478
|
+
const required = manual.filter((b) => b.required)
|
|
479
|
+
const exit = required.length ? 3 : 0
|
|
480
|
+
const planOnly = opts.dryRun || (opts.json && !opts.yes)
|
|
481
|
+
const app = { name: plans.map((p) => p.name).join(', '), repo: slug ?? undefined, platform: platformOf(detection.hosts) }
|
|
482
|
+
const KEEP = 'Keep the agent up to date: '
|
|
483
|
+
const keep = (updates.summary ?? []).filter((line) => line.startsWith(KEEP)).map((line) => line.slice(KEEP.length))
|
|
484
|
+
const last = { ask, opts, env, plans, required, root, hosts: detection.hosts, targets, secretName, app, cloudId, cloudLater, updates: updates.pr, bot: updates.bot, keep }
|
|
485
|
+
const record = plans.map((p) => ({ dir: p.s.dir, stack: p.stack, name: p.name, mode: p.mode, ...(p.channel ? { channel: p.channel } : {}),
|
|
486
|
+
...(p.manual.some((b) => b.required && b.verify) ? { steps: p.manual.filter((b) => b.required && b.verify).map((b) => b.title) } : {}) }))
|
|
487
|
+
// In a terminal, the default is 1 Yes: sign in, show the whole plan, ask once, do it all (runOnce). --step-by-step
|
|
488
|
+
// keeps a question for each step. A dry run shows the same plan, unless it is --step-by-step or in a pipeline.
|
|
489
|
+
const once = automates(opts, ui, env) && !opts.stepByStep
|
|
490
|
+
const dryOnce = opts.dryRun && !opts.json && !opts.stepByStep && !ciVariable(env)
|
|
491
|
+
const facts = { ...last, ws, changes, commands, manifest, record, detection, view: repo, manual }
|
|
207
492
|
|
|
208
493
|
if (!changes.length && !commands.length) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
494
|
+
// Set up: the manifest lists every service. Steps that this tool can check in the repo and that are not done
|
|
495
|
+
// yet leave it at steps-left (exit 3). Steps that it cannot check (often done on the platform) do not.
|
|
496
|
+
const recorded = Boolean(manifest) && plans.every((p) => manifest.services.some((m) => m.dir === p.s.dir))
|
|
497
|
+
if (recorded) {
|
|
498
|
+
const left = required.filter((b) => b.verify)
|
|
499
|
+
if (left.length) {
|
|
500
|
+
ui.print(fillIn(SETUP_STATUS.stepsLeft, { count: plural(left.length, 'step'), verb: left.length === 1 ? 'is' : 'are', pronoun: left.length === 1 ? 'it' : 'them' }))
|
|
501
|
+
ui.print()
|
|
502
|
+
for (const block of left) ui.block(block)
|
|
503
|
+
printManual(ui, manual.filter((b) => !left.includes(b)), true, SETUP_STATUS.doneSteps)
|
|
504
|
+
return ['steps-left', 3]
|
|
505
|
+
}
|
|
506
|
+
// After a no to the pull request, the files of setup are not committed yet: a rerun in a terminal asks again.
|
|
507
|
+
// It reads git only. The key steps are not asked again: npx @uknows/setup key does those. Then the first line
|
|
508
|
+
// says that setup has nothing more to change, so the question to commit does not read as a contradiction.
|
|
509
|
+
const terminal = automates(opts, ui, env)
|
|
510
|
+
const paths = [...manifestPaths(manifest)]
|
|
511
|
+
const pending = terminal ? await notCommitted(d.cli, { root, env, paths }) : []
|
|
512
|
+
ui.print(pending.length ? RERUN_UNCOMMITTED : SETUP_STATUS.done)
|
|
513
|
+
printManual(ui, manual, true, SETUP_STATUS.doneSteps)
|
|
514
|
+
if (terminal) {
|
|
515
|
+
const urls = plans.map((p) => connectUrl(p.name, null, appUrl(env)))
|
|
516
|
+
// .uknows/setup.json does not say if the key is stored, so the pull request and the last line say so (key: 'unknown').
|
|
517
|
+
const pr = await pullRequest(ui, d.cli, { root, env, paths, left: required.map((b) => b.title),
|
|
518
|
+
services: plans.map((p) => p.name), url: urls[0], rerun: true, key: 'unknown', logs: sendsLogs(plans), updates: updates.pr })
|
|
519
|
+
if (pr.state !== 'none') {
|
|
520
|
+
report.next = lastLine({ state: pr.state, urls, key: 'unknown' })
|
|
521
|
+
ui.print(report.next)
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return ['nothing-to-change', 0]
|
|
525
|
+
}
|
|
526
|
+
// Nothing for this tool to change. Show where the app runs, the steps, and the key steps, and write the record so
|
|
527
|
+
// the pipeline check and remove know this service.
|
|
528
|
+
const status = exit ? 'needs-steps' : 'nothing-to-change'
|
|
529
|
+
// No record for a service that this tool could not set up: a Dockerfile to pick, or a plan that stopped.
|
|
530
|
+
const stopped = plans.some((p) => p.plan.stop || p.settings.stop || p.s.problems.some((x) => x.code === 'dockerfile-ambiguous'))
|
|
531
|
+
ui.print(exit ? SETUP_STATUS.steps : SETUP_STATUS.done)
|
|
532
|
+
ui.print()
|
|
533
|
+
const plain = !(dryOnce && !stopped) && !(once && !stopped)
|
|
534
|
+
if (plain) {
|
|
535
|
+
printHosts(ui, detection)
|
|
536
|
+
printManual(ui, manual)
|
|
537
|
+
}
|
|
538
|
+
if (dryOnce && !stopped) {
|
|
539
|
+
await printDryOnce(ui, d, report, { ...facts, recordOnly: true })
|
|
540
|
+
return [status, exit]
|
|
541
|
+
}
|
|
542
|
+
if (opts.dryRun && !opts.json) await printQuestions(ui, d, report, { ...last, view: repo, files: [CLI.manifest], manifest, apply: false })
|
|
543
|
+
if (planOnly || stopped || (ciVariable(opts.env) && !opts.allowCi)) return [status, exit]
|
|
544
|
+
if (once) return runOnce(ui, d, report, { ...facts, recordOnly: true, status, exit, declined: [status, exit] })
|
|
545
|
+
ui.print()
|
|
546
|
+
ui.print(fillIn(SETUP_STATUS.record, { manifest: CLI.manifest }))
|
|
547
|
+
if (!opts.yes) {
|
|
548
|
+
if (!ui.interactive) { ui.print(`Run again with --yes to write ${CLI.manifest}.`); return [status, exit] }
|
|
549
|
+
if (!(await ui.confirm(`Write ${CLI.manifest}? (Y/n)`))) { ui.print('Nothing changed.'); return [status, exit] }
|
|
550
|
+
}
|
|
551
|
+
const saved = await apply({ root, ws, changes: [], commands: [], manifest, services: record, d })
|
|
552
|
+
if (!saved) return ['failed', 1]
|
|
553
|
+
ui.print(`Wrote ${CLI.manifest}. To undo it, run npx @uknows/setup remove.`)
|
|
554
|
+
// The steps printed above already (plain): finish does not print them again.
|
|
555
|
+
return (await finish(ui, d, report, { ...last, shown: plain })) ?? [status, exit]
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
// A run that applies checks the pipeline and the git tree first, before the long plan.
|
|
559
|
+
if (!planOnly) {
|
|
560
|
+
if (stopInCi(ui, report, opts, env)) return ['planned', 2]
|
|
561
|
+
if (!opts.force) {
|
|
562
|
+
const git = d.gitStatus(root, manifestPaths(manifest))
|
|
563
|
+
if (!git.repo) {
|
|
564
|
+
const message = git.git ? 'This folder is not in a git repo. Keep a copy, so you can review what this tool changes.'
|
|
565
|
+
: 'git is not installed, so this tool cannot check for changes that are not committed.'
|
|
566
|
+
ui.print(`Note: ${message}`)
|
|
567
|
+
report.problems.push({ code: 'no-git', message })
|
|
568
|
+
} else if (git.paths.length) {
|
|
569
|
+
ui.error('This repo has changes that are not committed:')
|
|
570
|
+
for (const path of git.paths.slice(0, 10)) ui.error(` ${path}`)
|
|
571
|
+
if (git.paths.length > 10) ui.error(` and ${git.paths.length - 10} more`)
|
|
572
|
+
ui.error('Commit or stash your changes first, so you can review what this tool changes. Or run again with --force.')
|
|
573
|
+
report.problems.push({ code: 'dirty-tree', message: `${plural(git.paths.length, 'path')} in the repo are not committed.` })
|
|
574
|
+
return ['refused', 1]
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
if (dryOnce) {
|
|
579
|
+
await printDryOnce(ui, d, report, facts)
|
|
580
|
+
return ['planned', exit]
|
|
212
581
|
}
|
|
582
|
+
if (once) return runOnce(ui, d, report, { ...facts, status: 'applied', exit, declined: ['planned', 0] })
|
|
213
583
|
printPlan(ui, plans, detection, changes, commands, opts)
|
|
214
|
-
if (
|
|
584
|
+
if (planOnly) {
|
|
215
585
|
printManual(ui, manual)
|
|
586
|
+
if (opts.dryRun && !opts.json) {
|
|
587
|
+
const files = unique([...changes.map((c) => c.file), ...commands.flatMap((c) => c.touches), CLI.manifest])
|
|
588
|
+
await printQuestions(ui, d, report, { ...last, view: repo, files, manifest, apply: true })
|
|
589
|
+
}
|
|
216
590
|
ui.print('This was a dry run. Nothing changed.')
|
|
217
591
|
return ['planned', exit]
|
|
218
592
|
}
|
|
219
|
-
|
|
220
|
-
if (!opts.force) {
|
|
221
|
-
const git = d.gitStatus(root, manifestPaths(manifest))
|
|
222
|
-
if (!git.repo) {
|
|
223
|
-
const message = git.git ? 'This folder is not in a git repo. Keep a copy, so you can review what this tool changes.'
|
|
224
|
-
: 'git is not installed, so this tool cannot check for changes that are not committed.'
|
|
225
|
-
ui.print(`Note: ${message}`)
|
|
226
|
-
report.problems.push({ code: 'no-git', message })
|
|
227
|
-
} else if (git.paths.length) {
|
|
228
|
-
ui.error('This repo has changes that are not committed:')
|
|
229
|
-
for (const path of git.paths.slice(0, 10)) ui.error(` ${path}`)
|
|
230
|
-
if (git.paths.length > 10) ui.error(` and ${git.paths.length - 10} more`)
|
|
231
|
-
ui.error('Commit or stash your changes first, so you can review what this tool changes. Or run again with --force.')
|
|
232
|
-
report.problems.push({ code: 'dirty-tree', message: `${plural(git.paths.length, 'path')} in the repo are not committed.` })
|
|
233
|
-
return ['refused', 1]
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
593
|
if (!opts.yes) {
|
|
237
594
|
if (!ui.interactive) {
|
|
238
595
|
ui.print('Run again with --yes to apply.')
|
|
@@ -244,17 +601,565 @@ async function setup(opts, d, report) {
|
|
|
244
601
|
}
|
|
245
602
|
}
|
|
246
603
|
|
|
247
|
-
const
|
|
248
|
-
const saved = await apply({ root, ws, changes, commands, manifest, services: record, d })
|
|
604
|
+
const saved = await apply({ root, ws, changes, commands, manifest, services: record, updates: updates.bot, d })
|
|
249
605
|
if (!saved) return ['failed', 1]
|
|
250
606
|
report.files = printResult(ui, ws, saved, changes, commands)
|
|
251
|
-
|
|
607
|
+
return (await finish(ui, d, report, last)) ?? ['applied', exit]
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The key variable of apps that keep their own OTLP metrics and logs: only the traces exporter reads it. */
|
|
611
|
+
const TRACES_HEADERS = 'OTEL_EXPORTER_OTLP_TRACES_HEADERS'
|
|
612
|
+
/** The start line that a stack needs on a platform that does not run the Dockerfile. */
|
|
613
|
+
const START_PREFIX = { python: 'opentelemetry-instrument', dotnet: 'sh ./instrument.sh' }
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* After the files: ask for the key and check it, then the steps in the repo, the key steps of each platform, and 1
|
|
617
|
+
* line on what to do next. The next line depends on the check and on the steps left. In a terminal (automates()),
|
|
618
|
+
* setup then asks to do the last steps itself (finishInTerminal). Returns [status, exit code] that replace the ones of
|
|
619
|
+
* the repo, or null: a terminal run whose key UKNOWS refuses ends with 1, and Ctrl+C during the sign-in with 130.
|
|
620
|
+
*/
|
|
621
|
+
async function finish(ui, d, report, ctx) {
|
|
622
|
+
const { ask, opts, env, plans, required } = ctx
|
|
623
|
+
// --yes asks nothing. In a terminal with the key already set, it still checks that key.
|
|
624
|
+
const auto = !ask && opts.yes && ui.terminal && Boolean(env[SECRET.name]?.trim())
|
|
625
|
+
let checked = ask || auto ? await checkKey(ui, d, env, auto, plans[0]?.name ?? null, { pasteKey: opts.pasteKey, app: ctx.app }) : null
|
|
626
|
+
if (checked?.stopped && checked.interrupt) {
|
|
627
|
+
// Ctrl+C while setup waits for the sign-in: stop here. The files are changed already.
|
|
628
|
+
ui.print(`Stopped. The changes to your files stay. To undo them, run npx @uknows/setup remove.`)
|
|
629
|
+
return ['cancelled', 130]
|
|
630
|
+
}
|
|
631
|
+
if (checked?.stopped) checked = null
|
|
632
|
+
report.check = checked?.entry ?? null
|
|
633
|
+
if (automates(opts, ui, env)) {
|
|
634
|
+
const code = await finishInTerminal(ui, d, report, { ...ctx, headers: checked?.headers ?? null, notReady: Boolean(checked?.notReady) })
|
|
635
|
+
return code === null ? null : ['key-refused', code]
|
|
636
|
+
}
|
|
637
|
+
if (!ctx.shown) printManual(ui, plans.flatMap((p) => p.manual), true)
|
|
252
638
|
for (const block of report.keySteps) ui.block(block)
|
|
253
639
|
ui.print('Never put the key in a file in your repo.')
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
640
|
+
ui.print()
|
|
641
|
+
const line = keyCheckLine(checkVars(env))
|
|
642
|
+
const status = report.check?.status
|
|
643
|
+
if (checked?.notReady) {
|
|
644
|
+
// A new key that UKNOWS did not get ready in 2 minutes: nothing to fix in the key, so no key advice.
|
|
645
|
+
ui.print(NEW_KEY.againFiles)
|
|
646
|
+
report.next = NEW_KEY.againFiles
|
|
647
|
+
return null
|
|
648
|
+
}
|
|
649
|
+
if (status === 'rejected' || status === 'network-error') {
|
|
650
|
+
ui.print(status === 'rejected' ? SETUP_NEXT.rejected : SETUP_NEXT.network)
|
|
651
|
+
ui.print(` ${line}`)
|
|
652
|
+
report.next = status === 'rejected' ? SETUP_NEXT.rejected : SETUP_NEXT.network
|
|
653
|
+
return null
|
|
654
|
+
}
|
|
655
|
+
if (!report.check) {
|
|
656
|
+
ui.print(SETUP_NEXT.skipped)
|
|
657
|
+
ui.print(` ${line}`)
|
|
658
|
+
ui.print()
|
|
659
|
+
}
|
|
660
|
+
report.next = required.length ? fillIn(SETUP_NEXT.steps, { count: plural(required.length, 'step') }) : nextData(env)
|
|
661
|
+
ui.print(report.next)
|
|
662
|
+
return null
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* The end of a run in a terminal: store the key on the platform, add the key to the deploy files, open the pull
|
|
667
|
+
* request, each after its own question (automate.mjs). What the user does not say yes to prints as today. A refused key
|
|
668
|
+
* stops here with exit 1: its steps print, and nothing is stored or committed.
|
|
669
|
+
*/
|
|
670
|
+
async function finishInTerminal(ui, d, report, { env, plans, required, root, hosts, targets, secretName, headers, cloudId = false, shown = false, updates = [], notReady = false }) {
|
|
671
|
+
const line = keyCheckLine(checkVars(env))
|
|
672
|
+
const status = report.check?.status
|
|
673
|
+
if (!shown) printManual(ui, plans.flatMap((p) => p.manual), true)
|
|
674
|
+
if (status === 'rejected') {
|
|
675
|
+
for (const block of report.keySteps) ui.block(block)
|
|
676
|
+
ui.print('Never put the key in a file in your repo.')
|
|
677
|
+
ui.print()
|
|
678
|
+
// A new key that UKNOWS did not get ready in 2 minutes: nothing to fix in the key, so no key advice.
|
|
679
|
+
report.next = notReady ? NEW_KEY.againFiles : SETUP_NEXT.rejected
|
|
680
|
+
ui.print(report.next)
|
|
681
|
+
if (!notReady) ui.print(` ${line}`)
|
|
682
|
+
return 1
|
|
683
|
+
}
|
|
684
|
+
const environment = report.check?.environment ?? null
|
|
685
|
+
const steps = await keyStepsInTerminal(ui, d.cli, report.keySteps, targets, { key: status === 'reached' ? bareKey(headers) : null,
|
|
686
|
+
environment, env, root, secretName, service: plans.length === 1 ? plans[0].name : null, services: plans.map((p) => p.name) })
|
|
687
|
+
const view = () => repoView(d.createWorkspace(root))
|
|
688
|
+
const githubStep = steps.github ? githubStepLines(steps.github) : null
|
|
689
|
+
const applied = await deployFilesInTerminal(ui, deployEdits(view(), hosts, plans, secretName, { githubStep }), (edit) => applyDeployEdit(d.createWorkspace(root), edit))
|
|
690
|
+
const rest = openChecks(report.keySteps, { edits: applied, stored: steps.stored.map((s) => s.host), skipped: steps.skipped, secretName })
|
|
691
|
+
// The GitHub secret is stored: the step that reads it is left, unless the deploy file question added it.
|
|
692
|
+
if (steps.github && !applied.some((e) => e.checks.includes('github-step'))) rest.unshift(githubStepBlock(steps.github))
|
|
693
|
+
// A Kubernetes secret is only half: each deployment loads it with envFrom. Without that edit, the step stays.
|
|
694
|
+
if (steps.stored.some((s) => s.host === 'kubernetes') && !k8sLoadsKey(view(), plans)) rest.unshift(loadKeyBlock(report.keySteps))
|
|
695
|
+
for (const block of rest) ui.block(block)
|
|
696
|
+
ui.print('Never put the key in a file in your repo.')
|
|
697
|
+
ui.print()
|
|
698
|
+
const urls = plans.map((p) => connectUrl(p.name, environment, appUrl(env)))
|
|
699
|
+
const left = unique([...plans.flatMap((p) => p.manual).filter((b) => b.required).map((b) => b.title), ...steps.left, ...rest.map((b) => b.title)])
|
|
700
|
+
// Nothing stored and the key steps printed: a skipped key check, a no to the store question or a store that failed.
|
|
701
|
+
// The pull request and the last line then say to store the key before the merge (F-012-153).
|
|
702
|
+
const key = !steps.stored.length && steps.left.length ? 'left' : null
|
|
703
|
+
const pr = await pullRequest(ui, d.cli, { root, env, paths: [...manifestPaths(readManifest(root))], left, done: steps.stored.map((s) => s.done),
|
|
704
|
+
services: plans.map((p) => p.name), url: urls[0], environment, key, logs: sendsLogs(plans), cloudId, updates })
|
|
705
|
+
if (!report.check || status === 'network-error') {
|
|
706
|
+
ui.print(report.check ? SETUP_NEXT.network : SETUP_NEXT.skipped)
|
|
707
|
+
ui.print(` ${line}`)
|
|
708
|
+
ui.print()
|
|
709
|
+
}
|
|
710
|
+
report.next = lastLine({ state: pr.state, required: required.length, urls, environment, key })
|
|
711
|
+
ui.print(report.next)
|
|
712
|
+
if (ui.terminal && urls[0]) d.openUrl(urls[0], env, () => ui.print(OPENING))
|
|
713
|
+
return null
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
* A run in a terminal with 1 Yes (the default). Nothing changes until the Yes:
|
|
718
|
+
* 1. the key: from this terminal, a sign-in in the browser, or pasted (--paste-key, or p while waiting), then the check;
|
|
719
|
+
* 2. read only: where the key goes (az, gh, kubectl), the deploy files that would drop it, and git for the pull request;
|
|
720
|
+
* 3. the whole plan, and 1 question: Do all of this? (Y/n). d shows every line that changes;
|
|
721
|
+
* 4. every step, 1 line each with done or failed: the files, the key, the deploy files, the pull request;
|
|
722
|
+
* 5. what is left for the person, and the last line with the link to watch the app connect, which opens.
|
|
723
|
+
* A refused key stops before anything changes (exit 1). Ctrl+C while waiting for the sign-in: exit 130. A failed step
|
|
724
|
+
* after the files does not stop the others: its steps print at the end. Returns [status, exit code].
|
|
725
|
+
*/
|
|
726
|
+
async function runOnce(ui, d, report, ctx) {
|
|
727
|
+
const { opts, env, plans, required, root, hosts, targets, secretName, ws, commands, manifest, record, recordOnly = false, app, cloudLater = null, updates = [] } = ctx
|
|
728
|
+
let { changes, cloudId = false } = ctx
|
|
729
|
+
const services = plans.map((p) => p.name)
|
|
730
|
+
const checked = await checkKey(ui, d, env, false, plans[0]?.name ?? null, { once: true, pasteKey: opts.pasteKey, app })
|
|
731
|
+
if (checked?.stopped) {
|
|
732
|
+
ui.print('Stopped. Nothing changed.')
|
|
733
|
+
return checked.interrupt ? ['cancelled', 130] : ['stopped', 1]
|
|
734
|
+
}
|
|
735
|
+
report.check = checked?.entry ?? null
|
|
736
|
+
const status = report.check?.status ?? null
|
|
737
|
+
if (status === 'rejected') {
|
|
738
|
+
if (checked.notReady) ui.print(NEW_KEY.again)
|
|
739
|
+
else if (checked.from === 'signin') ui.print('UKNOWS did not accept the new key. Run npx @uknows/setup again in a minute. Nothing changed.')
|
|
740
|
+
else {
|
|
741
|
+
ui.print(SETUP_NEXT.rejected)
|
|
742
|
+
ui.print(` ${keyCheckLine(checkVars(env))}`)
|
|
743
|
+
ui.print('Nothing changed.')
|
|
744
|
+
}
|
|
745
|
+
report.next = checked.notReady ? NEW_KEY.again : SETUP_NEXT.rejected
|
|
746
|
+
return ['key-refused', 1]
|
|
747
|
+
}
|
|
748
|
+
const mismatch = addressMismatch(checked?.link, opts.endpoint ?? INGEST_ENDPOINT)
|
|
749
|
+
if (mismatch) { ui.error(mismatch); return ['needs-input', 2] }
|
|
750
|
+
const environment = report.check?.environment ?? null
|
|
751
|
+
const keyCtx = { key: status === 'reached' ? bareKey(checked.headers) : null, environment, env, root, secretName,
|
|
752
|
+
service: plans.length === 1 ? plans[0].name : null, services }
|
|
753
|
+
|
|
754
|
+
// Read only: where the key goes, the deploy files, and git.
|
|
755
|
+
const look = ui.progress('Looking at where your app runs')
|
|
756
|
+
const stores = await planKeyStores(d.cli, report.keySteps, targets, keyCtx)
|
|
757
|
+
// The cloud id of a container app whose deploy command needs a look in Azure first (cloudid.mjs): read only.
|
|
758
|
+
if (cloudLater && !recordOnly) {
|
|
759
|
+
const view = repoView(ws)
|
|
760
|
+
const spots = cloudIdSpots(view, hosts)
|
|
761
|
+
const later = cloudIdPlan(view, spots, await cloudIdFacts(d.cli, { root, env }, cloudIdLookups(spots)), { dockerfiles: cloudLater.dockerfiles })
|
|
762
|
+
for (const edit of later.edits) ws.stage(edit)
|
|
763
|
+
if (later.edits.length) {
|
|
764
|
+
changes = ws.changes()
|
|
765
|
+
cloudId = true
|
|
766
|
+
if (plans.length) plans[0].settings.summary = [...plans[0].settings.summary, ...later.summary]
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
const ghStore = stores.stores.find((x) => x.t.kind === 'github')
|
|
770
|
+
const edits = deployEdits(repoView(ws), hosts, plans, secretName, { githubStep: ghStore && !ghStore.t.readsKey && !ghStore.found.partial ? githubStepLines(ghStore.block) : null })
|
|
771
|
+
const pr = await prPlan(d.cli, { root, env })
|
|
772
|
+
look.end(null)
|
|
773
|
+
const urls = plans.map((p) => connectUrl(p.name, environment, appUrl(env)))
|
|
774
|
+
const sendsTo = report.check?.workspace ? `${report.check.workspace}${environment ? `, environment ${environment}` : ''}` : null
|
|
775
|
+
printOnePlan(ui, onePlan({ ...ctx, changes, sendsTo, stores, edits, pr, urls, keyStatus: status, dry: false, keySteps: report.keySteps }))
|
|
776
|
+
if (!(await askOnce(ui, () => diffLines(changes, commands, edits)))) {
|
|
777
|
+
ui.print('Nothing changed.')
|
|
778
|
+
return ctx.declined ?? ['planned', 0]
|
|
779
|
+
}
|
|
780
|
+
ui.print()
|
|
781
|
+
|
|
782
|
+
// 1. The files, and the record that remove reads.
|
|
783
|
+
const quiet = muted(ui)
|
|
784
|
+
let saved = null
|
|
785
|
+
const files = await step(ui, recordOnly ? `Writing ${CLI.manifest}` : 'Adding OpenTelemetry to your app', async () => {
|
|
786
|
+
saved = await apply({ root, ws, changes, commands, manifest, services: record, updates: ctx.bot, d: { ...d, ui: quiet, spawn: (cmd, args, o) => d.spawn(cmd, args, { ...o, quiet: true }) } })
|
|
787
|
+
if (!saved) return { ok: false, line: 'Could not change your files. Nothing changed.', more: quiet.lines }
|
|
788
|
+
report.files = resultFiles(ws, saved, changes, commands).files
|
|
789
|
+
return { ok: true, line: recordOnly ? `Wrote ${CLI.manifest}` : `Added OpenTelemetry to your app (${plural(report.files.length, 'file')} changed)` }
|
|
790
|
+
})
|
|
791
|
+
if (!files.ok) return ['failed', 1]
|
|
792
|
+
|
|
793
|
+
// 2. The key, where setup found it can go.
|
|
794
|
+
const done = { stored: [], left: [], skipped: stores.skipped.map((x) => x.host), github: null }
|
|
795
|
+
let directFailed = false
|
|
796
|
+
for (const x of stores.stores) {
|
|
797
|
+
let put = { stored: false }
|
|
798
|
+
await step(ui, `Storing your key on ${storeName(x.found)}`, async () => {
|
|
799
|
+
put = await PUT[x.found.kind](d.cli, x.found, keyCtx)
|
|
800
|
+
if (put.stored) return { ok: true, line: put.done.replace(/\.$/, '') }
|
|
801
|
+
return { ok: false, line: put.why, more: [put.again === false ? 'Do the key steps at the end.' : 'Do the key steps at the end, or run npx @uknows/setup key to try again.'] }
|
|
802
|
+
})
|
|
803
|
+
if (put.stored) {
|
|
804
|
+
done.stored.push({ host: x.host, done: put.done })
|
|
805
|
+
if (x.t.kind === 'github' && put.partial) done.left.push(x.block.title)
|
|
806
|
+
else if (x.t.kind === 'github' && !x.t.readsKey) done.github = x.block
|
|
807
|
+
} else {
|
|
808
|
+
done.left.push(x.block.title)
|
|
809
|
+
if (x.t.kind !== 'github') directFailed = true
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
// The key is not on the app after all: the GitHub Actions block is needed again.
|
|
813
|
+
if (directFailed) for (const x of stores.skipped) { done.left.push(x.block.title); done.skipped = done.skipped.filter((h) => h !== x.host) }
|
|
814
|
+
|
|
815
|
+
// 3. The deploy files, as planned. The key step of a GitHub secret only when that secret is stored now.
|
|
816
|
+
const view = () => repoView(d.createWorkspace(root))
|
|
817
|
+
const planned = new Set(edits.map((e) => e.file))
|
|
818
|
+
const applied = []
|
|
819
|
+
for (const edit of deployEdits(view(), hosts, plans, secretName, { githubStep: done.github ? githubStepLines(done.github) : null }).filter((e) => planned.has(e.file))) {
|
|
820
|
+
const where = `${edit.file} ${edit.lines.length > 1 ? 'lines' : 'line'} ${list(edit.lines.map(String))}`
|
|
821
|
+
await step(ui, `Adding the key to ${edit.file}`, async () => {
|
|
822
|
+
try {
|
|
823
|
+
applyDeployEdit(d.createWorkspace(root), edit)
|
|
824
|
+
applied.push(edit)
|
|
825
|
+
return { ok: true, line: `Added the key to ${where}` }
|
|
826
|
+
} catch (error) {
|
|
827
|
+
return { ok: false, line: `Could not change ${edit.file}: ${error.message}. It is as it was.` }
|
|
828
|
+
}
|
|
829
|
+
})
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// What is left for the person: steps in the repo, key steps, and deploy file checks.
|
|
833
|
+
const rest = openChecks(report.keySteps, { edits: applied, stored: done.stored.map((x) => x.host), skipped: done.skipped, secretName })
|
|
834
|
+
if (done.github && !applied.some((e) => e.checks.includes('github-step'))) rest.unshift(githubStepBlock(done.github))
|
|
835
|
+
if (done.stored.some((x) => x.host === 'kubernetes') && !k8sLoadsKey(view(), plans)) rest.unshift(loadKeyBlock(report.keySteps))
|
|
836
|
+
const leftTitles = new Set([...stores.left.map((x) => x.block.title), ...done.left])
|
|
837
|
+
const keyLeft = report.keySteps.filter((b) => leftTitles.has(b.title))
|
|
838
|
+
const left = unique([...plans.flatMap((p) => p.manual).filter((b) => b.required).map((b) => b.title), ...keyLeft.map((b) => b.title), ...rest.map((b) => b.title)])
|
|
839
|
+
const key = !done.stored.length && keyLeft.length ? 'left' : null
|
|
840
|
+
|
|
841
|
+
// 4. The branch and the pull request. Commits of the person that origin does not have never go along with 1 Yes.
|
|
842
|
+
let state = 'none'
|
|
843
|
+
if (pr?.from) {
|
|
844
|
+
const paths = await changedFiles(d.cli, { root, env, paths: [...manifestPaths(readManifest(root))] })
|
|
845
|
+
if (paths.length) {
|
|
846
|
+
const noPush = pr.ahead?.count ? aheadNote(pr) : null
|
|
847
|
+
const lines = []
|
|
848
|
+
const r = await step(ui, pr.gh && !noPush ? 'Opening a pull request' : 'Committing the change on a new branch', async () => {
|
|
849
|
+
const out = await prRun(d.cli, pr, { root, env, files: paths, noPush,
|
|
850
|
+
body: () => prBody({ services, files: paths, left, done: done.stored.map((x) => x.done), url: urls[0], environment, key, logs: sendsLogs(plans), cloudId, updates }) }, (l) => lines.push(l), (l) => lines.push(l))
|
|
851
|
+
state = out.state
|
|
852
|
+
const others = lines.filter((l) => l !== out.why && !l.startsWith('Created the branch') && !l.startsWith('Pushed ') && !l.startsWith('Opened the pull request'))
|
|
853
|
+
if (out.state === 'pr') return { ok: true, line: `Opened the pull request: ${out.url}`, more: others }
|
|
854
|
+
if (out.state === 'committed' && !out.why) return { ok: true, line: `Created the branch ${out.branch} with 1 commit`, more: others.filter((l) => l !== noPush) }
|
|
855
|
+
return { ok: false, line: out.why ?? 'Could not commit.', more: lines.filter((l) => l !== out.why) }
|
|
856
|
+
})
|
|
857
|
+
if (!r.ok && state === 'none') state = 'declined'
|
|
858
|
+
}
|
|
859
|
+
} else if (pr && !pr.from) {
|
|
860
|
+
ui.print(' This repo has no branch checked out, so setup did not commit. Commit the change and open a pull request yourself.')
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
ui.print()
|
|
864
|
+
printManual(ui, plans.flatMap((p) => p.manual), false)
|
|
865
|
+
for (const block of [...keyLeft, ...rest]) ui.block(block)
|
|
866
|
+
if (keyLeft.length || rest.length) { ui.print('Never put the key in a file in your repo.'); ui.print() }
|
|
867
|
+
if (status === 'network-error') {
|
|
868
|
+
ui.print(SETUP_NEXT.network)
|
|
869
|
+
ui.print(` ${keyCheckLine(checkVars(env))}`)
|
|
870
|
+
ui.print()
|
|
871
|
+
}
|
|
872
|
+
report.next = lastLine({ state, required: required.length, urls, environment, key })
|
|
873
|
+
ui.print(report.next)
|
|
874
|
+
if (ui.terminal && urls[0]) d.openUrl(urls[0], env, () => ui.print(OPENING))
|
|
875
|
+
return [ctx.status, ctx.exit]
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** Why a run with 1 Yes commits but does not push: commits of the person that origin does not have would go along. */
|
|
879
|
+
function aheadNote(pr) {
|
|
880
|
+
const n = pr.ahead.count
|
|
881
|
+
const names = pr.ahead.subjects.map((x) => `"${x}"`)
|
|
882
|
+
const shown = n > names.length ? `${names.join(', ')} and ${n - names.length} more` : list(names)
|
|
883
|
+
return `Your branch has ${plural(n, 'commit')} that ${n === 1 ? 'is' : 'are'} not on origin/${pr.base} yet: ${shown}. A push would take ${n === 1 ? 'it' : 'them'} along, so setup only commits. Push when you are ready.`
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
/** A UI that keeps what it would print, for the steps whose lines go under their done or failed line. */
|
|
887
|
+
function muted(ui) {
|
|
888
|
+
const lines = []
|
|
889
|
+
return { ...ui, lines, print: (text = '') => { if (text) lines.push(text) }, error: (text) => lines.push(text), block: () => {} }
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
/**
|
|
893
|
+
* The plan of a run with 1 Yes, from what setup found: the app, the files, where the key goes, the deploy files, the
|
|
894
|
+
* pull request and the link. dry: a dry run, which signs in to nothing and looks up nothing outside this computer.
|
|
895
|
+
*/
|
|
896
|
+
function onePlan({ opts, plans, detection, changes, commands, manifest, recordOnly = false, stores, edits, pr, urls, sendsTo, keyStatus, dry, keySteps, secretName, keep = [] }) {
|
|
897
|
+
const one = plans.length === 1 ? plans[0] : null
|
|
898
|
+
const app = one ? [['App', `${one.name}, ${describeShort(one.s)}`]] : [['Apps', plans.map((p) => `${p.name} (${p.s.dir})`).join(', ')]]
|
|
899
|
+
const from = one && !opts.serviceName ? (one.s.nameFrom === 'existing' ? 'is set in your repo already' : one.s.nameFrom === 'folder' ? 'comes from the folder name' : `comes from ${one.s.nameFrom}`) : null
|
|
900
|
+
// GitHub Actions is how the app deploys, not where it runs.
|
|
901
|
+
const runs = detection.hosts.filter((h) => h.id !== 'github-actions')
|
|
902
|
+
if (runs.length) app.push(['Runs on', list(unique(runs.map((h) => hostLabel(h.id))))])
|
|
903
|
+
if (detection.hosts.some((h) => h.id === 'github-actions')) app.push(['Deploys with', hostLabel('github-actions')])
|
|
904
|
+
app.push(['Sends to', dry ? 'the workspace and environment that you pick when you sign in' : sendsTo ?? 'not checked: setup has no key yet'])
|
|
905
|
+
const steps = []
|
|
906
|
+
const good = []
|
|
907
|
+
steps.push(recordOnly
|
|
908
|
+
? { title: `Write ${CLI.manifest}`, rows: [], notes: [fillIn(SETUP_STATUS.record, { manifest: CLI.manifest })] }
|
|
909
|
+
: { title: plans.length > 1 ? 'Add OpenTelemetry to your apps' : 'Add OpenTelemetry to your app', rows: fileRows(changes, commands, { manifest: Boolean(manifest) }), notes: runNotes(commands) })
|
|
910
|
+
// How the agent stays up to date, and why (updates.mjs): the method, said once.
|
|
911
|
+
if (!recordOnly && keep.length) steps.push({ title: 'Keep the agent up to date', rows: [], notes: keep.map((line) => `${line[0].toUpperCase()}${line.slice(1)}`) })
|
|
912
|
+
for (const x of stores.stores) {
|
|
913
|
+
const r = storeRows(x.found)
|
|
914
|
+
steps.push({ title: r.title, rows: r.rows, notes: [r.note] })
|
|
915
|
+
}
|
|
916
|
+
// Left for the person: the steps in the repo, the key steps that setup cannot do here, and the deploy file checks that
|
|
917
|
+
// the planned edits and stores do not cover.
|
|
918
|
+
const checks = openChecks(keySteps, { edits, stored: stores.stores.map((x) => x.host), skipped: stores.skipped.map((x) => x.host), secretName })
|
|
919
|
+
const byHand = [...plans.flatMap((p) => p.manual).filter((b) => b.required).map((b) => b.title), ...stores.left.map((x) => x.block.title), ...checks.map((b) => b.title)]
|
|
920
|
+
// The reasons say "the steps below" in a run with a question for each step. Here the steps come at the end.
|
|
921
|
+
const why = unique(stores.left.map((x) => x.why).filter((w) => w && w !== 'no-key')
|
|
922
|
+
.map((w) => w.replace(/use the steps below/g, 'do the key steps at the end').replace(/Use the steps below/g, 'Do the key steps at the end').replace(/with the steps below/g, 'with the key steps at the end')))
|
|
923
|
+
if (stores.left.some((x) => x.why === 'no-key')) {
|
|
924
|
+
good.push(keyStatus === 'network-error' ? 'Setup could not check your key, so it does not store it. The key steps print at the end.'
|
|
925
|
+
: dry ? 'Setup stores your key after you sign in.' : 'Setup has no key, so it does not store one. The key steps print at the end. To store it later, run npx @uknows/setup key.')
|
|
926
|
+
}
|
|
927
|
+
good.push(...why)
|
|
928
|
+
if (edits.length) {
|
|
929
|
+
steps.push({ title: 'Keep the key on each deploy', rows: [], notes: [...edits.flatMap((e) => e.about), `Setup adds the key ${edits.length > 1 ? 'to those files' : 'there'}.`] })
|
|
930
|
+
}
|
|
931
|
+
if (pr?.from) {
|
|
932
|
+
const into = pr.base && pr.slug ? [['Into', `${pr.base} of ${pr.slug}`]] : []
|
|
933
|
+
const branch = [['Branch', `${pr.branch}, with 1 commit of the files above`]]
|
|
934
|
+
if (pr.ahead?.count) steps.push({ title: 'Commit the change on a new branch', rows: branch, notes: [aheadNote(pr)] })
|
|
935
|
+
else if (dry && pr.slug && pr.base) steps.push({ title: 'Open a pull request', rows: [...branch, ...into], notes: ['If gh is not signed in, setup only commits and prints the push command.'] })
|
|
936
|
+
else if (pr.gh) steps.push({ title: 'Open a pull request', rows: [...branch, ...into], notes: [] })
|
|
937
|
+
else {
|
|
938
|
+
const reason = !pr.remote ? 'This repo has no remote named origin, so setup does not push.'
|
|
939
|
+
: !pr.base ? 'Setup cannot tell which branch of origin the pull request goes to, so it does not push.'
|
|
940
|
+
: !pr.slug ? 'origin is not on github.com, so setup does not open the pull request.'
|
|
941
|
+
: 'gh is not signed in, so setup does not push or open the pull request.'
|
|
942
|
+
steps.push({ title: 'Commit the change on a new branch', rows: branch, notes: [`${reason} The push command prints at the end.`] })
|
|
943
|
+
}
|
|
944
|
+
} else if (pr && !pr.from) good.push('This repo has no branch checked out, so setup does not commit. Commit the change and open a pull request yourself.')
|
|
945
|
+
for (const p of plans) {
|
|
946
|
+
const chosen = p.s.evidence?.find((e) => e.what.startsWith('Dockerfile for this service: '))
|
|
947
|
+
const kind = /dockerfile/i.test(chosen?.file.split('/').pop() ?? '') ? 'file' : 'Dockerfile'
|
|
948
|
+
// Only a Dockerfile that setup had to pick from others is worth a line.
|
|
949
|
+
const why = chosen?.what.slice('Dockerfile for this service: '.length)
|
|
950
|
+
if (chosen && why !== 'it is in the service folder') good.push(`Setup uses the ${kind} ${chosen.file}${plans.length > 1 ? ` for ${p.name}` : ''}, because ${why}.`)
|
|
951
|
+
good.push(...p.notes, ...p.s.problems.filter((x) => x.code !== 'dockerfile-ambiguous').map((x) => x.message))
|
|
952
|
+
}
|
|
953
|
+
const link = urls.filter(Boolean)
|
|
954
|
+
const merge = pr?.from ? (steps.at(-1).title === 'Open a pull request' ? 'merge the pull request' : 'push the branch, open a pull request, merge it') : 'commit, open a pull request, merge it'
|
|
955
|
+
const then = link.length ? `Then you ${merge} and deploy as usual. Watch your ${link.length > 1 ? 'apps' : 'app'} connect at ${list(link)}`
|
|
956
|
+
: `Then you ${merge} and deploy as usual, and watch your app connect in your test copy of UKNOWS.`
|
|
957
|
+
return { app, nameNote: from ? `The name ${one.name} ${from}. To change it, use --service-name.` : null, steps, byHand: unique(byHand), good: unique(good), then }
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/** A short description of a service: its stack and frameworks, and its folder when it is not this one. */
|
|
961
|
+
function describeShort(s) {
|
|
962
|
+
const frameworks = s.frameworks?.length ? ` (${s.frameworks.map(frameworkLabel).join(', ')})` : ''
|
|
963
|
+
return `${stackLabel(s.lang)}${frameworks}${s.dir && s.dir !== '.' ? ` in ${s.dir}` : ''}`
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
/**
|
|
967
|
+
* The dry run of a run with 1 Yes: the same plan, from this computer only (no sign-in, no cloud CLI, no network),
|
|
968
|
+
* then every line that changes and the steps that are left for the person.
|
|
969
|
+
*/
|
|
970
|
+
async function printDryOnce(ui, d, report, ctx) {
|
|
971
|
+
const { env, plans, hosts, targets, secretName, ws, changes, commands, root, manifest, recordOnly = false } = ctx
|
|
972
|
+
const stores = dryStores(report.keySteps, targets, plans.length)
|
|
973
|
+
const ghStore = stores.stores.find((x) => x.t.kind === 'github')
|
|
974
|
+
const edits = deployEdits(repoView(ws), hosts, plans, secretName, { githubStep: ghStore && !ghStore.t.readsKey ? githubStepLines(ghStore.block) : null })
|
|
975
|
+
const pr = d.gitStatus(root, manifestPaths(manifest)).repo ? await prFacts((args, more = {}) => d.cli('git', ['-C', root, ...args], { cwd: root, env, ...more })) : null
|
|
976
|
+
const urls = plans.map((p) => connectUrl(p.name, null, appUrl(env)))
|
|
977
|
+
printOnePlan(ui, onePlan({ ...ctx, stores, edits, pr, urls, dry: true, keySteps: report.keySteps }))
|
|
978
|
+
ui.say('A real run in a terminal signs you in to UKNOWS in your browser first. Then it shows this plan with your workspace, and asks 1 question: Do all of this? (Y/n)')
|
|
979
|
+
const lines = diffLines(changes, commands, edits)
|
|
980
|
+
if (lines.some(Boolean)) {
|
|
981
|
+
ui.print()
|
|
982
|
+
ui.print('Every line that changes:')
|
|
983
|
+
ui.print()
|
|
984
|
+
for (const line of lines.slice(0, DIFF_LIMIT)) ui.print(line)
|
|
985
|
+
if (lines.length > DIFF_LIMIT) ui.print(`And ${plural(lines.length - DIFF_LIMIT, 'more line')}. Run with --json to see every planned file.`)
|
|
986
|
+
}
|
|
987
|
+
printManual(ui, plans.flatMap((p) => p.manual))
|
|
988
|
+
// The key steps that the plan does not do, for the person. The ones it does are only for doing it by hand.
|
|
989
|
+
const byHand = report.keySteps.filter((b) => !stores.stores.some((x) => x.block.title === b.title) && !stores.skipped.some((x) => x.block.title === b.title))
|
|
990
|
+
if (byHand.length) {
|
|
991
|
+
ui.print()
|
|
992
|
+
ui.print(`You do ${byHand.length === 1 ? 'this step' : 'these steps'} yourself:`)
|
|
993
|
+
ui.print()
|
|
994
|
+
for (const block of byHand) ui.block(block)
|
|
995
|
+
} else ui.print()
|
|
996
|
+
ui.print('This was a dry run. Nothing changed.')
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
/**
|
|
1000
|
+
* The stores of a dry run, from the repo only, as plannedQuestions knows them: an Azure app when the deploy files name 1
|
|
1001
|
+
* app and the run has 1 service, a Kubernetes secret when the namespace is known, and the GitHub secret when nothing
|
|
1002
|
+
* stores on the platform or a workflow reads the secret. found holds what the plan can name without a lookup.
|
|
1003
|
+
*/
|
|
1004
|
+
function dryStores(blocks, targets, services) {
|
|
1005
|
+
const out = { stores: [], left: [], skipped: [] }
|
|
1006
|
+
let direct = false
|
|
1007
|
+
for (const block of blocks.filter((b) => b.title.startsWith('Set your key once: '))) {
|
|
1008
|
+
const host = HOSTS.find((h) => block.title === `Set your key once: ${h.label}`)
|
|
1009
|
+
const t = host ? targets.get(host.id) : null
|
|
1010
|
+
if (host?.id === 'github-actions' && direct && !t?.readsKey) { out.skipped.push({ block, host: host.id }); continue }
|
|
1011
|
+
let found = null
|
|
1012
|
+
if (t && (t.kind === 'aca' || t.kind === 'webapp')) {
|
|
1013
|
+
const apps = pickApp(t.apps.map((a) => a.name))
|
|
1014
|
+
const groups = unique(t.groups.map((g) => g.name))
|
|
1015
|
+
if (apps.length === 1 && services === 1 && !(t.kind === 'webapp' && t.slots?.length)) {
|
|
1016
|
+
found = { kind: t.kind, name: apps[0], rg: groups.length === 1 ? groups[0] : 'the one that has this app', dry: true }
|
|
1017
|
+
}
|
|
1018
|
+
} else if (t?.kind === 'k8s') {
|
|
1019
|
+
const spaces = unique(t.workloads.map((w) => w.namespace))
|
|
1020
|
+
if (spaces.length === 1 && spaces[0]) found = { kind: 'k8s', ns: spaces[0], context: 'the one that has this app. A real run looks it up.', dry: true }
|
|
1021
|
+
} else if (t?.kind === 'github' && t.repo) {
|
|
1022
|
+
const g = githubSecret(t.jobs)
|
|
1023
|
+
if (!g.why) found = { kind: 'github', repo: t.repo, env: g.env === null ? null : 'the one of the environment that you pick when you sign in', dry: true }
|
|
1024
|
+
}
|
|
1025
|
+
if (!found) { out.left.push({ block, host: host?.id ?? null, why: null }); continue }
|
|
1026
|
+
out.stores.push({ block, host: host.id, t, found })
|
|
1027
|
+
if (t.kind !== 'github') direct = true
|
|
1028
|
+
}
|
|
1029
|
+
return out
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
/** Said just before the page where the app connects opens in the browser. Nothing prints when no browser can open. */
|
|
1033
|
+
const OPENING = 'Opening it in your browser.'
|
|
1034
|
+
|
|
1035
|
+
/** A rerun with nothing to change whose files are not committed yet: setup itself has nothing more to change. */
|
|
1036
|
+
const RERUN_UNCOMMITTED = 'Already set up. Setup itself has nothing more to change.'
|
|
1037
|
+
|
|
1038
|
+
/** The key of x-uknows-key in the header text of a check, or null. */
|
|
1039
|
+
function bareKey(headers) {
|
|
1040
|
+
const pairs = parseHeaders(headers ?? '')
|
|
1041
|
+
const name = Object.keys(pairs).find((n) => n.toLowerCase() === SECRET.header)
|
|
1042
|
+
const key = name ? pairs[name].trim() : ''
|
|
1043
|
+
return key || null
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Add the key to 1 deploy file, after a yes. The edit goes in the manifest like every other change, so remove gives back
|
|
1048
|
+
* every byte, and a rerun finds nothing to add.
|
|
1049
|
+
*/
|
|
1050
|
+
function applyDeployEdit(ws, edit) {
|
|
1051
|
+
for (const file of [edit.file, CLI.manifest]) inside(ws, file)
|
|
1052
|
+
const abs = ws.abs(edit.file)
|
|
1053
|
+
const beforeBytes = readBytes(abs)
|
|
1054
|
+
if (beforeBytes === null || text(beforeBytes) !== edit.before) throw new Error('it changed while this ran')
|
|
1055
|
+
const bom = beforeBytes.toString('utf8').startsWith(BOM)
|
|
1056
|
+
if (!beforeBytes.equals(Buffer.from((bom ? BOM : '') + edit.before, 'utf8'))) throw new Error('it is not UTF-8 text')
|
|
1057
|
+
const manifest = readManifest(ws.root)
|
|
1058
|
+
if (!manifest) throw new Error(`${CLI.manifest} is missing`)
|
|
1059
|
+
const afterBytes = Buffer.from((bom ? BOM : '') + edit.after, 'utf8')
|
|
1060
|
+
const entry = changeEntry({ file: edit.file, before: edit.before, after: edit.after, bom }, beforeBytes, afterBytes)
|
|
1061
|
+
writeAtomic(abs, afterBytes)
|
|
1062
|
+
try {
|
|
1063
|
+
writeAtomic(ws.abs(CLI.manifest), Buffer.from(manifestText(mergeManifest(manifest, { services: [], changes: [entry], packages: [] }))))
|
|
1064
|
+
} catch (error) {
|
|
1065
|
+
writeAtomic(abs, beforeBytes)
|
|
1066
|
+
throw error
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* The dry run: the key steps and the questions that a run in a terminal asks after the plan. Not in a pipeline. It
|
|
1072
|
+
* reads git on this computer only: no cloud CLI and no network. Without a terminal, the full key steps come first. In
|
|
1073
|
+
* a terminal, the questions come first: the key steps that a question does follow them, under a heading that says
|
|
1074
|
+
* they are only for doing it by hand. Key steps that no question does stay before the questions, as steps to do.
|
|
1075
|
+
*/
|
|
1076
|
+
async function printQuestions(ui, d, report, { env, hosts, plans, targets, secretName, view, files, manifest, root, apply }) {
|
|
1077
|
+
ui.print()
|
|
1078
|
+
if (ciVariable(env)) {
|
|
1079
|
+
for (const block of report.keySteps) ui.block(block)
|
|
1080
|
+
return
|
|
1081
|
+
}
|
|
1082
|
+
const edits = deployEdits(view, hosts, plans, secretName)
|
|
1083
|
+
const pr = d.gitStatus(root, manifestPaths(manifest)).repo ? await prFacts((args, more = {}) => d.cli('git', ['-C', root, ...args], { cwd: root, env, ...more })) : null
|
|
1084
|
+
const planned = plannedQuestions({ targets, edits, files: unique([...files, ...edits.map((e) => e.file)]), pr, services: plans.length })
|
|
1085
|
+
const questions = [{ question: apply ? 'Apply? (Y/n)' : `Write ${CLI.manifest}? (Y/n)` }, { question: KEY_QUESTION }, ...planned.questions]
|
|
1086
|
+
const asked = (block) => planned.hosts.some((id) => block.title.endsWith(`: ${HOSTS.find((h) => h.id === id)?.label}`))
|
|
1087
|
+
const byHand = ui.interactive ? report.keySteps.filter(asked) : []
|
|
1088
|
+
for (const block of report.keySteps.filter((b) => !byHand.includes(b))) ui.block(block)
|
|
1089
|
+
ui.print('In a terminal, a real run asks you:')
|
|
1090
|
+
questions.forEach((q, i) => {
|
|
1091
|
+
ui.print(` ${i + 1}. ${q.question}`)
|
|
1092
|
+
if (q.after) ui.print(` If you say no: ${q.after}`)
|
|
1093
|
+
})
|
|
1094
|
+
ui.print('Each question changes only what it says. When you answer no, setup prints that step for you to do.')
|
|
1095
|
+
ui.print()
|
|
1096
|
+
if (!byHand.length) return
|
|
1097
|
+
ui.print(BY_HAND)
|
|
1098
|
+
ui.print()
|
|
1099
|
+
for (const block of byHand) ui.block(block)
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** The heading of the key steps that the questions of a real run do, in a dry run in a terminal. */
|
|
1103
|
+
const BY_HAND = 'These steps are only for doing it by hand. When you answer yes, the questions above do them for you:'
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* The required step for an App Service code deploy (a zip or Oryx deploy, not an image): App Service never runs the
|
|
1107
|
+
* Dockerfile, so the start line and the settings that this tool put there must be set on App Service too. Only for a
|
|
1108
|
+
* service whose settings went into a Dockerfile.
|
|
1109
|
+
*/
|
|
1110
|
+
function appServiceCode(p, names, view = null) {
|
|
1111
|
+
const deploy = names.code?.find((c) => c.kind === 'deploy')
|
|
1112
|
+
if (!deploy || p.settings.target?.kind !== 'dockerfile') return []
|
|
1113
|
+
const T = APP_SERVICE_CODE
|
|
1114
|
+
const stack = STACKS.find((x) => x.id === p.stack)
|
|
1115
|
+
const pairs = [...(p.settings.pairs ?? []), ...(p.mode === 'standard' ? (stack?.platformEnv ?? []).map((l) => [l.slice(0, l.indexOf('=')), l.slice(l.indexOf('=') + 1)]) : [])]
|
|
1116
|
+
const web = names.webapp ?? { apps: [], groups: [], slots: [] }
|
|
1117
|
+
const group = new Set(web.groups.map((g) => g.name)).size === 1 ? web.groups[0] : null
|
|
1118
|
+
// The code goes to a deployment slot (then a swap moves it into production): the settings and the startup command go on
|
|
1119
|
+
// that slot too, as the deploy of the repo does. Plain app settings move with the code on swap. A slot that only a
|
|
1120
|
+
// workflow input or variable holds (slotUnknown) keeps an example SLOT, which the user replaces.
|
|
1121
|
+
const slot = deploy.slot ?? null, unknown = deploy.slotUnknown ?? null
|
|
1122
|
+
const scriptNames = { ...(web.apps[0] ? { app: web.apps[0].name } : {}), ...(group ? { resourceGroup: group.name } : {}),
|
|
1123
|
+
...(slot || unknown ? { slot: slot ?? unknown } : {}), ...(unknown ? { slotUnknown: true } : {}),
|
|
1124
|
+
from: { ...(web.apps[0] ? { app: web.apps[0].where } : {}), ...(group ? { resourceGroup: group.where } : {}), ...(slot || unknown ? { slot: deploy.where } : {}) } }
|
|
1125
|
+
const values = { where: deploy.where, dockerfile: p.settings.target.file, count: pairs.length }
|
|
1126
|
+
const lines = T.lines.map((l) => fillIn(l, values))
|
|
1127
|
+
if (unknown) lines.push(`${deploy.where} deploys to the slot ${unknown}. Set SLOT below to the name of that slot.`)
|
|
1128
|
+
const snippets = [settingsSnippet('azure-app-service', pairs, p.name, scriptNames)]
|
|
1129
|
+
const prefix = p.mode === 'standard' ? START_PREFIX[p.stack] : null
|
|
1130
|
+
if (!prefix) return [{ title: T.title, lines, snippets }]
|
|
1131
|
+
// The startup command of the slot that gets the code, else the first one.
|
|
1132
|
+
const starts = names.code.filter((c) => c.kind === 'startup')
|
|
1133
|
+
const startup = starts.find((c) => (c.slot ?? null) === slot && (c.slotUnknown ?? null) === unknown) ?? starts[0]
|
|
1134
|
+
if (startup?.startup.startsWith(prefix)) {
|
|
1135
|
+
// The deploy sets the startup command with the prefix already. The step is done when the deploy files also set
|
|
1136
|
+
// every setting. Else only the settings are left, and they may be set on the platform, so the repo cannot show them.
|
|
1137
|
+
return view && settingsInFiles(view, names, pairs) ? [] : [{ title: T.title, lines, snippets }]
|
|
1138
|
+
}
|
|
1139
|
+
const example = p.stack === 'python' ? stack.start.replace(/^opentelemetry-instrument\s+/, '') : `dotnet ${p.name}.dll`
|
|
1140
|
+
const command = startup ? `${prefix} ${startup.startup}` : `${prefix} ${example}`
|
|
1141
|
+
lines.push(startup ? fillIn(T.start, { startWhere: startup.where, startup: startup.startup, prefix }) : fillIn(T.startNone, { prefix }))
|
|
1142
|
+
// The same first lines as the settings snippet: the comment and the names, up to its az line.
|
|
1143
|
+
const head = settingsSnippet('azure-app-service', [], p.name, scriptNames).code.split('\n')
|
|
1144
|
+
snippets.push({ caption: 'The startup command of your web app', language: 'bash',
|
|
1145
|
+
code: [...head.slice(0, head.findIndex((l) => l.startsWith('az '))),
|
|
1146
|
+
`az webapp config set --subscription "$SUBSCRIPTION" -g "$RESOURCE_GROUP" -n "$APP"${slot || unknown ? ' --slot "$SLOT"' : ''} -o none --startup-file "${command.replace(/"/g, '\\"')}"`].join('\n') })
|
|
1147
|
+
// A deploy file sets the startup command, so each deploy sets it again: the repo shows when the step is done (F-012-10).
|
|
1148
|
+
// With no startup command in the deploy files, it is set on the platform, and the repo cannot show it.
|
|
1149
|
+
return [{ title: T.title, ...(startup ? { verify: true } : {}), lines, snippets }]
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
/**
|
|
1153
|
+
* The deploy files of the App Service code deploy set each setting: as NAME=value (az --settings), "name": "NAME" (the
|
|
1154
|
+
* JSON of azure/appservice-settings), or NAME: value and name: NAME (YAML). Comment lines do not count.
|
|
1155
|
+
*/
|
|
1156
|
+
function settingsInFiles(view, names, pairs) {
|
|
1157
|
+
const files = [...new Set(names.code.map((c) => c.where.replace(/ line \d+$/, '')))]
|
|
1158
|
+
const body = files.map((f) => { try { return view.read(f) ?? '' } catch { return '' } }).join('\n').replace(/^[ \t]*(?:#|\/\/).*$/gm, '')
|
|
1159
|
+
return pairs.every(([name]) => {
|
|
1160
|
+
const n = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
1161
|
+
return new RegExp(`(?:^|[\\s"'(,\\[])${n}=|"name"\\s*:\\s*"${n}"|^[ \\t]*(?:-[ \\t]+)?(?:name[ \\t]*:[ \\t]*["']?${n}["']?[ \\t]*$|${n}[ \\t]*:)`, 'm').test(body)
|
|
1162
|
+
})
|
|
258
1163
|
}
|
|
259
1164
|
|
|
260
1165
|
/**
|
|
@@ -262,7 +1167,7 @@ async function setup(opts, d, report) {
|
|
|
262
1167
|
* failure, put every file back and return null. Returns the original bytes
|
|
263
1168
|
* of every path it touched.
|
|
264
1169
|
*/
|
|
265
|
-
async function apply({ root, ws, changes, commands, manifest, services, d }) {
|
|
1170
|
+
async function apply({ root, ws, changes, commands, manifest, services, updates = null, d }) {
|
|
266
1171
|
const { ui } = d
|
|
267
1172
|
// Ctrl-C stops the package manager. Stay alive, so the failed run is rolled back below.
|
|
268
1173
|
const hold = () => {}
|
|
@@ -298,22 +1203,55 @@ async function apply({ root, ws, changes, commands, manifest, services, d }) {
|
|
|
298
1203
|
written.set(c.file, bytes)
|
|
299
1204
|
}
|
|
300
1205
|
for (const command of commands) for (const file of command.touches) save(file)
|
|
1206
|
+
const locked = []
|
|
301
1207
|
for (const command of commands) {
|
|
302
1208
|
const result = await d.spawn(command.cmd, command.args, { cwd: ws.abs(command.cwd) })
|
|
303
1209
|
if (result.code !== 0) {
|
|
304
1210
|
undo()
|
|
305
|
-
|
|
306
|
-
|
|
1211
|
+
const failed = command.kind === 'lock' && !result.error ? resolverFailure(command, result.output ?? '') : null
|
|
1212
|
+
if (failed) {
|
|
1213
|
+
for (const line of [failed.title, ...failed.lines]) ui.error(line)
|
|
1214
|
+
} else {
|
|
1215
|
+
ui.error(`${command.cmd} failed${result.error ? `: ${result.error}` : ''}. Nothing changed.`)
|
|
1216
|
+
ui.error(` ${[command.cmd, ...command.args].join(' ')}${command.cwd === '.' ? '' : ` (in ${command.cwd})`}`)
|
|
1217
|
+
}
|
|
1218
|
+
return null
|
|
1219
|
+
}
|
|
1220
|
+
if (command.kind !== 'lock') continue
|
|
1221
|
+
// After a restore, the packages the build check sees must match the plan (dotnet.mjs verifyRestore).
|
|
1222
|
+
const problem = command.verify ? d.verify(command.verify, (file) => { const b = readBytes(ws.abs(file)); return b === null ? null : text(b) }) : null
|
|
1223
|
+
if (problem) {
|
|
1224
|
+
undo()
|
|
1225
|
+
for (const line of [problem.title, ...problem.lines]) ui.error(line)
|
|
307
1226
|
return null
|
|
308
1227
|
}
|
|
1228
|
+
// A lock refresh must keep the lockfile format (the uv in the image reads only its own version).
|
|
1229
|
+
for (const { file, pattern } of command.keep ?? []) {
|
|
1230
|
+
const re = new RegExp(pattern, 'm')
|
|
1231
|
+
const was = re.exec(text(saved.get(file) ?? Buffer.alloc(0)))?.[0] ?? null, now = re.exec(text(readBytes(ws.abs(file)) ?? Buffer.alloc(0)))?.[0] ?? null
|
|
1232
|
+
if (was !== now) {
|
|
1233
|
+
undo()
|
|
1234
|
+
ui.error(`${command.cmd} changed ${file} from "${was}" to "${now}". The ${command.cmd} in your image may not read it. Nothing changed.`)
|
|
1235
|
+
ui.error(`Install the ${command.cmd} version that your image uses. Then run npx @uknows/setup again.`)
|
|
1236
|
+
return null
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
locked.push(command)
|
|
309
1240
|
}
|
|
310
1241
|
const entries = changes.map((c) => changeEntry(c, saved.get(c.file), written.get(c.file)))
|
|
311
|
-
|
|
312
|
-
|
|
1242
|
+
// Files that a lock refresh rewrote are ordinary text changes, so remove restores them byte for byte.
|
|
1243
|
+
for (const file of unique(locked.flatMap((c) => c.touches))) {
|
|
1244
|
+
const before = saved.get(file), after = readBytes(ws.abs(file))
|
|
1245
|
+
if (after === null || (before && before.equals(after))) continue
|
|
1246
|
+
entries.push(changeEntry({ file, before: before ? text(before) : null, after: text(after), bom: Boolean(before && text(before) !== before.toString('utf8')) },
|
|
1247
|
+
before, after))
|
|
1248
|
+
}
|
|
1249
|
+
const packages = commands.filter((c) => c.kind !== 'lock').map((c) => ({ cwd: c.cwd, cmd: c.cmd, added: c.added, undo: [c.undo.cmd, ...c.undo.args],
|
|
1250
|
+
touches: c.touches, before: Object.fromEntries(c.touches.map((file) => [file, sha256(saved.get(file))])) }))
|
|
313
1251
|
save(CLI.manifest)
|
|
314
1252
|
const file = ws.abs(CLI.manifest)
|
|
315
1253
|
makeDirs(dirname(file), made)
|
|
316
|
-
writeAtomic(file, Buffer.from(manifestText(mergeManifest(manifest, { services, changes: entries, packages }))))
|
|
1254
|
+
writeAtomic(file, Buffer.from(manifestText(mergeManifest(manifest, { services, changes: entries, packages, updates }))))
|
|
317
1255
|
return saved
|
|
318
1256
|
} catch (error) {
|
|
319
1257
|
undo()
|
|
@@ -344,6 +1282,7 @@ async function remove(opts, d, report) {
|
|
|
344
1282
|
}
|
|
345
1283
|
ui.print(` Delete ${CLI.manifest}.`)
|
|
346
1284
|
if (opts.json && !opts.yes) return ['planned', 0]
|
|
1285
|
+
if (stopInCi(ui, report, opts)) return ['planned', 2]
|
|
347
1286
|
if (!opts.yes) {
|
|
348
1287
|
if (!ui.interactive) {
|
|
349
1288
|
ui.print('Run again with --yes to remove.')
|
|
@@ -403,42 +1342,120 @@ async function remove(opts, d, report) {
|
|
|
403
1342
|
return ['removed', 0]
|
|
404
1343
|
}
|
|
405
1344
|
|
|
406
|
-
/** The
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
1345
|
+
/** The question that asks for the key. */
|
|
1346
|
+
const KEY_QUESTION = 'Paste your UKNOWS key to check it now. It does not show on the screen. Press Enter to skip:'
|
|
1347
|
+
|
|
1348
|
+
/** The test addresses of this run, as settings in front of the check command. */
|
|
1349
|
+
function checkVars(env) {
|
|
1350
|
+
return [CLI.siteEnv, CLI.endpointEnv].filter((name) => env[name]).map((name) => `${name}=${env[name]}`)
|
|
410
1351
|
}
|
|
411
1352
|
|
|
412
1353
|
/**
|
|
413
|
-
* Check your key
|
|
414
|
-
* written anywhere and never shows on the screen.
|
|
1354
|
+
* Check your key: the key from this terminal, a sign-in in the browser, or a key pasted now (--paste-key, or p while
|
|
1355
|
+
* setup waits for the sign-in). The key is never written anywhere and never shows on the screen. auto: --yes in a
|
|
1356
|
+
* terminal, so check the key in this terminal without a question. paste: after a no to the key of this terminal, get
|
|
1357
|
+
* another key (the key command, which is for another environment). once: a run with 1 Yes, before its plan: a key in
|
|
1358
|
+
* this terminal is used without a question, since the plan names its workspace and environment before the 1 Yes.
|
|
1359
|
+
* pasteKey: --paste-key. app: what the browser shows about this app while signing in.
|
|
1360
|
+
* Returns {entry, headers, from, link}: the --json check entry, the header text that was checked (in memory only, for
|
|
1361
|
+
* the key steps in a terminal), where the key came from ('terminal', 'signin' or 'paste') and the answer of the sign-in.
|
|
1362
|
+
* null when skipped. {stopped, interrupt} when the person stopped the sign-in (q, or Ctrl+C).
|
|
415
1363
|
*/
|
|
416
|
-
async function checkKey(ui, d, env) {
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
1364
|
+
async function checkKey(ui, d, env, auto = false, serviceName = null, { paste = false, once = false, pasteKey = false, app = null } = {}) {
|
|
1365
|
+
if (!once) {
|
|
1366
|
+
ui.print()
|
|
1367
|
+
ui.print('Check your key')
|
|
1368
|
+
}
|
|
1369
|
+
let headers = null, from = null, link = null
|
|
1370
|
+
const inTerminal = Boolean(env[SECRET.name]?.trim())
|
|
1371
|
+
if (auto) {
|
|
1372
|
+
ui.print(`Checking the key in ${SECRET.name} from this terminal.`)
|
|
1373
|
+
headers = env[SECRET.name]
|
|
1374
|
+
from = 'terminal'
|
|
1375
|
+
} else if (inTerminal && once) {
|
|
1376
|
+
ui.print(`Using the key in ${SECRET.name} from this terminal.`)
|
|
1377
|
+
headers = env[SECRET.name]
|
|
1378
|
+
from = 'terminal'
|
|
1379
|
+
} else if (inTerminal && (await ui.confirm(`Check the key in ${SECRET.name} from this terminal now? (Y/n)`))) {
|
|
1380
|
+
headers = env[SECRET.name]
|
|
1381
|
+
from = 'terminal'
|
|
1382
|
+
} else if (!inTerminal || paste) {
|
|
1383
|
+
const got = await keyFromPerson(ui, d, env, { pasteKey, app })
|
|
1384
|
+
if (got.stopped) return got
|
|
1385
|
+
;({ headers, from, link } = got)
|
|
427
1386
|
}
|
|
428
1387
|
if (!headers) return null
|
|
429
|
-
|
|
1388
|
+
// The test span carries the name of the service of this run, so the workspace never lists a made-up app.
|
|
1389
|
+
// A timeout gets 1 more try, which can take up to 30 seconds: say so, so the wait is not a surprise.
|
|
1390
|
+
// The key of the sign-in is new: the data intake of UKNOWS learns it about 30 seconds later, so the check waits for
|
|
1391
|
+
// it, for up to 2 minutes, with 1 line (check.mjs NEW_KEY). A pasted key or the key of this terminal gets 1 try.
|
|
1392
|
+
const result = await d.runCheck({ env, headers, serviceName, onRetry: (line) => ui.print(` ${line}`),
|
|
1393
|
+
newKey: from === 'signin', clock: d.clock, onWait: () => newKeyLine(ui, d.clock) })
|
|
430
1394
|
for (const line of result.lines) (result.ok ? ui.print : ui.error)(` ${line}`)
|
|
431
1395
|
ui.print()
|
|
432
1396
|
const status = result.ok ? 'reached' : result.status === null ? 'network-error' : 'rejected'
|
|
433
|
-
return { status, httpStatus: result.status, workspace: result.workspace, environment: result.environment }
|
|
1397
|
+
return { entry: { status, httpStatus: result.status, workspace: result.workspace, environment: result.environment }, headers, from, link,
|
|
1398
|
+
notReady: Boolean(result.notReady) }
|
|
434
1399
|
}
|
|
435
1400
|
|
|
436
|
-
|
|
1401
|
+
/** The 1 line while a new key gets ready (check.mjs NEW_KEY): a spinner with the time on a terminal, else 1 plain line. */
|
|
1402
|
+
function newKeyLine(ui, clock) {
|
|
1403
|
+
const started = clock.now()
|
|
1404
|
+
if (!ui.tty) ui.print(` ${NEW_KEY.waitingPlain}`)
|
|
1405
|
+
const spin = ui.progress(() => `${NEW_KEY.waiting} ${clockText(clock.now() - started)}`, ' ')
|
|
1406
|
+
return (line) => spin.end(line === null ? null : ` ${line}`)
|
|
1407
|
+
}
|
|
1408
|
+
const clockText = (ms) => { const s = Math.max(0, Math.floor(ms / 1000)); return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` }
|
|
1409
|
+
|
|
1410
|
+
/**
|
|
1411
|
+
* A key from the person at the terminal: a sign-in in the browser, or the hidden paste question (--paste-key, p while
|
|
1412
|
+
* waiting, or a test copy of UKNOWS with no app address). {headers, from, link}, {headers: null} when skipped, or
|
|
1413
|
+
* {stopped, interrupt}.
|
|
1414
|
+
*/
|
|
1415
|
+
async function keyFromPerson(ui, d, env, { pasteKey = false, app = null } = {}) {
|
|
1416
|
+
const base = appUrl(env)
|
|
1417
|
+
// A pipeline never starts a sign-in (only --allow-ci in a terminal gets here): it can only paste.
|
|
1418
|
+
let paste = pasteKey || Boolean(ciVariable(env))
|
|
1419
|
+
if (!paste && !base) {
|
|
1420
|
+
// A test address without the app of that UKNOWS: never sign in at the production app. Say which setting is missing.
|
|
1421
|
+
for (const line of SIGN_IN.noApp([CLI.siteEnv, CLI.endpointEnv].filter((name) => env[name]))) ui.print(line)
|
|
1422
|
+
paste = true
|
|
1423
|
+
}
|
|
1424
|
+
if (!paste) {
|
|
1425
|
+
const r = await d.signIn(ui, { base, app, env, openUrl: d.openUrl, request: d.request, clock: d.clock })
|
|
1426
|
+
if (r.stopped) return { stopped: true, interrupt: Boolean(r.interrupt) }
|
|
1427
|
+
if (r.approved) return { headers: `${SECRET.header}=${r.key}`, from: 'signin', link: r }
|
|
1428
|
+
paste = r.pressed ? 'pressed' : true
|
|
1429
|
+
}
|
|
1430
|
+
let key = (await ui.secret(KEY_QUESTION)).trim()
|
|
1431
|
+
// After p, an empty answer is more often a stray Enter than a choice to skip: ask once more.
|
|
1432
|
+
if (!key && paste === 'pressed') key = (await ui.secret('Nothing was pasted. Paste your key, or press Enter again to skip:')).trim()
|
|
1433
|
+
// Accept the key as copied, also with the header name or quotes around it.
|
|
1434
|
+
const bare = key.replace(/^["']|["']$/g, '').replace(new RegExp(`^${SECRET.header}\\s*[=:]\\s*`, 'i'), '').trim()
|
|
1435
|
+
return { headers: bare ? `${SECRET.header}=${bare}` : null, from: 'paste', link: null }
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* A sign-in answer whose ingest address is not the one that this run writes: the app would send to the wrong UKNOWS.
|
|
1440
|
+
* Only a test copy of UKNOWS can get here (UKNOWS_APP_URL without UKNOWS_INGEST_ENDPOINT). The text, or null.
|
|
1441
|
+
*/
|
|
1442
|
+
function addressMismatch(link, endpoint) {
|
|
1443
|
+
const norm = (u) => { try { const x = new URL(String(u).trim()); return `${x.origin}${x.pathname.replace(/\/+$/, '')}`.toLowerCase() } catch { return null } }
|
|
1444
|
+
if (!link?.ingestEndpoint || !norm(link.ingestEndpoint) || norm(link.ingestEndpoint) === norm(endpoint)) return null
|
|
1445
|
+
return `UKNOWS sends the data of this key to ${link.ingestEndpoint}, but this run would write ${endpoint}. Set ${CLI.endpointEnv}=${link.ingestEndpoint} and run setup again. Nothing changed.`
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/** Where the app runs, from the deploy files. */
|
|
1449
|
+
function printHosts(ui, detection) {
|
|
437
1450
|
ui.print('Runs on:')
|
|
438
1451
|
for (const h of detection.hosts) {
|
|
439
1452
|
const files = unique(h.evidence.map((e) => e.file))
|
|
440
1453
|
ui.print(` ${hostLabel(h.id)}. ${files.length ? `Found in ${files.join(', ')}.` : 'No deploy files found, so the steps are the general ones.'}`)
|
|
441
1454
|
}
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
function printPlan(ui, plans, detection, changes, commands, opts) {
|
|
1458
|
+
printHosts(ui, detection)
|
|
442
1459
|
for (const p of plans) {
|
|
443
1460
|
const from = opts.serviceName ? 'from --service-name' : p.s.nameFrom === 'existing' ? 'already set in the repo'
|
|
444
1461
|
: p.s.nameFrom === 'folder' ? 'from the folder name' : `from ${p.s.nameFrom}`
|
|
@@ -448,9 +1465,12 @@ function printPlan(ui, plans, detection, changes, commands, opts) {
|
|
|
448
1465
|
ui.print(`Plan for ${p.name}:`)
|
|
449
1466
|
} else ui.print(`Plan for ${p.name} in ${p.s.dir} (name ${from}):`)
|
|
450
1467
|
steps(p).forEach((step, i) => ui.print(` ${i + 1}. ${step}`))
|
|
451
|
-
|
|
1468
|
+
const required = p.manual.filter((b) => b.required)
|
|
1469
|
+
if (required.length) ui.print(` After this, you do ${plural(required.length, 'step')} by hand: ${required.map((b) => b.title).join('. ')}.`)
|
|
452
1470
|
const chosen = p.s.evidence?.find((e) => e.what.startsWith('Dockerfile for this service: '))
|
|
453
|
-
|
|
1471
|
+
// "the Dockerfile Dockerfile" reads as a typo: a file whose name says Dockerfile is "the file".
|
|
1472
|
+
const kind = /dockerfile/i.test(chosen?.file.split('/').pop() ?? '') ? 'file' : 'Dockerfile'
|
|
1473
|
+
if (chosen) ui.print(` This tool uses the ${kind} ${chosen.file} for this service, because ${chosen.what.slice('Dockerfile for this service: '.length)}.`)
|
|
454
1474
|
for (const note of [...p.notes, ...p.s.problems.filter((x) => x.code !== 'dockerfile-ambiguous').map((x) => x.message)]) ui.print(` Note: ${note}`)
|
|
455
1475
|
}
|
|
456
1476
|
ui.print()
|
|
@@ -461,8 +1481,7 @@ function printPlan(ui, plans, detection, changes, commands, opts) {
|
|
|
461
1481
|
if (created.length) ui.print(`Files to add: ${created.join(', ')}`)
|
|
462
1482
|
ui.print(`It also writes ${CLI.manifest}, so you can undo this with npx @uknows/setup remove.`)
|
|
463
1483
|
// Exactly what changes, before anything does: the diff of every file edit, and what each package manager adds.
|
|
464
|
-
const out =
|
|
465
|
-
...commands.map((c) => `${list(c.touches)}: ${c.cmd} adds ${list(c.added ?? [])}.`)]
|
|
1484
|
+
const out = diffLines(changes, commands)
|
|
466
1485
|
if (out.length) {
|
|
467
1486
|
ui.print()
|
|
468
1487
|
ui.print('The changes:')
|
|
@@ -471,16 +1490,55 @@ function printPlan(ui, plans, detection, changes, commands, opts) {
|
|
|
471
1490
|
if (out.length > DIFF_LIMIT) ui.print(`And ${plural(out.length - DIFF_LIMIT, 'more line')}. Run with --json to see every planned file.`)
|
|
472
1491
|
ui.print()
|
|
473
1492
|
}
|
|
474
|
-
const
|
|
1493
|
+
for (const line of runNotes(commands)) ui.print(line)
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
/** Every line that changes: the diff of each file edit, and what each package manager adds or updates. */
|
|
1497
|
+
function diffLines(changes, commands, edits = []) {
|
|
1498
|
+
return [...changes.flatMap((c) => unified(c.file, c.before, c.after).split('\n').map(redactKey)),
|
|
1499
|
+
...commands.map((c) => (c.kind === 'lock' ? `${list(c.touches)}: ${[c.cmd, ...c.args].join(' ')} updates it.` : `${list(c.touches)}: ${c.cmd} adds ${list(c.added ?? [])}.`)),
|
|
1500
|
+
...edits.flatMap((e) => unified(e.file, e.before, e.after).split('\n').map(redactKey))]
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
/** What the package managers and lock refreshes of the plan run, and what they write. */
|
|
1504
|
+
function runNotes(commands) {
|
|
1505
|
+
const out = []
|
|
1506
|
+
const installs = commands.filter((c) => c.kind !== 'lock'), locks = commands.filter((c) => c.kind === 'lock')
|
|
1507
|
+
const tools = unique(installs.map((c) => c.cmd))
|
|
475
1508
|
if (tools.length) {
|
|
476
|
-
|
|
477
|
-
? `${tools.length === 1 ? 'It writes' : 'They write'} only ${list(unique(
|
|
1509
|
+
out.push(`This runs ${tools.join(' and ')} with install scripts turned off. ${installs.every((c) => c.lockOnly)
|
|
1510
|
+
? `${tools.length === 1 ? 'It writes' : 'They write'} only ${list(unique(installs.flatMap((c) => c.touches)))}.`
|
|
478
1511
|
: `${tools.length === 1 ? 'It downloads' : 'They download'} the packages.`}`)
|
|
479
1512
|
}
|
|
1513
|
+
if (locks.length) {
|
|
1514
|
+
// dotnet restore also fills the NuGet folder and writes obj/ folders, which remove does not undo. uv and Poetry write the lock only.
|
|
1515
|
+
const restores = locks.filter((c) => c.cmd === 'dotnet'), others = locks.filter((c) => c.cmd !== 'dotnet')
|
|
1516
|
+
const files = (cs) => list(unique(cs.flatMap((c) => c.touches)))
|
|
1517
|
+
// The note on project build code names only the lock commands of this run.
|
|
1518
|
+
const tools = list(unique(locks.map((c) => `${c.cmd} ${c.args[0]}`)))
|
|
1519
|
+
out.push([`This runs ${list(unique(locks.map((c) => [c.cmd, ...c.args].join(' '))))}.`,
|
|
1520
|
+
...(others.length ? [fillIn(RESOLVE.writes, { command: list(unique(others.map((c) => `${c.cmd} ${c.args[0]}`))), files: files(others) })] : []),
|
|
1521
|
+
...(restores.length ? [fillIn(RESOLVE.restoreWrites, { files: files(restores) })] : []), fillIn(RESOLVE.buildCode, { commands: tools })].join(' '))
|
|
1522
|
+
}
|
|
1523
|
+
return out
|
|
480
1524
|
}
|
|
481
1525
|
|
|
482
1526
|
/** Print the diff of every file this run changed. Returns the --json files list. */
|
|
483
1527
|
function printResult(ui, ws, saved, changes, commands) {
|
|
1528
|
+
const { files, out } = resultFiles(ws, saved, changes, commands)
|
|
1529
|
+
ui.print()
|
|
1530
|
+
ui.print(`Done. Changed ${plural(files.length, 'file')}:`)
|
|
1531
|
+
ui.print()
|
|
1532
|
+
for (const line of out.slice(0, DIFF_LIMIT)) ui.print(line)
|
|
1533
|
+
if (out.length > DIFF_LIMIT) ui.print('Run git diff to see the rest.')
|
|
1534
|
+
ui.print()
|
|
1535
|
+
ui.print(`To undo this, run npx @uknows/setup remove. ${CLI.manifest} lists what changed. It has no secrets.`)
|
|
1536
|
+
ui.print()
|
|
1537
|
+
return files
|
|
1538
|
+
}
|
|
1539
|
+
|
|
1540
|
+
/** The --json files list of what this run changed, and the lines that say how (a diff when it differs from the plan). */
|
|
1541
|
+
function resultFiles(ws, saved, changes, commands) {
|
|
484
1542
|
const files = [], out = []
|
|
485
1543
|
for (const [file, before] of saved) {
|
|
486
1544
|
if (file === CLI.manifest) continue
|
|
@@ -502,55 +1560,143 @@ function printResult(ui, ws, saved, changes, commands) {
|
|
|
502
1560
|
out.push(...unified(file, a, b).split('\n').map(redactKey))
|
|
503
1561
|
}
|
|
504
1562
|
}
|
|
505
|
-
|
|
506
|
-
ui.print(`Done. Changed ${plural(files.length, 'file')}:`)
|
|
507
|
-
ui.print()
|
|
508
|
-
for (const line of out.slice(0, DIFF_LIMIT)) ui.print(line)
|
|
509
|
-
if (out.length > DIFF_LIMIT) ui.print('Run git diff to see the rest.')
|
|
510
|
-
ui.print()
|
|
511
|
-
ui.print(`To undo this, run npx @uknows/setup remove. ${CLI.manifest} lists what changed. It has no secrets.`)
|
|
512
|
-
ui.print()
|
|
513
|
-
return files
|
|
1563
|
+
return { files, out }
|
|
514
1564
|
}
|
|
515
1565
|
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
1566
|
+
/** Required steps first, then advice. Only the required steps count toward exit 3. title: another heading for the required steps. */
|
|
1567
|
+
function printManual(ui, manual, gap = true, title = null) {
|
|
1568
|
+
const required = manual.filter((b) => b.required), advice = manual.filter((b) => !b.required)
|
|
1569
|
+
for (const [blocks, heading] of [[required, title ?? `You need to do ${plural(required.length, 'more step')}:`], [advice, 'Advice:']]) {
|
|
1570
|
+
if (!blocks.length) continue
|
|
1571
|
+
if (gap) ui.print()
|
|
1572
|
+
gap = true
|
|
1573
|
+
ui.print(heading)
|
|
1574
|
+
ui.print()
|
|
1575
|
+
for (const block of blocks) ui.block(block)
|
|
1576
|
+
}
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
/**
|
|
1580
|
+
* In a pipeline, setup and remove change nothing, unless --allow-ci. Prints what happened and 1 next step, with the
|
|
1581
|
+
* link to CI/CD and pipelines on /docs. True when it stopped.
|
|
1582
|
+
*/
|
|
1583
|
+
function stopInCi(ui, report, opts, env = opts.env ?? {}) {
|
|
1584
|
+
const name = ciVariable(opts.env)
|
|
1585
|
+
if (!name || opts.allowCi) return false
|
|
1586
|
+
ui.block({ title: CI_CD.title, lines: [`${name} is set. ${CI_CD.stop}`, fillIn(CI_CD.next, { docs: `${docsUrl(env)}#${CI_CD.id}` }), CI_CD.allowCi] })
|
|
1587
|
+
report.problems.push({ code: 'ci', message: `${name} is set, so this run counts as a pipeline. ${CI_CD.allowCi}` })
|
|
1588
|
+
ui.print('Nothing changed.')
|
|
1589
|
+
return true
|
|
522
1590
|
}
|
|
523
1591
|
|
|
524
1592
|
const where = (dir) => (dir === '.' ? 'this folder' : dir)
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
1593
|
+
const fillIn = (text, values) => text.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
1594
|
+
/**
|
|
1595
|
+
* Drop empty lines from a block, so a docs note that is gone never prints as 'undefined'. A block is required
|
|
1596
|
+
* unless its producer says required: false (advice, such as a note on sampling).
|
|
1597
|
+
*/
|
|
1598
|
+
const tidy = (block) => ({ ...block, lines: block.lines.filter((line) => typeof line === 'string' && line), required: block.required !== false })
|
|
1599
|
+
/** Plan sentences: package installs, then the edits, then lock refreshes (they run after the edits), then the settings. */
|
|
1600
|
+
const steps = (p) => unique([...p.plan.commands.filter((c) => c.kind !== 'lock').map((c) => c.why), ...p.plan.summary,
|
|
1601
|
+
...p.plan.commands.filter((c) => c.kind === 'lock').map((c) => c.why), ...p.settings.summary])
|
|
528
1602
|
const text = (bytes) => { const t = bytes.toString('utf8'); return t.startsWith(BOM) ? t.slice(1) : t }
|
|
529
1603
|
|
|
530
1604
|
function describe(s, withDir = false) {
|
|
531
1605
|
const frameworks = s.frameworks.length ? ` (${s.frameworks.map(frameworkLabel).join(', ')})` : ''
|
|
532
|
-
const start = !s.start ? 'Its start command is not clear.'
|
|
533
|
-
: s.start.kind === 'package.json' ? `It starts
|
|
534
|
-
: `It starts
|
|
1606
|
+
const start = s.functions ? 'The Azure Functions host starts it.' : !s.start ? 'Its start command is not clear.'
|
|
1607
|
+
: s.start.kind === 'package.json' ? `It starts with the start script in ${s.start.file}.`
|
|
1608
|
+
: s.start.kind === 'dockerfile' ? `It starts with the start command in ${s.start.file} line ${s.start.line}.`
|
|
1609
|
+
: `It starts with ${s.start.file} line ${s.start.line}.`
|
|
535
1610
|
return `${stackLabel(s.lang)}${frameworks}${withDir ? ` in ${where(s.dir)}` : ''}. ${start}`
|
|
536
1611
|
}
|
|
537
1612
|
|
|
538
1613
|
/** A service in the --json report. start never holds the command text. */
|
|
539
1614
|
function serviceReport(s, extra = {}) {
|
|
540
|
-
return { dir: s.dir, lang: s.lang, stack: null, name: s.name, frameworks: s.frameworks,
|
|
1615
|
+
return { dir: s.dir, lang: s.lang, stack: null, mode: s.mode ?? null, name: s.name, frameworks: s.frameworks,
|
|
541
1616
|
start: s.start ? { kind: s.start.kind, file: s.start.file, line: s.start.line } : null, settings: null,
|
|
542
1617
|
summary: [], commands: [], manual: [], notes: [], otel: [], apm: [], ...extra }
|
|
543
1618
|
}
|
|
544
1619
|
|
|
545
|
-
/**
|
|
546
|
-
|
|
1620
|
+
/**
|
|
1621
|
+
* Probe the local tools that the services can need, once, before planning: uv and Poetry for a Python service when
|
|
1622
|
+
* the repo has their lockfile, dotnet for a locked .NET restore, npm for a Node.js service. Returns name -> version,
|
|
1623
|
+
* or null when missing.
|
|
1624
|
+
*/
|
|
1625
|
+
function probeTools(d, repo, items) {
|
|
1626
|
+
const tools = {}
|
|
1627
|
+
// The channel mode changes no packages, so it needs no package tool.
|
|
1628
|
+
const active = items.filter((it) => it.mode !== 'stop' && it.mode !== 'otel' && it.mode !== 'channel')
|
|
1629
|
+
if (active.some((it) => it.s.lang === 'python')) {
|
|
1630
|
+
let files = []
|
|
1631
|
+
try { files = repo.files() } catch { /* unreadable */ }
|
|
1632
|
+
for (const [name, re] of Object.entries(TOOLS)) if (files.some((f) => re.test(f))) tools[name] = d.probe(name)
|
|
1633
|
+
}
|
|
1634
|
+
// .NET with a locked restore: dotnet restore refreshes the lock, so it needs an SDK.
|
|
1635
|
+
if (active.some((it) => it.s.lang === 'dotnet' && lockedMode(it.s, repo, webProject(it.s, repo)))) tools.dotnet = d.probe('dotnet')
|
|
1636
|
+
// Node.js: npm here writes the package-lock.json that npm ci in the image reads (stacks/node.mjs npmGuard).
|
|
1637
|
+
if (active.some((it) => it.s.lang === 'node' && it.mode === 'standard')) tools.npm = d.probe('npm')
|
|
1638
|
+
return tools
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
/**
|
|
1642
|
+
* The version of a local tool from `<cmd> --version`, or null when it is missing or fails. For dotnet, the newest
|
|
1643
|
+
* SDK from `dotnet --list-sdks` (the SDK, not the runtime, restores). Runs nothing else.
|
|
1644
|
+
*/
|
|
1645
|
+
export function probeTool(cmd) {
|
|
1646
|
+
const args = cmd === 'dotnet' ? ['--list-sdks'] : ['--version']
|
|
1647
|
+
const run = (shell) => spawnSync(cmd, args, { encoding: 'utf8', timeout: 15000, shell, stdio: ['ignore', 'pipe', 'pipe'] })
|
|
1648
|
+
try {
|
|
1649
|
+
let r = run(false)
|
|
1650
|
+
if (r.error?.code === 'ENOENT' && process.platform === 'win32' && /^[\w.-]+$/.test(cmd)) r = run(true)
|
|
1651
|
+
if (r.status !== 0) return null
|
|
1652
|
+
if (cmd === 'dotnet') {
|
|
1653
|
+
const sdks = [...r.stdout.matchAll(/^(\d+\.\d+\.\d+[\w.-]*)\s+\[/gm)].map((m) => m[1])
|
|
1654
|
+
return sdks.sort((a, b) => a.localeCompare(b, 'en', { numeric: true })).at(-1) ?? null
|
|
1655
|
+
}
|
|
1656
|
+
return /(\d+\.\d+(?:\.\d+)?)/.exec(`${r.stdout} ${r.stderr}`)?.[1] ?? '0'
|
|
1657
|
+
} catch { return null }
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
/**
|
|
1661
|
+
* Why a lock command failed, from its output: the resolver text (uv "No solution found", Poetry "version solving
|
|
1662
|
+
* failed", NuGet "error NU1xxx") and the package it names. null when the output has none of these.
|
|
1663
|
+
*/
|
|
1664
|
+
export function resolverFailure(command, output) {
|
|
1665
|
+
const lines = output.split(/\r?\n/).map((l) => l.replace(/\u001b\[[0-9;]*m/g, '').trimEnd())
|
|
1666
|
+
const start = lines.findIndex((l) => /No solution found|version solving failed|SolverProblemError|Because .* depends on/.test(l))
|
|
1667
|
+
const nuget = lines.filter((l) => /\berror NU\d{4}\b/.test(l))
|
|
1668
|
+
if (start < 0 && !nuget.length) return null
|
|
1669
|
+
let text = nuget.length ? [...new Set(nuget.map((l) => l.trim()))].slice(0, 5) : []
|
|
1670
|
+
if (start >= 0) {
|
|
1671
|
+
const end = lines.findIndex((l, i) => i > start && !l.trim() && i - start > 1)
|
|
1672
|
+
text = lines.slice(start, end < 0 ? start + 14 : Math.min(end, start + 14)).map((l) => l.trim()).filter(Boolean)
|
|
1673
|
+
}
|
|
1674
|
+
const named = /Because (?:only )?(?:the following versions of )?([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(text.join(' '))?.[1]
|
|
1675
|
+
?? /Unable to find (?:a stable )?package '?([A-Za-z0-9][A-Za-z0-9._-]*)/.exec(text.join(' '))?.[1]
|
|
1676
|
+
?? /\b(?:on|for) ([A-Za-z0-9][A-Za-z0-9._-]*) (?:\(|[<>=])/.exec(text.join(' '))?.[1] ?? 'a package'
|
|
1677
|
+
const values = { tool: command.cmd, command: [command.cmd, ...command.args].join(' '), package: named }
|
|
1678
|
+
const fill = (t) => t.replace(/\{(\w+)\}/g, (m, key) => (key in values ? String(values[key]) : m))
|
|
1679
|
+
return { title: fill(RESOLVE.failed.title), lines: [fill(RESOLVE.failed.lines[0]), ...text.map((l) => ` ${l}`), fill(RESOLVE.failed.lines[1])], package: named }
|
|
1680
|
+
}
|
|
1681
|
+
|
|
1682
|
+
/**
|
|
1683
|
+
* Run a package manager. Its output goes to stderr, so --json stdout stays clean, or nowhere with quiet (the list of
|
|
1684
|
+
* steps of a run with 1 Yes). The end of the output comes back too (output), so a failed resolver can say why.
|
|
1685
|
+
*/
|
|
1686
|
+
export function runCommand(cmd, args, { cwd, quiet = false }) {
|
|
547
1687
|
return new Promise((resolve) => {
|
|
548
1688
|
const shell = process.platform === 'win32'
|
|
549
1689
|
if (shell && ![cmd, ...args].every((arg) => SAFE_ARG.test(arg))) return resolve({ code: null, error: 'an argument has characters this tool does not pass to a shell' })
|
|
550
|
-
let settled = false
|
|
551
|
-
const finish = (result) => { if (!settled) { settled = true; resolve(result) } }
|
|
1690
|
+
let settled = false, output = ''
|
|
1691
|
+
const finish = (result) => { if (!settled) { settled = true; resolve({ ...result, output }) } }
|
|
1692
|
+
const keep = (chunk) => {
|
|
1693
|
+
if (!quiet) process.stderr.write(chunk)
|
|
1694
|
+
output = (output + chunk.toString('utf8')).slice(-OUTPUT_LIMIT)
|
|
1695
|
+
}
|
|
552
1696
|
try {
|
|
553
|
-
const child = spawnChild(cmd, args, { cwd, stdio: ['ignore',
|
|
1697
|
+
const child = spawnChild(cmd, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'], shell })
|
|
1698
|
+
child.stdout.on('data', keep)
|
|
1699
|
+
child.stderr.on('data', keep)
|
|
554
1700
|
child.on('error', (error) => finish({ code: null, error: error.code === 'ENOENT' ? `${cmd} was not found` : error.message }))
|
|
555
1701
|
child.on('close', (code) => finish({ code, error: null }))
|
|
556
1702
|
} catch (error) {
|