@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/automate.mjs
ADDED
|
@@ -0,0 +1,1299 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The last steps of a setup run, done for the user in a terminal. Each one has its own question and runs only on a yes:
|
|
3
|
+
* 1. store the key on the platform (az for Container Apps and App Service, gh for GitHub secrets, kubectl for a
|
|
4
|
+
* Kubernetes secret), for the environment that the key check named;
|
|
5
|
+
* 2. add the key to a deploy file that would drop it on the next deploy;
|
|
6
|
+
* 3. create a branch, commit the files that setup changed, push it and open a pull request.
|
|
7
|
+
* Never in a pipeline, never with --yes, --json or --dry-run, never without a terminal (automates()).
|
|
8
|
+
*
|
|
9
|
+
* The key never goes in the arguments of a program, and never in a file: every program reads it from stdin. az reads
|
|
10
|
+
* the value of an argument @- from stdin, gh secret set reads stdin, and kubectl apply reads the Secret from stdin.
|
|
11
|
+
* Every az command names the subscription where the app was found: setup never uses the default subscription.
|
|
12
|
+
* Programs run through cli(cmd, args, {cwd, env, input}) (deps.cli), so tests use fakes.
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
import { accessSync, constants, readFileSync } from 'node:fs'
|
|
16
|
+
import { delimiter, join } from 'node:path'
|
|
17
|
+
import { ciVariable } from './ci.mjs'
|
|
18
|
+
import { RESOURCE_ATTRIBUTES, applyChanges, setEnvChange, updateCommandAt } from './cloudid.mjs'
|
|
19
|
+
import { ACA_SECRET_REF, CLI, FUNCTIONS, HOSTS, SECRET } from './contract.mjs'
|
|
20
|
+
import { HOOK_FILES } from './hooks/pins.mjs'
|
|
21
|
+
import { unified } from './diff.mjs'
|
|
22
|
+
import { Doc } from './doc.mjs'
|
|
23
|
+
import { appUrl } from './endpoint.mjs'
|
|
24
|
+
import { githubSlug } from './git.mjs'
|
|
25
|
+
import { hasKey } from './settings.mjs'
|
|
26
|
+
import { list, plural, unique } from './ui.mjs'
|
|
27
|
+
|
|
28
|
+
/** The secret of a container app that holds the key, from the env entry of the docs (secretref:uknows-otlp-headers). */
|
|
29
|
+
export const ACA_SECRET = ACA_SECRET_REF.slice(ACA_SECRET_REF.indexOf('secretref:') + 'secretref:'.length)
|
|
30
|
+
/** The Kubernetes secret and the GitHub secret of the docs steps. */
|
|
31
|
+
export const K8S_SECRET = 'uknows'
|
|
32
|
+
export const GITHUB_SECRET = 'UKNOWS_KEY'
|
|
33
|
+
/** The pull request: its title, the commit message, and the branch name (uknows-setup-2 and so on when taken). */
|
|
34
|
+
export const PULL_REQUEST = { title: 'Add UKNOWS monitoring', branch: 'uknows-setup' }
|
|
35
|
+
/** The page of the app that shows an app connect. The product serves it at exactly this path. */
|
|
36
|
+
export const CONNECT_PATH = '/live/connect'
|
|
37
|
+
const OUTPUT_LIMIT = 1 << 20
|
|
38
|
+
/** For every program: no questions, no colors, and az never installs an extension by itself. */
|
|
39
|
+
const QUIET = { AZURE_EXTENSION_USE_DYNAMIC_INSTALL: 'no', AZURE_CORE_ONLY_SHOW_ERRORS: 'true', AZURE_CORE_NO_COLOR: 'true',
|
|
40
|
+
GH_PROMPT_DISABLED: '1', GIT_TERMINAL_PROMPT: '0', NO_COLOR: '1' }
|
|
41
|
+
const AZURE = {
|
|
42
|
+
aca: { type: 'Microsoft.App/containerApps', what: 'container app', effect: 'It restarts the app.' },
|
|
43
|
+
webapp: { type: 'Microsoft.Web/sites', what: 'web app', effect: 'It restarts the app.' },
|
|
44
|
+
}
|
|
45
|
+
const WORDS = [['production', 'prod', 'prd', 'live'], ['staging', 'stage', 'stg'], ['development', 'dev'], ['test', 'testing', 'qa', 'uat']]
|
|
46
|
+
|
|
47
|
+
/** True when this run may do the last steps: a terminal that asks, and not --yes, --json, --dry-run or a pipeline. */
|
|
48
|
+
export function automates(opts, ui, env = {}) {
|
|
49
|
+
return Boolean(ui.interactive && !opts.yes && !opts.json && !opts.dryRun && !ciVariable(env))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Run a program and keep its output, which never goes to the screen. input: text for stdin (else stdin is closed).
|
|
54
|
+
* Resolves {code, stdout, stderr, error}: error is 'missing' when the program is not installed.
|
|
55
|
+
*/
|
|
56
|
+
export function runProgram(cmd, args, { cwd, env = process.env, input = null, timeoutMs = 300000 } = {}) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
let stdout = '', stderr = '', settled = false, timer = null, child
|
|
59
|
+
const finish = (result) => { if (!settled) { settled = true; clearTimeout(timer); resolve({ stdout, stderr, ...result }) } }
|
|
60
|
+
try {
|
|
61
|
+
child = spawn(cmd, args, { cwd, env: { ...env, ...QUIET }, stdio: [input === null ? 'ignore' : 'pipe', 'pipe', 'pipe'], windowsHide: true })
|
|
62
|
+
} catch (error) {
|
|
63
|
+
finish({ code: null, error: error.code === 'ENOENT' ? 'missing' : error.message })
|
|
64
|
+
return
|
|
65
|
+
}
|
|
66
|
+
child.stdout.on('data', (chunk) => { if (stdout.length < OUTPUT_LIMIT) stdout += chunk })
|
|
67
|
+
child.stderr.on('data', (chunk) => { if (stderr.length < OUTPUT_LIMIT) stderr += chunk })
|
|
68
|
+
child.on('error', (error) => finish({ code: null, error: error.code === 'ENOENT' ? 'missing' : error.message }))
|
|
69
|
+
child.on('close', (code) => finish({ code, error: null }))
|
|
70
|
+
if (input !== null) { child.stdin.on('error', () => {}); child.stdin.end(input) }
|
|
71
|
+
timer = setTimeout(() => { child.kill(); finish({ code: null, error: 'it took too long' }) }, timeoutMs)
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The page where the user watches the app connect, for 1 service and the environment of the key check. base: the app
|
|
77
|
+
* address (appUrl). null when the run does not know the app (a test copy of UKNOWS without UKNOWS_APP_URL).
|
|
78
|
+
*/
|
|
79
|
+
export function connectUrl(service, environment = null, base = appUrl()) {
|
|
80
|
+
if (!base) return null
|
|
81
|
+
const env = String(environment ?? '').trim().toLowerCase().replace(/\s+/g, '-')
|
|
82
|
+
return `${base}${CONNECT_PATH}?app=${encodeURIComponent(service ?? '')}${env ? `&env=${encodeURIComponent(env)}` : ''}`
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The end of the last line: the link to the page where the apps connect, or what to do without one. environment: the
|
|
87
|
+
* one of the key check. Without it (a skipped check), the link names the app only, and the page watches every
|
|
88
|
+
* environment of the workspace, so the line says so (F-012-145).
|
|
89
|
+
*/
|
|
90
|
+
export function watchLine(urls, environment = null) {
|
|
91
|
+
const known = urls.filter(Boolean)
|
|
92
|
+
if (!known.length) return 'Then watch your app connect in your test copy of UKNOWS. To get the link, set UKNOWS_APP_URL to the address of its app.'
|
|
93
|
+
const apps = known.length > 1 ? 'apps' : 'app'
|
|
94
|
+
return String(environment ?? '').trim() ? `Then watch your ${apps} connect: ${list(known)}` : `Then watch your ${apps} connect. The page watches every environment: ${list(known)}`
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* True when this computer can show a browser to the person at the terminal: macOS and Windows, except in an ssh
|
|
99
|
+
* session (the browser would open on the other computer), and Linux with a display and xdg-open.
|
|
100
|
+
*/
|
|
101
|
+
export function canOpenBrowser(env = process.env, platform = process.platform) {
|
|
102
|
+
if (platform !== 'linux' && (env.SSH_CONNECTION || env.SSH_TTY)) return false
|
|
103
|
+
if (platform === 'win32') return true
|
|
104
|
+
if (platform !== 'darwin' && !env.DISPLAY && !env.WAYLAND_DISPLAY) return false
|
|
105
|
+
return onPath(platform === 'darwin' ? 'open' : 'xdg-open', env)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** True when an executable named cmd is in a folder of PATH. */
|
|
109
|
+
function onPath(cmd, env) {
|
|
110
|
+
const path = env.PATH ?? env.Path ?? ''
|
|
111
|
+
return path.split(delimiter).filter(Boolean).some((dir) => {
|
|
112
|
+
try { accessSync(join(dir, cmd), constants.X_OK); return true } catch { return false }
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Open a link in the browser. say() runs just before, so the screen says that the browser opens. Without a browser,
|
|
118
|
+
* nothing happens and nothing prints. Never fails. Returns true when it opened the link.
|
|
119
|
+
*/
|
|
120
|
+
export function openBrowser(url, env = process.env, say = () => {}) {
|
|
121
|
+
if (!canOpenBrowser(env)) return false
|
|
122
|
+
const [cmd, args] = process.platform === 'darwin' ? ['open', [url]] : process.platform === 'win32' ? ['rundll32', ['url.dll,FileProtocolHandler', url]] : ['xdg-open', [url]]
|
|
123
|
+
say()
|
|
124
|
+
try {
|
|
125
|
+
const child = spawn(cmd, args, { env, stdio: 'ignore', detached: true, windowsHide: true })
|
|
126
|
+
child.on('error', () => {})
|
|
127
|
+
child.unref()
|
|
128
|
+
} catch { /* no browser */ }
|
|
129
|
+
return true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// --- where the key goes -------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
/** The names that mean 1 environment: Production is also prod, Staging also stg. Lower case. */
|
|
135
|
+
export function envWords(environment) {
|
|
136
|
+
const name = String(environment ?? '').trim().toLowerCase()
|
|
137
|
+
if (!name) return []
|
|
138
|
+
return WORDS.find((words) => words.includes(name)) ?? [name]
|
|
139
|
+
}
|
|
140
|
+
const tokens = (name) => String(name).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* The 1 app that gets the key of this environment, from the names in the deploy files: the only one, else the ones
|
|
144
|
+
* whose name says the environment (ca-web-staging), else the ones whose name holds the service name. More or none: [].
|
|
145
|
+
*/
|
|
146
|
+
export function pickApp(names, environment = null, service = null) {
|
|
147
|
+
let pool = unique(names)
|
|
148
|
+
if (pool.length > 1 && environment) {
|
|
149
|
+
const words = envWords(environment)
|
|
150
|
+
const hit = pool.filter((n) => tokens(n).some((t) => words.includes(t)))
|
|
151
|
+
if (hit.length) pool = hit
|
|
152
|
+
}
|
|
153
|
+
if (pool.length > 1 && service) {
|
|
154
|
+
const hit = pool.filter((n) => n === service || tokens(n).join('-').includes(service))
|
|
155
|
+
if (hit.length) pool = hit
|
|
156
|
+
}
|
|
157
|
+
return pool
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The GitHub secret for the key of this environment: the environment secret of the deploy job of that environment, or
|
|
162
|
+
* the repository secret when no deploy job has an environment: line. {env} (null: repository secret) or {why}.
|
|
163
|
+
*/
|
|
164
|
+
export function githubSecret(jobs = [], environment = null) {
|
|
165
|
+
const dynamic = (j) => Boolean(j.environment?.includes('$'))
|
|
166
|
+
const envs = unique(jobs.flatMap((j) => (!j.environment ? [] : dynamic(j) ? (j.environments ?? []).map((e) => e.name) : [j.environment])))
|
|
167
|
+
const unknown = jobs.some((j) => dynamic(j) && !(j.environments ?? []).length)
|
|
168
|
+
if (!jobs.length) return { why: 'Setup found no deploy job in your workflows, so it does not store a GitHub secret.' }
|
|
169
|
+
if (!envs.length && !unknown) return { env: null }
|
|
170
|
+
if (!envs.length) return { why: 'Your deploy job picks its GitHub environment when it runs, so setup cannot tell which environment gets this key.' }
|
|
171
|
+
if (!environment) return { why: 'UKNOWS did not say which environment this key is for, so setup cannot pick the GitHub environment.' }
|
|
172
|
+
const words = envWords(environment)
|
|
173
|
+
const match = envs.filter((e) => words.includes(e.toLowerCase()))
|
|
174
|
+
if (match.length === 1) return { env: match[0], partial: jobs.some((j) => !j.environment) }
|
|
175
|
+
return { why: `Your deploy jobs use the GitHub environments ${list(envs)}. ${match.length ? 'More than 1' : 'None'} of them is ${environment}, so setup cannot tell where this key goes.` }
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* What setup can store the key on, for each platform of the repo that has a key script: host id -> target. Only from
|
|
180
|
+
* the repo, nothing runs. plans: [{name, s}] with s.k8s, for the Kubernetes workloads. repo: owner/name on GitHub.
|
|
181
|
+
*/
|
|
182
|
+
export function keyTargets({ hosts = [], names, view, plans = [], repo = null }) {
|
|
183
|
+
const ids = new Set(hosts.map((h) => h.id))
|
|
184
|
+
const out = new Map()
|
|
185
|
+
if (ids.has('azure-container-apps')) out.set('azure-container-apps', { kind: 'aca', apps: names.aca.apps, groups: names.aca.groups })
|
|
186
|
+
if (ids.has('azure-app-service')) out.set('azure-app-service', { kind: 'webapp', apps: names.webapp.apps, groups: names.webapp.groups, slots: names.webapp.slots })
|
|
187
|
+
if (ids.has('kubernetes')) out.set('kubernetes', { kind: 'k8s', workloads: workloads(view, plans) })
|
|
188
|
+
if (ids.has('github-actions')) out.set('github-actions', { kind: 'github', repo, jobs: names.jobs ?? [], readsKey: readsKey(view) })
|
|
189
|
+
return out
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** True when a workflow of the repo reads the UKNOWS_KEY secret already. */
|
|
193
|
+
function readsKey(view) {
|
|
194
|
+
let files = []
|
|
195
|
+
try { files = view.files() } catch { files = [] }
|
|
196
|
+
return files.filter((f) => /^(?:\.\.\/)*\.github\/workflows\/[^/]+\.ya?ml$/.test(f)).some((f) => {
|
|
197
|
+
try { return new RegExp(`\\bsecrets\\.${GITHUB_SECRET}\\b`).test(view.read(f) ?? '') } catch { return false }
|
|
198
|
+
})
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The workloads (kind, name, namespace) of the Kubernetes containers of the services of this run. */
|
|
202
|
+
function workloads(view, plans) {
|
|
203
|
+
const out = []
|
|
204
|
+
for (const p of plans) {
|
|
205
|
+
for (const k of p.s?.k8s ?? []) {
|
|
206
|
+
let text = null
|
|
207
|
+
try { text = view.read(k.file) } catch { text = null }
|
|
208
|
+
const w = text === null ? null : workloadOf(text, k.doc)
|
|
209
|
+
if (w?.kind && w.name && !out.some((x) => x.kind === w.kind && x.name === w.name && x.namespace === w.namespace)) out.push({ ...w, file: k.file })
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
return out
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** kind, metadata.name and metadata.namespace of document doc (0-based, split on --- lines) of a YAML file. */
|
|
216
|
+
export function workloadOf(text, doc = 0) {
|
|
217
|
+
const lines = docLines(new Doc(text), doc).map((i) => text.split(/\r?\n/)[i])
|
|
218
|
+
const kind = lines.map((l) => /^kind:\s*['"]?([A-Za-z]+)['"]?\s*(?:#.*)?$/.exec(l)?.[1]).find(Boolean) ?? null
|
|
219
|
+
const at = lines.findIndex((l) => /^metadata:\s*(?:#.*)?$/.test(l))
|
|
220
|
+
let name = null, namespace = null
|
|
221
|
+
if (at >= 0) {
|
|
222
|
+
const rows = []
|
|
223
|
+
for (let i = at + 1; i < lines.length && (!lines[i].trim() || /^\s/.test(lines[i])); i++) if (lines[i].trim() && !/^\s*#/.test(lines[i])) rows.push(lines[i])
|
|
224
|
+
const pad = rows.length ? /^\s*/.exec(rows[0])[0].length : 0
|
|
225
|
+
const value = (key) => {
|
|
226
|
+
const row = rows.find((r) => /^\s*/.exec(r)[0].length === pad && new RegExp(`^\\s*${key}:`).test(r))
|
|
227
|
+
const v = row?.replace(new RegExp(`^\\s*${key}:\\s*`), '').replace(/\s+#.*$/, '').trim().replace(/^(['"])(.*)\1$/, '$2')
|
|
228
|
+
return v && /^[a-z0-9]([-a-z0-9.]*[a-z0-9])?$/.test(v) ? v : null
|
|
229
|
+
}
|
|
230
|
+
name = value('name')
|
|
231
|
+
namespace = value('namespace')
|
|
232
|
+
}
|
|
233
|
+
return { kind, name, namespace }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** The line indexes of document doc of a YAML file, split on --- lines. */
|
|
237
|
+
function docLines(d, doc) {
|
|
238
|
+
const out = []
|
|
239
|
+
let n = 0
|
|
240
|
+
for (let i = 0; i < d.length; i++) {
|
|
241
|
+
if (/^---/.test(d.get(i))) { n++; continue }
|
|
242
|
+
if (n === doc) out.push(i)
|
|
243
|
+
}
|
|
244
|
+
return out
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// --- storing the key ----------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
const why = (text) => ({ stored: false, why: text })
|
|
250
|
+
const parse = (text) => { try { return JSON.parse(text) } catch { return null } }
|
|
251
|
+
/** The first line of an error, without the key (also in base64, as a Kubernetes secret holds it) and without the ERROR: prefix of az. */
|
|
252
|
+
const firstLine = (text, key) => {
|
|
253
|
+
const line = String(text ?? '').split(/\r?\n/).map((l) => l.trim()).find(Boolean) ?? 'no reason given'
|
|
254
|
+
const forms = key ? [Buffer.from(`${SECRET.header}=${key}`).toString('base64'), key] : []
|
|
255
|
+
return forms.reduce((out, form) => out.split(form).join(SECRET.placeholder), line).replace(/^ERROR:\s*/, '').slice(0, 300)
|
|
256
|
+
}
|
|
257
|
+
const keyWord = (environment) => (environment ? `this ${environment} key` : 'this key')
|
|
258
|
+
const failed = (r, key) => (r.error ? r.error : firstLine(r.stderr || r.stdout, key))
|
|
259
|
+
/** An error that the same command gives again: a usage error. Then setup never says to try again. */
|
|
260
|
+
const REPEATS = /\busage error\b|\bis required\b|unrecognized arguments/i
|
|
261
|
+
/** A store that failed: why, and whether npx @uknows/setup key can work on a second try. */
|
|
262
|
+
const failure = (prefix, r, key) => { const text = failed(r, key); return { stored: false, failed: true, why: `${prefix}: ${text}`, again: !REPEATS.test(text) } }
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Azure Container Apps and App Service, read only: find the 1 app that gets this key, in the subscriptions that the user
|
|
266
|
+
* can see. {found} or {stored: false, why}. say(line): where the line about the lookup goes.
|
|
267
|
+
*/
|
|
268
|
+
async function findAzure(cli, t, ctx, say = () => {}) {
|
|
269
|
+
const A = AZURE[t.kind]
|
|
270
|
+
if (t.kind === 'webapp' && t.slots?.length) return why('Your deploy uses a deployment slot, so set the key on each slot with the steps below.')
|
|
271
|
+
if (!t.apps.length) return why(`Your deploy files do not name the ${A.what}, so setup cannot tell where the key goes.`)
|
|
272
|
+
// The deploy files do not say which app runs which service, so 1 store could reach only 1 of them.
|
|
273
|
+
if ((ctx.services?.length ?? 0) > 1) return why(`You set up ${ctx.services.length} services, so setup cannot tell which ${A.what} runs each one. Set the key on each app with the steps below.`)
|
|
274
|
+
const apps = pickApp(t.apps.map((a) => a.name), ctx.environment, ctx.service)
|
|
275
|
+
if (apps.length !== 1) return why(`Your deploy files name ${t.apps.length} ${A.what}s, ${list(t.apps.map((a) => a.name))}, so setup cannot tell which one gets this key.`)
|
|
276
|
+
if (process.platform === 'win32') return why('On Windows, setup does not run az. Use the steps below in WSL or Azure Cloud Shell.')
|
|
277
|
+
const app = apps[0]
|
|
278
|
+
const groups = unique(t.groups.map((g) => g.name.toLowerCase()))
|
|
279
|
+
const group = groups.length === 1 ? groups[0] : null
|
|
280
|
+
const az = azure(cli, ctx)
|
|
281
|
+
const accounts = await az(['account', 'list', '--query', "[?state=='Enabled'].{id:id, name:name}", '-o', 'json'])
|
|
282
|
+
if (accounts.error === 'missing') return why('az is not installed, so setup cannot store the key. Install the Azure CLI and sign in with az login. Then run npx @uknows/setup key, or use the steps below.')
|
|
283
|
+
const subs = (accounts.code === 0 ? parse(accounts.stdout) : null)?.filter?.((s) => typeof s?.id === 'string' && s.id) ?? []
|
|
284
|
+
if (!subs.length) return why('az is not signed in, so setup cannot store the key. Sign in with az login. Then run npx @uknows/setup key, or use the steps below.')
|
|
285
|
+
say(`Looking for ${app} in the ${plural(subs.length, 'Azure subscription')} that you can see.`)
|
|
286
|
+
// Read only, 4 subscriptions at a time, in the order az lists them.
|
|
287
|
+
const answers = await inTurn(subs, 4, (sub) => az(['resource', 'list', '--subscription', sub.id, '--name', app, '--resource-type', A.type,
|
|
288
|
+
'--query', '[].{id:id, name:name, resourceGroup:resourceGroup}', '-o', 'json'], { timeoutMs: 60000 }))
|
|
289
|
+
const found = []
|
|
290
|
+
subs.forEach((sub, i) => {
|
|
291
|
+
const rows = answers[i].code === 0 ? parse(answers[i].stdout) : null
|
|
292
|
+
for (const row of Array.isArray(rows) ? rows : []) {
|
|
293
|
+
if (typeof row?.name !== 'string' || row.name.toLowerCase() !== app.toLowerCase() || typeof row.resourceGroup !== 'string') continue
|
|
294
|
+
if (group && row.resourceGroup.toLowerCase() !== group) continue
|
|
295
|
+
found.push({ sub, group: row.resourceGroup, name: row.name })
|
|
296
|
+
}
|
|
297
|
+
})
|
|
298
|
+
const named = group ? ` in the resource group ${t.groups[0].name}` : ''
|
|
299
|
+
if (!found.length) return why(`Setup did not find the ${A.what} ${app}${named} in your Azure subscriptions, so it does not store the key.`)
|
|
300
|
+
if (found.length > 1) {
|
|
301
|
+
return why(`Setup found ${app} in ${found.length} places, ${list(found.map((f) => `${f.group} in ${f.sub.name ?? f.sub.id}`))}, so it cannot tell which one gets this key.`)
|
|
302
|
+
}
|
|
303
|
+
const { sub, group: rg, name } = found[0]
|
|
304
|
+
const at = ['--subscription', sub.id, '-g', rg, '-n', name]
|
|
305
|
+
if (t.kind === 'aca') {
|
|
306
|
+
// az containerapp update sets the env entry on the only container. With more, it needs --container-name.
|
|
307
|
+
const containers = listed(await az(['containerapp', 'show', ...at, '--query', 'properties.template.containers[].name', '-o', 'json']))?.filter((c) => typeof c === 'string')
|
|
308
|
+
if (containers?.length > 1) {
|
|
309
|
+
return why(`${name} has ${containers.length} containers, ${list(containers)}, so setup cannot tell which one runs your app. Use the steps below, and add --container-name to the update command.`)
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const mark = t.kind === 'aca' ? ACA_SECRET : ctx.secretName
|
|
313
|
+
const have = await azureNames(az, t.kind, at)
|
|
314
|
+
return { found: { kind: t.kind, name, rg, sub, at, mark, replaces: Boolean(have?.includes(mark)) } }
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const azure = (cli, ctx) => (args, more = {}) => cli('az', [...args, '--only-show-errors'], { cwd: ctx.root, env: ctx.env, ...more })
|
|
318
|
+
const listed = (r, field = null) => { const v = r.code === 0 ? parse(r.stdout) : null; return Array.isArray(v) ? v.map((x) => (field ? x?.[field] : x)) : null }
|
|
319
|
+
/** The names of the secrets of a container app, or of the settings of a web app. Never their values. */
|
|
320
|
+
const azureNames = async (az, kind, at) => listed(await (kind === 'aca' ? az(['containerapp', 'secret', 'list', ...at, '--query', '[].name', '-o', 'json'])
|
|
321
|
+
: az(['webapp', 'config', 'appsettings', 'list', ...at, '--query', '[].name', '-o', 'json'])))
|
|
322
|
+
|
|
323
|
+
/** Set the key on the app that findAzure found, then check that it is there. */
|
|
324
|
+
async function putAzure(cli, f, ctx) {
|
|
325
|
+
const az = azure(cli, ctx)
|
|
326
|
+
let r
|
|
327
|
+
if (f.kind === 'aca') {
|
|
328
|
+
// az reads the value of an argument @- from stdin, so the key is in no argument and in no file.
|
|
329
|
+
r = await az(['containerapp', 'secret', 'set', ...f.at, '-o', 'none', '--secrets', '@-'], { input: `${ACA_SECRET}=${SECRET.header}=${ctx.key}` })
|
|
330
|
+
if (r.code === 0) r = await az(['containerapp', 'update', ...f.at, '-o', 'none', '--set-env-vars', `${ctx.secretName}=secretref:${ACA_SECRET}`])
|
|
331
|
+
} else {
|
|
332
|
+
r = await az(['webapp', 'config', 'appsettings', 'set', ...f.at, '-o', 'none', '--settings', '@-'],
|
|
333
|
+
{ input: JSON.stringify([{ name: ctx.secretName, value: `${SECRET.header}=${ctx.key}`, slotSetting: false }]) })
|
|
334
|
+
}
|
|
335
|
+
if (r.code !== 0) return failure(`Could not store the key on ${f.name}`, r, ctx.key)
|
|
336
|
+
if (!(await azureNames(az, f.kind, f.at))?.includes(f.mark)) {
|
|
337
|
+
return { stored: false, failed: true, why: `Setup set the key, but cannot find ${f.kind === 'aca' ? `the secret ${ACA_SECRET}` : `the setting ${f.mark}`} on ${f.name}. Check the app in the Azure portal.` }
|
|
338
|
+
}
|
|
339
|
+
return { stored: true, where: f.name, done: `Stored ${keyWord(ctx.environment).replace(/^this /, 'the ')} on ${f.name} in ${f.rg}.` }
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const azureQuestion = (f, ctx) => `Store ${keyWord(ctx.environment)} on ${f.name} in ${f.rg} (subscription ${f.sub.name ?? f.sub.id}, ${f.sub.id.slice(0, 8)}...)? ${AZURE[f.kind].effect}${f.replaces ? ' It replaces the key that the app has now.' : ''} (y/N)`
|
|
343
|
+
|
|
344
|
+
/** Azure Container Apps and App Service: find the app, ask, then set the key on it. */
|
|
345
|
+
async function storeAzure(ui, cli, t, ctx) {
|
|
346
|
+
const f = await findAzure(cli, t, ctx, ui.print)
|
|
347
|
+
if (!f.found) return f
|
|
348
|
+
if (!(await ui.confirm(azureQuestion(f.found, ctx), false))) return { stored: false, declined: true }
|
|
349
|
+
ui.print(`Storing the key on ${f.found.name}. This can take a minute.`)
|
|
350
|
+
return putAzure(cli, f.found, ctx)
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** GitHub Actions, read only: the environment secret of the deploy job of this environment, or the repository secret. */
|
|
354
|
+
async function findGithub(cli, t, ctx) {
|
|
355
|
+
if (!t.repo) return why('The origin remote of this repo is not on github.com, so setup cannot store the key as a GitHub secret.')
|
|
356
|
+
const target = githubSecret(t.jobs, ctx.environment)
|
|
357
|
+
if (target.why) return why(target.why)
|
|
358
|
+
const gh = github(cli, ctx)
|
|
359
|
+
const auth = await gh(['auth', 'status', '--hostname', 'github.com'])
|
|
360
|
+
if (auth.error === 'missing') return why('gh is not installed, so setup cannot store the key as a GitHub secret. Install the GitHub CLI and sign in with gh auth login. Then run npx @uknows/setup key, or use the steps below.')
|
|
361
|
+
if (auth.code !== 0) return why('gh is not signed in, so setup cannot store the key. Sign in with gh auth login. Then run npx @uknows/setup key, or use the steps below.')
|
|
362
|
+
const where = target.env ? ['--env', target.env] : []
|
|
363
|
+
if (target.env) {
|
|
364
|
+
const env = await gh(['api', `repos/${t.repo}/environments/${encodeURIComponent(target.env)}`, '--silent'])
|
|
365
|
+
if (env.code !== 0) {
|
|
366
|
+
return why(/\b404\b|not found/i.test(`${env.stderr}${env.stdout}`)
|
|
367
|
+
? `The GitHub environment ${target.env} does not exist yet in ${t.repo}. Create it in the settings of the repo, under Environments. Then run npx @uknows/setup key, or use the steps below.`
|
|
368
|
+
: `Setup could not read the environments of ${t.repo}: ${failed(env)}`)
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
const have = await githubNames(gh, t.repo, where)
|
|
372
|
+
return { found: { kind: 'github', repo: t.repo, env: target.env, where, partial: target.partial, replaces: Boolean(have?.includes(GITHUB_SECRET)) } }
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const github = (cli, ctx) => (args, more = {}) => cli('gh', args, { cwd: ctx.root, env: ctx.env, timeoutMs: 60000, ...more })
|
|
376
|
+
/** The names of the secrets, never their values: --json name, or the first column on a gh without --json. */
|
|
377
|
+
async function githubNames(gh, repo, where) {
|
|
378
|
+
const json = await gh(['secret', 'list', '--repo', repo, ...where, '--json', 'name'])
|
|
379
|
+
const v = json.code === 0 ? parse(json.stdout) : null
|
|
380
|
+
if (Array.isArray(v)) return v.map((x) => x?.name)
|
|
381
|
+
const plain = await gh(['secret', 'list', '--repo', repo, ...where])
|
|
382
|
+
return plain.code === 0 ? plain.stdout.split(/\r?\n/).map((l) => l.split(/\s+/)[0]).filter(Boolean) : null
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/** Set the GitHub secret that findGithub found, then check that it is there. */
|
|
386
|
+
async function putGithub(cli, f, ctx) {
|
|
387
|
+
const gh = github(cli, ctx)
|
|
388
|
+
// gh reads the value from stdin. The secret holds the key only, as the docs steps say.
|
|
389
|
+
const r = await gh(['secret', 'set', GITHUB_SECRET, '--repo', f.repo, ...f.where], { input: ctx.key })
|
|
390
|
+
if (r.code !== 0) return failure('Could not store the GitHub secret', r, ctx.key)
|
|
391
|
+
if (!(await githubNames(gh, f.repo, f.where))?.includes(GITHUB_SECRET)) {
|
|
392
|
+
return { stored: false, failed: true, why: `Setup set the secret, but cannot find ${GITHUB_SECRET} in ${f.repo}. Check the secrets in the settings of the repo.` }
|
|
393
|
+
}
|
|
394
|
+
return { stored: true, partial: f.partial, done: f.env ? `Stored the GitHub secret ${GITHUB_SECRET} of the environment ${f.env}.` : `Stored the repository secret ${GITHUB_SECRET}.` }
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const githubQuestion = (f, ctx) => {
|
|
398
|
+
const replaces = f.replaces ? ' It replaces the secret that is there now.' : ''
|
|
399
|
+
return f.env
|
|
400
|
+
? `Store ${keyWord(ctx.environment)} as the secret ${GITHUB_SECRET} of the GitHub environment ${f.env} in ${f.repo}? The deploy jobs of that environment can read it.${replaces} (y/N)`
|
|
401
|
+
: `Store ${keyWord(ctx.environment)} as the repository secret ${GITHUB_SECRET} of ${f.repo}? Every workflow of the repo can read it.${replaces} (y/N)`
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** GitHub Actions: find where the secret goes, ask, then set it. */
|
|
405
|
+
async function storeGithub(ui, cli, t, ctx) {
|
|
406
|
+
const f = await findGithub(cli, t, ctx)
|
|
407
|
+
if (!f.found) return f
|
|
408
|
+
if (!(await ui.confirm(githubQuestion(f.found, ctx), false))) return { stored: false, declined: true }
|
|
409
|
+
return putGithub(cli, f.found, ctx)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/** Kubernetes, read only: the 1 kubectl context that has the workload, and the namespace of the secret uknows. */
|
|
413
|
+
async function findK8s(cli, t, ctx, say = () => {}) {
|
|
414
|
+
if (!t.workloads.length) return why('Setup did not find the Kubernetes workload of this app, so it does not store the key.')
|
|
415
|
+
const spaces = unique(t.workloads.map((w) => w.namespace))
|
|
416
|
+
if (spaces.includes(null)) return why('Your Kubernetes files do not name a namespace, so setup cannot tell where the secret goes.')
|
|
417
|
+
if (spaces.length > 1) return why(`Your Kubernetes files use ${spaces.length} namespaces, ${list(spaces)}, so setup cannot tell which one gets this key.`)
|
|
418
|
+
const ns = spaces[0], w = t.workloads[0]
|
|
419
|
+
const k = kube(cli, ctx)
|
|
420
|
+
const contexts = await k(['config', 'get-contexts', '-o', 'name'])
|
|
421
|
+
if (contexts.error === 'missing') return why('kubectl is not installed, so setup cannot store the key. Then run npx @uknows/setup key, or use the steps below.')
|
|
422
|
+
const all = contexts.code === 0 ? unique(contexts.stdout.split(/\r?\n/).map((l) => l.trim()).filter(Boolean)) : []
|
|
423
|
+
if (!all.length) return why('kubectl has no cluster set up, so setup cannot store the key. Set up kubectl for your cluster. Then run npx @uknows/setup key, or use the steps below.')
|
|
424
|
+
const resource = `${w.kind.toLowerCase()}/${w.name}`
|
|
425
|
+
say(`Looking for ${w.kind} ${w.name} in the namespace ${ns}, in the ${plural(all.length, 'kubectl context')} on this computer.`)
|
|
426
|
+
const found = []
|
|
427
|
+
for (const c of all) if ((await k(['--context', c, '-n', ns, 'get', resource, '-o', 'name', '--request-timeout=10s'])).code === 0) found.push(c)
|
|
428
|
+
if (!found.length) return why(`Setup did not find ${w.kind} ${w.name} in the namespace ${ns} in your kubectl contexts, so it does not store the key.`)
|
|
429
|
+
if (found.length > 1) return why(`Setup found ${w.kind} ${w.name} in ${found.length} kubectl contexts, ${list(found)}, so it cannot tell which one gets this key.`)
|
|
430
|
+
const at = ['--context', found[0], '-n', ns]
|
|
431
|
+
const had = (await k([...at, 'get', 'secret', K8S_SECRET, '-o', 'name', '--request-timeout=10s'])).code === 0
|
|
432
|
+
return { found: { kind: 'k8s', context: found[0], ns, at, replaces: had } }
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// A cluster that wants a new sign-in (a device code) would wait for it: every kubectl call has a time limit.
|
|
436
|
+
const kube = (cli, ctx) => (args, more = {}) => cli('kubectl', args, { cwd: ctx.root, env: ctx.env, timeoutMs: 60000, ...more })
|
|
437
|
+
|
|
438
|
+
/** Apply the secret uknows that findK8s found, then check that it is there. */
|
|
439
|
+
async function putK8s(cli, f, ctx) {
|
|
440
|
+
const k = kube(cli, ctx)
|
|
441
|
+
// The Secret goes to kubectl apply on stdin, so the key is in no argument and in no file.
|
|
442
|
+
const r = await k([...f.at, 'apply', '-f', '-'], { input: secretYaml(K8S_SECRET, f.ns, ctx.secretName, `${SECRET.header}=${ctx.key}`) })
|
|
443
|
+
if (r.code !== 0) return failure('Could not store the key', r, ctx.key)
|
|
444
|
+
if ((await k([...f.at, 'get', 'secret', K8S_SECRET, '-o', 'name', '--request-timeout=10s'])).code !== 0) {
|
|
445
|
+
return { stored: false, failed: true, why: `Setup applied the secret, but cannot find ${K8S_SECRET} in the namespace ${f.ns}.` }
|
|
446
|
+
}
|
|
447
|
+
return { stored: true, where: `the namespace ${f.ns}`, done: `Stored ${keyWord(ctx.environment).replace(/^this /, 'the ')} as the secret ${K8S_SECRET} in the namespace ${f.ns}.` }
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const k8sQuestion = (f, ctx) => `Store ${keyWord(ctx.environment)} as the secret ${K8S_SECRET} in the namespace ${f.ns} (kubectl context ${f.context})? Pods that start after this get the key.${f.replaces ? ' It replaces the key in that secret.' : ''} (y/N)`
|
|
451
|
+
|
|
452
|
+
/** Kubernetes: find the context, ask, then apply the secret. */
|
|
453
|
+
async function storeK8s(ui, cli, t, ctx) {
|
|
454
|
+
const f = await findK8s(cli, t, ctx, ui.print)
|
|
455
|
+
if (!f.found) return f
|
|
456
|
+
if (!(await ui.confirm(k8sQuestion(f.found, ctx), false))) return { stored: false, declined: true }
|
|
457
|
+
return putK8s(cli, f.found, ctx)
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/** A Kubernetes Secret with 1 value: the Secret that kubectl create secret generic makes. */
|
|
461
|
+
export function secretYaml(name, namespace, key, value) {
|
|
462
|
+
return ['apiVersion: v1', 'kind: Secret', 'metadata:', ` name: ${name}`, ` namespace: ${namespace}`, 'type: Opaque', 'data:',
|
|
463
|
+
` ${key}: ${Buffer.from(value, 'utf8').toString('base64')}`, ''].join('\n')
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Read only lookups, and the writes after a yes, for each kind of place that can hold the key. */
|
|
467
|
+
export const FIND = { aca: findAzure, webapp: findAzure, github: findGithub, k8s: findK8s }
|
|
468
|
+
export const PUT = { aca: putAzure, webapp: putAzure, github: putGithub, k8s: putK8s }
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* Where a store puts the key, for the plan: a title, the rows (label, value) and 1 line on what happens. Only from what
|
|
472
|
+
* FIND found, so the plan names the exact app, subscription, repository or cluster.
|
|
473
|
+
*/
|
|
474
|
+
export function storeRows(f) {
|
|
475
|
+
if (f.kind === 'aca' || f.kind === 'webapp') {
|
|
476
|
+
// A dry run looks up nothing: it names what the deploy files name, and says what a real run looks up.
|
|
477
|
+
const sub = f.dry ? 'the one that has this app. A real run looks it up.' : `${f.sub.name ?? f.sub.id} (${f.sub.id.slice(0, 8)}...)`
|
|
478
|
+
return { title: `Store your key on your ${AZURE[f.kind].what}`, rows: [['App', f.name], ['Resource group', f.rg], ['Subscription', sub]],
|
|
479
|
+
note: `${AZURE[f.kind].effect.replace('It restarts the app.', 'The app restarts with the key.')}${f.replaces ? ' It replaces the key that the app has now.' : ''}` }
|
|
480
|
+
}
|
|
481
|
+
if (f.kind === 'github') {
|
|
482
|
+
return { title: 'Store your key as a GitHub secret', rows: [['Secret', GITHUB_SECRET], ['Repository', f.repo], ...(f.env ? [['Environment', f.env]] : [])],
|
|
483
|
+
note: `${f.env ? 'The deploy jobs of that environment can read it.' : 'Every workflow of the repo can read it.'}${f.replaces ? ' It replaces the secret that is there now.' : ''}` }
|
|
484
|
+
}
|
|
485
|
+
return { title: 'Store your key as a Kubernetes secret', rows: [['Secret', K8S_SECRET], ['Namespace', f.ns], ['kubectl context', f.context]],
|
|
486
|
+
note: `Pods that start after this get the key.${f.replaces ? ' It replaces the key in that secret.' : ''}` }
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/** The name that a progress line uses for a store: the app, the GitHub secret or the Kubernetes secret. */
|
|
490
|
+
export const storeName = (f) => (f.kind === 'github' ? `the GitHub secret ${GITHUB_SECRET}` : f.kind === 'k8s' ? `the secret ${K8S_SECRET} in ${f.ns}` : f.name)
|
|
491
|
+
|
|
492
|
+
const STORE = { aca: storeAzure, webapp: storeAzure, github: storeGithub, k8s: storeK8s }
|
|
493
|
+
|
|
494
|
+
/** fn for each item, at most n at a time. The results keep the order of the items. */
|
|
495
|
+
async function inTurn(items, n, fn) {
|
|
496
|
+
const out = new Array(items.length)
|
|
497
|
+
let next = 0
|
|
498
|
+
const worker = async () => { while (next < items.length) { const i = next++; out[i] = await fn(items[i]) } }
|
|
499
|
+
await Promise.all(Array.from({ length: Math.min(n, items.length) }, worker))
|
|
500
|
+
return out
|
|
501
|
+
}
|
|
502
|
+
const keyTitle = (title) => HOSTS.find((h) => title === `Set your key once: ${h.label}`)
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The key steps in a terminal. For each "Set your key once" block whose platform setup can store the key on: ask, and
|
|
506
|
+
* store it. The block prints when the key is not stored. GitHub Actions needs no secret when setup stored the key on
|
|
507
|
+
* the app itself and no workflow reads the secret. Returns {stored, left, skipped, direct, github}: what is done, the
|
|
508
|
+
* titles of the blocks that are left, the hosts whose block is not needed, where the key went on the platform itself,
|
|
509
|
+
* and the GitHub Actions block when its secret is stored but no workflow step reads it yet.
|
|
510
|
+
* ctx: {key, environment, env, root, secretName, service, services}: service is the 1 service of the run, services every
|
|
511
|
+
* service of the run. key null: the key was not checked, so nothing is stored.
|
|
512
|
+
*/
|
|
513
|
+
export async function keyStepsInTerminal(ui, cli, blocks, targets, ctx) {
|
|
514
|
+
const out = { stored: [], left: [], skipped: [], direct: null }
|
|
515
|
+
const keyBlocks = blocks.filter((b) => keyTitle(b.title))
|
|
516
|
+
if (!keyBlocks.length) return out
|
|
517
|
+
if (!ctx.key && keyBlocks.some((b) => targets.has(keyTitle(b.title).id))) {
|
|
518
|
+
ui.print('Setup stores your key only after UKNOWS accepts it. To store it later, run npx @uknows/setup key. Or use the steps below.')
|
|
519
|
+
ui.print()
|
|
520
|
+
} else if (ctx.key && keyBlocks.some((b) => targets.has(keyTitle(b.title).id))) {
|
|
521
|
+
ui.print('Store your key')
|
|
522
|
+
}
|
|
523
|
+
for (const block of keyBlocks) {
|
|
524
|
+
const host = keyTitle(block.title), t = targets.get(host.id)
|
|
525
|
+
if (host.id === 'github-actions' && out.direct && !t?.readsKey) {
|
|
526
|
+
ui.print(`GitHub Actions needs no ${GITHUB_SECRET} secret: setup stored the key on ${out.direct}.`)
|
|
527
|
+
ui.print()
|
|
528
|
+
out.skipped.push(host.id)
|
|
529
|
+
continue
|
|
530
|
+
}
|
|
531
|
+
if (!t || !ctx.key) { ui.block(block); out.left.push(block.title); continue }
|
|
532
|
+
const r = await STORE[t.kind](ui, cli, t, ctx)
|
|
533
|
+
if (r.stored) {
|
|
534
|
+
ui.print('Stored.')
|
|
535
|
+
ui.print()
|
|
536
|
+
out.stored.push({ host: host.id, done: r.done })
|
|
537
|
+
if (t.kind !== 'github') out.direct = r.where
|
|
538
|
+
// A GitHub secret reaches the app only through a step of the deploy job. Without one, that step is left: the deploy
|
|
539
|
+
// file question can add it (githubStep), else it prints after the deploy files (githubStepBlock).
|
|
540
|
+
if (t.kind === 'github' && r.partial) { ui.block(block); out.left.push(block.title) } else if (t.kind === 'github' && !t.readsKey) out.github = block
|
|
541
|
+
continue
|
|
542
|
+
}
|
|
543
|
+
if (r.why) (r.failed ? ui.error : ui.print)(r.why)
|
|
544
|
+
if (r.failed) ui.print(r.again ? 'Use the steps below, or run npx @uknows/setup key to try again.' : 'Use the steps below.')
|
|
545
|
+
if (r.why || r.failed) ui.print()
|
|
546
|
+
ui.block(block)
|
|
547
|
+
out.left.push(block.title)
|
|
548
|
+
}
|
|
549
|
+
return out
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* The key steps of a run with 1 Yes, read only, before the plan. For each "Set your key once" block: a store that setup
|
|
554
|
+
* can do ({block, host, t, found}), or the block left for the person with why not. GitHub Actions needs no secret when the
|
|
555
|
+
* key goes on the app itself and no workflow reads the secret (skipped), as in keyStepsInTerminal. ctx as there.
|
|
556
|
+
* Returns {stores, left: [{block, host, why}], skipped: [{block, host}]}. why null: setup has no way to store it there.
|
|
557
|
+
*/
|
|
558
|
+
export async function planKeyStores(cli, blocks, targets, ctx, say = () => {}) {
|
|
559
|
+
const out = { stores: [], left: [], skipped: [] }
|
|
560
|
+
let direct = false
|
|
561
|
+
for (const block of blocks.filter((b) => keyTitle(b.title))) {
|
|
562
|
+
const host = keyTitle(block.title), t = targets.get(host.id)
|
|
563
|
+
if (host.id === 'github-actions' && direct && !t?.readsKey) { out.skipped.push({ block, host: host.id }); continue }
|
|
564
|
+
if (!t || !ctx.key) { out.left.push({ block, host: host.id, why: t ? 'no-key' : null }); continue }
|
|
565
|
+
const f = await FIND[t.kind](cli, t, ctx, say)
|
|
566
|
+
if (!f.found) { out.left.push({ block, host: host.id, why: f.why }); continue }
|
|
567
|
+
out.stores.push({ block, host: host.id, t, found: f.found })
|
|
568
|
+
if (t.kind !== 'github') direct = true
|
|
569
|
+
}
|
|
570
|
+
return out
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** The step of the GitHub Actions block that is left after the secret is stored: add the key step to the deploy job. */
|
|
574
|
+
export function githubStepBlock(block) {
|
|
575
|
+
return { title: 'Add the key step to your deploy workflow', lines: block.lines.filter((l) => /^Add the step below|^Your deploy files name \d+ container apps|^The app must load the secret|^On other platforms/.test(l)),
|
|
576
|
+
snippets: (block.snippets ?? []).filter((x) => /- name: Set the UKNOWS key/.test(x.code)) }
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** The lines of the key step of the GitHub Actions block, from its - name: line to its az line, without comments. */
|
|
580
|
+
export function githubStepLines(block) {
|
|
581
|
+
const code = (block?.snippets ?? []).find((x) => /- name: Set the UKNOWS key/.test(x.code))?.code
|
|
582
|
+
if (!code) return null
|
|
583
|
+
const rows = code.split('\n')
|
|
584
|
+
const from = rows.findIndex((l) => /^- name: Set the UKNOWS key/.test(l)), to = rows.findIndex((l) => /\baz containerapp secret set\b/.test(l))
|
|
585
|
+
return from >= 0 && to > from ? rows.slice(from, to + 1).map((l) => l.replace(/\s+#[^'"]*$/, '')) : null
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// --- deploy files -------------------------------------------------------------------
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The edits that make each deploy keep the key, 1 per file, from the files on disk now. Only the safe cases:
|
|
592
|
+
* --replace-env-vars of az containerapp update: the env entry goes after the last pair of the list.
|
|
593
|
+
* environmentVariables of the Container Apps deploy action, on 1 line: the env entry goes at its end.
|
|
594
|
+
* a container of a plain Kubernetes file with no envFrom: envFrom with the secret uknows, optional.
|
|
595
|
+
* Anything else stays a printed step. A file that holds a key, or above the setup folder, is never changed.
|
|
596
|
+
* Each edit: {file, before, after, lines (1-based, in before), checks (the deploy check ids it does), about}.
|
|
597
|
+
* plans: [{s}] with s.k8s. secretName: the variable that holds the key.
|
|
598
|
+
*/
|
|
599
|
+
export function deployEdits(view, hosts, plans, secretName, { githubStep = null } = {}) {
|
|
600
|
+
const files = new Map()
|
|
601
|
+
const evidence = (id, what) => unique(hosts.filter((h) => h.id === id).flatMap((h) => h.evidence ?? []).filter((e) => e.what === what).map((e) => e.file))
|
|
602
|
+
const add = (file, check, apply) => {
|
|
603
|
+
if (file.startsWith('../')) return
|
|
604
|
+
if (!files.has(file)) {
|
|
605
|
+
let text = null
|
|
606
|
+
try { text = view.read(file) } catch { text = null }
|
|
607
|
+
if (text === null || hasKey(text)) return
|
|
608
|
+
files.set(file, { file, before: text, after: text, lines: [], checks: [], about: [] })
|
|
609
|
+
}
|
|
610
|
+
const entry = files.get(file)
|
|
611
|
+
const result = apply(entry.after)
|
|
612
|
+
if (!result) return
|
|
613
|
+
entry.after = result.after
|
|
614
|
+
entry.lines.push(...result.lines)
|
|
615
|
+
entry.checks.push(check)
|
|
616
|
+
entry.about.push(`${file} ${result.lines.length > 1 ? 'lines' : 'line'} ${list(result.lines.map(String))} ${result.about}`)
|
|
617
|
+
}
|
|
618
|
+
const pair = `${secretName}=secretref:${ACA_SECRET}`
|
|
619
|
+
for (const file of evidence('azure-container-apps', 'Deploy step runs az containerapp')) add(file, 'replace-env-vars', (text) => replaceEnvEdit(text, pair, secretName))
|
|
620
|
+
for (const file of evidence('azure-container-apps', 'Azure Container Apps deploy action')) add(file, 'deploy-action-env', (text) => actionEnvEdit(text, pair, secretName))
|
|
621
|
+
// The key is in a GitHub secret now: the deploy job sets it on the container app, and its deploy command loads it.
|
|
622
|
+
if (githubStep && hosts.some((h) => h.id === 'azure-container-apps')) {
|
|
623
|
+
for (const file of evidence('github-actions', 'GitHub Actions workflow that deploys')) add(file, 'github-step', (text) => githubStepEdit(text, githubStep, pair, secretName))
|
|
624
|
+
}
|
|
625
|
+
if (hosts.some((h) => h.id === 'kubernetes')) {
|
|
626
|
+
for (const p of plans) for (const k of p.s?.k8s ?? []) add(k.file, 'env-from', (text) => envFromEdit(text, k.doc, k.container))
|
|
627
|
+
}
|
|
628
|
+
return [...files.values()].filter((e) => e.after !== e.before).map((e) => ({ ...e, lines: unique(e.lines).sort((a, b) => a - b),
|
|
629
|
+
question: `Add the key to ${e.file} ${e.lines.length > 1 ? 'lines' : 'line'} ${list(unique(e.lines).sort((a, b) => a - b).map(String))}? (y/N)` }))
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const setsVar = (name) => new RegExp(`(?:^|[\\s"'])${name}=`)
|
|
633
|
+
|
|
634
|
+
/** --replace-env-vars of each az containerapp update in text: the pair after the last value. null when nothing to add. */
|
|
635
|
+
export function replaceEnvEdit(text, pair, name) {
|
|
636
|
+
const d = new Doc(text)
|
|
637
|
+
const lines = []
|
|
638
|
+
for (let i = 0; i < d.length; i++) {
|
|
639
|
+
const line = d.get(i)
|
|
640
|
+
if (/^\s*#/.test(line)) continue
|
|
641
|
+
const flag = /(^|\s)--replace-env-vars(?=\s|$)/.exec(line)
|
|
642
|
+
if (!flag) continue
|
|
643
|
+
let start = i
|
|
644
|
+
while (start > 0 && /\\\s*$/.test(d.get(start - 1))) start--
|
|
645
|
+
let end = i
|
|
646
|
+
while (end + 1 < d.length && /\\\s*$/.test(d.get(end))) end++
|
|
647
|
+
const command = Array.from({ length: end - start + 1 }, (_, k) => d.get(start + k)).join('\n')
|
|
648
|
+
if (!/\baz\s+containerapp\s+update\b/.test(command) || setsVar(name).test(command)) continue
|
|
649
|
+
// The values after the flag, up to the next option, the end of the command, a comment or a shell operator.
|
|
650
|
+
let row = i, col = flag.index + flag[0].length, last = null
|
|
651
|
+
for (;;) {
|
|
652
|
+
const rest = d.get(row).slice(col)
|
|
653
|
+
const re = /("[^"]*"|'[^']*'|[^\s"']+)/g
|
|
654
|
+
let m, stop = false, more = false
|
|
655
|
+
while ((m = re.exec(rest))) {
|
|
656
|
+
const token = m[1]
|
|
657
|
+
if (token === '\\' && !rest.slice(m.index + 1).trim()) { more = true; break }
|
|
658
|
+
if (/^(?:-|#|&&|\|\||;|\|)/.test(token)) { stop = true; break }
|
|
659
|
+
if (!/=/.test(token)) { stop = true; break }
|
|
660
|
+
last = { row, col: col + m.index + token.length }
|
|
661
|
+
}
|
|
662
|
+
if (stop || !more || row + 1 >= d.length) break
|
|
663
|
+
row++
|
|
664
|
+
col = 0
|
|
665
|
+
}
|
|
666
|
+
if (!last) continue
|
|
667
|
+
const target = d.get(last.row)
|
|
668
|
+
d.set(last.row, `${target.slice(0, last.col)} ${pair}${target.slice(last.col)}`)
|
|
669
|
+
lines.push(i + 1)
|
|
670
|
+
}
|
|
671
|
+
return lines.length ? { after: d.toString(), lines,
|
|
672
|
+
about: `runs az containerapp update with --replace-env-vars. That replaces every environment variable of the app, so each deploy would remove the key.` } : null
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* The key step of the docs before each workflow step that runs az containerapp update, in a job that does not read the
|
|
677
|
+
* UKNOWS_KEY secret yet, and the env entry that loads the key on that command (unless it has --replace-env-vars, whose
|
|
678
|
+
* edit adds it). null when nothing to add, and when a command names another app than the APP of the key step: that
|
|
679
|
+
* step would set the key on 1 app only, so it stays a printed step.
|
|
680
|
+
*/
|
|
681
|
+
export function githubStepEdit(text, stepLines, pair, name) {
|
|
682
|
+
const d = new Doc(text)
|
|
683
|
+
const indent = (l) => /^\s*/.exec(l)[0].length
|
|
684
|
+
const unquote = (v) => v?.replace(/^(["'])(.*)\1$/, '$2') ?? null
|
|
685
|
+
const app = unquote(stepLines.map((l) => /^\s+APP:\s*(\S+)\s*$/.exec(l)?.[1]).find(Boolean))
|
|
686
|
+
const spots = []
|
|
687
|
+
for (let i = 0; i < d.length; i++) {
|
|
688
|
+
if (/^\s*#/.test(d.get(i)) || !/\baz\s+containerapp\s+update\b/.test(d.get(i))) continue
|
|
689
|
+
let step = i
|
|
690
|
+
while (step >= 0 && !(/^\s*-\s+\S/.test(d.get(step)) && indent(d.get(step)) < indent(d.get(i)))) step--
|
|
691
|
+
if (step < 0) return null
|
|
692
|
+
// The job of the step: up to the line with less indent than its steps: key.
|
|
693
|
+
let top = step
|
|
694
|
+
while (top > 0 && !/^\s*steps\s*:/.test(d.get(top))) top--
|
|
695
|
+
let job = top
|
|
696
|
+
while (job > 0 && !(d.get(job).trim() && indent(d.get(job)) < indent(d.get(top)))) job--
|
|
697
|
+
let end = top + 1
|
|
698
|
+
while (end < d.length && (!d.get(end).trim() || indent(d.get(end)) > indent(d.get(job)))) end++
|
|
699
|
+
const body = Array.from({ length: end - job }, (_, k) => d.get(job + k)).join('\n')
|
|
700
|
+
if (new RegExp(`\\bsecrets\\.${GITHUB_SECRET}\\b`).test(body) || /- name: Set the UKNOWS key/.test(body)) continue
|
|
701
|
+
let last = i
|
|
702
|
+
while (last + 1 < d.length && /\\\s*$/.test(d.get(last))) last++
|
|
703
|
+
const command = Array.from({ length: last - i + 1 }, (_, k) => d.get(i + k)).join('\n')
|
|
704
|
+
// A literal app name that is not the APP of the key step. A $VARIABLE is the deploy's own: the names came from it.
|
|
705
|
+
const named = unquote(/(?:^|\s)(?:-n|--name)[\s=]+("[^"]*"|'[^']*'|\S+)/.exec(command)?.[1])
|
|
706
|
+
if (app && named && !named.includes('$') && named !== app) return null
|
|
707
|
+
// The subscription of the key step: the one of the deploy command, else the one that azure/login signs in to.
|
|
708
|
+
const subscription = /--subscription[\s=]+("[^"]*"|'[^']*'|\S+)/.exec(command)?.[1]?.replace(/^(["'])(.*)\1$/, '$2')
|
|
709
|
+
?? /^\s*subscription-id\s*:\s*(.+?)\s*$/m.exec(body)?.[1]?.replace(/^(["'])(.*)\1$/, '$2') ?? null
|
|
710
|
+
spots.push({ step, i, subscription, first: !spots.some((x) => x.step === step),
|
|
711
|
+
env: !setsVar(name).test(command) && !/(?:^|\s)--replace-env-vars(?=\s|$)/.test(command) })
|
|
712
|
+
}
|
|
713
|
+
if (!spots.length) return null
|
|
714
|
+
for (const spot of [...spots].reverse()) {
|
|
715
|
+
if (spot.env) {
|
|
716
|
+
// On the --set-env-vars list of the command if it has one: az keeps only the last --set-env-vars list.
|
|
717
|
+
const command = updateCommandAt(d, spot.i)
|
|
718
|
+
if (!command) return null
|
|
719
|
+
applyChanges(d, [setEnvChange(command, pair)])
|
|
720
|
+
}
|
|
721
|
+
if (!spot.first) continue
|
|
722
|
+
const pad = ' '.repeat(indent(d.get(spot.step)))
|
|
723
|
+
const lines = stepLines.map((l) => (spot.subscription && /^\s+SUBSCRIPTION:/.test(l) ? `${/^\s*/.exec(l)[0]}SUBSCRIPTION: ${spot.subscription}` : l))
|
|
724
|
+
d.insert(spot.step, ...lines.map((l) => `${pad}${l}`), '')
|
|
725
|
+
}
|
|
726
|
+
// Lines in the text before the edit: the step that the key step goes before.
|
|
727
|
+
return { after: d.toString(), lines: unique(spots.map((s) => s.step + 1)),
|
|
728
|
+
about: 'deploys with az containerapp update. This adds the step that sets the key from the UKNOWS_KEY secret before it, and loads the key in the deploy command. Your GitHub secret then reaches the app on each deploy.' }
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/** environmentVariables of the Container Apps deploy action, on 1 line: the pair at its end. null when nothing to add. */
|
|
732
|
+
export function actionEnvEdit(text, pair, name) {
|
|
733
|
+
const d = new Doc(text)
|
|
734
|
+
const lines = []
|
|
735
|
+
for (let i = 0; i < d.length; i++) {
|
|
736
|
+
const m = /^(\s*environmentVariables\s*:\s*)(.*?)((?:\s+#.*)?)\s*$/.exec(d.get(i))
|
|
737
|
+
if (!m || !inActionStep(d, i)) continue
|
|
738
|
+
const value = m[2]
|
|
739
|
+
if (!value || /^[|>]/.test(value) || setsVar(name).test(value)) continue
|
|
740
|
+
const quoted = /^(["'])(.*)\1$/.exec(value)
|
|
741
|
+
if (!quoted && /["']/.test(value)) continue
|
|
742
|
+
const next = quoted ? `${quoted[1]}${quoted[2]} ${pair}${quoted[1]}` : `${value} ${pair}`
|
|
743
|
+
d.set(i, `${m[1]}${next}${m[3]}`)
|
|
744
|
+
lines.push(i + 1)
|
|
745
|
+
}
|
|
746
|
+
return lines.length ? { after: d.toString(), lines,
|
|
747
|
+
about: 'gives the Container Apps deploy action its environmentVariables. The action replaces every environment variable, so each deploy would remove the key.' } : null
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/** True when line i is in a workflow step that uses azure/container-apps-deploy-action. */
|
|
751
|
+
function inActionStep(d, i) {
|
|
752
|
+
const indent = (l) => /^\s*/.exec(l)[0].length
|
|
753
|
+
let start = i
|
|
754
|
+
while (start >= 0 && !/^\s*-\s/.test(d.get(start))) start--
|
|
755
|
+
if (start < 0) return false
|
|
756
|
+
const pad = indent(d.get(start))
|
|
757
|
+
for (let j = start; j < d.length && (j === start || !d.get(j).trim() || indent(d.get(j)) > pad); j++) {
|
|
758
|
+
if (/^\s*(?:-\s+)?uses\s*:\s*['"]?azure\/container-apps-deploy-action@/.test(d.get(j))) return true
|
|
759
|
+
}
|
|
760
|
+
return false
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/** The rows of 1 container of document doc of a YAML file, with top: the indent of its keys. null when not found. */
|
|
764
|
+
function containerItem(d, doc, container) {
|
|
765
|
+
const rows = docLines(d, doc).filter((i) => d.get(i).trim() && !/^\s*#/.test(d.get(i)))
|
|
766
|
+
.map((i) => ({ i, indent: /^\s*/.exec(d.get(i))[0].length, text: d.get(i).trim() }))
|
|
767
|
+
for (let r = 0; r < rows.length; r++) {
|
|
768
|
+
if (!/^containers:\s*(?:#.*)?$/.test(rows[r].text)) continue
|
|
769
|
+
for (const item of items(rows, r)) {
|
|
770
|
+
const name = item.find((x) => x.indent === item.top && /^name:/.test(x.text))?.text.replace(/^name:\s*/, '').replace(/\s+#.*$/, '').replace(/^(['"])(.*)\1$/, '$2')
|
|
771
|
+
if (name === container) return item
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
return null
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/** True when the container loads the secret uknows with envFrom. */
|
|
778
|
+
function loadsSecret(item) {
|
|
779
|
+
const from = item.findIndex((x) => x.indent === item.top && /^envFrom:/.test(x.text))
|
|
780
|
+
if (from < 0) return false
|
|
781
|
+
for (let k = from + 1; k < item.length && item[k].indent > item.top; k++) if (new RegExp(`^name:\\s*['"]?${K8S_SECRET}['"]?\\s*(?:#.*)?$`).test(item[k].text)) return true
|
|
782
|
+
return false
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
/** True when every Kubernetes container of the services loads the secret uknows (after the deploy file step). */
|
|
786
|
+
export function k8sLoadsKey(view, plans) {
|
|
787
|
+
for (const p of plans) {
|
|
788
|
+
for (const k of p.s?.k8s ?? []) {
|
|
789
|
+
let text = null
|
|
790
|
+
try { text = view.read(k.file) } catch { text = null }
|
|
791
|
+
const item = text === null ? null : containerItem(new Doc(text), k.doc, k.container)
|
|
792
|
+
if (!item || !loadsSecret(item)) return false
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
return true
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** envFrom with the secret uknows for 1 container of document doc. null when it has envFrom already, or is not found. */
|
|
799
|
+
export function envFromEdit(text, doc, container) {
|
|
800
|
+
const d = new Doc(text)
|
|
801
|
+
const item = containerItem(d, doc, container)
|
|
802
|
+
if (!item || item.some((x) => x.indent === item.top && /^envFrom:/.test(x.text))) return null
|
|
803
|
+
const pad = ' '.repeat(item.top)
|
|
804
|
+
d.insert(item[item.length - 1].i + 1, `${pad}envFrom:`, `${pad} - secretRef:`, `${pad} name: ${K8S_SECRET}`, `${pad} optional: true`)
|
|
805
|
+
const line = item.find((x) => x.indent === item.top && /^name:/.test(x.text))?.i ?? item[0].i
|
|
806
|
+
return { after: d.toString(), lines: [line + 1],
|
|
807
|
+
about: `runs the container ${container}, which does not load the secret ${K8S_SECRET} yet. This adds envFrom, so new pods get the key.` }
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/** The items of the block list under rows[at], each with its rows and top: the indent of its keys. */
|
|
811
|
+
function items(rows, at) {
|
|
812
|
+
const out = []
|
|
813
|
+
let dash = -1
|
|
814
|
+
for (let r = at + 1; r < rows.length; r++) {
|
|
815
|
+
const { indent, text } = rows[r]
|
|
816
|
+
const isDash = /^-(?:\s|$)/.test(text)
|
|
817
|
+
if (dash < 0) { if (!isDash || indent < rows[at].indent) break; dash = indent }
|
|
818
|
+
if (indent < dash || (indent === dash && !isDash)) break
|
|
819
|
+
if (indent === dash) {
|
|
820
|
+
const rest = text.slice(1), trimmed = rest.trimStart()
|
|
821
|
+
const item = trimmed ? [{ i: rows[r].i, indent: indent + 1 + rest.length - trimmed.length, text: trimmed }] : []
|
|
822
|
+
out.push(Object.assign(item, { top: item[0]?.indent ?? -1 }))
|
|
823
|
+
} else {
|
|
824
|
+
const item = out[out.length - 1]
|
|
825
|
+
if (item.top < 0) item.top = indent
|
|
826
|
+
item.push(rows[r])
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
return out.filter((item) => item.length)
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* Ask for each deploy file edit, with what it changes and why, and apply it with apply(edit) on a yes. Returns the
|
|
834
|
+
* edits that were applied.
|
|
835
|
+
*/
|
|
836
|
+
export async function deployFilesInTerminal(ui, edits, apply) {
|
|
837
|
+
const done = []
|
|
838
|
+
for (const edit of edits) {
|
|
839
|
+
for (const about of edit.about) ui.print(about)
|
|
840
|
+
for (const line of unified(edit.file, edit.before, edit.after).split('\n')) ui.print(line)
|
|
841
|
+
if (!(await ui.confirm(edit.question, false))) { ui.print(); continue }
|
|
842
|
+
try {
|
|
843
|
+
apply(edit)
|
|
844
|
+
done.push(edit)
|
|
845
|
+
ui.print(`Changed ${edit.file}. To undo it, run npx @uknows/setup remove.`)
|
|
846
|
+
} catch (error) {
|
|
847
|
+
ui.error(`Could not change ${edit.file}: ${error.message}. It is as it was.`)
|
|
848
|
+
}
|
|
849
|
+
ui.print()
|
|
850
|
+
}
|
|
851
|
+
return done
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* The deploy check lines that are done: the ones that a deploy file edit did for every file that the line names, the
|
|
856
|
+
* secret-first line when the key is on the container app, and the GitHub guard when its block is not needed.
|
|
857
|
+
*/
|
|
858
|
+
export function openChecks(blocks, { edits = [], stored = [], skipped = [], secretName = SECRET.name }) {
|
|
859
|
+
const text = (host, id) => {
|
|
860
|
+
const t = HOSTS.find((h) => h.id === host)?.checks?.find((c) => c.id === id)?.text
|
|
861
|
+
return t ? t.split(SECRET.name).join(secretName) : null
|
|
862
|
+
}
|
|
863
|
+
const doneBy = new Map([['replace-env-vars', 'azure-container-apps'], ['deploy-action-env', 'azure-container-apps']])
|
|
864
|
+
return blocks.flatMap((block) => {
|
|
865
|
+
if (keyTitle(block.title)) return []
|
|
866
|
+
const host = HOSTS.find((h) => block.title.endsWith(`: ${h.label}`) && block.title.startsWith('Check your deploy files'))
|
|
867
|
+
if (!host) return [block]
|
|
868
|
+
if (skipped.includes(host.id)) return []
|
|
869
|
+
// The key step that the GitHub step edit added sets the secret before the deploy, and fails the job without it.
|
|
870
|
+
const step = edits.some((e) => e.checks.includes('github-step'))
|
|
871
|
+
const lines = block.lines.filter((line) => {
|
|
872
|
+
if (host.id === 'azure-container-apps' && (stored.includes('azure-container-apps') || step) && line === text(host.id, 'secret-first')) return false
|
|
873
|
+
if (host.id === 'github-actions' && step && line === text(host.id, 'guard')) return false
|
|
874
|
+
for (const [id, hostId] of doneBy) {
|
|
875
|
+
const t = text(hostId, id)
|
|
876
|
+
if (host.id !== hostId || !t || !line.startsWith(t)) continue
|
|
877
|
+
const files = [...line.slice(t.length).matchAll(/([^\s,]+) line \d+/g)].map((m) => m[1])
|
|
878
|
+
if (files.length && files.every((f) => edits.some((e) => e.file === f && e.checks.includes(id)))) return false
|
|
879
|
+
}
|
|
880
|
+
return true
|
|
881
|
+
})
|
|
882
|
+
return lines.length ? [{ ...block, lines }] : []
|
|
883
|
+
})
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
// --- the pull request ---------------------------------------------------------------
|
|
887
|
+
|
|
888
|
+
/** The paths in the porcelain -z output of git status, relative to the folder whose prefix is given. */
|
|
889
|
+
function statusPaths(out, prefix) {
|
|
890
|
+
const tokens = out.split('\0'), paths = []
|
|
891
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
892
|
+
const entry = tokens[i]
|
|
893
|
+
if (entry.length < 4) continue
|
|
894
|
+
if (entry[0] === 'R' || entry[0] === 'C') i++
|
|
895
|
+
const path = entry.slice(3)
|
|
896
|
+
paths.push(path.startsWith(prefix) ? path.slice(prefix.length) : path)
|
|
897
|
+
}
|
|
898
|
+
return paths
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/** ssh never asks for a passphrase: the terminal belongs to the questions of setup. A command of the user stays. */
|
|
902
|
+
async function sshEnv(git, env) {
|
|
903
|
+
return env.GIT_SSH_COMMAND || (await git(['config', '--get', 'core.sshCommand'])).code === 0 ? {} : { GIT_SSH_COMMAND: 'ssh -o BatchMode=yes' }
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** The name after this one: uknows-setup, uknows-setup-2, uknows-setup-3 and so on. */
|
|
907
|
+
const nextBranch = (name) => (name === PULL_REQUEST.branch ? `${name}-2` : name.replace(/-(\d+)$/, (m, n) => `-${Number(n) + 1}`))
|
|
908
|
+
|
|
909
|
+
/** A commit subject as the question shows it: 1 line, no control characters, at most 72 characters. */
|
|
910
|
+
const subject = (text) => {
|
|
911
|
+
const line = String(text).replace(/[\u0000-\u001f\u007f]/g, '').trim()
|
|
912
|
+
return line.length > 72 ? `${line.slice(0, 71)}...` : line
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* What the pull request step needs to know, before its question. git: (args, more) => result, in the repo.
|
|
917
|
+
* from: the branch checked out (null: none). remote: the origin address. slug: owner/name on GitHub.
|
|
918
|
+
* base: the branch of origin that the pull request goes to: the branch checked out when origin has it, else the
|
|
919
|
+
* default branch of origin (origin/HEAD). null when origin has neither: then setup does not push.
|
|
920
|
+
* ahead: {count, subjects}, the commits on HEAD that origin/<base> does not have. A push takes them along.
|
|
921
|
+
* branch: the first free name, uknows-setup, then uknows-setup-2 and so on.
|
|
922
|
+
* network: also ask origin which branches it has (git ls-remote), so a branch that a teammate pushed is never taken.
|
|
923
|
+
* The dry run passes false and reads only this computer.
|
|
924
|
+
*/
|
|
925
|
+
export async function prFacts(git, { env = {}, network = false } = {}) {
|
|
926
|
+
const head = await git(['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
927
|
+
if (head.code !== 0) return null
|
|
928
|
+
const from = head.stdout.trim()
|
|
929
|
+
if (from === 'HEAD') return { from: null }
|
|
930
|
+
const exists = async (ref) => (await git(['rev-parse', '--verify', '--quiet', ref])).code === 0
|
|
931
|
+
const origin = await git(['config', '--get', 'remote.origin.url'])
|
|
932
|
+
const remote = origin.code === 0 ? origin.stdout.trim() || null : null
|
|
933
|
+
let base = null
|
|
934
|
+
if (remote) {
|
|
935
|
+
if (await exists(`refs/remotes/origin/${from}`)) base = from
|
|
936
|
+
else {
|
|
937
|
+
const def = await git(['symbolic-ref', '--quiet', '--short', 'refs/remotes/origin/HEAD'])
|
|
938
|
+
const name = def.code === 0 && def.stdout.trim().startsWith('origin/') ? def.stdout.trim().slice('origin/'.length) : null
|
|
939
|
+
if (name && (await exists(`refs/remotes/origin/${name}`))) base = name
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
const ahead = { count: 0, subjects: [] }
|
|
943
|
+
if (base) {
|
|
944
|
+
const range = `refs/remotes/origin/${base}..HEAD`
|
|
945
|
+
ahead.count = Number((await git(['rev-list', '--count', range])).stdout.trim()) || 0
|
|
946
|
+
if (ahead.count) ahead.subjects = (await git(['log', '-n', '3', '--format=%s', range])).stdout.split('\n').map(subject).filter(Boolean)
|
|
947
|
+
}
|
|
948
|
+
const ssh = network && remote ? await sshEnv(git, env) : {}
|
|
949
|
+
const taken = new Set()
|
|
950
|
+
if (network && remote) {
|
|
951
|
+
// The heads of origin, where the push goes. Read only. Without an answer, the local names are all setup knows.
|
|
952
|
+
const push = await git(['remote', 'get-url', '--push', 'origin'])
|
|
953
|
+
const heads = await git(['ls-remote', '--heads', push.code === 0 && push.stdout.trim() ? push.stdout.trim() : 'origin'], { timeoutMs: 30000, env: { ...env, ...ssh } })
|
|
954
|
+
if (heads.code === 0) for (const m of heads.stdout.matchAll(/\trefs\/heads\/(\S+)/g)) taken.add(m[1])
|
|
955
|
+
}
|
|
956
|
+
const free = async (name) => !taken.has(name) && !(await exists(`refs/heads/${name}`)) && !(await exists(`refs/remotes/origin/${name}`))
|
|
957
|
+
let branch = PULL_REQUEST.branch
|
|
958
|
+
for (let n = 0; n < 100 && !(await free(branch)); n++) branch = nextBranch(branch)
|
|
959
|
+
return { from, remote, slug: githubSlug(remote), base, ahead, branch, ssh }
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* The files of the commit, as the questions name them: the files of the app that setup changed, and its record
|
|
964
|
+
* (.uknows/setup.json), so the count matches the "Changed N files" line of the run. earlier: a rerun names the files
|
|
965
|
+
* that an earlier run changed.
|
|
966
|
+
*/
|
|
967
|
+
export function commitFiles(files, earlier = false) {
|
|
968
|
+
const n = files.filter((f) => f !== CLI.manifest).length
|
|
969
|
+
const changed = `the ${plural(n, 'file')} setup changed${earlier ? ' earlier' : ''}`
|
|
970
|
+
if (!files.includes(CLI.manifest)) return changed
|
|
971
|
+
return n ? `${changed} and its record, ${CLI.manifest}` : `the record of setup, ${CLI.manifest}`
|
|
972
|
+
}
|
|
973
|
+
|
|
974
|
+
/** The paths that git shows as changed and not committed, of the paths given, relative to the folder of git. */
|
|
975
|
+
async function changedPaths(git, paths) {
|
|
976
|
+
const prefix = (await git(['rev-parse', '--show-prefix'])).stdout.trim()
|
|
977
|
+
const status = await git(['status', '--porcelain=v1', '-z', '--untracked-files=all', '--', ...paths])
|
|
978
|
+
return status.code === 0 ? unique(statusPaths(status.stdout, prefix)).sort() : []
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* The files of setup that are not committed yet: paths is the manifest and its paths. [] when git cannot tell. A rerun
|
|
983
|
+
* with nothing to change reads it, to say that setup has nothing more to change but its files are not committed yet.
|
|
984
|
+
*/
|
|
985
|
+
export function notCommitted(cli, { root, env, paths }) {
|
|
986
|
+
return changedPaths((args) => cli('git', ['-C', root, ...args], { cwd: root, env }), paths)
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/**
|
|
990
|
+
* The question of the pull request step. files: the paths of the commit. pr: setup opens the pull request (gh is
|
|
991
|
+
* signed in, origin is on GitHub and has a base). Commits of the user that origin does not have go along on a push:
|
|
992
|
+
* the question names them, and no is the default then.
|
|
993
|
+
*/
|
|
994
|
+
export function prQuestion({ branch, files, pr, base = null, ahead = { count: 0, subjects: [] } }) {
|
|
995
|
+
const what = pr ? `Create branch ${branch}, commit ${commitFiles(files)}, and open a pull request?` : `Create branch ${branch} and commit ${commitFiles(files)}?`
|
|
996
|
+
if (!ahead.count) return `${what} (Y/n)`
|
|
997
|
+
const names = ahead.subjects.map((s) => `"${s}"`)
|
|
998
|
+
const shown = ahead.count > names.length ? `${names.join(', ')} and ${ahead.count - names.length} more` : list(names)
|
|
999
|
+
return `${what} ${pr ? 'The pull request also gets' : 'The branch also has'} ${plural(ahead.count, 'commit')} of yours that ${ahead.count === 1 ? 'is' : 'are'} not on origin/${base} yet: ${shown}. (y/N)`
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** Why a git push failed: the line of origin or git that says so, and whether the branch name is taken on origin. */
|
|
1003
|
+
export function pushFailure(stderr) {
|
|
1004
|
+
const lines = String(stderr ?? '').split(/\r?\n/).map((l) => l.trim().replace(/\s+/g, ' ')).filter(Boolean)
|
|
1005
|
+
const rejected = lines.find((l) => /^! \[(?:remote )?rejected\]/.test(l))
|
|
1006
|
+
const told = lines.map((l) => /^remote:\s*(.+)$/.exec(l)?.[1]?.trim()).find((l) => l && !/^error: failed to push/i.test(l))
|
|
1007
|
+
const other = lines.find((l) => !/^(?:To |hint:|error: failed to push some refs)/.test(l))
|
|
1008
|
+
const reason = (told ?? rejected ?? other ?? 'no reason given').replace(/^(?:fatal|error):\s*/, '').slice(0, 300)
|
|
1009
|
+
return { reason, taken: Boolean(rejected && /\((?:fetch first|non-fast-forward|already exists)\)/.test(rejected)) }
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* The pull request step, read only: the facts of prFacts (with the network: the branches of origin) and whether gh
|
|
1014
|
+
* can open the pull request. null: not a git repo. {from: null}: no branch checked out.
|
|
1015
|
+
*/
|
|
1016
|
+
export async function prPlan(cli, { root, env, gh = true }) {
|
|
1017
|
+
const git = (args, more = {}) => cli('git', ['-C', root, ...args], { cwd: root, env, ...more })
|
|
1018
|
+
const facts = await prFacts(git, { env, network: true })
|
|
1019
|
+
if (!facts || !facts.from) return facts
|
|
1020
|
+
return gh ? { ...facts, gh: await ghReady(cli, facts, { root, env }) } : facts
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
/** True when gh is signed in and origin is on GitHub with a base, so setup can push and open the pull request. */
|
|
1024
|
+
const ghReady = async (cli, facts, { root, env }) => (facts.slug && facts.base ? (await cli('gh', ['auth', 'status', '--hostname', 'github.com'], { cwd: root, env })).code === 0 : false)
|
|
1025
|
+
|
|
1026
|
+
/**
|
|
1027
|
+
* The pull request step. With a yes: a new branch, 1 commit of exactly the files that setup changed (never git add -A),
|
|
1028
|
+
* then git push and gh pr create when gh is signed in and origin is on GitHub with a base for the pull request. Without
|
|
1029
|
+
* gh, a remote or a base: the commit, then the push command and where to open the pull request. Never a force push.
|
|
1030
|
+
* paths: every path that setup changed (the manifest and its paths), relative to root. left: the titles of the steps
|
|
1031
|
+
* that are left, for the checklist of the pull request. environment: the one of the key check, for the link. rerun: a run
|
|
1032
|
+
* with nothing to change, after a no to this question earlier: it says first that the files are not committed yet.
|
|
1033
|
+
* key: what the pull request says about the key (see prBody). Returns {state: 'pr'|'pushed'|'committed'|'declined'|'none',
|
|
1034
|
+
* branch, url}.
|
|
1035
|
+
*/
|
|
1036
|
+
export async function pullRequest(ui, cli, { root, env, paths, left = [], done = [], services = [], url, environment = null, rerun = false, key = null, logs = true, cloudId = false, updates = [] }) {
|
|
1037
|
+
const facts = await prPlan(cli, { root, env, gh: false })
|
|
1038
|
+
if (!facts) return { state: 'none' }
|
|
1039
|
+
if (!facts.from) {
|
|
1040
|
+
ui.print('This repo has no branch checked out, so setup does not commit. Commit the change and open a pull request yourself.')
|
|
1041
|
+
ui.print()
|
|
1042
|
+
return { state: 'none' }
|
|
1043
|
+
}
|
|
1044
|
+
const git = (args, more = {}) => cli('git', ['-C', root, ...args], { cwd: root, env, ...more })
|
|
1045
|
+
const files = await changedPaths(git, paths)
|
|
1046
|
+
if (!files.length) return { state: 'none' }
|
|
1047
|
+
facts.gh = await ghReady(cli, facts, { root, env })
|
|
1048
|
+
if (rerun) ui.print()
|
|
1049
|
+
ui.print('Open a pull request')
|
|
1050
|
+
if (rerun) {
|
|
1051
|
+
const earlier = commitFiles(files, true)
|
|
1052
|
+
ui.print(`${earlier[0].toUpperCase()}${earlier.slice(1)}${files.includes(CLI.manifest) ? ',' : ''} ${files.length === 1 ? 'is' : 'are'} not committed yet.`)
|
|
1053
|
+
}
|
|
1054
|
+
if (!(await ui.confirm(prQuestion({ branch: facts.branch, files, pr: facts.gh, base: facts.base, ahead: facts.ahead }), !facts.ahead.count))) {
|
|
1055
|
+
ui.print('Nothing committed.')
|
|
1056
|
+
ui.print()
|
|
1057
|
+
return { state: 'declined' }
|
|
1058
|
+
}
|
|
1059
|
+
const r = await prRun(cli, facts, { root, env, files, body: () => prBody({ services, files, left, done, url, environment, key, logs, cloudId, updates }) }, ui.print, ui.error)
|
|
1060
|
+
ui.print()
|
|
1061
|
+
return r
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Create the branch, commit, push and open the pull request, as pullRequest does after its yes. say(line) and
|
|
1066
|
+
* warn(line) get every line it prints. files: the paths of the commit. body(): the text of the pull request.
|
|
1067
|
+
* noPush: why setup only commits, though gh could push (a run with 1 Yes does not push commits of the user).
|
|
1068
|
+
* Returns {state: 'pr'|'pushed'|'committed'|'declined', branch, url, why}.
|
|
1069
|
+
*/
|
|
1070
|
+
export async function prRun(cli, facts, { root, env, files, body, noPush = null }, say, warn) {
|
|
1071
|
+
const git = (args, more = {}) => cli('git', ['-C', root, ...args], { cwd: root, env, ...more })
|
|
1072
|
+
const { from, remote, slug, base, branch, ssh } = facts
|
|
1073
|
+
const named = commitFiles(files)
|
|
1074
|
+
let r = await git(['switch', '-c', branch])
|
|
1075
|
+
if (r.code !== 0) r = await git(['checkout', '-b', branch])
|
|
1076
|
+
if (r.code !== 0) { const why = `Could not create the branch ${branch}: ${failed(r)}`; warn(why); return { state: 'declined', why } }
|
|
1077
|
+
r = await git(['add', '--', ...files])
|
|
1078
|
+
if (r.code === 0) r = await git(['commit', '-q', '-m', PULL_REQUEST.title, '-m', 'Made with npx @uknows/setup. To undo it, run npx @uknows/setup remove.', '--', ...files])
|
|
1079
|
+
if (r.code !== 0) {
|
|
1080
|
+
const why = `Could not commit: ${failed(r)}`
|
|
1081
|
+
warn(why)
|
|
1082
|
+
await git(['switch', from])
|
|
1083
|
+
await git(['branch', '-d', branch])
|
|
1084
|
+
say('Commit the change yourself, then open a pull request.')
|
|
1085
|
+
return { state: 'declined', why }
|
|
1086
|
+
}
|
|
1087
|
+
say(`Created the branch ${branch} with 1 commit, ${PULL_REQUEST.title}. It has ${named}.`)
|
|
1088
|
+
const newPr = slug ? `https://github.com/${slug}/pull/new/${branch}` : null
|
|
1089
|
+
const here = `You are on the branch ${branch} now.`
|
|
1090
|
+
if (!facts.gh || noPush) {
|
|
1091
|
+
if (noPush) say(noPush)
|
|
1092
|
+
else if (remote && !base) say('Setup cannot tell which branch of origin the pull request goes to, so it does not push.')
|
|
1093
|
+
say(remote ? `Push it with: git push -u origin ${branch}` : `This repo has no remote named origin. Push the branch ${branch} to your git host.`)
|
|
1094
|
+
say(newPr ? `Then open the pull request at ${newPr}` : 'Then open a pull request there.')
|
|
1095
|
+
say(here)
|
|
1096
|
+
return { state: 'committed', branch }
|
|
1097
|
+
}
|
|
1098
|
+
// On GitHub the commit goes up through the GitHub API, so GitHub signs it, and a branch rule that requires signed
|
|
1099
|
+
// commits passes with no key on this computer (owner decision, 22 Oct 2026). The local branch then points at that
|
|
1100
|
+
// commit: the same files, so nothing changes in the folder. When the API does not take it, setup pushes as before.
|
|
1101
|
+
const signed = await apiCommit(git, cli, { root, env, slug, branch })
|
|
1102
|
+
if (signed.ok) {
|
|
1103
|
+
say(`Pushed ${branch} to origin as 1 commit that GitHub signed.`)
|
|
1104
|
+
if (!signed.local) say(`Your local branch ${branch} still has the commit made on this computer. Run git fetch origin and git reset --soft origin/${branch} to match it.`)
|
|
1105
|
+
r = { code: 0 }
|
|
1106
|
+
} else {
|
|
1107
|
+
if (signed.why) say(`GitHub did not take the commit through its API (${signed.why}), so setup pushes it.`)
|
|
1108
|
+
// If the push fails, setup prints the push command (ssh never asks for a passphrase here, see sshEnv).
|
|
1109
|
+
r = await git(['push', '-u', 'origin', branch], { timeoutMs: 180000, env: { ...env, ...ssh } })
|
|
1110
|
+
}
|
|
1111
|
+
if (r.code !== 0) {
|
|
1112
|
+
const why = pushFailure(r.error ?? r.stderr)
|
|
1113
|
+
warn(`Could not push the branch: ${why.reason}`)
|
|
1114
|
+
if (why.taken) {
|
|
1115
|
+
// A teammate pushed this name after setup looked: push under the next name.
|
|
1116
|
+
const next = nextBranch(branch)
|
|
1117
|
+
say(`The branch ${branch} is on origin already. Rename yours with: git branch -m ${branch} ${next}`)
|
|
1118
|
+
say(`Then push it with: git push -u origin ${next}`)
|
|
1119
|
+
say(`Then open the pull request at https://github.com/${slug}/pull/new/${next}`)
|
|
1120
|
+
} else {
|
|
1121
|
+
say(`Fix that, then push it with: git push -u origin ${branch}`)
|
|
1122
|
+
say(`Then open the pull request at ${newPr}`)
|
|
1123
|
+
}
|
|
1124
|
+
say(here)
|
|
1125
|
+
return { state: 'committed', branch, why: `Could not push the branch: ${why.reason}` }
|
|
1126
|
+
}
|
|
1127
|
+
if (!signed.ok) say(`Pushed ${branch} to origin.`)
|
|
1128
|
+
r = await cli('gh', ['pr', 'create', '--repo', slug, '--base', base, '--head', branch, '--title', PULL_REQUEST.title, '--body-file', '-'],
|
|
1129
|
+
{ cwd: root, env, input: body(), timeoutMs: 120000 })
|
|
1130
|
+
const link = /https:\/\/\S+/.exec(r.stdout ?? '')?.[0]
|
|
1131
|
+
if (r.code !== 0 || !link) {
|
|
1132
|
+
const why = `Could not open the pull request: ${failed(r)}`
|
|
1133
|
+
warn(why)
|
|
1134
|
+
say(`Open it at ${newPr}`)
|
|
1135
|
+
say(here)
|
|
1136
|
+
return { state: 'pushed', branch, why }
|
|
1137
|
+
}
|
|
1138
|
+
say(`Opened the pull request: ${link}`)
|
|
1139
|
+
say(here)
|
|
1140
|
+
return { state: 'pr', branch, url: link }
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
const COMMIT_MUTATION = 'mutation($input: CreateCommitOnBranchInput!) { createCommitOnBranch(input: $input) { commit { oid url } } }'
|
|
1144
|
+
|
|
1145
|
+
/**
|
|
1146
|
+
* The commit of the pull request through the GitHub API (createCommitOnBranch), which GitHub signs. The local commit is
|
|
1147
|
+
* HEAD and its parent is on origin. Only plain files, whose bytes on disk are the bytes of the commit (no line-ending
|
|
1148
|
+
* filter), can go this way: the API writes every file as a plain file. Returns {ok, local} or {ok: false, why}.
|
|
1149
|
+
*/
|
|
1150
|
+
async function apiCommit(git, cli, { root, env, slug, branch }) {
|
|
1151
|
+
const gh = (args, more = {}) => cli('gh', args, { cwd: root, env, timeoutMs: 120000, ...more })
|
|
1152
|
+
const rev = async (ref) => { const r = await git(['rev-parse', '--verify', '-q', ref]); return r.code === 0 ? r.stdout.trim() : null }
|
|
1153
|
+
const base = await rev('HEAD~1'), head = await rev('HEAD')
|
|
1154
|
+
if (!base || !head) return { ok: false }
|
|
1155
|
+
const raw = await git(['diff', '--raw', '--no-abbrev', '--no-renames', '-z', base, head])
|
|
1156
|
+
if (raw.code !== 0) return { ok: false }
|
|
1157
|
+
const parts = raw.stdout.split('\0').filter((x) => x !== '')
|
|
1158
|
+
const additions = [], deletions = []
|
|
1159
|
+
for (let i = 0; i + 1 < parts.length; i += 2) {
|
|
1160
|
+
const [src, dst, , blob, status] = parts[i].replace(/^:/, '').split(' ')
|
|
1161
|
+
const path = parts[i + 1]
|
|
1162
|
+
if (status === 'D') { deletions.push({ path }); continue }
|
|
1163
|
+
if (dst !== '100644' || !['100644', '000000'].includes(src)) return { ok: false, why: `${path} is not a plain file` }
|
|
1164
|
+
const disk = await git(['hash-object', '--no-filters', '--', path])
|
|
1165
|
+
if (disk.code !== 0 || disk.stdout.trim() !== blob) return { ok: false, why: `${path} changes when git writes it` }
|
|
1166
|
+
additions.push({ path, contents: readFileSync(join(root, path)).toString('base64') })
|
|
1167
|
+
}
|
|
1168
|
+
if (!additions.length && !deletions.length) return { ok: false }
|
|
1169
|
+
let r = await gh(['api', '-X', 'POST', `repos/${slug}/git/refs`, '-f', `ref=refs/heads/${branch}`, '-f', `sha=${base}`])
|
|
1170
|
+
if (r.code !== 0) return { ok: false, why: failed(r) }
|
|
1171
|
+
const input = { branch: { repositoryNameWithOwner: slug, branchName: branch }, expectedHeadOid: base,
|
|
1172
|
+
message: { headline: PULL_REQUEST.title, body: 'Made with npx @uknows/setup. To undo it, run npx @uknows/setup remove.' },
|
|
1173
|
+
fileChanges: { additions, deletions } }
|
|
1174
|
+
r = await gh(['api', 'graphql', '--input', '-'], { input: JSON.stringify({ query: COMMIT_MUTATION, variables: { input } }) })
|
|
1175
|
+
const oid = parse(r.stdout ?? '')?.data?.createCommitOnBranch?.commit?.oid
|
|
1176
|
+
if (r.code !== 0 || !oid) {
|
|
1177
|
+
await gh(['api', '-X', 'DELETE', `repos/${slug}/git/refs/heads/${branch}`])
|
|
1178
|
+
return { ok: false, why: failed(r) }
|
|
1179
|
+
}
|
|
1180
|
+
// The local branch takes the signed commit. Its tree is the tree of the local commit, so only HEAD moves.
|
|
1181
|
+
r = await git(['fetch', '-q', 'origin', `+refs/heads/${branch}:refs/remotes/origin/${branch}`], { timeoutMs: 180000 })
|
|
1182
|
+
const same = r.code === 0 && (await rev(`refs/remotes/origin/${branch}^{tree}`)) === (await rev('HEAD^{tree}'))
|
|
1183
|
+
if (same) r = await git(['reset', '-q', '--soft', `refs/remotes/origin/${branch}`])
|
|
1184
|
+
if (same && r.code === 0) await git(['branch', '-q', `--set-upstream-to=origin/${branch}`])
|
|
1185
|
+
return { ok: true, oid, local: same && r.code === 0 }
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** The paths that git shows as changed and not committed, of the paths given (the files of setup). */
|
|
1189
|
+
export function changedFiles(cli, { root, env, paths }) {
|
|
1190
|
+
return changedPaths((args) => cli('git', ['-C', root, ...args], { cwd: root, env }), paths)
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* What the last line and the pull request say about the key when it may not be stored. 'left': this run did not store
|
|
1195
|
+
* it (a skipped key check, a no to the store question, a store that failed), and the key steps printed. A deploy without
|
|
1196
|
+
* the key sends nothing, so the app never connects (F-012-153). 'unknown': a rerun, which does not ask for the key.
|
|
1197
|
+
* .uknows/setup.json lists only the files that setup changed, so the rerun cannot tell if an earlier run stored the key.
|
|
1198
|
+
*/
|
|
1199
|
+
export const KEY_NOTE = {
|
|
1200
|
+
left: {
|
|
1201
|
+
line: 'Your key is not stored yet. Store it before you merge and deploy: run npx @uknows/setup key, or do the key steps above.',
|
|
1202
|
+
pr: 'Your key is not stored yet. Store it before you merge and deploy: run npx @uknows/setup key in the repo, or do the key steps below.',
|
|
1203
|
+
},
|
|
1204
|
+
unknown: {
|
|
1205
|
+
line: 'Setup cannot tell if your key is stored. If it is not stored yet, run npx @uknows/setup key before you merge and deploy.',
|
|
1206
|
+
pr: `Setup cannot tell from ${CLI.manifest} if your key is stored. If it is not stored yet, run npx @uknows/setup key in the repo before you merge and deploy.`,
|
|
1207
|
+
},
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* The text of the pull request: what it does, the files, and the checklist of what is left. Never the key. key: null when
|
|
1212
|
+
* the key is stored or not asked for, else 'left' or 'unknown' (KEY_NOTE): a line on the key comes first in Left to do.
|
|
1213
|
+
* updates: how the agent stays up to date and why (updates.mjs), 1 paragraph each. Without them (an older record), the
|
|
1214
|
+
* text says what the update bot files in the change do.
|
|
1215
|
+
*/
|
|
1216
|
+
export function prBody({ services = [], files = [], left = [], done = [], url, environment = null, key = null, logs = true, cloudId = false, updates = [] }) {
|
|
1217
|
+
const names = services.length ? list(services) : 'this app'
|
|
1218
|
+
const dependabot = files.some((f) => /(^|\/)\.github\/workflows\/uknows-updates\.yml$/.test(f))
|
|
1219
|
+
const renovate = files.includes('renovate.json')
|
|
1220
|
+
const hookFiles = files.filter((f) => HOOK_FILES.includes(f.split('/').pop()))
|
|
1221
|
+
return [
|
|
1222
|
+
`Adds OpenTelemetry to ${names}, so ${services.length > 1 ? 'they send' : 'it sends'} ${logs ? 'traces and logs' : 'traces'} to UKNOWS.`,
|
|
1223
|
+
'',
|
|
1224
|
+
...updates.flatMap((line) => [line, '']),
|
|
1225
|
+
...(!updates.length && renovate ? ['Adds an update bot too. Renovate proposes the UKNOWS and OpenTelemetry updates in 1 pull request, 3 days after each release, and merges it when your build check passes. Where your branch rules ask for a review, it waits for that review.', ''] : []),
|
|
1226
|
+
...(!updates.length && dependabot ? ['Adds an update bot too. Dependabot proposes OpenTelemetry updates every day. Each update merges when your build check passes, then deploys. Where your branch rules ask for a review, it waits for that review.', ''] : []),
|
|
1227
|
+
// host.json changes only for Azure Functions (stacks/functions.mjs), the hook files only for its worker hooks.
|
|
1228
|
+
...(files.some((f) => /(^|\/)host\.json$/.test(f)) ? [FUNCTIONS.pr, ''] : []),
|
|
1229
|
+
...(hookFiles.length ? [FUNCTIONS.hooks.pr.replace('{files}', hookFiles.join(', ')), '',
|
|
1230
|
+
...(hookFiles.some((f) => f.endsWith('.py')) ? [FUNCTIONS.hooks.prPython, ''] : [])] : []),
|
|
1231
|
+
...(cloudId ? [`Your deploy now also sets ${RESOURCE_ATTRIBUTES} with the Azure resource id of the app, so UKNOWS knows which app in Azure the data comes from. Attributes that your app sets there already stay as they are.`, ''] : []),
|
|
1232
|
+
'Made with npx @uknows/setup. To undo it, run npx @uknows/setup remove.',
|
|
1233
|
+
'',
|
|
1234
|
+
'## Files',
|
|
1235
|
+
...files.map((f) => `- ${f}`),
|
|
1236
|
+
'',
|
|
1237
|
+
'## Left to do',
|
|
1238
|
+
...(KEY_NOTE[key] ? [KEY_NOTE[key].pr, ''] : []),
|
|
1239
|
+
...left.map((title) => `- [ ] ${title}`),
|
|
1240
|
+
'- [ ] Merge this pull request and deploy as usual.',
|
|
1241
|
+
url ? `- [ ] ${watchLine([url], environment).replace(/^Then w/, 'W')}` : '- [ ] Watch your app connect in UKNOWS.',
|
|
1242
|
+
'',
|
|
1243
|
+
...(done.length ? ['## Done', ...done.map((d) => `- [x] ${d}`), ''] : []),
|
|
1244
|
+
'To see the steps again, run npx @uknows/setup --dry-run in the repo.',
|
|
1245
|
+
'',
|
|
1246
|
+
].join('\n')
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* The last line: what is left, then the link to the page where the app connects (environment: of the key check). key:
|
|
1251
|
+
* null when the key is stored or not asked for, else 'left' or 'unknown' (KEY_NOTE): the line says it before the merge.
|
|
1252
|
+
*/
|
|
1253
|
+
export function lastLine({ state, required = 0, urls = [], environment = null, key = null }) {
|
|
1254
|
+
const action = state === 'pr' ? 'Merge the pull request and deploy as usual.'
|
|
1255
|
+
: state === 'pushed' ? 'Open the pull request, merge it and deploy as usual.'
|
|
1256
|
+
: state === 'committed' ? 'Push the branch, open a pull request, merge it and deploy as usual.'
|
|
1257
|
+
: 'Commit, open a pull request, merge it and deploy as usual.'
|
|
1258
|
+
const steps = required ? `Do the ${plural(required, 'step')} above first. ` : ''
|
|
1259
|
+
const stored = KEY_NOTE[key] ? `${KEY_NOTE[key].line} ` : ''
|
|
1260
|
+
return `${steps}${stored}${action} ${watchLine(urls, environment)}`
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* The questions that a run in a terminal asks after Apply, for the dry run. From the repo and this computer only: no
|
|
1265
|
+
* cloud CLI and no network. Each is {question, after}: after is the question that comes after a no to it. A store
|
|
1266
|
+
* question only where 1 app is known and the run has 1 service (storeAzure). The GitHub secret is a question of its
|
|
1267
|
+
* own when no store on the platform is planned or a workflow reads it already, else it comes after a no.
|
|
1268
|
+
* pr: prFacts of the repo without the network (null or no branch: no pull request question). files: the paths that a
|
|
1269
|
+
* real run commits. services: how many services the run sets up. Returns {questions, hosts}: hosts are the platforms
|
|
1270
|
+
* whose key step a question does, so the dry run can print those steps after the questions, as the steps by hand.
|
|
1271
|
+
*/
|
|
1272
|
+
export function plannedQuestions({ targets, edits = [], files = [], pr = null, services = 1 }) {
|
|
1273
|
+
const stores = []
|
|
1274
|
+
for (const [host, t] of targets) {
|
|
1275
|
+
if (t.kind === 'aca' || t.kind === 'webapp') {
|
|
1276
|
+
const apps = pickApp(t.apps.map((a) => a.name))
|
|
1277
|
+
if (apps.length !== 1 || services > 1 || (t.kind === 'webapp' && t.slots?.length)) continue
|
|
1278
|
+
const groups = unique(t.groups.map((g) => g.name))
|
|
1279
|
+
stores.push({ host, question: `Store this key on ${apps[0]}${groups.length === 1 ? ` in ${groups[0]}` : ''}? ${AZURE[t.kind].effect} (y/N)` })
|
|
1280
|
+
} else if (t.kind === 'k8s') {
|
|
1281
|
+
const spaces = unique(t.workloads.map((w) => w.namespace))
|
|
1282
|
+
if (spaces.length === 1 && spaces[0]) stores.push({ host, question: `Store this key as the secret ${K8S_SECRET} in the namespace ${spaces[0]}? Pods that start after this get the key. (y/N)` })
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
const out = [...stores]
|
|
1286
|
+
const gh = [...targets.values()].find((t) => t.kind === 'github' && t.repo)
|
|
1287
|
+
const hosts = stores.map((q) => q.host)
|
|
1288
|
+
if (gh) {
|
|
1289
|
+
const s = githubSecret(gh.jobs)
|
|
1290
|
+
const question = s.why ? null : s.env === null ? `Store this key as the repository secret ${GITHUB_SECRET} of ${gh.repo}? Every workflow of the repo can read it. (y/N)`
|
|
1291
|
+
: `Store this key as the secret ${GITHUB_SECRET} of its GitHub environment in ${gh.repo}? (y/N)`
|
|
1292
|
+
if (question && (gh.readsKey || !stores.length)) out.push({ question })
|
|
1293
|
+
else if (question) stores.at(-1).after = question
|
|
1294
|
+
if (question) hosts.push('github-actions')
|
|
1295
|
+
}
|
|
1296
|
+
for (const e of edits) out.push({ question: e.question })
|
|
1297
|
+
if (pr?.from && files.length) out.push({ question: prQuestion({ branch: pr.branch, files, pr: Boolean(pr.slug && pr.base), base: pr.base, ahead: pr.ahead }) })
|
|
1298
|
+
return { questions: out, hosts: unique(hosts) }
|
|
1299
|
+
}
|