@fabrika/provider-zerops 0.2.0 → 0.3.1

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.
Files changed (82) hide show
  1. package/package.json +18 -5
  2. package/src/api/client.ts +522 -0
  3. package/src/api/errors.ts +50 -0
  4. package/src/api/polling.ts +59 -0
  5. package/src/api/readers.ts +185 -0
  6. package/src/api/service-env-response.ts +34 -0
  7. package/src/api/types.ts +602 -0
  8. package/src/api.ts +29 -1378
  9. package/src/authoring.ts +7 -10
  10. package/src/cli-args.ts +0 -5
  11. package/src/cli.ts +3 -28
  12. package/src/collaborators.ts +2 -4
  13. package/src/compile.ts +3 -1
  14. package/src/control.ts +203 -450
  15. package/src/descriptor.ts +135 -0
  16. package/src/index.ts +23 -127
  17. package/src/installation/admin-options.ts +128 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +155 -0
  20. package/src/installation/deploy.ts +668 -0
  21. package/src/installation/generated/platform-console-schema.ts +41 -0
  22. package/src/installation/generated/platform-proxy-manifest.ts +72 -0
  23. package/src/installation/hosts.ts +117 -0
  24. package/src/installation/index.ts +366 -0
  25. package/src/installation/init.ts +521 -0
  26. package/src/installation/install-collaborators.ts +28 -0
  27. package/src/installation/install-environment.ts +193 -0
  28. package/src/installation/install-options.ts +193 -0
  29. package/src/installation/install-project.ts +201 -0
  30. package/src/installation/install-provisioning.ts +185 -0
  31. package/src/installation/install.ts +286 -0
  32. package/src/installation/installation-source.ts +42 -0
  33. package/src/installation/log.ts +77 -0
  34. package/src/installation/machine-key.ts +65 -0
  35. package/src/installation/manifest.ts +95 -0
  36. package/src/installation/placement-options.ts +33 -0
  37. package/src/installation/platform-source.ts +106 -0
  38. package/src/installation/platform-upload.ts +34 -0
  39. package/src/installation/proxy-manifest.ts +113 -0
  40. package/src/installation/secrets.ts +79 -0
  41. package/src/installation/sidecar.ts +122 -0
  42. package/src/installation/source-options.ts +12 -0
  43. package/src/installation/templates/README.md +116 -0
  44. package/src/installation/templates/fabrika.ref +1 -0
  45. package/src/installation/templates/gitignore +3 -0
  46. package/src/installation/templates/platform.yml +144 -0
  47. package/src/installation/topology/artifacts.ts +141 -0
  48. package/src/installation/topology/index.ts +13 -0
  49. package/src/installation/topology/installation-schemas.ts +225 -0
  50. package/src/installation/topology/invariants.ts +86 -0
  51. package/src/installation/topology/setups.ts +442 -0
  52. package/src/installation/topology/topology.ts +501 -0
  53. package/src/installation/topology/validate.ts +26 -0
  54. package/src/installation/upgrade-options.ts +108 -0
  55. package/src/installation/upgrade-output.ts +31 -0
  56. package/src/installation/upgrade.ts +373 -0
  57. package/src/managed-env.ts +105 -0
  58. package/src/manifest.ts +191 -344
  59. package/src/namespace/claims.ts +77 -0
  60. package/src/namespace/codec.ts +179 -0
  61. package/src/namespace/constants.ts +30 -0
  62. package/src/namespace/errors.ts +60 -0
  63. package/src/namespace/manifest.ts +28 -0
  64. package/src/namespace/operator.ts +151 -0
  65. package/src/namespace/provisioning.ts +482 -0
  66. package/src/namespace/proxy-attempt.ts +220 -0
  67. package/src/namespace/proxy-source.ts +22 -0
  68. package/src/namespace/topology.ts +115 -0
  69. package/src/namespace/types.ts +114 -0
  70. package/src/namespace.ts +34 -1271
  71. package/src/plan.ts +21 -23
  72. package/src/provider.ts +266 -241
  73. package/src/source/constants.ts +11 -0
  74. package/src/source/port.ts +49 -0
  75. package/src/source/types.ts +4 -0
  76. package/src/source/validation.ts +72 -0
  77. package/src/source.ts +2 -1220
  78. package/src/types.ts +8 -7
  79. package/src/version-attempt.ts +18 -0
  80. package/src/codec.ts +0 -108
  81. package/src/namespace-command.ts +0 -209
  82. package/src/source-connection.ts +0 -344
@@ -0,0 +1,521 @@
1
+ // `fabrika platform init --provider=zerops <installation>` — maintain the operator's sidecar repository
2
+ // for an installation that ALREADY EXISTS.
3
+ //
4
+ // ── What this does, and the one thing it does not ─────────────────────────────────────────────────
5
+ //
6
+ // It reads the installation's `control` and `proxy` configuration, creates
7
+ // `<owner>/fabrika-zerops-<installation>`, pushes the pipeline that calls
8
+ // `fabrika platform deploy --provider=zerops`, writes the GitHub Environment that pipeline reads, and
9
+ // triggers it. It does NOT create the installation: `platform install` owns the first bring-up.
10
+ //
11
+ // ── It does the whole job, and confirms before every step that leaves this disk ───────────────────
12
+ //
13
+ // Reading the project, reading its services, creating or pushing the repository, writing the Environment,
14
+ // and triggering the workflow each ask first. Full automation, never silent. Declining a write stops the outward steps and prints
15
+ // what to run instead; `init` is idempotent, so the answer to a declined step is to run it again. Declining the read of
16
+ // `control` and `proxy` only leaves the console and IAM origins out of what init prints.
17
+ //
18
+ // ── Credentials ───────────────────────────────────────────────────────────────────────────────────
19
+ //
20
+ // Two deployment credentials, and this command generates neither. Both already belong to the
21
+ // installation. It writes nothing to Zerops. GitHub App setup belongs to the authenticated Control UI.
22
+
23
+ import {
24
+ action as consoleAction,
25
+ configureEnvironment,
26
+ confirm as promptConfirm,
27
+ type EnvironmentConfig,
28
+ ghRepoExists,
29
+ info as consoleInfo,
30
+ ok as consoleOk,
31
+ scaffoldSidecarRepository,
32
+ secretOrEnv,
33
+ select as promptSelect,
34
+ type SidecarScaffoldInput,
35
+ type SidecarScaffoldResult,
36
+ step as consoleStep,
37
+ text as promptText,
38
+ triggerPlatformWorkflow,
39
+ url,
40
+ warn as consoleWarn,
41
+ } from '@fabrika/installation-init'
42
+ import { createZeropsApi, type ZeropsApi, type ZeropsService } from '../index'
43
+ import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
44
+ import { derivePlatformHosts, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
45
+ import type { InitLog } from './log'
46
+ import { MACHINE_KEY_NEXT_STEP_TITLE, machineKeyNextStepLines } from './machine-key'
47
+ import { assertPinnedTag, defaultCheckoutDir, defaultSidecarRepo, materializeSidecarScaffold, readPinnedTag, SIDECAR_FILES } from './sidecar'
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
+ }
60
+
61
+ /** Everything `runInit` does outside the operator's own disk. Every one of them is confirmed first. */
62
+ export interface InitEffects {
63
+ /** Only decides how the scaffold confirmation is phrased; the scaffold itself re-reads it. */
64
+ repositoryExists(repo: string): Promise<boolean>
65
+ scaffold(input: SidecarScaffoldInput): Promise<SidecarScaffoldResult>
66
+ configureEnvironment(config: EnvironmentConfig): Promise<void>
67
+ triggerWorkflow(repo: string): Promise<void>
68
+ /** Read one Zerops project's name — the cheapest proof that the token and the project id agree. */
69
+ describeProject(input: { projectId: string; accessToken: string; apiBaseUrl?: string }): Promise<string>
70
+ inspectPlatform(input: InspectPlatformInput): Promise<PlatformInspection>
71
+ }
72
+
73
+ export interface InitCollaborators {
74
+ readonly log: InitLog
75
+ readonly prompts: InitPrompts
76
+ readonly effects: InitEffects
77
+ }
78
+
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
+ /** Where this installation answers, as far as `init` needs to know it. */
133
+ interface Placement {
134
+ readonly hosts?: { readonly iam: string; readonly control: string; readonly operations: string }
135
+ }
136
+
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
+ export interface InspectPlatformInput {
150
+ readonly projectId: string
151
+ readonly accessToken: string
152
+ readonly apiBaseUrl?: string
153
+ }
154
+
155
+ /** What `init` reads off the live installation to print origins it can verify. Values are never logged. */
156
+ export interface PlatformInspection {
157
+ readonly controlEnv: ReadonlyMap<string, string>
158
+ readonly proxyEnv: ReadonlyMap<string, string>
159
+ readonly proxyPublished: boolean
160
+ }
161
+
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
+ const oneService = (services: readonly ZeropsService[], name: string): ZeropsService | undefined => {
232
+ const matches = services.filter((service) => service.name === name)
233
+ if (matches.length > 1) throw new Error(`Zerops project has ${matches.length} services named \`${name}\``)
234
+ return matches[0]
235
+ }
236
+
237
+ /** Read `control` and `proxy` without writing anything. */
238
+ export const inspectPlatform = async (projectId: string, api: ZeropsApi, signal: AbortSignal): Promise<PlatformInspection> => {
239
+ const services = await api.listProjectServices({ projectId, signal })
240
+ const control = oneService(services, 'control')
241
+ if (control === undefined || control.id === '') throw new Error(`Zerops project ${projectId} has no \`control\` service`)
242
+ const proxy = oneService(services, 'proxy')
243
+ if (proxy === undefined || proxy.id === '') throw new Error(`Zerops project ${projectId} has no \`proxy\` service`)
244
+ const readEnv = async (serviceId: string): Promise<Map<string, string>> =>
245
+ new Map((await api.listServiceEnv({ serviceId, signal })).map((item) => [item.key, item.content]))
246
+ return {
247
+ controlEnv: await readEnv(control.id),
248
+ proxyEnv: await readEnv(proxy.id),
249
+ proxyPublished: proxy.subdomainAccess === true,
250
+ }
251
+ }
252
+
253
+ const strictHttpsOrigin = (hostValue: string): string | undefined => {
254
+ const host = hostValue.trim().toLowerCase()
255
+ const labels = host.split('.')
256
+ if (
257
+ host === '' || host.length > 253 || labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
258
+ ) {
259
+ return undefined
260
+ }
261
+ try {
262
+ const parsed = new URL(`https://${host}`)
263
+ if (parsed.origin !== `https://${host}` || parsed.hostname !== host || parsed.port !== '' || parsed.username !== '' || parsed.password !== '') {
264
+ return undefined
265
+ }
266
+ return parsed.origin
267
+ } catch {
268
+ return undefined
269
+ }
270
+ }
271
+
272
+ /**
273
+ * IAM's PUBLIC origin as the installation itself records it, or nothing when init would have to guess.
274
+ *
275
+ * `platform install` writes it to `control` as `FABRIKA_IAM_ISSUER`, so this reads the live value
276
+ * rather than re-deriving one — and refuses it when the operator named a different IAM host, because
277
+ * a printed origin an operator then pastes into `FABRIKA_IAM_RPC_URL` must be the one that answers.
278
+ */
279
+ export const platformIamOrigin = (placement: Placement, platform: PlatformInspection): string | undefined => {
280
+ const issuer = platform.controlEnv.get('FABRIKA_IAM_ISSUER')
281
+ if (issuer === undefined) return undefined
282
+ let hostname: string
283
+ try {
284
+ const parsed = new URL(issuer.trim())
285
+ if (parsed.protocol !== 'https:' || parsed.port !== '' || parsed.username !== '' || parsed.password !== '') return undefined
286
+ if (parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== '') return undefined
287
+ hostname = parsed.hostname
288
+ } catch {
289
+ return undefined
290
+ }
291
+ const origin = strictHttpsOrigin(hostname)
292
+ if (origin === undefined) return undefined
293
+ if (placement.hosts !== undefined && placement.hosts.iam.trim().toLowerCase() !== hostname) return undefined
294
+ return origin
295
+ }
296
+
297
+ /** Return a verified live console URL, or no URL when init would have to guess. */
298
+ export const sourceConnectionSettingsUrl = (placement: Placement, platform: PlatformInspection): string | undefined => {
299
+ const liveHost = platform.controlEnv.get('FABRIKA_CONTROL_DOMAIN')
300
+ if (liveHost === undefined) return undefined
301
+ const origin = strictHttpsOrigin(liveHost)
302
+ if (origin === undefined) return undefined
303
+ const host = new URL(origin).hostname
304
+ if (placement.hosts !== undefined) {
305
+ if (placement.hosts.control.toLowerCase() !== host) return undefined
306
+ return `${origin}/settings/source`
307
+ }
308
+ if (!platform.proxyPublished) return undefined
309
+ const subdomains = platform.proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE)
310
+ if (subdomains === undefined || subdomains.trim() === '') return undefined
311
+ try {
312
+ const listeners = PLATFORM_PROXY_MANIFEST_TEMPLATE.apps.map((app) => ({ service: app.service, port: app.port }))
313
+ if (derivePlatformHosts(subdomains, listeners).control.toLowerCase() !== host) return undefined
314
+ return `${origin}/settings/source`
315
+ } catch {
316
+ return undefined
317
+ }
318
+ }
319
+
320
+ /** The Environment the generated workflow reads. Secret VALUES appear here and nowhere else. */
321
+ const environmentConfig = (installation: string, collected: Collected): EnvironmentConfig => ({
322
+ repo: collected.repo,
323
+ environment: installation,
324
+ secrets: {
325
+ FABRIKA_ZEROPS_ACCESS_TOKEN: collected.accessToken,
326
+ FABRIKA_IAM_PROVISIONING_KEY: collected.provisioningKey,
327
+ },
328
+ vars: {
329
+ FABRIKA_ZEROPS_PROJECT_ID: collected.projectId,
330
+ FABRIKA_PLATFORM_ENVIRONMENT: collected.environmentName,
331
+ // Stated rather than defaulted, so the Environment describes the whole deploy.
332
+ FABRIKA_PLATFORM_SCHEME: 'https',
333
+ ...(collected.placement.hosts === undefined ? {} : {
334
+ FABRIKA_PLATFORM_IAM_HOST: collected.placement.hosts.iam,
335
+ FABRIKA_PLATFORM_CONSOLE_HOST: collected.placement.hosts.control,
336
+ FABRIKA_PLATFORM_OPERATIONS_HOST: collected.placement.hosts.operations,
337
+ }),
338
+ ...(collected.apiBaseUrl === undefined ? {} : { FABRIKA_ZEROPS_API_URL: collected.apiBaseUrl }),
339
+ },
340
+ })
341
+
342
+ /** Read the project back, so a wrong token or a mistyped id fails here rather than in someone's CI. */
343
+ const verifyProject = async (collected: Collected, { log, prompts, effects }: InitCollaborators): Promise<void> => {
344
+ log.step('Check the token against the project')
345
+ if (!(await prompts.confirm(`Read Zerops project ${collected.projectId} to check the access token and the id?`, true))) {
346
+ log.info('Skipped. A wrong token or project id will surface in the first pipeline run instead.')
347
+ return
348
+ }
349
+ // A caught error is never re-thrown as itself: it may quote a request URL or a platform payload, and
350
+ // this is the one step holding both credentials. A short sentence naming the two causes is enough.
351
+ const name = await effects.describeProject({
352
+ projectId: collected.projectId,
353
+ accessToken: collected.accessToken,
354
+ ...(collected.apiBaseUrl === undefined ? {} : { apiBaseUrl: collected.apiBaseUrl }),
355
+ }).catch(() => {
356
+ throw new Error(
357
+ `Zerops project ${collected.projectId} could not be read: the access token does not grant it, or the id is wrong. `
358
+ + 'Nothing has been created — check both and run init again',
359
+ )
360
+ })
361
+ log.ok(`Zerops project ${name} (${collected.projectId}) is readable with this token.`)
362
+ }
363
+
364
+ /**
365
+ * Read the live `control` and `proxy` configuration, so the origins init prints are ones the installation records.
366
+ *
367
+ * Declining is not fatal: init then prints no origin rather than one it would have to guess.
368
+ */
369
+ const readPlatform = async (collected: Collected, { log, prompts, effects }: InitCollaborators): Promise<PlatformInspection | undefined> => {
370
+ log.step('Read the installation')
371
+ if (!(await prompts.confirm(`Read \`control\` and \`proxy\` in Zerops project ${collected.projectId} for the console and IAM origins?`, true))) {
372
+ log.info('Skipped. No console or IAM origin will be printed.')
373
+ return undefined
374
+ }
375
+ return effects.inspectPlatform({
376
+ projectId: collected.projectId,
377
+ accessToken: collected.accessToken,
378
+ ...(collected.apiBaseUrl === undefined ? {} : { apiBaseUrl: collected.apiBaseUrl }),
379
+ }).catch(() => {
380
+ 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
+ })
382
+ }
383
+
384
+ /**
385
+ * Create or refresh the sidecar repository — the first step that leaves this disk.
386
+ *
387
+ * Returns the local checkout, or `undefined` when the operator declined, which stops every later
388
+ * outward step: an Environment cannot be written to a repository that does not exist, and a workflow
389
+ * cannot be triggered without one.
390
+ */
391
+ const scaffold = async (
392
+ installation: string,
393
+ collected: Collected,
394
+ { log, prompts, effects }: InitCollaborators,
395
+ ): Promise<string | undefined> => {
396
+ log.step('Scaffold the sidecar repository')
397
+ const exists = await effects.repositoryExists(collected.repo)
398
+ const question = exists
399
+ ? `Refresh the pipeline on ${collected.repo} and push?`
400
+ : `Create ${collected.repo} (private) on GitHub and push the pipeline?`
401
+ if (!(await prompts.confirm(question, true))) {
402
+ log.action('OPERATOR ACTION — nothing was created; run init again when ready', [
403
+ `fabrika platform init --provider=zerops ${installation} --repo=${collected.repo}`,
404
+ ])
405
+ return undefined
406
+ }
407
+ const dir = defaultCheckoutDir(installation)
408
+ const result = await effects.scaffold({
409
+ repo: collected.repo,
410
+ dir,
411
+ files: [...SIDECAR_FILES],
412
+ materialize: (target) =>
413
+ materializeSidecarScaffold(target, {
414
+ installation,
415
+ repo: collected.repo,
416
+ fabrikaRef: collected.fabrikaRef,
417
+ }),
418
+ })
419
+ const pinned = await readPinnedTag(result.dir)
420
+ log.ok(`${collected.repo} ${result.created ? 'created' : 'refreshed'} — pinned fabrika tag ${pinned}`)
421
+ if (pinned !== collected.fabrikaRef) {
422
+ log.warn(`the existing pin ${pinned} was kept; ${collected.fabrikaRef} was not written, because fabrika.ref is operator-owned`)
423
+ }
424
+ return result.dir
425
+ }
426
+
427
+ /** Run the whole thing for `<installation>`. */
428
+ export const runInit = async (args: InitArguments, collaborators: InitCollaborators): Promise<void> => {
429
+ const { log, prompts, effects } = collaborators
430
+ log.info(`fabrika platform init — the ${args.installation} Zerops installation's sidecar repository`)
431
+ log.info('This UPDATES an installation that already exists. It does not create one.')
432
+
433
+ const collected = await collect(args, collaborators)
434
+ await verifyProject(collected, collaborators)
435
+ const platform = await readPlatform(collected, collaborators)
436
+ const settingsUrl = platform === undefined ? undefined : sourceConnectionSettingsUrl(collected.placement, platform)
437
+ log.info('Public repositories deploy without a GitHub App. Connect private GitHub source in Control.')
438
+ log.action(
439
+ 'OPERATOR ACTION — connect GitHub source in Control',
440
+ settingsUrl === undefined
441
+ ? ['Deploy the platform, then open Settings → Source in the authenticated Control console. No unverified URL was guessed.']
442
+ : [url(settingsUrl)],
443
+ )
444
+
445
+ const dir = await scaffold(args.installation, collected, collaborators)
446
+ if (dir === undefined) {
447
+ return
448
+ }
449
+
450
+ log.step(`Configure the ${args.installation} GitHub Environment`)
451
+ const config = environmentConfig(args.installation, collected)
452
+ log.info(`${Object.keys(config.secrets).length} secret(s) and ${Object.keys(config.vars).length} variable(s) on ${collected.repo}.`)
453
+ if (!(await prompts.confirm(`Write them to the ${args.installation} Environment now (secret VALUES go to GitHub over \`gh\` stdin)?`, true))) {
454
+ log.action('OPERATOR ACTION — the pipeline cannot run until the Environment holds these', [
455
+ `fabrika platform init --provider=zerops ${args.installation} --repo=${collected.repo}`,
456
+ ])
457
+ return
458
+ }
459
+ await effects.configureEnvironment(config)
460
+ log.ok(`Environment ${args.installation} written: ${Object.keys(config.secrets).join(', ')} and ${Object.keys(config.vars).join(', ')}.`)
461
+
462
+ log.step('Trigger the deploy')
463
+ 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 ${collected.repo} now?`, true))) {
465
+ log.action('OPERATOR ACTION — run it when ready', [
466
+ `gh workflow run platform.yml --repo ${collected.repo}`,
467
+ `or: ${url(`https://github.com/${collected.repo}/actions`)} → platform → Run workflow`,
468
+ ])
469
+ } else {
470
+ await effects.triggerWorkflow(collected.repo)
471
+ log.ok('Platform workflow triggered.')
472
+ log.info(`Watch: ${url(`https://github.com/${collected.repo}/actions`)} (or: gh run watch --repo ${collected.repo})`)
473
+ }
474
+
475
+ log.step('Done')
476
+ log.ok(`Sidecar repository: ${collected.repo} (local checkout: ${dir})`)
477
+ log.info('Roll this installation forward by bumping fabrika.ref to a newer published tag and pushing.')
478
+ log.info("Who may administer the installation is IAM's to say: this repository holds no admission list to close later.")
479
+ // The origin comes off the live control service, never from what was typed here: an operator who
480
+ // pastes a guessed hostname into FABRIKA_IAM_RPC_URL gets "Unable to connect" and no explanation.
481
+ const iamOrigin = platform === undefined ? undefined : platformIamOrigin(collected.placement, platform)
482
+ log.action(
483
+ MACHINE_KEY_NEXT_STEP_TITLE,
484
+ machineKeyNextStepLines({ sidecarEnvironment: args.installation, ...(iamOrigin === undefined ? {} : { iamOrigin }) }),
485
+ )
486
+ }
487
+
488
+ /** The real collaborators: a TTY, `gh`, git, and one read of the Zerops API. */
489
+ export const consoleInitCollaborators = (): InitCollaborators => ({
490
+ log: {
491
+ step: (title) => consoleStep(title),
492
+ info: (message) => consoleInfo(message),
493
+ warn: (message) => consoleWarn(message),
494
+ ok: (message) => consoleOk(message),
495
+ action: (title, lines) => consoleAction(title, [...lines]),
496
+ },
497
+ prompts: {
498
+ text: (question, fallback) => promptText(question, fallback),
499
+ confirm: (question, defaultYes) => promptConfirm(question, defaultYes),
500
+ select: (question, options) => promptSelect(question, options),
501
+ secret: (variable, question) => secretOrEnv(variable, question),
502
+ },
503
+ effects: {
504
+ repositoryExists: (repo) => ghRepoExists(repo),
505
+ scaffold: (input) => scaffoldSidecarRepository(input),
506
+ configureEnvironment: (config) => configureEnvironment(config),
507
+ triggerWorkflow: (repo) => triggerPlatformWorkflow(repo),
508
+ describeProject: async ({ projectId, accessToken, apiBaseUrl }) => {
509
+ const api = createZeropsApi({ token: accessToken, ...(apiBaseUrl === undefined ? {} : { baseUrl: apiBaseUrl }) })
510
+ const project = await api.getProject({ projectId, signal: new AbortController().signal })
511
+ if (project.id === '') {
512
+ throw new Error(`Zerops project ${projectId} could not be read with this token`)
513
+ }
514
+ return project.name
515
+ },
516
+ inspectPlatform: async (input) => {
517
+ const api = createZeropsApi({ token: input.accessToken, ...(input.apiBaseUrl === undefined ? {} : { baseUrl: input.apiBaseUrl }) })
518
+ return inspectPlatform(input.projectId, api, new AbortController().signal)
519
+ },
520
+ },
521
+ })
@@ -0,0 +1,28 @@
1
+ import type { SchemaReconciler } from '@fabrika/provider-contract'
2
+ import type { Sleeper, ZeropsApi } from '../index'
3
+ import type { PlatformSourcePreparer } from './installation-source'
4
+ import type { InitLog } from './log'
5
+ import type { PlatformUploadFetch } from './platform-upload'
6
+
7
+ /** The one question this command asks. Injected so the flow can be exercised without a TTY. */
8
+ export interface InstallPrompts {
9
+ confirm(question: string, defaultYes?: boolean): Promise<boolean>
10
+ }
11
+
12
+ /** Everything `runInstall` reaches the outside world through. */
13
+ export interface InstallCollaborators {
14
+ readonly prepareSource: PlatformSourcePreparer
15
+ readonly uploadFetch: PlatformUploadFetch
16
+ readonly api: ZeropsApi
17
+ readonly reconcileSchema: SchemaReconciler
18
+ readonly sleep: Sleeper
19
+ readonly log: InitLog
20
+ readonly prompts: InstallPrompts
21
+ readonly signal: AbortSignal
22
+ }
23
+
24
+ export const assertRunning = (signal: AbortSignal): void => {
25
+ if (signal.aborted) {
26
+ throw new Error('the Zerops platform install was cancelled')
27
+ }
28
+ }