@fabrika/provider-zerops 0.3.1 → 0.3.3
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/package.json +6 -6
- package/src/cli-args.ts +2 -1
- package/src/descriptor.ts +45 -1
- package/src/installation/admin-options.ts +4 -32
- package/src/installation/deploy-options.ts +6 -34
- package/src/installation/index.ts +41 -17
- package/src/installation/init-options.ts +148 -0
- package/src/installation/init.ts +94 -221
- package/src/installation/install-collaborators.ts +2 -3
- package/src/installation/install-options.ts +6 -43
- package/src/installation/install.ts +7 -31
- package/src/installation/sidecar.ts +4 -1
- package/src/installation/upgrade-options.ts +3 -12
- package/src/managed-env.ts +11 -3
- package/src/manifest.ts +6 -3
- package/src/provider.ts +1 -9
package/src/installation/init.ts
CHANGED
|
@@ -23,18 +23,17 @@
|
|
|
23
23
|
import {
|
|
24
24
|
action as consoleAction,
|
|
25
25
|
configureEnvironment,
|
|
26
|
-
|
|
26
|
+
type Confirmations,
|
|
27
|
+
confirmations,
|
|
27
28
|
type EnvironmentConfig,
|
|
28
29
|
ghRepoExists,
|
|
29
30
|
info as consoleInfo,
|
|
30
31
|
ok as consoleOk,
|
|
31
32
|
scaffoldSidecarRepository,
|
|
32
|
-
|
|
33
|
-
select as promptSelect,
|
|
33
|
+
shellWord,
|
|
34
34
|
type SidecarScaffoldInput,
|
|
35
35
|
type SidecarScaffoldResult,
|
|
36
36
|
step as consoleStep,
|
|
37
|
-
text as promptText,
|
|
38
37
|
triggerPlatformWorkflow,
|
|
39
38
|
url,
|
|
40
39
|
warn as consoleWarn,
|
|
@@ -42,21 +41,10 @@ import {
|
|
|
42
41
|
import { createZeropsApi, type ZeropsApi, type ZeropsService } from '../index'
|
|
43
42
|
import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
|
|
44
43
|
import { derivePlatformHosts, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
|
|
44
|
+
import type { InitInput } from './init-options'
|
|
45
45
|
import type { InitLog } from './log'
|
|
46
46
|
import { MACHINE_KEY_NEXT_STEP_TITLE, machineKeyNextStepLines } from './machine-key'
|
|
47
|
-
import {
|
|
48
|
-
|
|
49
|
-
/** The name every bypass in this platform is gated on, and therefore the one name an installation cannot take. */
|
|
50
|
-
const LOCAL_ENVIRONMENT = 'local'
|
|
51
|
-
|
|
52
|
-
/** Everything `runInit` asks the operator. Injected so the flow can be exercised without a TTY. */
|
|
53
|
-
export interface InitPrompts {
|
|
54
|
-
text(question: string, fallback?: string): Promise<string>
|
|
55
|
-
confirm(question: string, defaultYes?: boolean): Promise<boolean>
|
|
56
|
-
select<T>(question: string, options: { label: string; value: T }[]): Promise<T>
|
|
57
|
-
/** A hidden prompt, or the named environment variable when it is already set. NEVER echoed. */
|
|
58
|
-
secret(variable: string, question: string): Promise<string>
|
|
59
|
-
}
|
|
47
|
+
import { materializeSidecarScaffold, readPinnedTag, SIDECAR_FILES } from './sidecar'
|
|
60
48
|
|
|
61
49
|
/** Everything `runInit` does outside the operator's own disk. Every one of them is confirmed first. */
|
|
62
50
|
export interface InitEffects {
|
|
@@ -72,80 +60,15 @@ export interface InitEffects {
|
|
|
72
60
|
|
|
73
61
|
export interface InitCollaborators {
|
|
74
62
|
readonly log: InitLog
|
|
75
|
-
readonly prompts:
|
|
63
|
+
readonly prompts: Confirmations
|
|
76
64
|
readonly effects: InitEffects
|
|
77
65
|
}
|
|
78
66
|
|
|
79
|
-
/** `<installation>` plus the one optional flag, so the repository can be named without a prompt. */
|
|
80
|
-
export interface InitArguments {
|
|
81
|
-
readonly installation: string
|
|
82
|
-
readonly repo?: string
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export const parseInitArgs = (argv: readonly string[]): InitArguments => {
|
|
86
|
-
let installation: string | undefined
|
|
87
|
-
let repo: string | undefined
|
|
88
|
-
for (const arg of argv) {
|
|
89
|
-
if (arg.startsWith('--repo=')) {
|
|
90
|
-
repo = arg.slice('--repo='.length).trim()
|
|
91
|
-
continue
|
|
92
|
-
}
|
|
93
|
-
if (arg.startsWith('-')) {
|
|
94
|
-
throw new Error(`unexpected argument \`${arg}\`. Usage: fabrika platform init --provider=zerops <installation> [--repo=<owner>/<name>]`)
|
|
95
|
-
}
|
|
96
|
-
if (installation !== undefined) {
|
|
97
|
-
throw new Error(`unexpected argument \`${arg}\`: name ONE installation`)
|
|
98
|
-
}
|
|
99
|
-
installation = arg.trim()
|
|
100
|
-
}
|
|
101
|
-
if (installation === undefined || installation === '') {
|
|
102
|
-
throw new Error('Zerops installation init requires an installation name, for example `fabrika platform init --provider=zerops test`')
|
|
103
|
-
}
|
|
104
|
-
if (repo !== undefined && !/^[^/\s]+\/[^/\s]+$/.test(repo)) {
|
|
105
|
-
throw new Error(`\`${repo}\` is not a <owner>/<name> repository`)
|
|
106
|
-
}
|
|
107
|
-
return { installation, ...(repo === undefined || repo === '' ? {} : { repo }) }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* The installation's environment name, refused when it claims to be local.
|
|
112
|
-
*
|
|
113
|
-
* The same refusal the services enforce at boot: `local` is the value `localDevLogin`, the ephemeral
|
|
114
|
-
* signing key and the credential-less caller path are all gated on, and a deployed installation is
|
|
115
|
-
* never a loopback address. Refusing it here means the operator finds out at the prompt rather than in
|
|
116
|
-
* a failed readiness check.
|
|
117
|
-
*/
|
|
118
|
-
export const checkedEnvironmentName = (declared: string): string => {
|
|
119
|
-
const trimmed = declared.trim()
|
|
120
|
-
if (trimmed === '') {
|
|
121
|
-
throw new Error('an installation environment name is required — it is written to every service as ENVIRONMENT')
|
|
122
|
-
}
|
|
123
|
-
if (trimmed === LOCAL_ENVIRONMENT) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
`\`${LOCAL_ENVIRONMENT}\` is refused: it is the name every development bypass is gated on, and a deployed `
|
|
126
|
-
+ 'installation is not a loopback address. Name it after the installation instead',
|
|
127
|
-
)
|
|
128
|
-
}
|
|
129
|
-
return trimmed
|
|
130
|
-
}
|
|
131
|
-
|
|
132
67
|
/** Where this installation answers, as far as `init` needs to know it. */
|
|
133
68
|
interface Placement {
|
|
134
69
|
readonly hosts?: { readonly iam: string; readonly control: string; readonly operations: string }
|
|
135
70
|
}
|
|
136
71
|
|
|
137
|
-
/** Everything collected before anything leaves this disk. */
|
|
138
|
-
interface Collected {
|
|
139
|
-
readonly repo: string
|
|
140
|
-
readonly fabrikaRef: string
|
|
141
|
-
readonly projectId: string
|
|
142
|
-
readonly environmentName: string
|
|
143
|
-
readonly placement: Placement
|
|
144
|
-
readonly apiBaseUrl?: string
|
|
145
|
-
readonly accessToken: string
|
|
146
|
-
readonly provisioningKey: string
|
|
147
|
-
}
|
|
148
|
-
|
|
149
72
|
export interface InspectPlatformInput {
|
|
150
73
|
readonly projectId: string
|
|
151
74
|
readonly accessToken: string
|
|
@@ -159,75 +82,6 @@ export interface PlatformInspection {
|
|
|
159
82
|
readonly proxyPublished: boolean
|
|
160
83
|
}
|
|
161
84
|
|
|
162
|
-
const requiredAnswer = async (prompts: InitPrompts, question: string, fallback?: string): Promise<string> => {
|
|
163
|
-
const answer = (await prompts.text(question, fallback)).trim()
|
|
164
|
-
if (answer === '') {
|
|
165
|
-
throw new Error(`${question} is required`)
|
|
166
|
-
}
|
|
167
|
-
return answer
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const bareHost = (value: string, label: string): string => {
|
|
171
|
-
const host = value.trim()
|
|
172
|
-
if (host === '' || host.includes('/') || host.includes(':')) {
|
|
173
|
-
throw new Error(`\`${value}\` is not a bare hostname — ${label} carries no scheme and no port`)
|
|
174
|
-
}
|
|
175
|
-
return host
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const collect = async (args: InitArguments, { log, prompts }: InitCollaborators): Promise<Collected> => {
|
|
179
|
-
log.step('The installation this repository will deploy')
|
|
180
|
-
log.info('Nothing leaves this machine until every question below is answered.')
|
|
181
|
-
const repo = args.repo ?? await requiredAnswer(prompts, 'Sidecar repository (<owner>/<name>)', defaultSidecarRepo(args.installation))
|
|
182
|
-
// Refused HERE, before the credential prompts and long before anything is created: a branch pin is a
|
|
183
|
-
// question for the operator, not a failure in their CI.
|
|
184
|
-
const fabrikaRef = assertPinnedTag(await requiredAnswer(prompts, 'fabrika-platform tag to pin (a published tag, never a branch)'))
|
|
185
|
-
const projectId = await requiredAnswer(prompts, 'Zerops project id (the project holding iam, operations, proxy and control)')
|
|
186
|
-
const environmentName = checkedEnvironmentName(await requiredAnswer(prompts, 'Installation environment name', args.installation))
|
|
187
|
-
|
|
188
|
-
const custom = await prompts.select('Where does this installation answer?', [
|
|
189
|
-
{ label: 'Zerops subdomains — derived from the proxy, and the deploy publishes them', value: false },
|
|
190
|
-
{ label: 'Custom domains — bound to the project balancer out of band', value: true },
|
|
191
|
-
])
|
|
192
|
-
const placement: Placement = custom
|
|
193
|
-
? {
|
|
194
|
-
hosts: {
|
|
195
|
-
iam: bareHost(await requiredAnswer(prompts, 'IAM hostname'), 'a platform host'),
|
|
196
|
-
control: bareHost(await requiredAnswer(prompts, 'Console hostname'), 'a platform host'),
|
|
197
|
-
operations: bareHost(await requiredAnswer(prompts, 'Operations ingest hostname'), 'a platform host'),
|
|
198
|
-
},
|
|
199
|
-
}
|
|
200
|
-
: {}
|
|
201
|
-
|
|
202
|
-
const apiBaseUrl = (await prompts.text('Zerops region API base (blank = the default region)', '')).trim()
|
|
203
|
-
|
|
204
|
-
log.step('Credentials')
|
|
205
|
-
log.info('Both already belong to the installation. Neither is generated here, printed, or written to disk;')
|
|
206
|
-
log.info('each is sent to GitHub over `gh` stdin and reaches the deploy as an Environment secret.')
|
|
207
|
-
const accessToken = await prompts.secret(
|
|
208
|
-
'FABRIKA_ZEROPS_ACCESS_TOKEN',
|
|
209
|
-
"Zerops access token (an INTEGRATION token scoped to this installation's projects, never a personal one)",
|
|
210
|
-
)
|
|
211
|
-
const provisioningKey = await prompts.secret(
|
|
212
|
-
'FABRIKA_IAM_PROVISIONING_KEY',
|
|
213
|
-
"IAM provisioning key (the px_ admin key this installation's IAM already holds)",
|
|
214
|
-
)
|
|
215
|
-
if (accessToken.trim() === '' || provisioningKey.trim() === '') {
|
|
216
|
-
throw new Error('both credentials are required: the deploy authenticates to Zerops and to IAM with them')
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return {
|
|
220
|
-
repo,
|
|
221
|
-
fabrikaRef,
|
|
222
|
-
projectId,
|
|
223
|
-
environmentName,
|
|
224
|
-
placement,
|
|
225
|
-
accessToken: accessToken.trim(),
|
|
226
|
-
provisioningKey: provisioningKey.trim(),
|
|
227
|
-
...(apiBaseUrl === '' ? {} : { apiBaseUrl }),
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
85
|
const oneService = (services: readonly ZeropsService[], name: string): ZeropsService | undefined => {
|
|
232
86
|
const matches = services.filter((service) => service.name === name)
|
|
233
87
|
if (matches.length > 1) throw new Error(`Zerops project has ${matches.length} services named \`${name}\``)
|
|
@@ -318,47 +172,64 @@ export const sourceConnectionSettingsUrl = (placement: Placement, platform: Plat
|
|
|
318
172
|
}
|
|
319
173
|
|
|
320
174
|
/** The Environment the generated workflow reads. Secret VALUES appear here and nowhere else. */
|
|
321
|
-
const environmentConfig = (installation: string,
|
|
322
|
-
repo:
|
|
175
|
+
const environmentConfig = (installation: string, input: InitInput): EnvironmentConfig => ({
|
|
176
|
+
repo: input.repo,
|
|
323
177
|
environment: installation,
|
|
324
178
|
secrets: {
|
|
325
|
-
FABRIKA_ZEROPS_ACCESS_TOKEN:
|
|
326
|
-
FABRIKA_IAM_PROVISIONING_KEY:
|
|
179
|
+
FABRIKA_ZEROPS_ACCESS_TOKEN: input.accessToken,
|
|
180
|
+
FABRIKA_IAM_PROVISIONING_KEY: input.provisioningKey,
|
|
327
181
|
},
|
|
328
182
|
vars: {
|
|
329
|
-
FABRIKA_ZEROPS_PROJECT_ID:
|
|
330
|
-
FABRIKA_PLATFORM_ENVIRONMENT:
|
|
183
|
+
FABRIKA_ZEROPS_PROJECT_ID: input.projectId,
|
|
184
|
+
FABRIKA_PLATFORM_ENVIRONMENT: input.environmentName,
|
|
331
185
|
// Stated rather than defaulted, so the Environment describes the whole deploy.
|
|
332
186
|
FABRIKA_PLATFORM_SCHEME: 'https',
|
|
333
|
-
...(
|
|
334
|
-
FABRIKA_PLATFORM_IAM_HOST:
|
|
335
|
-
FABRIKA_PLATFORM_CONSOLE_HOST:
|
|
336
|
-
FABRIKA_PLATFORM_OPERATIONS_HOST:
|
|
187
|
+
...(input.hosts === undefined ? {} : {
|
|
188
|
+
FABRIKA_PLATFORM_IAM_HOST: input.hosts.iam,
|
|
189
|
+
FABRIKA_PLATFORM_CONSOLE_HOST: input.hosts.control,
|
|
190
|
+
FABRIKA_PLATFORM_OPERATIONS_HOST: input.hosts.operations,
|
|
337
191
|
}),
|
|
338
|
-
...(
|
|
192
|
+
...(input.apiBaseUrl === undefined ? {} : { FABRIKA_ZEROPS_API_URL: input.apiBaseUrl }),
|
|
339
193
|
},
|
|
340
194
|
})
|
|
341
195
|
|
|
196
|
+
/** The command that repeats this run, with every value but the two credentials, which stay in the environment. */
|
|
197
|
+
const rerunCommand = (input: InitInput): string =>
|
|
198
|
+
[
|
|
199
|
+
'fabrika platform init --provider=zerops',
|
|
200
|
+
...[
|
|
201
|
+
input.installation,
|
|
202
|
+
`--repo=${input.repo}`,
|
|
203
|
+
`--checkout=${input.checkoutDir}`,
|
|
204
|
+
`--tag=${input.fabrikaRef}`,
|
|
205
|
+
`--project-id=${input.projectId}`,
|
|
206
|
+
`--env=${input.environmentName}`,
|
|
207
|
+
...(input.hosts === undefined
|
|
208
|
+
? []
|
|
209
|
+
: [`--iam-host=${input.hosts.iam}`, `--console-host=${input.hosts.control}`, `--operations-host=${input.hosts.operations}`]),
|
|
210
|
+
].map(shellWord),
|
|
211
|
+
].join(' ')
|
|
212
|
+
|
|
342
213
|
/** Read the project back, so a wrong token or a mistyped id fails here rather than in someone's CI. */
|
|
343
|
-
const verifyProject = async (
|
|
214
|
+
const verifyProject = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<void> => {
|
|
344
215
|
log.step('Check the token against the project')
|
|
345
|
-
if (!(await prompts.confirm(`Read Zerops project ${
|
|
216
|
+
if (!(await prompts.confirm(`Read Zerops project ${input.projectId} to check the access token and the id?`, true))) {
|
|
346
217
|
log.info('Skipped. A wrong token or project id will surface in the first pipeline run instead.')
|
|
347
218
|
return
|
|
348
219
|
}
|
|
349
220
|
// A caught error is never re-thrown as itself: it may quote a request URL or a platform payload, and
|
|
350
221
|
// this is the one step holding both credentials. A short sentence naming the two causes is enough.
|
|
351
222
|
const name = await effects.describeProject({
|
|
352
|
-
projectId:
|
|
353
|
-
accessToken:
|
|
354
|
-
...(
|
|
223
|
+
projectId: input.projectId,
|
|
224
|
+
accessToken: input.accessToken,
|
|
225
|
+
...(input.apiBaseUrl === undefined ? {} : { apiBaseUrl: input.apiBaseUrl }),
|
|
355
226
|
}).catch(() => {
|
|
356
227
|
throw new Error(
|
|
357
|
-
`Zerops project ${
|
|
228
|
+
`Zerops project ${input.projectId} could not be read: the access token does not grant it, or the id is wrong. `
|
|
358
229
|
+ 'Nothing has been created — check both and run init again',
|
|
359
230
|
)
|
|
360
231
|
})
|
|
361
|
-
log.ok(`Zerops project ${name} (${
|
|
232
|
+
log.ok(`Zerops project ${name} (${input.projectId}) is readable with this token.`)
|
|
362
233
|
}
|
|
363
234
|
|
|
364
235
|
/**
|
|
@@ -366,16 +237,16 @@ const verifyProject = async (collected: Collected, { log, prompts, effects }: In
|
|
|
366
237
|
*
|
|
367
238
|
* Declining is not fatal: init then prints no origin rather than one it would have to guess.
|
|
368
239
|
*/
|
|
369
|
-
const readPlatform = async (
|
|
240
|
+
const readPlatform = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<PlatformInspection | undefined> => {
|
|
370
241
|
log.step('Read the installation')
|
|
371
|
-
if (!(await prompts.confirm(`Read \`control\` and \`proxy\` in Zerops project ${
|
|
242
|
+
if (!(await prompts.confirm(`Read \`control\` and \`proxy\` in Zerops project ${input.projectId} for the console and IAM origins?`, true))) {
|
|
372
243
|
log.info('Skipped. No console or IAM origin will be printed.')
|
|
373
244
|
return undefined
|
|
374
245
|
}
|
|
375
246
|
return effects.inspectPlatform({
|
|
376
|
-
projectId:
|
|
377
|
-
accessToken:
|
|
378
|
-
...(
|
|
247
|
+
projectId: input.projectId,
|
|
248
|
+
accessToken: input.accessToken,
|
|
249
|
+
...(input.apiBaseUrl === undefined ? {} : { apiBaseUrl: input.apiBaseUrl }),
|
|
379
250
|
}).catch(() => {
|
|
380
251
|
throw new Error('the `control` and `proxy` services could not be read; no credential value is shown. Inspect them in Zerops, then run init again')
|
|
381
252
|
})
|
|
@@ -388,52 +259,53 @@ const readPlatform = async (collected: Collected, { log, prompts, effects }: Ini
|
|
|
388
259
|
* outward step: an Environment cannot be written to a repository that does not exist, and a workflow
|
|
389
260
|
* cannot be triggered without one.
|
|
390
261
|
*/
|
|
391
|
-
const scaffold = async (
|
|
392
|
-
installation: string,
|
|
393
|
-
collected: Collected,
|
|
394
|
-
{ log, prompts, effects }: InitCollaborators,
|
|
395
|
-
): Promise<string | undefined> => {
|
|
262
|
+
const scaffold = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<string | undefined> => {
|
|
396
263
|
log.step('Scaffold the sidecar repository')
|
|
397
|
-
const exists = await effects.repositoryExists(
|
|
264
|
+
const exists = await effects.repositoryExists(input.repo)
|
|
398
265
|
const question = exists
|
|
399
|
-
? `Refresh the pipeline on ${
|
|
400
|
-
: `Create ${
|
|
266
|
+
? `Refresh the pipeline on ${input.repo} and push?`
|
|
267
|
+
: `Create ${input.repo} (private) on GitHub and push the pipeline?`
|
|
401
268
|
if (!(await prompts.confirm(question, true))) {
|
|
402
269
|
log.action('OPERATOR ACTION — nothing was created; run init again when ready', [
|
|
403
|
-
|
|
270
|
+
rerunCommand(input),
|
|
404
271
|
])
|
|
405
272
|
return undefined
|
|
406
273
|
}
|
|
407
|
-
const dir = defaultCheckoutDir(installation)
|
|
408
274
|
const result = await effects.scaffold({
|
|
409
|
-
repo:
|
|
410
|
-
dir,
|
|
275
|
+
repo: input.repo,
|
|
276
|
+
dir: input.checkoutDir,
|
|
411
277
|
files: [...SIDECAR_FILES],
|
|
412
278
|
materialize: (target) =>
|
|
413
279
|
materializeSidecarScaffold(target, {
|
|
414
|
-
installation,
|
|
415
|
-
repo:
|
|
416
|
-
fabrikaRef:
|
|
280
|
+
installation: input.installation,
|
|
281
|
+
repo: input.repo,
|
|
282
|
+
fabrikaRef: input.fabrikaRef,
|
|
417
283
|
}),
|
|
418
284
|
})
|
|
419
285
|
const pinned = await readPinnedTag(result.dir)
|
|
420
|
-
log.ok(`${
|
|
421
|
-
if (pinned !==
|
|
422
|
-
log.warn(`the existing pin ${pinned} was kept; ${
|
|
286
|
+
log.ok(`${input.repo} ${result.created ? 'created' : 'refreshed'} — pinned fabrika tag ${pinned}`)
|
|
287
|
+
if (pinned !== input.fabrikaRef) {
|
|
288
|
+
log.warn(`the existing pin ${pinned} was kept; ${input.fabrikaRef} was not written, because fabrika.ref is operator-owned`)
|
|
423
289
|
}
|
|
424
290
|
return result.dir
|
|
425
291
|
}
|
|
426
292
|
|
|
427
293
|
/** Run the whole thing for `<installation>`. */
|
|
428
|
-
export const runInit = async (
|
|
294
|
+
export const runInit = async (input: InitInput, collaborators: InitCollaborators): Promise<void> => {
|
|
429
295
|
const { log, prompts, effects } = collaborators
|
|
430
|
-
log.info(`fabrika platform init — the ${
|
|
296
|
+
log.info(`fabrika platform init — the ${input.installation} Zerops installation's sidecar repository`)
|
|
431
297
|
log.info('This UPDATES an installation that already exists. It does not create one.')
|
|
432
298
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
299
|
+
log.step('The installation this repository will deploy')
|
|
300
|
+
log.info(`repository ${input.repo}, pinned to ${input.fabrikaRef}; Zerops project ${input.projectId}, environment \`${input.environmentName}\``)
|
|
301
|
+
log.info(
|
|
302
|
+
input.hosts === undefined
|
|
303
|
+
? "hosts: derived from the proxy's Zerops subdomains"
|
|
304
|
+
: `hosts: iam ${input.hosts.iam}, console ${input.hosts.control}, operations ${input.hosts.operations}`,
|
|
305
|
+
)
|
|
306
|
+
await verifyProject(input, collaborators)
|
|
307
|
+
const platform = await readPlatform(input, collaborators)
|
|
308
|
+
const settingsUrl = platform === undefined ? undefined : sourceConnectionSettingsUrl(input, platform)
|
|
437
309
|
log.info('Public repositories deploy without a GitHub App. Connect private GitHub source in Control.')
|
|
438
310
|
log.action(
|
|
439
311
|
'OPERATOR ACTION — connect GitHub source in Control',
|
|
@@ -442,51 +314,51 @@ export const runInit = async (args: InitArguments, collaborators: InitCollaborat
|
|
|
442
314
|
: [url(settingsUrl)],
|
|
443
315
|
)
|
|
444
316
|
|
|
445
|
-
const dir = await scaffold(
|
|
317
|
+
const dir = await scaffold(input, collaborators)
|
|
446
318
|
if (dir === undefined) {
|
|
447
319
|
return
|
|
448
320
|
}
|
|
449
321
|
|
|
450
|
-
log.step(`Configure the ${
|
|
451
|
-
const config = environmentConfig(
|
|
452
|
-
log.info(`${Object.keys(config.secrets).length} secret(s) and ${Object.keys(config.vars).length} variable(s) on ${
|
|
453
|
-
if (!(await prompts.confirm(`Write them to the ${
|
|
322
|
+
log.step(`Configure the ${input.installation} GitHub Environment`)
|
|
323
|
+
const config = environmentConfig(input.installation, input)
|
|
324
|
+
log.info(`${Object.keys(config.secrets).length} secret(s) and ${Object.keys(config.vars).length} variable(s) on ${input.repo}.`)
|
|
325
|
+
if (!(await prompts.confirm(`Write them to the ${input.installation} Environment now (secret VALUES go to GitHub over \`gh\` stdin)?`, true))) {
|
|
454
326
|
log.action('OPERATOR ACTION — the pipeline cannot run until the Environment holds these', [
|
|
455
|
-
|
|
327
|
+
rerunCommand(input),
|
|
456
328
|
])
|
|
457
329
|
return
|
|
458
330
|
}
|
|
459
331
|
await effects.configureEnvironment(config)
|
|
460
|
-
log.ok(`Environment ${
|
|
332
|
+
log.ok(`Environment ${input.installation} written: ${Object.keys(config.secrets).join(', ')} and ${Object.keys(config.vars).join(', ')}.`)
|
|
461
333
|
|
|
462
334
|
log.step('Trigger the deploy')
|
|
463
335
|
log.info("The pipeline runs the whole ordered sequence in the operator's CI; this machine deploys nothing.")
|
|
464
|
-
if (!(await prompts.confirm(`Run the platform workflow on ${
|
|
336
|
+
if (!(await prompts.confirm(`Run the platform workflow on ${input.repo} now?`, true))) {
|
|
465
337
|
log.action('OPERATOR ACTION — run it when ready', [
|
|
466
|
-
`gh workflow run platform.yml --repo ${
|
|
467
|
-
`or: ${url(`https://github.com/${
|
|
338
|
+
`gh workflow run platform.yml --repo ${input.repo}`,
|
|
339
|
+
`or: ${url(`https://github.com/${input.repo}/actions`)} → platform → Run workflow`,
|
|
468
340
|
])
|
|
469
341
|
} else {
|
|
470
|
-
await effects.triggerWorkflow(
|
|
342
|
+
await effects.triggerWorkflow(input.repo)
|
|
471
343
|
log.ok('Platform workflow triggered.')
|
|
472
|
-
log.info(`Watch: ${url(`https://github.com/${
|
|
344
|
+
log.info(`Watch: ${url(`https://github.com/${input.repo}/actions`)} (or: gh run watch --repo ${input.repo})`)
|
|
473
345
|
}
|
|
474
346
|
|
|
475
347
|
log.step('Done')
|
|
476
|
-
log.ok(`Sidecar repository: ${
|
|
348
|
+
log.ok(`Sidecar repository: ${input.repo} (local checkout: ${dir})`)
|
|
477
349
|
log.info('Roll this installation forward by bumping fabrika.ref to a newer published tag and pushing.')
|
|
478
350
|
log.info("Who may administer the installation is IAM's to say: this repository holds no admission list to close later.")
|
|
479
351
|
// The origin comes off the live control service, never from what was typed here: an operator who
|
|
480
352
|
// pastes a guessed hostname into FABRIKA_IAM_RPC_URL gets "Unable to connect" and no explanation.
|
|
481
|
-
const iamOrigin = platform === undefined ? undefined : platformIamOrigin(
|
|
353
|
+
const iamOrigin = platform === undefined ? undefined : platformIamOrigin(input, platform)
|
|
482
354
|
log.action(
|
|
483
355
|
MACHINE_KEY_NEXT_STEP_TITLE,
|
|
484
|
-
machineKeyNextStepLines({ sidecarEnvironment:
|
|
356
|
+
machineKeyNextStepLines({ sidecarEnvironment: input.installation, ...(iamOrigin === undefined ? {} : { iamOrigin }) }),
|
|
485
357
|
)
|
|
486
358
|
}
|
|
487
359
|
|
|
488
360
|
/** The real collaborators: a TTY, `gh`, git, and one read of the Zerops API. */
|
|
489
|
-
export const consoleInitCollaborators = (): InitCollaborators => ({
|
|
361
|
+
export const consoleInitCollaborators = (input: InitInput): InitCollaborators => ({
|
|
490
362
|
log: {
|
|
491
363
|
step: (title) => consoleStep(title),
|
|
492
364
|
info: (message) => consoleInfo(message),
|
|
@@ -494,12 +366,13 @@ export const consoleInitCollaborators = (): InitCollaborators => ({
|
|
|
494
366
|
ok: (message) => consoleOk(message),
|
|
495
367
|
action: (title, lines) => consoleAction(title, [...lines]),
|
|
496
368
|
},
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
369
|
+
// First, before anything is contacted: a run nobody can confirm does not start.
|
|
370
|
+
prompts: confirmations(
|
|
371
|
+
input.unattended,
|
|
372
|
+
process.stdin,
|
|
373
|
+
(line) => consoleInfo(line),
|
|
374
|
+
'creates or pushes the sidecar repository, writes its GitHub Environment secrets and triggers the platform workflow',
|
|
375
|
+
),
|
|
503
376
|
effects: {
|
|
504
377
|
repositoryExists: (repo) => ghRepoExists(repo),
|
|
505
378
|
scaffold: (input) => scaffoldSidecarRepository(input),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Confirmations } from '@fabrika/installation-init'
|
|
1
2
|
import type { SchemaReconciler } from '@fabrika/provider-contract'
|
|
2
3
|
import type { Sleeper, ZeropsApi } from '../index'
|
|
3
4
|
import type { PlatformSourcePreparer } from './installation-source'
|
|
@@ -5,9 +6,7 @@ import type { InitLog } from './log'
|
|
|
5
6
|
import type { PlatformUploadFetch } from './platform-upload'
|
|
6
7
|
|
|
7
8
|
/** The one question this command asks. Injected so the flow can be exercised without a TTY. */
|
|
8
|
-
export
|
|
9
|
-
confirm(question: string, defaultYes?: boolean): Promise<boolean>
|
|
10
|
-
}
|
|
9
|
+
export type InstallPrompts = Confirmations
|
|
11
10
|
|
|
12
11
|
/** Everything `runInstall` reaches the outside world through. */
|
|
13
12
|
export interface InstallCollaborators {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// operator's disk — but nothing here prompts: a missing required value is an error naming both the flag
|
|
9
9
|
// and the variable that would supply it, so a mistyped install fails before it contacts anything.
|
|
10
10
|
|
|
11
|
+
import { describeArgument, readEnv, readFlag, requiredSetting, setting, UNATTENDED_FLAG } from '@fabrika/installation-init'
|
|
11
12
|
import { parsePlatformScheme } from './placement-options'
|
|
12
13
|
import { readInstallationSourceDir } from './source-options'
|
|
13
14
|
|
|
@@ -52,50 +53,12 @@ export const FABRIKA_REPOSITORY_URL = 'https://github.com/contember/fabrika-plat
|
|
|
52
53
|
|
|
53
54
|
const FLAGS = ['--project-id', '--project-name', '--client-id', '--env', '--scheme', '--source-dir', '--tier'] as const
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
* The one way this command runs with nobody watching it.
|
|
57
|
-
*
|
|
58
|
-
* Every confirmation defaults to yes, so a pipe carrying blank lines answers all six of them with
|
|
59
|
-
* nobody reading what they agree to — which is why saying yes unattended is a FLAG and never an
|
|
60
|
-
* inference: it makes the choice explicit, and it puts it in the shell history of whoever ran it.
|
|
61
|
-
*/
|
|
62
|
-
export const UNATTENDED_FLAG = '--yes'
|
|
63
|
-
|
|
64
56
|
/** `--create-project`: bring the project up too, instead of naming one the operator made. */
|
|
65
57
|
export const CREATE_PROJECT_FLAG = '--create-project'
|
|
66
58
|
|
|
67
59
|
/** The two flags that carry no value. Written with one, they are `unexpected argument` like any other. */
|
|
68
60
|
const BOOLEAN_FLAGS = [UNATTENDED_FLAG, CREATE_PROJECT_FLAG] as const
|
|
69
61
|
|
|
70
|
-
const readFlag = (argv: readonly string[], name: string): string | undefined => {
|
|
71
|
-
const prefix = `${name}=`
|
|
72
|
-
const matches = argv.filter((arg) => arg.startsWith(prefix))
|
|
73
|
-
if (matches.length > 1) {
|
|
74
|
-
throw new Error(`${name} was given more than once`)
|
|
75
|
-
}
|
|
76
|
-
const value = matches[0]?.slice(prefix.length)
|
|
77
|
-
return value === undefined || value.trim() === '' ? undefined : value.trim()
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const readEnv = (env: Record<string, string | undefined>, name: string): string | undefined => {
|
|
81
|
-
const value = env[name]
|
|
82
|
-
return value === undefined || value.trim() === '' ? undefined : value.trim()
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const setting = (
|
|
86
|
-
argv: readonly string[],
|
|
87
|
-
env: Record<string, string | undefined>,
|
|
88
|
-
flag: string,
|
|
89
|
-
variable: string,
|
|
90
|
-
): string | undefined => readFlag(argv, flag) ?? readEnv(env, variable)
|
|
91
|
-
|
|
92
|
-
const required = (value: string | undefined, flag: string, variable: string): string => {
|
|
93
|
-
if (value === undefined) {
|
|
94
|
-
throw new Error(`${flag}=<value> or ${variable} is required`)
|
|
95
|
-
}
|
|
96
|
-
return value
|
|
97
|
-
}
|
|
98
|
-
|
|
99
62
|
const assertKnownArguments = (argv: readonly string[]): void => {
|
|
100
63
|
for (const arg of argv) {
|
|
101
64
|
if (BOOLEAN_FLAGS.some((flag) => flag === arg)) {
|
|
@@ -104,7 +67,7 @@ const assertKnownArguments = (argv: readonly string[]): void => {
|
|
|
104
67
|
const name = arg.split('=')[0] ?? arg
|
|
105
68
|
if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
|
|
106
69
|
throw new Error(
|
|
107
|
-
`unexpected argument
|
|
70
|
+
`unexpected argument ${describeArgument(arg)}. Credentials are read from the environment and have no flag; run `
|
|
108
71
|
+ '`fabrika platform install --provider=zerops --help` for the surface',
|
|
109
72
|
)
|
|
110
73
|
}
|
|
@@ -135,7 +98,7 @@ const readScheme = (argv: readonly string[], env: Record<string, string | undefi
|
|
|
135
98
|
}
|
|
136
99
|
|
|
137
100
|
const readEnvironment = (argv: readonly string[], env: Record<string, string | undefined>): string =>
|
|
138
|
-
|
|
101
|
+
requiredSetting(setting(argv, env, '--env', 'FABRIKA_PLATFORM_ENVIRONMENT'), '--env', 'FABRIKA_PLATFORM_ENVIRONMENT')
|
|
139
102
|
|
|
140
103
|
/** The name a created project takes when the operator did not choose one: the installation's own. */
|
|
141
104
|
const defaultProjectName = (environment: string): string => `fabrika-${environment}`
|
|
@@ -158,7 +121,7 @@ const readProjectSource = (argv: readonly string[], env: Record<string, string |
|
|
|
158
121
|
+ 'exists takes --project-id=<id>, which names it by id',
|
|
159
122
|
)
|
|
160
123
|
}
|
|
161
|
-
return { kind: 'existing', projectId:
|
|
124
|
+
return { kind: 'existing', projectId: requiredSetting(projectId, '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID') }
|
|
162
125
|
}
|
|
163
126
|
if (projectId !== undefined) {
|
|
164
127
|
const source = readFlag(argv, '--project-id') === undefined ? 'FABRIKA_ZEROPS_PROJECT_ID' : '--project-id'
|
|
@@ -181,8 +144,8 @@ export const parsePlatformInstallArgs = (
|
|
|
181
144
|
project: readProjectSource(argv, env),
|
|
182
145
|
// Named even when the project id is known: `POST /client/{id}/integration-token` is addressed by
|
|
183
146
|
// CLIENT, and the project read does not hand one back.
|
|
184
|
-
clientId:
|
|
185
|
-
accessToken:
|
|
147
|
+
clientId: requiredSetting(setting(argv, env, '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'), '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'),
|
|
148
|
+
accessToken: requiredSetting(readEnv(env, 'FABRIKA_ZEROPS_ACCESS_TOKEN'), '(no flag)', 'FABRIKA_ZEROPS_ACCESS_TOKEN'),
|
|
186
149
|
environment: readEnvironment(argv, env),
|
|
187
150
|
scheme: readScheme(argv, env),
|
|
188
151
|
sourceDir,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
action as consoleAction,
|
|
7
|
-
|
|
7
|
+
confirmations,
|
|
8
8
|
info as consoleInfo,
|
|
9
9
|
ok as consoleOk,
|
|
10
10
|
step as consoleStep,
|
|
@@ -24,10 +24,10 @@ import {
|
|
|
24
24
|
} from './deploy'
|
|
25
25
|
import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
|
|
26
26
|
import { derivePlatformHosts, type PlatformHosts, platformOrigin, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
|
|
27
|
-
import { checkedEnvironmentName } from './init'
|
|
27
|
+
import { checkedEnvironmentName } from './init-options'
|
|
28
28
|
import { assertRunning, type InstallCollaborators, type InstallPrompts } from './install-collaborators'
|
|
29
29
|
import { installationServiceEnv, passOneProxyEnv, writeServiceEnv } from './install-environment'
|
|
30
|
-
import {
|
|
30
|
+
import type { PlatformInstallInput } from './install-options'
|
|
31
31
|
import { resolveInstallationProject } from './install-project'
|
|
32
32
|
import {
|
|
33
33
|
assertNoServiceIsBeingCreated,
|
|
@@ -226,39 +226,15 @@ const installPreparedPlatform = async (
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* The confirmations are the only thing standing between a stray invocation and an installation: this
|
|
232
|
-
* command generates six credentials, mints a Zerops token on the account and deploys. Every one of
|
|
233
|
-
* them defaults to yes, so a pipe carrying blank lines answers all six of them with nobody reading
|
|
234
|
-
* what they agree to. So a run with no terminal is refused unless the operator asked for it by name.
|
|
229
|
+
* The confirmations stand between a stray invocation and an installation: this command generates six
|
|
230
|
+
* credentials, mints a Zerops token on the account and deploys.
|
|
235
231
|
*/
|
|
236
232
|
export const installPrompts = (
|
|
237
233
|
input: PlatformInstallInput,
|
|
238
234
|
stdin: { readonly isTTY?: boolean },
|
|
239
235
|
announce: (line: string) => void,
|
|
240
|
-
): InstallPrompts =>
|
|
241
|
-
|
|
242
|
-
// The flag IS the yes, given once for the whole sequence; nothing reads stdin after it. Each
|
|
243
|
-
// question is still printed with the answer it was given, so an unattended run leaves the same
|
|
244
|
-
// record of what was agreed to that an interactive one does.
|
|
245
|
-
return {
|
|
246
|
-
confirm: (question) => {
|
|
247
|
-
announce(`${question} yes (${UNATTENDED_FLAG})`)
|
|
248
|
-
return Promise.resolve(true)
|
|
249
|
-
},
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
if (stdin.isTTY !== true) {
|
|
253
|
-
throw new Error(
|
|
254
|
-
"stdin is not a terminal, so nobody is here to confirm what this command does — it generates this installation's "
|
|
255
|
-
+ 'credentials, mints a Zerops integration token and deploys. Run it from a terminal, or pass `'
|
|
256
|
-
+ UNATTENDED_FLAG
|
|
257
|
-
+ '` to answer every confirmation yes unattended',
|
|
258
|
-
)
|
|
259
|
-
}
|
|
260
|
-
return { confirm: (question, defaultYes) => promptConfirm(question, defaultYes) }
|
|
261
|
-
}
|
|
236
|
+
): InstallPrompts =>
|
|
237
|
+
confirmations(input.unattended, stdin, announce, "generates this installation's credentials, mints a Zerops integration token and deploys")
|
|
262
238
|
|
|
263
239
|
/** The real collaborators: a TTY for the confirmations, the Zerops API, and IAM's own schema reconciler. */
|
|
264
240
|
export const consoleInstallCollaborators = (input: PlatformInstallInput, reconcileSchema: SchemaReconciler): InstallCollaborators => {
|
|
@@ -119,4 +119,7 @@ export const readPinnedTag = async (dir: string): Promise<string> => {
|
|
|
119
119
|
export const defaultSidecarRepo = (installation: string): string => `contember/fabrika-zerops-${installation}`
|
|
120
120
|
|
|
121
121
|
/** Where the sidecar is checked out locally: `./fabrika-zerops-<installation>` under the cwd. */
|
|
122
|
-
|
|
122
|
+
/** The sidecar checkout's directory name when `--checkout` is not given; `init` creates it and `upgrade` reads it. */
|
|
123
|
+
export const defaultCheckoutName = (installation: string): string => `fabrika-zerops-${installation}`
|
|
124
|
+
|
|
125
|
+
export const defaultCheckoutDir = (installation: string): string => resolve(process.cwd(), defaultCheckoutName(installation))
|