@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/hosts.mjs
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
/*
|
|
2
2
|
* Where the app runs, read from deploy files in the repo, and the "set your
|
|
3
|
-
* key once" steps for those hosts.
|
|
4
|
-
*
|
|
5
|
-
* this computer are never used: the CLI runs on a laptop, not on the host.
|
|
3
|
+
* key once" steps for those hosts. Environment variables of this computer
|
|
4
|
+
* are never used: the CLI runs on a laptop, not on the host.
|
|
6
5
|
*/
|
|
7
|
-
import { DOCS_URL, HOSTS, SETTINGS_FILE, SETTINGS_FILE_LOADERS
|
|
6
|
+
import { APP_SERVICE_SLOTS, DEPLOY_CHECKS, DOCS_URL, EXAMPLE_NAMES, EXAMPLE_SERVICE, FUNCTIONS, GITHUB_JOBS, HOSTS, SECRET, SETTINGS, SETTINGS_FILE, SETTINGS_FILE_LOADERS,
|
|
7
|
+
acaKeyScript, appServiceKeyScript, appServiceSlotScript, functionsKeyScript, githubKeyStep, githubSecretCommands } from './contract.mjs'
|
|
8
8
|
|
|
9
9
|
const MAX_BYTES = 262144
|
|
10
|
-
const RUNTIME = ['azure-container-apps', 'azure-app-service', 'aws-ecs', 'kubernetes', 'alibaba-sae', 'vm-docker']
|
|
11
|
-
/**
|
|
12
|
-
const DOCS_ID = { 'azure-aca': 'azure-container-apps', 'azure-app-service': 'azure-app-service', 'aws-ecs': 'aws-ecs',
|
|
10
|
+
const RUNTIME = ['azure-container-apps', 'azure-app-service', 'azure-functions', 'aws-ecs', 'kubernetes', 'alibaba-sae', 'vm-docker']
|
|
11
|
+
/** Adapter ids -> docs host ids. */
|
|
12
|
+
const DOCS_ID = { 'azure-aca': 'azure-container-apps', 'azure-app-service': 'azure-app-service', 'azure-functions': 'azure-functions', 'aws-ecs': 'aws-ecs',
|
|
13
13
|
kubernetes: 'kubernetes', 'alibaba-ack': 'kubernetes',
|
|
14
14
|
'alibaba-sae': 'alibaba-sae', 'alibaba-ecs': 'vm-docker', vm: 'vm-docker', docker: 'vm-docker' }
|
|
15
15
|
|
|
@@ -22,7 +22,7 @@ const SCRIPT = /\.(?:ya?ml|sh|ps1)$/
|
|
|
22
22
|
const TF = /\.tf$/
|
|
23
23
|
const API = /^[ \t]*apiVersion[ \t]*:/m
|
|
24
24
|
const WORKFLOW = /^(?:\.\.\/)*\.github\/workflows\/[^/]+\.ya?ml$/
|
|
25
|
-
const DEPLOY_CUE = /deploy(?![._-]?tests?\b)|kubectl|containerapp|webapp|docker push|aliyun|aws ecs|helm upgrade/i
|
|
25
|
+
const DEPLOY_CUE = /deploy(?![._-]?tests?\b)|kubectl|containerapp|webapp|functionapp|docker push|aliyun|aws ecs|helm upgrade/i
|
|
26
26
|
/** An ECS task definition file: a JSON file with containerDefinitions. */
|
|
27
27
|
const TASK_DEF = /(?:^|\/)[^/]*task[-_.]?def(?:inition)?[^/]*\.json$/i
|
|
28
28
|
|
|
@@ -45,6 +45,14 @@ const RULES = [
|
|
|
45
45
|
['azure-app-service', 'Azure App Service deploy action', null, /azure\/webapps-deploy@/],
|
|
46
46
|
['azure-app-service', 'azure.yaml deploys to App Service', /(?:^|\/)azure\.ya?ml$/, /^[ \t]*host[ \t]*:[ \t]*['"]?appservice\b/m],
|
|
47
47
|
['azure-app-service', 'Deploy step runs az webapp', SCRIPT, /\baz[ \t]+webapp[ \t]+(?:create|up|deploy|config[ \t]+(?:container|appsettings))\b/],
|
|
48
|
+
// Azure Functions: not a host of the docs page yet (FUNCTIONS in the contract). host.json with version 2.0 is the
|
|
49
|
+
// project file of a Functions app.
|
|
50
|
+
['azure-functions', 'Azure Functions in Terraform', TF, /\bresource\s+"azurerm_(?:linux_|windows_)?function_app(?:_flex_consumption)?"\s+"[^"\r\n]+"/],
|
|
51
|
+
['azure-functions', 'Azure Functions in Bicep or ARM', null, /\bkind['"]?\s*:\s*['"]functionapp/i, /Microsoft\.Web\/sites(?:@|["'\s])/],
|
|
52
|
+
['azure-functions', 'Azure Functions deploy action', null, /\bAzure\/functions-action@/i],
|
|
53
|
+
['azure-functions', 'Deploy step runs az functionapp', SCRIPT, /\baz[ \t]+functionapp[ \t]+(?:create|deployment|config|update|deploy)\b/],
|
|
54
|
+
['azure-functions', 'azure.yaml deploys to Azure Functions', /(?:^|\/)azure\.ya?ml$/, /^[ \t]*host[ \t]*:[ \t]*['"]?function\b/m],
|
|
55
|
+
['azure-functions', 'host.json of an Azure Functions app', /(?:^|\/)host\.json$/, /"version"\s*:\s*"2\.0"/],
|
|
48
56
|
['aws-ecs', 'AWS ECS in Terraform', TF, /\bresource\s+"aws_ecs_(?:service|task_definition)"\s+"[^"\r\n]+"/],
|
|
49
57
|
['aws-ecs', 'AWS ECS in CloudFormation or CDK output', null, /\bAWS::ECS::(?:TaskDefinition|Service)\b/],
|
|
50
58
|
['aws-ecs', 'ECS task definition', TASK_DEF, /"containerDefinitions"\s*:/],
|
|
@@ -59,8 +67,31 @@ const RULES = [
|
|
|
59
67
|
['alibaba-sae', 'Deploy step runs aliyun sae', SCRIPT, /\baliyun[ \t]+sae\b/],
|
|
60
68
|
]
|
|
61
69
|
|
|
70
|
+
/**
|
|
71
|
+
* The checks of install.ts HOSTS[].checks, and the deploy files that select them: [check id, host id, the evidence
|
|
72
|
+
* (RULES text) of the files to read, the line that selects the check, the snippet languages to print]. A check
|
|
73
|
+
* with no evidence is printed for every repo with that host. The texts come from install.ts.
|
|
74
|
+
*/
|
|
75
|
+
const CHECKS = [
|
|
76
|
+
['replace-env-vars', 'azure-container-apps', ['Deploy step runs az containerapp'], /--replace-env-vars\b/],
|
|
77
|
+
['deploy-action-env', 'azure-container-apps', ['Azure Container Apps deploy action'], /^[ \t]*environmentVariables[ \t]*:/m],
|
|
78
|
+
['deploy-action-yaml', 'azure-container-apps', ['Azure Container Apps deploy action'], /^[ \t]*yamlConfigPath[ \t]*:/m],
|
|
79
|
+
['template', 'azure-container-apps', ['Azure Container App in Bicep or ARM'], /Microsoft\.App\/containerApps(?:@|["'\s])/, ['bicep']],
|
|
80
|
+
['template', 'azure-container-apps', ['Azure Container App in Terraform'], /\bresource\s+"azurerm_container_app"/, ['hcl']],
|
|
81
|
+
// lifecycle ignore_changes on the env or the secrets: terraform apply keeps what az sets, so the template check does not apply to that file.
|
|
82
|
+
// The list on 1 line (with [0] indexes in it), or over several lines up to its closing ] line.
|
|
83
|
+
['template-ignored', 'azure-container-apps', ['Azure Container App in Terraform'],
|
|
84
|
+
/\bignore_changes\s*=\s*\[(?:[^\n\]]*(?:\[[^\n\]]*\][^\n\]]*)*\b(?:env|secret)\b|[ \t]*\n(?:(?!\n\s*\])[\s\S]){0,400}?\b(?:env|secret)\b)/],
|
|
85
|
+
['secret-first', 'azure-container-apps', null],
|
|
86
|
+
['slot', 'azure-app-service', ['Azure App Service deploy action'], /^[ \t]*slot-name[ \t]*:[ \t]*\S/m],
|
|
87
|
+
['slot', 'azure-app-service', ['Deploy step runs az webapp'], /\baz[ \t]+webapp\b[^\n]*(?:\\\n[^\n]*)*?[ \t]--slot(?:[ \t=]|$)/m],
|
|
88
|
+
['task-definition', 'aws-ecs', ['ECS task definition'], /"containerDefinitions"\s*:/],
|
|
89
|
+
['optional', 'kubernetes', null],
|
|
90
|
+
['guard', 'github-actions', null],
|
|
91
|
+
]
|
|
92
|
+
|
|
62
93
|
const deployFile = (path) => DOCKERFILE.test(path) || /\.(?:ya?ml|tf|bicep|service|sh|ps1)$/.test(path) || TASK_DEF.test(path)
|
|
63
|
-
|| /(?:^|\/)(?:containerapp|azure-container-app|azuredeploy|template|ecs|ack|sae|webapp|appservice)\.json$/.test(path)
|
|
94
|
+
|| /(?:^|\/)(?:containerapp|azure-container-app|azuredeploy|template|ecs|ack|sae|webapp|appservice|host)\.json$/.test(path)
|
|
64
95
|
|
|
65
96
|
/**
|
|
66
97
|
* Where the app runs, from deploy files in the repo.
|
|
@@ -105,22 +136,51 @@ export function detectHosts(ws) {
|
|
|
105
136
|
}
|
|
106
137
|
|
|
107
138
|
/**
|
|
108
|
-
* The 'set your key once' steps for the detected hosts, from contract HOSTS.
|
|
109
|
-
*
|
|
139
|
+
* The 'set your key once' steps for the detected hosts, from contract HOSTS. After the steps of each host, the
|
|
140
|
+
* checks for the deploy files of the repo (HOSTS[].checks), with the file and line that selected each one.
|
|
110
141
|
* Fields are read defensively: the docs file keeps changing.
|
|
142
|
+
*
|
|
143
|
+
* options (all optional):
|
|
144
|
+
* envFile a service got otel.env and no repo file loads it, so the steps also say how to load it.
|
|
145
|
+
* otelEnv a service keeps its settings in otel.env. Without it, the VM and Docker steps leave out otel.env.
|
|
146
|
+
* docker every service starts from a Dockerfile: the VM and Docker steps are the key file and docker run only.
|
|
147
|
+
* view reads the evidence files. Without it, only the checks that need no file are printed, and no names.
|
|
148
|
+
* names deployNames(): the app names, resource groups, slots and GitHub jobs that the deploy files hold.
|
|
149
|
+
* repo the GitHub repo, as owner/name, for the gh commands.
|
|
150
|
+
* service the name of the 1 service of this run, for the container name in the YAML, Bicep and Terraform examples.
|
|
151
|
+
* settings the settings of this run, as [name, value] pairs, so "the 5 settings" and the Kubernetes snippets fit.
|
|
152
|
+
* startLine true when the stack of the run needs its start line on the platform (Python and .NET).
|
|
153
|
+
* secretName the variable that holds the key: OTEL_EXPORTER_OTLP_TRACES_HEADERS for apps that keep their own metrics and logs.
|
|
111
154
|
* @param {import('./types.mjs').Host[]} hosts
|
|
112
|
-
* @param {{envFile?: boolean}} [options]
|
|
113
155
|
* @returns {import('./types.mjs').Block[]}
|
|
114
156
|
*/
|
|
115
157
|
export function keySteps(hosts, options = {}) {
|
|
116
|
-
const
|
|
158
|
+
const list = (Array.isArray(hosts) ? hosts : []).filter((h) => h && typeof h === 'object')
|
|
159
|
+
const ids = [...new Set(list.map((h) => h.id))]
|
|
160
|
+
const names = options.names ?? emptyNames()
|
|
161
|
+
const count = options.settings?.length ?? SETTINGS.length
|
|
117
162
|
const blocks = []
|
|
163
|
+
const runtimes = ids.filter((id) => id !== 'github-actions')
|
|
118
164
|
for (const id of ids) {
|
|
165
|
+
if (id === 'azure-functions') {
|
|
166
|
+
const fn = scriptNames(names.functions ?? { apps: [], groups: [] })
|
|
167
|
+
blocks.push({ title: FUNCTIONS.key.title, lines: [...FUNCTIONS.key.lines], snippets: [{ caption: 'Set the key on your function apps', language: 'bash', code: functionsKeyScript(fn) }] })
|
|
168
|
+
continue
|
|
169
|
+
}
|
|
170
|
+
// A function app keeps its app settings when new code is deployed: the key needs no step in the workflow.
|
|
171
|
+
if (id === 'github-actions' && runtimes.length && runtimes.every((r) => r === 'azure-functions')) continue
|
|
119
172
|
const host = (Array.isArray(HOSTS) ? HOSTS : []).find((h) => h?.id === id)
|
|
120
173
|
if (!host) continue
|
|
121
|
-
|
|
174
|
+
let lines = [...strings(host.steps), ...strings(host.notes)]
|
|
175
|
+
let code = snippets(host.snippets)
|
|
122
176
|
if (typeof host.stricter?.title === 'string') lines.push(`${host.stricter.title}. See ${DOCS_URL}.`)
|
|
123
|
-
|
|
177
|
+
const fitted = fit(id, lines, code, { ...options, names })
|
|
178
|
+
lines = fitted.lines.map((line) => line.replace(/\bthe 5 settings\b/g, `the ${count} settings`).replace(/\bThe 5 settings\b/g, `The ${count} settings`))
|
|
179
|
+
code = fitted.snippets.map((x) => ({ ...x, caption: x.caption.replace(/\b5 settings\b/g, `${count} settings`) }))
|
|
180
|
+
blocks.push({ title: `Set your key once: ${host.label ?? id}`, lines, snippets: code })
|
|
181
|
+
const checks = hostChecks(host, list.filter((h) => h.id === id).flatMap((h) => h.evidence ?? []), options.view)
|
|
182
|
+
if (checks.lines.length) blocks.push({ title: `${DEPLOY_CHECKS.title}: ${host.label ?? id}`, ...checks })
|
|
183
|
+
if (id === 'azure-app-service') blocks.push(...slotBlocks(names))
|
|
124
184
|
}
|
|
125
185
|
if (options.envFile) {
|
|
126
186
|
const loaders = (Array.isArray(SETTINGS_FILE_LOADERS) ? SETTINGS_FILE_LOADERS : [])
|
|
@@ -129,9 +189,454 @@ export function keySteps(hosts, options = {}) {
|
|
|
129
189
|
lines: loaders.filter((l) => l.summary).map((l) => `${l.label ?? l.id}: ${l.summary}`),
|
|
130
190
|
snippets: snippets(loaders.map((l) => l.snippet)) })
|
|
131
191
|
}
|
|
132
|
-
|
|
192
|
+
// Apps that keep their own OTLP metrics and logs get the key in the traces-only variable (settings.mjs).
|
|
193
|
+
const secret = options.secretName ?? SECRET.name
|
|
194
|
+
if (secret === SECRET.name) return blocks
|
|
195
|
+
const rename = (text) => text.split(SECRET.name).join(secret)
|
|
196
|
+
return blocks.map((b) => ({ ...b, title: rename(b.title), lines: b.lines.map(rename), snippets: (b.snippets ?? []).map((x) => ({ ...x, caption: rename(x.caption), code: rename(x.code) })) }))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The steps and snippets of 1 host, fitted to this repo: its names, its settings and how its apps start. */
|
|
200
|
+
function fit(id, lines, code, options) {
|
|
201
|
+
const { names, repo, service, settings } = options
|
|
202
|
+
const container = (text) => (service ? text.split(EXAMPLE_SERVICE).join(service) : text)
|
|
203
|
+
if (id === 'azure-container-apps') {
|
|
204
|
+
const aca = scriptNames(names.aca)
|
|
205
|
+
code = code.map((x, i) => (i === 0 ? { ...x, code: acaKeyScript(aca) } : { ...x, code: container(x.code) }))
|
|
206
|
+
if (names.aca.apps.length) lines = lines.map((l) => (/^Set SUBSCRIPTION, RESOURCE_GROUP and APPS/.test(l) ? `${l} The script lists the apps that your deploy files name.` : l))
|
|
207
|
+
} else if (id === 'azure-app-service') {
|
|
208
|
+
const web = scriptNames(names.webapp)
|
|
209
|
+
code = code.map((x, i) => (i === 0 ? { ...x, code: appServiceKeyScript(web) } : x))
|
|
210
|
+
// The start line goes on App Service only for a stack that has one (Python, .NET).
|
|
211
|
+
lines = lines.filter((l) => options.startLine !== false || !/^For Python and \.NET, put the start line/.test(l))
|
|
212
|
+
if (names.webapp.slots.length) lines = lines.filter((l) => !/^If you swap a deployment slot/.test(l))
|
|
213
|
+
} else if (id === 'github-actions') return github(lines, code, options)
|
|
214
|
+
else if (id === 'kubernetes' && settings?.length) {
|
|
215
|
+
// The settings of this run, not the 5 of the page: an app with OpenTelemetry keeps its metrics and logs.
|
|
216
|
+
code = code.map((x) => (/settings in your deployment/.test(x.caption) ? { ...x, code: k8sEnv(x.code, settings, 10) }
|
|
217
|
+
: /^Helm/.test(x.caption) ? { ...x, code: helmEnv(x.code, settings) } : { ...x, code: container(x.code) }))
|
|
218
|
+
} else if (id === 'vm-docker') {
|
|
219
|
+
// otel.env only when a service keeps its settings there. systemd only when a service does not start from a Dockerfile.
|
|
220
|
+
const settingsFile = (t) => t.includes(SETTINGS_FILE.path)
|
|
221
|
+
const systemd = (t) => /systemd|EnvironmentFile|daemon-reload/.test(t)
|
|
222
|
+
lines = lines.filter((l) => (options.otelEnv || !settingsFile(l)) && (!options.docker || !systemd(l)))
|
|
223
|
+
code = code.filter((x) => !options.docker || !systemd(`${x.caption}\n${x.code}`))
|
|
224
|
+
.map((x) => ({ ...x, code: container(options.otelEnv ? x.code : x.code.split('\n').filter((l, i, all) => !settingsFile(l)
|
|
225
|
+
&& !(settingsFile(all[i + 1] ?? '') && /^#/.test(l))).join('\n')) }))
|
|
226
|
+
} else code = code.map((x) => ({ ...x, code: container(x.code) }))
|
|
227
|
+
if (repo) lines = lines.map((l) => l.split(EXAMPLE_NAMES.repo).join(repo))
|
|
228
|
+
return { lines, snippets: code }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The GitHub Actions steps with the environments of the deploy jobs, the repo and the app names of this repo. */
|
|
232
|
+
function github(lines, code, { names, repo }) {
|
|
233
|
+
const jobs = names.jobs ?? []
|
|
234
|
+
const where = (j) => `${j.file} line ${j.line}`
|
|
235
|
+
// A job with environment: ${{ inputs.X }} deploys to the values that the deploy files give X.
|
|
236
|
+
const dynamic = (j) => Boolean(j.environment?.includes('$'))
|
|
237
|
+
const envs = [...new Set(jobs.flatMap((j) => (!j.environment ? [] : dynamic(j) ? (j.environments ?? []).map((e) => e.name) : [j.environment])))]
|
|
238
|
+
const none = jobs.filter((j) => !j.environment)
|
|
239
|
+
const slug = repo ?? EXAMPLE_NAMES.repo
|
|
240
|
+
const aca = names.aca.apps[0]?.name, group = names.aca.groups[0]?.name
|
|
241
|
+
const found = jobs.map((j) => (dynamic(j) ? dynamicJob(j, where(j))
|
|
242
|
+
: (j.environment ? GITHUB_JOBS.found : GITHUB_JOBS.none).replace('{where}', where(j)).replace('{job}', j.job).replace('{environment}', j.environment)))
|
|
243
|
+
const noEnvNote = lines.find((l) => /^A job can read an environment secret only/.test(l))
|
|
244
|
+
// Every deploy job has no environment: line, so the key is a repository secret and no environment name applies.
|
|
245
|
+
// A job whose environment is a ${{ }} value still reads an environment secret.
|
|
246
|
+
const repoSecretOnly = jobs.length > 0 && none.length === jobs.length
|
|
247
|
+
lines = lines.flatMap((l) => {
|
|
248
|
+
if (l === noEnvNote) return !jobs.length || none.length > 0 ? [l.split(EXAMPLE_NAMES.repo).join(slug)] : []
|
|
249
|
+
// "Replace <repo> with your repo, and <environment> with the environment: name of your deploy job. Never rename ...":
|
|
250
|
+
// only the names that are still examples.
|
|
251
|
+
const replace = /^Replace (\S+ with your repo), and (\S+ with the environment: name of your deploy job)\. (.+)$/.exec(l)
|
|
252
|
+
if (replace) {
|
|
253
|
+
const ask = [repo ? null : replace[1], envs.length || repoSecretOnly ? null : replace[2]].filter(Boolean)
|
|
254
|
+
const head = ask.length ? `Replace ${ask.join(', and ')}.` : null
|
|
255
|
+
return repoSecretOnly ? [head].filter(Boolean) : [[head, replace[3]].filter(Boolean).join(' ')]
|
|
256
|
+
}
|
|
257
|
+
// "Each environment has its own key. gh secret set UKNOWS_KEY --env <environment> sets the key for <environment> only. For staging, ...":
|
|
258
|
+
// the environments of the deploy jobs. No line when no job has one.
|
|
259
|
+
const each = /^(Each environment has its own key\. gh secret set UKNOWS_KEY --env )(\S+)( sets the key for )\2( only\.)( For (\S+), run it again with --env \6 and the \6 key\.)$/.exec(l)
|
|
260
|
+
if (each) {
|
|
261
|
+
if (repoSecretOnly) return []
|
|
262
|
+
if (!envs.length) return [l]
|
|
263
|
+
const [first, second] = envs
|
|
264
|
+
return [`${each[1]}${first}${each[3]}${first}${each[4]}${second ? each[5].split(each[6]).join(second) : ''}`]
|
|
265
|
+
}
|
|
266
|
+
// "In the example, replace <resource group> and <app> with your resource group and app.": only the names still examples.
|
|
267
|
+
const example = /^In the example, replace (\S+) and (\S+) with your (resource group) and (app)\.$/.exec(l)
|
|
268
|
+
if (example) {
|
|
269
|
+
if (aca && group) return []
|
|
270
|
+
if (aca || group) return [`In the example, replace ${aca ? example[1] : example[2]} with your ${aca ? example[3] : example[4]}.`]
|
|
271
|
+
return [l]
|
|
272
|
+
}
|
|
273
|
+
// A repository secret holds 1 key: it is run once, not once for each environment.
|
|
274
|
+
if (repoSecretOnly && /^Run the command once for each GitHub environment that your deploy jobs use\. Paste the key of that environment when it asks\./.test(l)) {
|
|
275
|
+
return [l.replace(/^.*?when it asks\./, 'Run the command once. Paste the key of the UKNOWS environment that this repo deploys to when it asks.')]
|
|
276
|
+
}
|
|
277
|
+
if (repo && /--repos /.test(l)) return [l.replace(/--repos \S+ /, `--repos ${repo.split('/')[1]} `)]
|
|
278
|
+
// The key step names 1 app. Deploy files that name more get 1 step for each app (review 2 of the installer, F2).
|
|
279
|
+
const apps = [...new Set(names.aca.apps.map((a) => a.name))]
|
|
280
|
+
if (/^Add the step below to each deploy job/.test(l) && apps.length > 1) {
|
|
281
|
+
return [l, `Your deploy files name ${apps.length} container apps, ${apps.slice(0, -1).join(', ')} and ${apps.at(-1)}. The step below sets the key on ${aca}. Add 1 step for each app, with its own APP and RESOURCE_GROUP.`]
|
|
282
|
+
}
|
|
283
|
+
return [l.split(EXAMPLE_NAMES.repo).join(slug)]
|
|
284
|
+
})
|
|
285
|
+
const secret = [envs.length ? githubSecretCommands(envs, slug) : !none.length ? githubSecretCommands(undefined, slug) : null,
|
|
286
|
+
none.length ? `# For a deploy job with no environment: line, a repository secret:\ngh secret set UKNOWS_KEY --repo ${slug}` : null].filter(Boolean).join('\n')
|
|
287
|
+
// The job of the key step: the first job with an environment: line, as it is written (staging, or ${{ inputs.environment }}).
|
|
288
|
+
const first = jobs.find((j) => j.environment)
|
|
289
|
+
code = code.map((x, i) => (i === 0 ? { ...x, code: secret }
|
|
290
|
+
: i === 1 ? { ...x, code: githubKeyStep({ environment: first ? first.environment : none.length ? null : undefined, resourceGroup: group, app: aca }) }
|
|
291
|
+
: { ...x, code: repo ? x.code.split(EXAMPLE_NAMES.org).join(repo.split('/')[0]) : x.code }))
|
|
292
|
+
return { lines: [...found, ...lines], snippets: code }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** What a deploy job with environment: ${{ inputs.X }} deploys to: the workflows that call it and the choices of its own input. */
|
|
296
|
+
function dynamicJob(job, at) {
|
|
297
|
+
const head = `${at}: the deploy job ${job.job} has environment: ${job.environment}.`
|
|
298
|
+
const from = job.environments ?? []
|
|
299
|
+
if (!from.length) return `${head} This tool cannot tell which environments that is. Set the key for each environment that this job deploys to.`
|
|
300
|
+
const said = [...new Set(from.map((e) => e.where))].map((w) => {
|
|
301
|
+
const here = from.filter((e) => e.where === w)
|
|
302
|
+
const list = here.map((e) => e.name)
|
|
303
|
+
const or = list.length > 1 ? `${list.slice(0, -1).join(', ')} or ${list.at(-1)}` : list[0]
|
|
304
|
+
return here[0].how === 'call' ? `${w} sets it to ${or}.` : `${w} lets you pick ${or}.`
|
|
305
|
+
})
|
|
306
|
+
return `${head} ${said.join(' ')} Set the key for each of these environments.`
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* The App Service slot block: both slots get the key as a slot setting, with 1 script each. A slot that only a workflow
|
|
311
|
+
* input or variable holds keeps an example SLOT that the script says to replace.
|
|
312
|
+
*/
|
|
313
|
+
function slotBlocks(names) {
|
|
314
|
+
const slot = names.webapp.slots[0]
|
|
315
|
+
if (!slot) return []
|
|
316
|
+
const app = names.webapp.apps[0]
|
|
317
|
+
const base = scriptNames(names.webapp, true)
|
|
318
|
+
const unknown = slot.name === null
|
|
319
|
+
const values = { app: app?.name ?? 'your web app', slot: unknown ? slot.raw : slot.name, where: slot.where }
|
|
320
|
+
const fill = (t) => t.replace(/\{(\w+)\}/g, (m, k) => (k in values ? values[k] : m))
|
|
321
|
+
return [{ title: fill(APP_SERVICE_SLOTS.title), lines: APP_SERVICE_SLOTS.lines.map(fill), snippets: [
|
|
322
|
+
{ caption: 'The production slot', language: 'bash', code: appServiceSlotScript({ ...base, slot: undefined }) },
|
|
323
|
+
{ caption: unknown ? 'The deployment slot' : `The ${slot.name} slot`, language: 'bash',
|
|
324
|
+
code: appServiceSlotScript({ ...base, slot: values.slot, ...(unknown ? { slotUnknown: true } : {}), from: { ...base.from, slot: slot.where } }) },
|
|
325
|
+
] }]
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** The ScriptNames for the builders of install.ts: the names found, or none, so the examples stay with their comment. */
|
|
329
|
+
function scriptNames(found, one = false) {
|
|
330
|
+
const apps = found.apps.map((a) => a.name)
|
|
331
|
+
const group = found.groups.length === 1 || new Set(found.groups.map((g) => g.name)).size === 1 ? found.groups[0] : null
|
|
332
|
+
const out = { from: {} }
|
|
333
|
+
if (apps.length) { out.apps = apps.join(' '); out.app = apps[0]; out.from.apps = joinAnd([...new Set(found.apps.map((a) => a.where))]); out.from.app = found.apps[0].where }
|
|
334
|
+
if (group) { out.resourceGroup = group.name; out.from.resourceGroup = group.where }
|
|
335
|
+
if (one) delete out.apps
|
|
336
|
+
return out
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const emptyNames = () => ({ aca: { apps: [], groups: [] }, webapp: { apps: [], groups: [], slots: [] }, functions: { apps: [], groups: [] }, jobs: [], code: [] })
|
|
340
|
+
|
|
341
|
+
/** env list items for a Kubernetes container: the YAML of the docs snippet, with the settings of this run. */
|
|
342
|
+
function k8sEnv(text, settings, indent) {
|
|
343
|
+
const head = text.split('\n').slice(0, text.split('\n').findIndex((l) => /^\s*env:\s*$/.test(l)) + 1)
|
|
344
|
+
const pad = ' '.repeat(indent + 2)
|
|
345
|
+
return [...head, ...settings.flatMap(([name, value]) => [`${' '.repeat(indent)} - name: ${name}`, `${pad} value: ${value}`])].join('\n')
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function helmEnv(text, settings) {
|
|
349
|
+
const rows = text.split('\n')
|
|
350
|
+
const at = rows.findIndex((l) => /^env:\s*$/.test(l)), end = rows.findIndex((l, i) => i > at && /^\S/.test(l))
|
|
351
|
+
return [...rows.slice(0, at + 1), ...settings.flatMap(([name, value]) => [` - name: ${name}`, ` value: ${value}`]), ...rows.slice(end)].join('\n')
|
|
133
352
|
}
|
|
134
353
|
|
|
354
|
+
/**
|
|
355
|
+
* The names that the deploy files of the repo hold, for the key steps: the container apps and web apps that an az
|
|
356
|
+
* command, a deploy action or a workflow variable names, their resource groups, the App Service slots, the deploy jobs
|
|
357
|
+
* of each GitHub workflow with their environment: line, and the App Service code deploys (deploys of a package, not
|
|
358
|
+
* an image) with their startup command. A value that only a workflow variable or secret holds (${{ vars.X }}) is unknown.
|
|
359
|
+
* A slot is null for the production slot; a slot that only an input or variable holds is kept as slotUnknown (code)
|
|
360
|
+
* or { name: null, raw } (webapp.slots). A job with environment: ${{ inputs.X }} gets environments: the values that the
|
|
361
|
+
* workflows calling it pass as X, and the options of its own workflow_dispatch choice input X.
|
|
362
|
+
* @param {Pick<import('./workspace.mjs').RepoView, 'read'|'stat'>} view
|
|
363
|
+
* @param {import('./types.mjs').Host[]} hosts
|
|
364
|
+
*/
|
|
365
|
+
export function deployNames(view, hosts) {
|
|
366
|
+
const out = emptyNames()
|
|
367
|
+
const add = (list, name, where) => { if (name && !list.some((x) => x.name === name)) list.push({ name, where }) }
|
|
368
|
+
const files = [...new Set((hosts ?? []).flatMap((h) => (h.evidence ?? []).map((e) => e.file)))].filter((f) => /\.(?:ya?ml|sh|ps1)$/.test(f))
|
|
369
|
+
const rowsOf = (file) => {
|
|
370
|
+
let body = null
|
|
371
|
+
try { body = (view.stat?.(file)?.size ?? 0) <= MAX_BYTES ? view.read(file) : null } catch { body = null }
|
|
372
|
+
return body ? body.split(/\r?\n/).map((l) => (/^\s*#/.test(l) ? '' : l)) : null
|
|
373
|
+
}
|
|
374
|
+
// The calls of reusable workflows, from every workflow of the repo: they give the environment: ${{ inputs.X }} of a job.
|
|
375
|
+
const workflows = new Map()
|
|
376
|
+
let listed = []
|
|
377
|
+
try { listed = view.files?.() ?? [] } catch { listed = [] }
|
|
378
|
+
for (const file of [...files, ...listed].filter((f) => WORKFLOW.test(f))) if (!workflows.has(file)) workflows.set(file, rowsOf(file))
|
|
379
|
+
const calls = [...workflows].flatMap(([file, rows]) => (rows ? workflowCalls(file, rows) : []))
|
|
380
|
+
for (const file of files) {
|
|
381
|
+
const rows = workflows.get(file) ?? rowsOf(file)
|
|
382
|
+
if (!rows) continue
|
|
383
|
+
const vars = new Map()
|
|
384
|
+
for (const l of rows) {
|
|
385
|
+
const m = /^\s*([A-Z][A-Z0-9_]*)\s*:\s*(.+?)\s*$/.exec(l) ?? /^\s*(?:export\s+)?([A-Z][A-Z0-9_]*)=(\S+)\s*$/.exec(l)
|
|
386
|
+
if (m && !vars.has(m[1])) vars.set(m[1], m[2].replace(/\s+#.*$/, ''))
|
|
387
|
+
}
|
|
388
|
+
const value = (raw) => {
|
|
389
|
+
let v = String(raw ?? '').trim().replace(/^["']|["']$/g, '')
|
|
390
|
+
for (let hop = 0; hop < 3; hop++) {
|
|
391
|
+
const ref = /^\$\{\{\s*env\.([A-Za-z_]\w*)\s*\}\}$/.exec(v) ?? /^\$\{?([A-Za-z_]\w*)\}?$/.exec(v)
|
|
392
|
+
if (!ref) break
|
|
393
|
+
v = String(vars.get(ref[1]) ?? '').replace(/^["']|["']$/g, '')
|
|
394
|
+
}
|
|
395
|
+
return /^[A-Za-z0-9][\w.-]{0,79}$/.test(v) ? v : null
|
|
396
|
+
}
|
|
397
|
+
// The slot of an App Service command or deploy step. The production slot is no slot: none, an empty one, or one named
|
|
398
|
+
// production (the portal template writes slot-name: 'Production'). unknown: the text of a slot that only a workflow
|
|
399
|
+
// input or variable holds.
|
|
400
|
+
const slotOf = (raw) => {
|
|
401
|
+
const text = String(raw ?? '').trim().replace(/^["']|["']$/g, '').trim()
|
|
402
|
+
if (!text) return { slot: null, unknown: null }
|
|
403
|
+
const v = value(text)
|
|
404
|
+
if (v) return { slot: v.toLowerCase() === 'production' ? null : v, unknown: null }
|
|
405
|
+
return { slot: null, unknown: text }
|
|
406
|
+
}
|
|
407
|
+
const addSlot = (raw, where) => {
|
|
408
|
+
const { slot, unknown } = slotOf(raw)
|
|
409
|
+
if (slot) add(out.webapp.slots, slot, where)
|
|
410
|
+
else if (unknown && !out.webapp.slots.some((s) => s.raw === unknown)) out.webapp.slots.push({ name: null, raw: unknown, where })
|
|
411
|
+
}
|
|
412
|
+
const code = (entry, raw) => {
|
|
413
|
+
const { slot, unknown } = slotOf(raw)
|
|
414
|
+
out.code.push({ ...entry, slot, ...(unknown ? { slotUnknown: unknown } : {}) })
|
|
415
|
+
}
|
|
416
|
+
// Logical lines: a command over several lines (\ at the end) counts as 1, at the line where it starts.
|
|
417
|
+
const logical = []
|
|
418
|
+
for (let i = 0; i < rows.length; i++) {
|
|
419
|
+
let text = rows[i], j = i
|
|
420
|
+
while (/\\\s*$/.test(text) && j + 1 < rows.length) text = text.replace(/\\\s*$/, ' ') + rows[++j]
|
|
421
|
+
logical.push({ text, line: i + 1 })
|
|
422
|
+
i = j
|
|
423
|
+
}
|
|
424
|
+
const opt = (text, re) => re.exec(text)?.[1] ?? null
|
|
425
|
+
for (const { text, line } of logical) {
|
|
426
|
+
const where = `${file} line ${line}`
|
|
427
|
+
const az = /\baz\s+(containerapp|webapp|functionapp)\b(.*)$/.exec(text)
|
|
428
|
+
if (az) {
|
|
429
|
+
const target = az[1] === 'containerapp' ? out.aca : az[1] === 'functionapp' ? out.functions : out.webapp
|
|
430
|
+
add(target.apps, value(opt(az[2], /(?:^|\s)(?:--name|-n)[\s=]+("[^"]*"|'[^']*'|\S+)/)), where)
|
|
431
|
+
add(target.groups, value(opt(az[2], /(?:^|\s)(?:--resource-group|-g)[\s=]+("[^"]*"|'[^']*'|\S+)/)), where)
|
|
432
|
+
// The --slot of the command as written. slotOf() makes it null for the production slot.
|
|
433
|
+
const slot = az[1] === 'webapp' ? opt(az[2], /(?:^|\s)--slot[\s=]+("[^"]*"|'[^']*'|\S+)/) : null
|
|
434
|
+
if (az[1] === 'webapp') addSlot(slot, where)
|
|
435
|
+
const startup = /(?:^|\s)--startup-file[\s=]+("[^"]*"|'[^']*'|\S+)/.exec(az[2])
|
|
436
|
+
if (az[1] === 'webapp' && startup) code({ kind: 'startup', startup: startup[1].replace(/^["']|["']$/g, ''), where }, slot)
|
|
437
|
+
if (az[1] === 'webapp' && (/\bdeploy\b.*--src-(?:path|url)\b|\bdeployment\s+source\s+config-zip\b|\bup\b/.test(az[2]))) code({ kind: 'deploy', where }, slot)
|
|
438
|
+
}
|
|
439
|
+
const key = /^\s*(?:-\s+)?([\w-]+)\s*:\s*(.+?)\s*$/.exec(text)
|
|
440
|
+
if (key) {
|
|
441
|
+
if (key[1] === 'containerAppName') add(out.aca.apps, value(key[2]), where)
|
|
442
|
+
if (key[1] === 'resourceGroup') add(out.aca.groups, value(key[2]), where)
|
|
443
|
+
if (key[1] === 'app-name') add(out.webapp.apps, value(key[2]), where)
|
|
444
|
+
if (key[1] === 'slot-name') addSlot(key[2], where)
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
// azure/webapps-deploy with package: and no images: deploys code, not an image.
|
|
448
|
+
rows.forEach((l, i) => {
|
|
449
|
+
const uses = /^(\s*)(?:-\s+)?uses\s*:\s*azure\/webapps-deploy@/.exec(l)
|
|
450
|
+
if (!uses) return
|
|
451
|
+
const step = stepRows(rows, i)
|
|
452
|
+
const slot = step.map((r) => /^\s*slot-name\s*:\s*(.+?)\s*$/.exec(r)?.[1]).find(Boolean) ?? null
|
|
453
|
+
if (step.some((r) => /^\s*package\s*:/.test(r)) && !step.some((r) => /^\s*images\s*:/.test(r))) code({ kind: 'deploy', where: `${file} line ${i + 1}` }, slot)
|
|
454
|
+
})
|
|
455
|
+
if (WORKFLOW.test(file)) out.jobs.push(...deployJobs(file, rows))
|
|
456
|
+
}
|
|
457
|
+
// Known slots first, then the slots that only a workflow input or variable holds.
|
|
458
|
+
out.webapp.slots.sort((a, b) => (a.name === null) - (b.name === null))
|
|
459
|
+
// A job that only builds and pushes the image is not where the key goes, when another job deploys.
|
|
460
|
+
if (out.jobs.some((j) => !j.push)) out.jobs = out.jobs.filter((j) => !j.push)
|
|
461
|
+
// environment: ${{ inputs.X }}: the values that the workflows that call this one pass as X, and the options of a
|
|
462
|
+
// workflow_dispatch choice input X of the same workflow.
|
|
463
|
+
out.jobs = out.jobs.map(({ push, input, ...job }) => {
|
|
464
|
+
if (!input) return job
|
|
465
|
+
const own = job.file.replace(/^(?:\.\.\/)*/, '')
|
|
466
|
+
const environments = []
|
|
467
|
+
const put = (name, where, how) => { if (plainName(name) && !environments.some((e) => e.name === name && e.where === where)) environments.push({ name, where, how }) }
|
|
468
|
+
for (const call of calls.filter((c) => c.path === own)) {
|
|
469
|
+
const given = call.values.get(input)
|
|
470
|
+
if (given) put(given.value, `${call.file} line ${given.line}`, 'call')
|
|
471
|
+
}
|
|
472
|
+
for (const option of choices(workflows.get(job.file) ?? [], input)) put(option.name, `${job.file} line ${option.line}`, 'pick')
|
|
473
|
+
return { ...job, environments }
|
|
474
|
+
})
|
|
475
|
+
return out
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** The rows of the workflow step that row i is in: from its - line to the next - at the same indent or less. */
|
|
479
|
+
function stepRows(rows, i) {
|
|
480
|
+
let start = i
|
|
481
|
+
while (start > 0 && !/^\s*-\s/.test(rows[start])) start--
|
|
482
|
+
const indent = /^\s*/.exec(rows[start])[0].length
|
|
483
|
+
let end = start + 1
|
|
484
|
+
while (end < rows.length && (!rows[end].trim() || /^\s*/.exec(rows[end])[0].length > indent)) end++
|
|
485
|
+
return rows.slice(start, end)
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const indentOf = (line) => /^\s*/.exec(line)[0].length
|
|
489
|
+
/** A ${{ ... }} expression. It names other jobs and steps, so it is never a deploy cue. */
|
|
490
|
+
const EXPRESSION = /\$\{\{[^}]*\}\}/g
|
|
491
|
+
/** A job id, a step name or a step id that says deploy: deploy-prod, Deploy the new image. Not deployment, nor deploy tests or a deploy summary. */
|
|
492
|
+
const NAME_CUE = /(?:^|[^a-z])(?:re)?deploy(?:s|ing)?(?![a-z])(?![\s._-]*(?:tests?|summary|report|status|notes?)\b)/i
|
|
493
|
+
/** az commands that only read an app: they deploy nothing. */
|
|
494
|
+
const READ_ONLY_AZ = /\baz\s+(?:containerapp|webapp)\s+(?:(?:revision|replica|secret|env|ingress|logs?|config(?:\s+appsettings)?|deployment\s+slot)\s+)?(?:show|list|tail|browse)\b/g
|
|
495
|
+
/** The input of an environment: ${{ inputs.X }} line. */
|
|
496
|
+
const INPUT_REF = /^\$\{\{\s*(?:github\.event\.)?inputs\.([A-Za-z_][\w-]*)\s*\}\}$/
|
|
497
|
+
const plainName = (text) => /^[A-Za-z0-9][\w.-]{0,79}$/.test(text)
|
|
498
|
+
const unquote = (text) => String(text ?? '').replace(/\s+#.*$/, '').trim().replace(/^["']|["']$/g, '')
|
|
499
|
+
|
|
500
|
+
/** The jobs of a workflow: id, 1-based line, the rows of the job, and the indent of its keys. */
|
|
501
|
+
function jobBlocks(rows) {
|
|
502
|
+
const at = rows.findIndex((l) => /^jobs\s*:\s*$/.test(l))
|
|
503
|
+
if (at < 0) return []
|
|
504
|
+
let end = at + 1
|
|
505
|
+
while (end < rows.length && !/^\S/.test(rows[end] || ' ')) end++
|
|
506
|
+
const heads = []
|
|
507
|
+
for (let i = at + 1; i < end; i++) {
|
|
508
|
+
const m = /^( {1,8})([\w-]+)\s*:\s*$/.exec(rows[i])
|
|
509
|
+
if (m && (!heads.length || m[1].length === heads[0].indent)) heads.push({ job: m[2], line: i + 1, indent: m[1].length })
|
|
510
|
+
}
|
|
511
|
+
return heads.map((h, k) => {
|
|
512
|
+
const body = rows.slice(h.line, k + 1 < heads.length ? heads[k + 1].line - 1 : end)
|
|
513
|
+
const first = body.find((l) => l.trim())
|
|
514
|
+
return { ...h, body, pad: first ? indentOf(first) : h.indent + 2 }
|
|
515
|
+
})
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* 1 key of a job, such as steps: or environment:. head: its line, i: its index in the job rows, rows: the lines under it
|
|
520
|
+
* (a list may sit at the same indent as its key). null when the job has no such key.
|
|
521
|
+
*/
|
|
522
|
+
function jobKey(block, name) {
|
|
523
|
+
const i = block.body.findIndex((l) => indentOf(l) === block.pad && new RegExp(`^\\s*${name}\\s*:`).test(l))
|
|
524
|
+
if (i < 0) return null
|
|
525
|
+
let j = i + 1
|
|
526
|
+
while (j < block.body.length && (!block.body[j].trim() || indentOf(block.body[j]) > block.pad || (indentOf(block.body[j]) === block.pad && /^\s*-/.test(block.body[j])))) j++
|
|
527
|
+
return { i, head: block.body[i], rows: block.body.slice(i + 1, j) }
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/**
|
|
531
|
+
* The jobs of 1 workflow that deploy, with their environment: value (null when none). A job deploys when its id says so
|
|
532
|
+
* (deploy-prod) or its steps do: a deploy command or action, or a step named Deploy. Echo lines, ${{ }} expressions and
|
|
533
|
+
* az commands that only read do not count, so a summary job that prints "Deployment Summary" deploys nothing.
|
|
534
|
+
* push: the only cue is docker push, which builds the image (deployNames drops such a job when another job deploys).
|
|
535
|
+
* input: the X of environment: ${{ inputs.X }}.
|
|
536
|
+
*/
|
|
537
|
+
function deployJobs(file, rows) {
|
|
538
|
+
const jobs = []
|
|
539
|
+
for (const block of jobBlocks(rows)) {
|
|
540
|
+
// A call of a reusable workflow (job-level uses:) has no steps. The jobs of the workflow it calls deploy.
|
|
541
|
+
if (jobKey(block, 'uses')) continue
|
|
542
|
+
const steps = (jobKey(block, 'steps')?.rows ?? []).map((l) => l.replace(EXPRESSION, ''))
|
|
543
|
+
.filter((l) => !/^\s*(?:-\s+)?(?:run\s*:\s*)?(?:echo|printf)\b/.test(l))
|
|
544
|
+
const named = (l) => /^\s*(?:-\s+)?(?:name|id)\s*:\s*(.*)$/.exec(l)?.[1]
|
|
545
|
+
const cue = (l) => (named(l) !== undefined ? NAME_CUE.test(named(l)) : DEPLOY_CUE.test(l.replace(READ_ONLY_AZ, '').replace(/\bdocker\s+push\b/gi, '')))
|
|
546
|
+
const strong = NAME_CUE.test(block.job) || steps.some(cue)
|
|
547
|
+
if (!strong && !steps.some((l) => named(l) === undefined && /\bdocker\s+push\b/i.test(l))) continue
|
|
548
|
+
const env = jobKey(block, 'environment')
|
|
549
|
+
let environment = env ? unquote(env.head.replace(/^\s*environment\s*:\s*/, '')) : null
|
|
550
|
+
if (environment === '') environment = unquote(env.rows.map((r) => /^\s*name\s*:\s*(.+?)\s*$/.exec(r)?.[1]).find(Boolean) ?? '') || null
|
|
551
|
+
const input = environment ? INPUT_REF.exec(environment)?.[1] : undefined
|
|
552
|
+
jobs.push({ file, job: block.job, line: block.line, environment, ...(strong ? {} : { push: true }), ...(input ? { input } : {}) })
|
|
553
|
+
}
|
|
554
|
+
return jobs
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** The jobs of 1 workflow that call a reusable workflow of this repo (uses: ./.github/workflows/x.yml), with their with: values. */
|
|
558
|
+
function workflowCalls(file, rows) {
|
|
559
|
+
return jobBlocks(rows).flatMap((block) => {
|
|
560
|
+
const uses = jobKey(block, 'uses')
|
|
561
|
+
const path = uses && /^\s*uses\s*:\s*["']?\.\/(\S+?\.ya?ml)["']?\s*$/.exec(uses.head)?.[1]
|
|
562
|
+
if (!path) return []
|
|
563
|
+
const given = jobKey(block, 'with')
|
|
564
|
+
const values = new Map()
|
|
565
|
+
given?.rows.forEach((r, k) => {
|
|
566
|
+
const m = /^\s*([\w-]+)\s*:\s*(.+?)\s*$/.exec(r)
|
|
567
|
+
if (m && !values.has(m[1])) values.set(m[1], { value: unquote(m[2]), line: block.line + given.i + k + 2 })
|
|
568
|
+
})
|
|
569
|
+
return [{ file, path, values }]
|
|
570
|
+
})
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** The options of a workflow_dispatch choice input of 1 workflow, with the line of each: [{name, line}]. */
|
|
574
|
+
function choices(rows, input) {
|
|
575
|
+
const at = rows.findIndex((l) => /^\s*workflow_dispatch\s*:\s*$/.test(l))
|
|
576
|
+
if (at < 0) return []
|
|
577
|
+
const base = indentOf(rows[at])
|
|
578
|
+
let end = at + 1
|
|
579
|
+
while (end < rows.length && (!rows[end].trim() || indentOf(rows[end]) > base)) end++
|
|
580
|
+
const inputs = rows.slice(at + 1, end).findIndex((l) => /^\s*inputs\s*:\s*$/.test(l))
|
|
581
|
+
if (inputs < 0) return []
|
|
582
|
+
const from = at + 1 + inputs
|
|
583
|
+
const head = rows.slice(from + 1, end).findIndex((l) => new RegExp(`^\\s*${input}\\s*:\\s*$`).test(l) && indentOf(l) > indentOf(rows[from]))
|
|
584
|
+
if (head < 0) return []
|
|
585
|
+
const i = from + 1 + head, pad = indentOf(rows[i])
|
|
586
|
+
let stop = i + 1
|
|
587
|
+
while (stop < end && (!rows[stop].trim() || indentOf(rows[stop]) > pad)) stop++
|
|
588
|
+
const block = rows.slice(i + 1, stop)
|
|
589
|
+
if (!block.some((l) => /^\s*type\s*:\s*["']?choice["']?\s*$/.test(l))) return []
|
|
590
|
+
const k = block.findIndex((l) => /^\s*options\s*:/.test(l))
|
|
591
|
+
if (k < 0) return []
|
|
592
|
+
const line = i + 1 + k + 1
|
|
593
|
+
const inline = /^\s*options\s*:\s*\[(.*)\]\s*$/.exec(block[k])
|
|
594
|
+
const names = inline ? inline[1].split(',').map(unquote)
|
|
595
|
+
: block.slice(k + 1).filter((l) => l.trim()).map((l) => /^\s*-\s*(.+?)\s*$/.exec(l)?.[1]).filter((v, n, all) => v !== undefined && all.slice(0, n).every((x) => x !== undefined)).map(unquote)
|
|
596
|
+
return names.filter(plainName).map((name) => ({ name, line }))
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The checks of 1 host that the repo needs: each check of CHECKS whose deploy files the evidence names and whose
|
|
601
|
+
* line is in them, and each check that needs no file. 1 line per check, with the files and lines it found. A file
|
|
602
|
+
* with template-ignored (Terraform ignore_changes on the env or secrets) does not get the template check.
|
|
603
|
+
*/
|
|
604
|
+
function hostChecks(host, evidence, view) {
|
|
605
|
+
const texts = new Map((Array.isArray(host.checks) ? host.checks : []).filter((c) => typeof c?.text === 'string').map((c) => [c.id, c]))
|
|
606
|
+
const found = new Map(), languages = new Map()
|
|
607
|
+
for (const [id, hostId, what, re, langs = []] of CHECKS) {
|
|
608
|
+
if (hostId !== host.id || !texts.has(id)) continue
|
|
609
|
+
if (!what) { if (!found.has(id)) found.set(id, []); continue }
|
|
610
|
+
for (const file of [...new Set(evidence.filter((e) => what.includes(e.what)).map((e) => e.file))]) {
|
|
611
|
+
let body = null
|
|
612
|
+
try { body = view?.read(file) ?? null } catch { body = null }
|
|
613
|
+
const code = body?.replace(/^[ \t]*(?:#|\/\/).*$/gm, '')
|
|
614
|
+
if (!code || !re.test(code)) continue
|
|
615
|
+
// The line where the match ends, so a command over several lines points at its option.
|
|
616
|
+
const m = new RegExp(re.source, re.flags.replace('g', '')).exec(code)
|
|
617
|
+
const end = m.index + m[0].trimEnd().length
|
|
618
|
+
found.set(id, [...(found.get(id) ?? []), { file, at: `${file} line ${code.slice(0, end).split('\n').length}`, langs }])
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
const ignored = new Set((found.get('template-ignored') ?? []).map((x) => x.file))
|
|
622
|
+
if (ignored.size && found.has('template')) {
|
|
623
|
+
const left = found.get('template').filter((x) => !ignored.has(x.file))
|
|
624
|
+
if (left.length) found.set('template', left); else found.delete('template')
|
|
625
|
+
}
|
|
626
|
+
for (const [id, hits] of found) languages.set(id, hits.flatMap((x) => x.langs))
|
|
627
|
+
const lines = [], out = []
|
|
628
|
+
for (const [id, check] of texts) {
|
|
629
|
+
if (!found.has(id)) continue
|
|
630
|
+
const where = found.get(id).map((x) => x.at)
|
|
631
|
+
lines.push(where.length ? `${check.text} ${DEPLOY_CHECKS.found.replace('{where}', joinAnd([...new Set(where)]))}` : check.text)
|
|
632
|
+
const langs = languages.get(id) ?? []
|
|
633
|
+
out.push(...snippets(check.snippets).filter((x) => !langs.length || langs.includes(x.language)))
|
|
634
|
+
}
|
|
635
|
+
return { lines, snippets: out }
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const joinAnd = (items) => (items.length > 1 ? `${items.slice(0, -1).join(', ')} and ${items.at(-1)}` : items.join(''))
|
|
639
|
+
|
|
135
640
|
/** Deploy files before deploy steps (RULES order), then by path and line. */
|
|
136
641
|
const rank = (e) => RULES.findIndex((rule) => rule[1] === e.what)
|
|
137
642
|
const byStrength = (a, b) => rank(a) - rank(b) || (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line)
|