@fabrika/provider-zerops 0.2.0 → 0.3.2

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 (83) 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 +2 -6
  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 +179 -0
  16. package/src/index.ts +23 -127
  17. package/src/installation/admin-options.ts +100 -0
  18. package/src/installation/admin.ts +123 -0
  19. package/src/installation/deploy-options.ts +127 -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 +390 -0
  25. package/src/installation/init-options.ts +148 -0
  26. package/src/installation/init.ts +394 -0
  27. package/src/installation/install-collaborators.ts +27 -0
  28. package/src/installation/install-environment.ts +193 -0
  29. package/src/installation/install-options.ts +156 -0
  30. package/src/installation/install-project.ts +201 -0
  31. package/src/installation/install-provisioning.ts +185 -0
  32. package/src/installation/install.ts +262 -0
  33. package/src/installation/installation-source.ts +42 -0
  34. package/src/installation/log.ts +77 -0
  35. package/src/installation/machine-key.ts +65 -0
  36. package/src/installation/manifest.ts +95 -0
  37. package/src/installation/placement-options.ts +33 -0
  38. package/src/installation/platform-source.ts +106 -0
  39. package/src/installation/platform-upload.ts +34 -0
  40. package/src/installation/proxy-manifest.ts +113 -0
  41. package/src/installation/secrets.ts +79 -0
  42. package/src/installation/sidecar.ts +125 -0
  43. package/src/installation/source-options.ts +12 -0
  44. package/src/installation/templates/README.md +116 -0
  45. package/src/installation/templates/fabrika.ref +1 -0
  46. package/src/installation/templates/gitignore +3 -0
  47. package/src/installation/templates/platform.yml +144 -0
  48. package/src/installation/topology/artifacts.ts +141 -0
  49. package/src/installation/topology/index.ts +13 -0
  50. package/src/installation/topology/installation-schemas.ts +225 -0
  51. package/src/installation/topology/invariants.ts +86 -0
  52. package/src/installation/topology/setups.ts +442 -0
  53. package/src/installation/topology/topology.ts +501 -0
  54. package/src/installation/topology/validate.ts +26 -0
  55. package/src/installation/upgrade-options.ts +99 -0
  56. package/src/installation/upgrade-output.ts +31 -0
  57. package/src/installation/upgrade.ts +373 -0
  58. package/src/managed-env.ts +113 -0
  59. package/src/manifest.ts +194 -344
  60. package/src/namespace/claims.ts +77 -0
  61. package/src/namespace/codec.ts +179 -0
  62. package/src/namespace/constants.ts +30 -0
  63. package/src/namespace/errors.ts +60 -0
  64. package/src/namespace/manifest.ts +28 -0
  65. package/src/namespace/operator.ts +151 -0
  66. package/src/namespace/provisioning.ts +482 -0
  67. package/src/namespace/proxy-attempt.ts +220 -0
  68. package/src/namespace/proxy-source.ts +22 -0
  69. package/src/namespace/topology.ts +115 -0
  70. package/src/namespace/types.ts +114 -0
  71. package/src/namespace.ts +34 -1271
  72. package/src/plan.ts +21 -23
  73. package/src/provider.ts +266 -241
  74. package/src/source/constants.ts +11 -0
  75. package/src/source/port.ts +49 -0
  76. package/src/source/types.ts +4 -0
  77. package/src/source/validation.ts +72 -0
  78. package/src/source.ts +2 -1220
  79. package/src/types.ts +8 -7
  80. package/src/version-attempt.ts +18 -0
  81. package/src/codec.ts +0 -108
  82. package/src/namespace-command.ts +0 -209
  83. package/src/source-connection.ts +0 -344
@@ -0,0 +1,148 @@
1
+ // The CLI surface of `fabrika platform init --provider=zerops`, parsed once.
2
+ //
3
+ // The same two rules `deploy-options.ts` states: every CREDENTIAL comes from the environment and only
4
+ // from the environment, and everything else may come from a flag OR the environment, flag first.
5
+ // Nothing here prompts — a missing required value is an error naming both the flag and the variable —
6
+ // so a pipeline or an agent drives init end to end. Its confirmations stay, answered by `--yes`.
7
+
8
+ import {
9
+ CHECKOUT_FLAG,
10
+ type CommandEnv,
11
+ describeArgument,
12
+ readEnv,
13
+ readFlag,
14
+ requiredSetting,
15
+ setting,
16
+ sidecarCheckoutDir,
17
+ UNATTENDED_FLAG,
18
+ } from '@fabrika/installation-init'
19
+ import { parsePlatformHosts } from './placement-options'
20
+ import { assertPinnedTag, defaultCheckoutName, defaultSidecarRepo } from './sidecar'
21
+
22
+ /** The name every bypass in this platform is gated on, and therefore the one name an installation cannot take. */
23
+ const LOCAL_ENVIRONMENT = 'local'
24
+
25
+ /** Every input `runInit` takes from the operator. */
26
+ export interface InitInput {
27
+ /** Names the GitHub Environment, the default repository and the local checkout directory. */
28
+ readonly installation: string
29
+ readonly repo: string
30
+ /** The sidecar's local checkout: `--checkout`, or `fabrika-zerops-<installation>` in the working directory. */
31
+ readonly checkoutDir: string
32
+ /** The published fabrika-platform tag the sidecar pins in `fabrika.ref`. */
33
+ readonly fabrikaRef: string
34
+ readonly projectId: string
35
+ /** The name this installation calls itself — written to every service as `ENVIRONMENT`. */
36
+ readonly environmentName: string
37
+ /** All three or none: none derives them from the proxy's Zerops subdomains. */
38
+ readonly hosts?: { readonly iam: string; readonly control: string; readonly operations: string }
39
+ /** Region API base, when the installation is not on the default one. */
40
+ readonly apiBaseUrl?: string
41
+ /** The Zerops INTEGRATION token `platform install` minted. Environment only. */
42
+ readonly accessToken: string
43
+ /** The `px_` key this installation's IAM was seeded with. Environment only. */
44
+ readonly provisioningKey: string
45
+ /** `--yes`: answer every confirmation yes. */
46
+ readonly unattended: boolean
47
+ }
48
+
49
+ const FLAGS = ['--repo', CHECKOUT_FLAG, '--tag', '--project-id', '--env', '--iam-host', '--console-host', '--operations-host'] as const
50
+
51
+ const REPO_PATTERN = /^[^/\s]+\/[^/\s]+$/
52
+
53
+ /**
54
+ * The installation's environment name, refused when it claims to be local.
55
+ *
56
+ * The same refusal the services enforce at boot: `local` is the value `localDevLogin`, the ephemeral
57
+ * signing key and the credential-less caller path are all gated on, and a deployed installation is
58
+ * never a loopback address.
59
+ */
60
+ export const checkedEnvironmentName = (declared: string): string => {
61
+ const trimmed = declared.trim()
62
+ if (trimmed === '') {
63
+ throw new Error('an installation environment name is required — it is written to every service as ENVIRONMENT')
64
+ }
65
+ if (trimmed === LOCAL_ENVIRONMENT) {
66
+ throw new Error(
67
+ `\`${LOCAL_ENVIRONMENT}\` is refused: it is the name every development bypass is gated on, and a deployed `
68
+ + 'installation is not a loopback address. Name it after the installation instead',
69
+ )
70
+ }
71
+ return trimmed
72
+ }
73
+
74
+ const assertKnownArguments = (argv: readonly string[]): void => {
75
+ for (const arg of argv) {
76
+ if (arg === UNATTENDED_FLAG || !arg.startsWith('-')) {
77
+ continue
78
+ }
79
+ const name = arg.split('=')[0] ?? arg
80
+ if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
81
+ throw new Error(
82
+ `unexpected argument ${describeArgument(arg)}. Credentials are read from the environment and have no flag; run `
83
+ + '`fabrika platform init --provider=zerops --help` for the surface',
84
+ )
85
+ }
86
+ }
87
+ }
88
+
89
+ const readInstallation = (argv: readonly string[]): string => {
90
+ const positional = argv.filter((arg) => !arg.startsWith('-')).map((arg) => arg.trim())
91
+ if (positional.length > 1) {
92
+ throw new Error('unexpected second positional argument: name ONE installation')
93
+ }
94
+ const installation = positional[0]
95
+ if (installation === undefined || installation === '') {
96
+ throw new Error('Zerops installation init requires an installation name, for example `fabrika platform init --provider=zerops test`')
97
+ }
98
+ return installation
99
+ }
100
+
101
+ const readRepo = (argv: readonly string[], installation: string): string => {
102
+ const repo = readFlag(argv, '--repo')
103
+ if (repo === undefined) {
104
+ return defaultSidecarRepo(installation)
105
+ }
106
+ if (!REPO_PATTERN.test(repo)) {
107
+ throw new Error(`\`${repo}\` is not a <owner>/<name> repository`)
108
+ }
109
+ return repo
110
+ }
111
+
112
+ /** A credential, with the one message that says why there is no flag for it. */
113
+ const credential = (env: CommandEnv, variable: string, what: string): string => {
114
+ const value = readEnv(env, variable)
115
+ if (value === undefined) {
116
+ throw new Error(`${variable} is required: ${what}. It has no flag — a credential on a command line lands in logs and shell history`)
117
+ }
118
+ return value
119
+ }
120
+
121
+ /** Parse the command's arguments and environment into the one input `runInit` takes. */
122
+ export const parseInitArgs = (argv: readonly string[], env: CommandEnv, cwd: string = process.cwd()): InitInput => {
123
+ assertKnownArguments(argv)
124
+ const installation = readInstallation(argv)
125
+ const hosts = parsePlatformHosts({
126
+ iam: setting(argv, env, '--iam-host', 'FABRIKA_PLATFORM_IAM_HOST'),
127
+ control: setting(argv, env, '--console-host', 'FABRIKA_PLATFORM_CONSOLE_HOST'),
128
+ operations: setting(argv, env, '--operations-host', 'FABRIKA_PLATFORM_OPERATIONS_HOST'),
129
+ })
130
+ const apiBaseUrl = readEnv(env, 'FABRIKA_ZEROPS_API_URL')
131
+ return {
132
+ installation,
133
+ repo: readRepo(argv, installation),
134
+ checkoutDir: sidecarCheckoutDir(readFlag(argv, CHECKOUT_FLAG), defaultCheckoutName(installation), cwd),
135
+ fabrikaRef: assertPinnedTag(requiredSetting(setting(argv, env, '--tag', 'FABRIKA_PLATFORM_TAG'), '--tag', 'FABRIKA_PLATFORM_TAG')),
136
+ projectId: requiredSetting(setting(argv, env, '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID'), '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID'),
137
+ environmentName: checkedEnvironmentName(setting(argv, env, '--env', 'FABRIKA_PLATFORM_ENVIRONMENT') ?? installation),
138
+ accessToken: credential(
139
+ env,
140
+ 'FABRIKA_ZEROPS_ACCESS_TOKEN',
141
+ "the Zerops INTEGRATION token `platform install` minted, read from the `control` service's environment",
142
+ ),
143
+ provisioningKey: credential(env, 'FABRIKA_IAM_PROVISIONING_KEY', 'the px_ key `platform install` printed'),
144
+ unattended: argv.includes(UNATTENDED_FLAG),
145
+ ...(hosts === undefined ? {} : { hosts }),
146
+ ...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
147
+ }
148
+ }
@@ -0,0 +1,394 @@
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
+ type Confirmations,
27
+ confirmations,
28
+ type EnvironmentConfig,
29
+ ghRepoExists,
30
+ info as consoleInfo,
31
+ ok as consoleOk,
32
+ scaffoldSidecarRepository,
33
+ shellWord,
34
+ type SidecarScaffoldInput,
35
+ type SidecarScaffoldResult,
36
+ step as consoleStep,
37
+ triggerPlatformWorkflow,
38
+ url,
39
+ warn as consoleWarn,
40
+ } from '@fabrika/installation-init'
41
+ import { createZeropsApi, type ZeropsApi, type ZeropsService } from '../index'
42
+ import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
43
+ import { derivePlatformHosts, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
44
+ import type { InitInput } from './init-options'
45
+ import type { InitLog } from './log'
46
+ import { MACHINE_KEY_NEXT_STEP_TITLE, machineKeyNextStepLines } from './machine-key'
47
+ import { materializeSidecarScaffold, readPinnedTag, SIDECAR_FILES } from './sidecar'
48
+
49
+ /** Everything `runInit` does outside the operator's own disk. Every one of them is confirmed first. */
50
+ export interface InitEffects {
51
+ /** Only decides how the scaffold confirmation is phrased; the scaffold itself re-reads it. */
52
+ repositoryExists(repo: string): Promise<boolean>
53
+ scaffold(input: SidecarScaffoldInput): Promise<SidecarScaffoldResult>
54
+ configureEnvironment(config: EnvironmentConfig): Promise<void>
55
+ triggerWorkflow(repo: string): Promise<void>
56
+ /** Read one Zerops project's name — the cheapest proof that the token and the project id agree. */
57
+ describeProject(input: { projectId: string; accessToken: string; apiBaseUrl?: string }): Promise<string>
58
+ inspectPlatform(input: InspectPlatformInput): Promise<PlatformInspection>
59
+ }
60
+
61
+ export interface InitCollaborators {
62
+ readonly log: InitLog
63
+ readonly prompts: Confirmations
64
+ readonly effects: InitEffects
65
+ }
66
+
67
+ /** Where this installation answers, as far as `init` needs to know it. */
68
+ interface Placement {
69
+ readonly hosts?: { readonly iam: string; readonly control: string; readonly operations: string }
70
+ }
71
+
72
+ export interface InspectPlatformInput {
73
+ readonly projectId: string
74
+ readonly accessToken: string
75
+ readonly apiBaseUrl?: string
76
+ }
77
+
78
+ /** What `init` reads off the live installation to print origins it can verify. Values are never logged. */
79
+ export interface PlatformInspection {
80
+ readonly controlEnv: ReadonlyMap<string, string>
81
+ readonly proxyEnv: ReadonlyMap<string, string>
82
+ readonly proxyPublished: boolean
83
+ }
84
+
85
+ const oneService = (services: readonly ZeropsService[], name: string): ZeropsService | undefined => {
86
+ const matches = services.filter((service) => service.name === name)
87
+ if (matches.length > 1) throw new Error(`Zerops project has ${matches.length} services named \`${name}\``)
88
+ return matches[0]
89
+ }
90
+
91
+ /** Read `control` and `proxy` without writing anything. */
92
+ export const inspectPlatform = async (projectId: string, api: ZeropsApi, signal: AbortSignal): Promise<PlatformInspection> => {
93
+ const services = await api.listProjectServices({ projectId, signal })
94
+ const control = oneService(services, 'control')
95
+ if (control === undefined || control.id === '') throw new Error(`Zerops project ${projectId} has no \`control\` service`)
96
+ const proxy = oneService(services, 'proxy')
97
+ if (proxy === undefined || proxy.id === '') throw new Error(`Zerops project ${projectId} has no \`proxy\` service`)
98
+ const readEnv = async (serviceId: string): Promise<Map<string, string>> =>
99
+ new Map((await api.listServiceEnv({ serviceId, signal })).map((item) => [item.key, item.content]))
100
+ return {
101
+ controlEnv: await readEnv(control.id),
102
+ proxyEnv: await readEnv(proxy.id),
103
+ proxyPublished: proxy.subdomainAccess === true,
104
+ }
105
+ }
106
+
107
+ const strictHttpsOrigin = (hostValue: string): string | undefined => {
108
+ const host = hostValue.trim().toLowerCase()
109
+ const labels = host.split('.')
110
+ if (
111
+ host === '' || host.length > 253 || labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))
112
+ ) {
113
+ return undefined
114
+ }
115
+ try {
116
+ const parsed = new URL(`https://${host}`)
117
+ if (parsed.origin !== `https://${host}` || parsed.hostname !== host || parsed.port !== '' || parsed.username !== '' || parsed.password !== '') {
118
+ return undefined
119
+ }
120
+ return parsed.origin
121
+ } catch {
122
+ return undefined
123
+ }
124
+ }
125
+
126
+ /**
127
+ * IAM's PUBLIC origin as the installation itself records it, or nothing when init would have to guess.
128
+ *
129
+ * `platform install` writes it to `control` as `FABRIKA_IAM_ISSUER`, so this reads the live value
130
+ * rather than re-deriving one — and refuses it when the operator named a different IAM host, because
131
+ * a printed origin an operator then pastes into `FABRIKA_IAM_RPC_URL` must be the one that answers.
132
+ */
133
+ export const platformIamOrigin = (placement: Placement, platform: PlatformInspection): string | undefined => {
134
+ const issuer = platform.controlEnv.get('FABRIKA_IAM_ISSUER')
135
+ if (issuer === undefined) return undefined
136
+ let hostname: string
137
+ try {
138
+ const parsed = new URL(issuer.trim())
139
+ if (parsed.protocol !== 'https:' || parsed.port !== '' || parsed.username !== '' || parsed.password !== '') return undefined
140
+ if (parsed.pathname !== '/' || parsed.search !== '' || parsed.hash !== '') return undefined
141
+ hostname = parsed.hostname
142
+ } catch {
143
+ return undefined
144
+ }
145
+ const origin = strictHttpsOrigin(hostname)
146
+ if (origin === undefined) return undefined
147
+ if (placement.hosts !== undefined && placement.hosts.iam.trim().toLowerCase() !== hostname) return undefined
148
+ return origin
149
+ }
150
+
151
+ /** Return a verified live console URL, or no URL when init would have to guess. */
152
+ export const sourceConnectionSettingsUrl = (placement: Placement, platform: PlatformInspection): string | undefined => {
153
+ const liveHost = platform.controlEnv.get('FABRIKA_CONTROL_DOMAIN')
154
+ if (liveHost === undefined) return undefined
155
+ const origin = strictHttpsOrigin(liveHost)
156
+ if (origin === undefined) return undefined
157
+ const host = new URL(origin).hostname
158
+ if (placement.hosts !== undefined) {
159
+ if (placement.hosts.control.toLowerCase() !== host) return undefined
160
+ return `${origin}/settings/source`
161
+ }
162
+ if (!platform.proxyPublished) return undefined
163
+ const subdomains = platform.proxyEnv.get(ZEROPS_SUBDOMAIN_VARIABLE)
164
+ if (subdomains === undefined || subdomains.trim() === '') return undefined
165
+ try {
166
+ const listeners = PLATFORM_PROXY_MANIFEST_TEMPLATE.apps.map((app) => ({ service: app.service, port: app.port }))
167
+ if (derivePlatformHosts(subdomains, listeners).control.toLowerCase() !== host) return undefined
168
+ return `${origin}/settings/source`
169
+ } catch {
170
+ return undefined
171
+ }
172
+ }
173
+
174
+ /** The Environment the generated workflow reads. Secret VALUES appear here and nowhere else. */
175
+ const environmentConfig = (installation: string, input: InitInput): EnvironmentConfig => ({
176
+ repo: input.repo,
177
+ environment: installation,
178
+ secrets: {
179
+ FABRIKA_ZEROPS_ACCESS_TOKEN: input.accessToken,
180
+ FABRIKA_IAM_PROVISIONING_KEY: input.provisioningKey,
181
+ },
182
+ vars: {
183
+ FABRIKA_ZEROPS_PROJECT_ID: input.projectId,
184
+ FABRIKA_PLATFORM_ENVIRONMENT: input.environmentName,
185
+ // Stated rather than defaulted, so the Environment describes the whole deploy.
186
+ FABRIKA_PLATFORM_SCHEME: 'https',
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,
191
+ }),
192
+ ...(input.apiBaseUrl === undefined ? {} : { FABRIKA_ZEROPS_API_URL: input.apiBaseUrl }),
193
+ },
194
+ })
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
+
213
+ /** Read the project back, so a wrong token or a mistyped id fails here rather than in someone's CI. */
214
+ const verifyProject = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<void> => {
215
+ log.step('Check the token against the project')
216
+ if (!(await prompts.confirm(`Read Zerops project ${input.projectId} to check the access token and the id?`, true))) {
217
+ log.info('Skipped. A wrong token or project id will surface in the first pipeline run instead.')
218
+ return
219
+ }
220
+ // A caught error is never re-thrown as itself: it may quote a request URL or a platform payload, and
221
+ // this is the one step holding both credentials. A short sentence naming the two causes is enough.
222
+ const name = await effects.describeProject({
223
+ projectId: input.projectId,
224
+ accessToken: input.accessToken,
225
+ ...(input.apiBaseUrl === undefined ? {} : { apiBaseUrl: input.apiBaseUrl }),
226
+ }).catch(() => {
227
+ throw new Error(
228
+ `Zerops project ${input.projectId} could not be read: the access token does not grant it, or the id is wrong. `
229
+ + 'Nothing has been created — check both and run init again',
230
+ )
231
+ })
232
+ log.ok(`Zerops project ${name} (${input.projectId}) is readable with this token.`)
233
+ }
234
+
235
+ /**
236
+ * Read the live `control` and `proxy` configuration, so the origins init prints are ones the installation records.
237
+ *
238
+ * Declining is not fatal: init then prints no origin rather than one it would have to guess.
239
+ */
240
+ const readPlatform = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<PlatformInspection | undefined> => {
241
+ log.step('Read the installation')
242
+ if (!(await prompts.confirm(`Read \`control\` and \`proxy\` in Zerops project ${input.projectId} for the console and IAM origins?`, true))) {
243
+ log.info('Skipped. No console or IAM origin will be printed.')
244
+ return undefined
245
+ }
246
+ return effects.inspectPlatform({
247
+ projectId: input.projectId,
248
+ accessToken: input.accessToken,
249
+ ...(input.apiBaseUrl === undefined ? {} : { apiBaseUrl: input.apiBaseUrl }),
250
+ }).catch(() => {
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')
252
+ })
253
+ }
254
+
255
+ /**
256
+ * Create or refresh the sidecar repository — the first step that leaves this disk.
257
+ *
258
+ * Returns the local checkout, or `undefined` when the operator declined, which stops every later
259
+ * outward step: an Environment cannot be written to a repository that does not exist, and a workflow
260
+ * cannot be triggered without one.
261
+ */
262
+ const scaffold = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<string | undefined> => {
263
+ log.step('Scaffold the sidecar repository')
264
+ const exists = await effects.repositoryExists(input.repo)
265
+ const question = exists
266
+ ? `Refresh the pipeline on ${input.repo} and push?`
267
+ : `Create ${input.repo} (private) on GitHub and push the pipeline?`
268
+ if (!(await prompts.confirm(question, true))) {
269
+ log.action('OPERATOR ACTION — nothing was created; run init again when ready', [
270
+ rerunCommand(input),
271
+ ])
272
+ return undefined
273
+ }
274
+ const result = await effects.scaffold({
275
+ repo: input.repo,
276
+ dir: input.checkoutDir,
277
+ files: [...SIDECAR_FILES],
278
+ materialize: (target) =>
279
+ materializeSidecarScaffold(target, {
280
+ installation: input.installation,
281
+ repo: input.repo,
282
+ fabrikaRef: input.fabrikaRef,
283
+ }),
284
+ })
285
+ const pinned = await readPinnedTag(result.dir)
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`)
289
+ }
290
+ return result.dir
291
+ }
292
+
293
+ /** Run the whole thing for `<installation>`. */
294
+ export const runInit = async (input: InitInput, collaborators: InitCollaborators): Promise<void> => {
295
+ const { log, prompts, effects } = collaborators
296
+ log.info(`fabrika platform init — the ${input.installation} Zerops installation's sidecar repository`)
297
+ log.info('This UPDATES an installation that already exists. It does not create one.')
298
+
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)
309
+ log.info('Public repositories deploy without a GitHub App. Connect private GitHub source in Control.')
310
+ log.action(
311
+ 'OPERATOR ACTION — connect GitHub source in Control',
312
+ settingsUrl === undefined
313
+ ? ['Deploy the platform, then open Settings → Source in the authenticated Control console. No unverified URL was guessed.']
314
+ : [url(settingsUrl)],
315
+ )
316
+
317
+ const dir = await scaffold(input, collaborators)
318
+ if (dir === undefined) {
319
+ return
320
+ }
321
+
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))) {
326
+ log.action('OPERATOR ACTION — the pipeline cannot run until the Environment holds these', [
327
+ rerunCommand(input),
328
+ ])
329
+ return
330
+ }
331
+ await effects.configureEnvironment(config)
332
+ log.ok(`Environment ${input.installation} written: ${Object.keys(config.secrets).join(', ')} and ${Object.keys(config.vars).join(', ')}.`)
333
+
334
+ log.step('Trigger the deploy')
335
+ log.info("The pipeline runs the whole ordered sequence in the operator's CI; this machine deploys nothing.")
336
+ if (!(await prompts.confirm(`Run the platform workflow on ${input.repo} now?`, true))) {
337
+ log.action('OPERATOR ACTION — run it when ready', [
338
+ `gh workflow run platform.yml --repo ${input.repo}`,
339
+ `or: ${url(`https://github.com/${input.repo}/actions`)} → platform → Run workflow`,
340
+ ])
341
+ } else {
342
+ await effects.triggerWorkflow(input.repo)
343
+ log.ok('Platform workflow triggered.')
344
+ log.info(`Watch: ${url(`https://github.com/${input.repo}/actions`)} (or: gh run watch --repo ${input.repo})`)
345
+ }
346
+
347
+ log.step('Done')
348
+ log.ok(`Sidecar repository: ${input.repo} (local checkout: ${dir})`)
349
+ log.info('Roll this installation forward by bumping fabrika.ref to a newer published tag and pushing.')
350
+ log.info("Who may administer the installation is IAM's to say: this repository holds no admission list to close later.")
351
+ // The origin comes off the live control service, never from what was typed here: an operator who
352
+ // pastes a guessed hostname into FABRIKA_IAM_RPC_URL gets "Unable to connect" and no explanation.
353
+ const iamOrigin = platform === undefined ? undefined : platformIamOrigin(input, platform)
354
+ log.action(
355
+ MACHINE_KEY_NEXT_STEP_TITLE,
356
+ machineKeyNextStepLines({ sidecarEnvironment: input.installation, ...(iamOrigin === undefined ? {} : { iamOrigin }) }),
357
+ )
358
+ }
359
+
360
+ /** The real collaborators: a TTY, `gh`, git, and one read of the Zerops API. */
361
+ export const consoleInitCollaborators = (input: InitInput): InitCollaborators => ({
362
+ log: {
363
+ step: (title) => consoleStep(title),
364
+ info: (message) => consoleInfo(message),
365
+ warn: (message) => consoleWarn(message),
366
+ ok: (message) => consoleOk(message),
367
+ action: (title, lines) => consoleAction(title, [...lines]),
368
+ },
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
+ ),
376
+ effects: {
377
+ repositoryExists: (repo) => ghRepoExists(repo),
378
+ scaffold: (input) => scaffoldSidecarRepository(input),
379
+ configureEnvironment: (config) => configureEnvironment(config),
380
+ triggerWorkflow: (repo) => triggerPlatformWorkflow(repo),
381
+ describeProject: async ({ projectId, accessToken, apiBaseUrl }) => {
382
+ const api = createZeropsApi({ token: accessToken, ...(apiBaseUrl === undefined ? {} : { baseUrl: apiBaseUrl }) })
383
+ const project = await api.getProject({ projectId, signal: new AbortController().signal })
384
+ if (project.id === '') {
385
+ throw new Error(`Zerops project ${projectId} could not be read with this token`)
386
+ }
387
+ return project.name
388
+ },
389
+ inspectPlatform: async (input) => {
390
+ const api = createZeropsApi({ token: input.accessToken, ...(input.apiBaseUrl === undefined ? {} : { baseUrl: input.apiBaseUrl }) })
391
+ return inspectPlatform(input.projectId, api, new AbortController().signal)
392
+ },
393
+ },
394
+ })
@@ -0,0 +1,27 @@
1
+ import type { Confirmations } from '@fabrika/installation-init'
2
+ import type { SchemaReconciler } from '@fabrika/provider-contract'
3
+ import type { Sleeper, ZeropsApi } from '../index'
4
+ import type { PlatformSourcePreparer } from './installation-source'
5
+ import type { InitLog } from './log'
6
+ import type { PlatformUploadFetch } from './platform-upload'
7
+
8
+ /** The one question this command asks. Injected so the flow can be exercised without a TTY. */
9
+ export type InstallPrompts = Confirmations
10
+
11
+ /** Everything `runInstall` reaches the outside world through. */
12
+ export interface InstallCollaborators {
13
+ readonly prepareSource: PlatformSourcePreparer
14
+ readonly uploadFetch: PlatformUploadFetch
15
+ readonly api: ZeropsApi
16
+ readonly reconcileSchema: SchemaReconciler
17
+ readonly sleep: Sleeper
18
+ readonly log: InitLog
19
+ readonly prompts: InstallPrompts
20
+ readonly signal: AbortSignal
21
+ }
22
+
23
+ export const assertRunning = (signal: AbortSignal): void => {
24
+ if (signal.aborted) {
25
+ throw new Error('the Zerops platform install was cancelled')
26
+ }
27
+ }