@fabrika/provider-zerops 0.4.1 → 0.4.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 +8 -7
- package/src/api/client.ts +11 -14
- package/src/api/polling.ts +42 -0
- package/src/api/readers.ts +9 -2
- package/src/api/service-env-limit.ts +29 -0
- package/src/api/service-env-response.ts +34 -2
- package/src/api/types.ts +39 -9
- package/src/api.ts +10 -1
- package/src/control.ts +23 -8
- package/src/execution.ts +41 -3
- package/src/index.ts +5 -0
- package/src/installation/admin.ts +4 -2
- package/src/installation/checker-release.ts +44 -0
- package/src/installation/deploy.ts +66 -22
- package/src/installation/generated/platform-console-schema.ts +18 -2
- package/src/installation/generated/platform-proxy-manifest.ts +20 -0
- package/src/installation/guide.md +319 -0
- package/src/installation/index.ts +42 -16
- package/src/installation/init-options.ts +2 -2
- package/src/installation/init.ts +8 -2
- package/src/installation/install-collaborators.ts +4 -0
- package/src/installation/install-environment.ts +52 -30
- package/src/installation/install-options.ts +20 -19
- package/src/installation/install-project.ts +23 -15
- package/src/installation/install-provisioning.ts +12 -12
- package/src/installation/install-secrets.ts +89 -0
- package/src/installation/install-token.ts +37 -0
- package/src/installation/install.ts +34 -21
- package/src/installation/machine-key.ts +3 -2
- package/src/installation/manifest.ts +29 -12
- package/src/installation/platform-archive.ts +87 -0
- package/src/installation/platform-upload.ts +2 -2
- package/src/installation/secrets.ts +3 -4
- package/src/installation/topology/setups.ts +15 -27
- package/src/installation/topology/topology.ts +105 -104
- package/src/managed-env.ts +11 -0
- package/src/manifest.ts +25 -3
- package/src/namespace/codec.ts +6 -1
- package/src/namespace/errors.ts +3 -1
- package/src/namespace/provisioning.ts +18 -5
- package/src/namespace/proxy-attempt.ts +56 -14
- package/src/namespace/update.ts +50 -8
- package/src/provider.ts +57 -13
- package/src/proxy-manifest-carrier.ts +32 -0
- package/src/source/port.ts +9 -1
|
@@ -9,9 +9,11 @@ import {
|
|
|
9
9
|
} from '@fabrika/installation-init'
|
|
10
10
|
import type { SchemaReconciler } from '@fabrika/provider-contract'
|
|
11
11
|
import type { InstallationCli, InstallationCommand } from '@fabrika/provider-contract/installation'
|
|
12
|
+
import { fileURLToPath } from 'node:url'
|
|
12
13
|
import { createZeropsApi, defaultSleep } from '../index'
|
|
13
14
|
import { consoleAdminCollaborators, runPlatformAdmin } from './admin'
|
|
14
15
|
import { parsePlatformAdminArgs, REISSUE_FLAG } from './admin-options'
|
|
16
|
+
import { npmCheckerPublication } from './checker-release'
|
|
15
17
|
import { deployPlatform, PLATFORM_CONCURRENT_DEPLOY, PLATFORM_DEPLOY_ORDER, PLATFORM_SEQUENTIAL_DEPLOY } from './deploy'
|
|
16
18
|
import { parsePlatformDeployArgs } from './deploy-options'
|
|
17
19
|
import { consoleInitCollaborators, runInit } from './init'
|
|
@@ -44,7 +46,11 @@ export {
|
|
|
44
46
|
|
|
45
47
|
const USAGE = `Zerops installation
|
|
46
48
|
|
|
49
|
+
New here, or an agent? Run \`fabrika platform guide --provider=zerops\` first: the whole bring-up in order,
|
|
50
|
+
what to ask the human for, and the traps this text cannot show.
|
|
51
|
+
|
|
47
52
|
Commands:
|
|
53
|
+
fabrika platform guide --provider=zerops
|
|
48
54
|
fabrika platform install --provider=zerops [options]
|
|
49
55
|
fabrika platform init --provider=zerops <installation> --tag=<tag> --project-id=<id> [options]
|
|
50
56
|
fabrika platform plan --provider=zerops
|
|
@@ -61,9 +67,9 @@ deploy that push triggers.
|
|
|
61
67
|
|
|
62
68
|
── platform install ──────────────────────────────────────────────────────────────────────────────
|
|
63
69
|
|
|
64
|
-
The from-scratch bring-up. With \`--create-project\` it creates the project too, with core
|
|
65
|
-
LIGHT and \`envIsolation: service\` — the two settings Zerops accepts at project
|
|
66
|
-
afterwards. \`--project-id=<id>\` installs into an empty project you created yourself instead, with both
|
|
70
|
+
The from-scratch bring-up. With \`--create-project\` it creates the project too, with the tier's core
|
|
71
|
+
package (LIGHT or SERIOUS) and \`envIsolation: service\` — the two settings Zerops accepts at project
|
|
72
|
+
CREATION only and never afterwards. \`--project-id=<id>\` installs into an empty project you created yourself instead, with both
|
|
67
73
|
already right; nothing here can read \`envIsolation\` back or correct it.
|
|
68
74
|
|
|
69
75
|
Interactive and laptop-side. It CONFIRMS before every step that leaves your machine: reading the
|
|
@@ -74,27 +80,30 @@ is not consent.
|
|
|
74
80
|
|
|
75
81
|
1. create the project and wait for it to be ACTIVE (\`--create-project\`; its id is printed before
|
|
76
82
|
the wait), or read the one you named — and refuse a core package the tier does not match
|
|
77
|
-
2. import
|
|
83
|
+
2. import the tier's services without code, and wait for the import's processes
|
|
78
84
|
3. generate six secrets and mint the control plane's Zerops INTEGRATION token
|
|
79
|
-
(client role NO_ACCESS, ADMIN on this project only — never your personal token)
|
|
85
|
+
(client role NO_ACCESS, ADMIN on this project only — never your personal token), and write
|
|
86
|
+
all of them to \`--secrets-file\` (mode 0600) before the first one is placed
|
|
80
87
|
4. pass 1: give the proxy an EMPTY manifest and build it, so it publishes one public hostname per
|
|
81
88
|
HTTP port; the three platform hosts are then READ off those, never composed
|
|
82
89
|
5. write every remaining variable on all four runtime services
|
|
83
90
|
6. pass 2: hand the whole ordered sequence to \`platform deploy\`
|
|
84
|
-
7. print the
|
|
91
|
+
7. print the next commands; they read their credentials from the secrets file. Zerops holds the
|
|
92
|
+
same values as SENSITIVE variables and does not hand them back, so that file is the only copy
|
|
85
93
|
|
|
86
94
|
It REFUSES a project that already holds an installation's generated secrets: a second bring-up would
|
|
87
95
|
roll a new vault key over the old one, which is unrecoverable, and new signing keys, which logs
|
|
88
96
|
everyone out. Once every bootstrap variable is placed, \`platform deploy\` can update the installation
|
|
89
|
-
or finish a failed pass 2
|
|
90
|
-
FABRIKA_ZEROPS_ACCESS_TOKEN from control in Zerops if the handoff was interrupted.
|
|
97
|
+
or finish a failed pass 2, with the credentials from the secrets file.
|
|
91
98
|
Neither deploy nor init completes a partial bootstrap. If interrupted after a generated secret was
|
|
92
99
|
placed but before every bootstrap variable was placed, preserve the existing keys and arrange
|
|
93
100
|
operator-owned recovery, or start a fresh installation in a separate empty project.
|
|
94
101
|
Re-running install after any generated secret was placed is refused.
|
|
95
102
|
|
|
96
|
-
|
|
97
|
-
|
|
103
|
+
Two tiers, one project each. \`light\` (core LIGHT): one single-node PostgreSQL and one bucket shared by
|
|
104
|
+
IAM, control and Operations, one container per service. \`standard\` (core SERIOUS): an HA PostgreSQL for
|
|
105
|
+
IAM and control, a second HA PostgreSQL and bucket for Operations, two containers of IAM and the proxy.
|
|
106
|
+
Authentication is PASSWORD ONLY; no administrator exists yet when this finishes.
|
|
98
107
|
|
|
99
108
|
Options (a flag beats the environment variable beside it):
|
|
100
109
|
|
|
@@ -107,7 +116,8 @@ Options (a flag beats the environment variable beside it):
|
|
|
107
116
|
--env=<name> FABRIKA_PLATFORM_ENVIRONMENT written to every service as ENVIRONMENT
|
|
108
117
|
--scheme=<http|https> FABRIKA_PLATFORM_SCHEME default https
|
|
109
118
|
--source-dir=<checkout> required clean full checkout running this CLI
|
|
110
|
-
--tier
|
|
119
|
+
--tier=<light|standard> FABRIKA_PLATFORM_TIER default light
|
|
120
|
+
--secrets-file=<path> FABRIKA_PLATFORM_SECRETS_FILE required: a NEW file outside the checkout
|
|
111
121
|
${UNATTENDED_FLAG} answer every confirmation yes —
|
|
112
122
|
required when stdin is not a terminal
|
|
113
123
|
|
|
@@ -243,23 +253,24 @@ commit availability before its effects; a local commit is not thereby published
|
|
|
243
253
|
The first human. A fresh installation has none — nothing seeds an admission list, and this command is
|
|
244
254
|
why it does not have to. It is unattended, prompts for nothing, and changes nothing on a re-run:
|
|
245
255
|
|
|
246
|
-
1. find the principal holding that
|
|
256
|
+
1. find the principal holding that address, or create one
|
|
247
257
|
2. grant it the CROSS-APP \`admin\` role, unless it already holds one
|
|
248
258
|
3. issue ONE password enrollment and print its URL
|
|
249
259
|
|
|
250
260
|
The grant is cross-app (\`app: null\`) and that is load-bearing: grants filter to the calling app, so an
|
|
251
261
|
\`admin\` grant scoped to the console's own app id leaves Delivery and Operations working while the
|
|
252
|
-
Access plane refuses. A re-run
|
|
262
|
+
Access plane refuses. A re-run creates nobody twice, grants nothing twice and issues no second
|
|
253
263
|
enrollment — a password that is already set, or an enrollment already outstanding, is REPORTED. Pass
|
|
254
264
|
\`${REISSUE_FLAG}\` to replace an outstanding one, which is the way out when the first expired unopened.
|
|
255
265
|
|
|
256
|
-
|
|
266
|
+
Nothing is e-mailed: a Zerops installation has no mail service. Hand the URL to the administrator
|
|
267
|
+
yourself. It is a credential: it is printed once, on a line of its own, and stored nowhere.
|
|
257
268
|
|
|
258
269
|
\`install\` reports the host this needs as \`✓ iam <host>\` and ends with this command ready to run.
|
|
259
270
|
|
|
260
271
|
Options (a flag beats the environment variable beside it):
|
|
261
272
|
|
|
262
|
-
--email=<address> FABRIKA_PLATFORM_ADMIN_EMAIL the administrator's
|
|
273
|
+
--email=<address> FABRIKA_PLATFORM_ADMIN_EMAIL the administrator's sign-in address
|
|
263
274
|
--iam-host=<host> FABRIKA_PLATFORM_IAM_HOST IAM's public hostname, as \`deploy\` names it
|
|
264
275
|
--scheme=<http|https> FABRIKA_PLATFORM_SCHEME default https
|
|
265
276
|
${REISSUE_FLAG} replace an outstanding enrollment
|
|
@@ -305,6 +316,16 @@ laptop and not in the pipeline it triggers.
|
|
|
305
316
|
/** No cancellation source exists behind a CLI invocation; the ports all take a signal regardless. */
|
|
306
317
|
const neverAborted = (): AbortSignal => new AbortController().signal
|
|
307
318
|
|
|
319
|
+
/** The operator's guide of this release, shipped beside this module. */
|
|
320
|
+
export const readInstallationGuide = (): Promise<string> => Bun.file(fileURLToPath(new URL('./guide.md', import.meta.url))).text()
|
|
321
|
+
|
|
322
|
+
const runGuide = async (argv: readonly string[]): Promise<void> => {
|
|
323
|
+
if (argv.length > 0) {
|
|
324
|
+
throw new Error(`Unexpected Zerops guide arguments: ${argv.map(describeArgument).join(' ')}`)
|
|
325
|
+
}
|
|
326
|
+
console.info(await readInstallationGuide())
|
|
327
|
+
}
|
|
328
|
+
|
|
308
329
|
const runPlan = (argv: readonly string[]): void => {
|
|
309
330
|
if (argv.length > 0) {
|
|
310
331
|
throw new Error(`Unexpected Zerops plan arguments: ${argv.map(describeArgument).join(' ')}`)
|
|
@@ -332,6 +353,7 @@ const runDeploy = async (argv: readonly string[]): Promise<void> => {
|
|
|
332
353
|
const input = parsePlatformDeployArgs(argv, process.env)
|
|
333
354
|
await deployPlatform(input, {
|
|
334
355
|
prepareSource: prepareInstallationSource,
|
|
356
|
+
checkerPublished: npmCheckerPublication(),
|
|
335
357
|
uploadFetch: (url, init) => fetch(url, init),
|
|
336
358
|
api: createZeropsApi({
|
|
337
359
|
token: input.accessToken,
|
|
@@ -368,9 +390,13 @@ const runPlatformUpgrade = async (argv: readonly string[]): Promise<void> => {
|
|
|
368
390
|
|
|
369
391
|
export const installationCli: InstallationCli = {
|
|
370
392
|
provider: 'zerops',
|
|
371
|
-
commands: ['install', 'init', 'plan', 'deploy', 'admin', 'upgrade'],
|
|
393
|
+
commands: ['install', 'init', 'plan', 'deploy', 'admin', 'upgrade', 'guide'],
|
|
372
394
|
usage: USAGE,
|
|
373
395
|
run: async (command: InstallationCommand, argv: readonly string[]) => {
|
|
396
|
+
if (command === 'guide') {
|
|
397
|
+
await runGuide(argv)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
374
400
|
if (command === 'install') {
|
|
375
401
|
await runPlatformInstall(argv)
|
|
376
402
|
return
|
|
@@ -138,9 +138,9 @@ export const parseInitArgs = (argv: readonly string[], env: CommandEnv, cwd: str
|
|
|
138
138
|
accessToken: credential(
|
|
139
139
|
env,
|
|
140
140
|
'FABRIKA_ZEROPS_ACCESS_TOKEN',
|
|
141
|
-
|
|
141
|
+
'the Zerops INTEGRATION token `platform install` minted, from its secrets file',
|
|
142
142
|
),
|
|
143
|
-
provisioningKey: credential(env, 'FABRIKA_IAM_PROVISIONING_KEY', 'the px_ key `platform install`
|
|
143
|
+
provisioningKey: credential(env, 'FABRIKA_IAM_PROVISIONING_KEY', 'the px_ key from the secrets file `platform install` wrote'),
|
|
144
144
|
unattended: argv.includes(UNATTENDED_FLAG),
|
|
145
145
|
...(hosts === undefined ? {} : { hosts }),
|
|
146
146
|
...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
|
package/src/installation/init.ts
CHANGED
|
@@ -95,8 +95,14 @@ export const inspectPlatform = async (projectId: string, api: ZeropsApi, signal:
|
|
|
95
95
|
if (control === undefined || control.id === '') throw new Error(`Zerops project ${projectId} has no \`control\` service`)
|
|
96
96
|
const proxy = oneService(services, 'proxy')
|
|
97
97
|
if (proxy === undefined || proxy.id === '') throw new Error(`Zerops project ${projectId} has no \`proxy\` service`)
|
|
98
|
-
|
|
99
|
-
|
|
98
|
+
// Only the readable values: everything init prints is derived from non-secret configuration.
|
|
99
|
+
const readEnv = async (serviceId: string): Promise<Map<string, string>> => {
|
|
100
|
+
const readable = new Map<string, string>()
|
|
101
|
+
for (const item of await api.listServiceEnv({ serviceId, signal })) {
|
|
102
|
+
if (!item.sensitive) readable.set(item.key, item.content)
|
|
103
|
+
}
|
|
104
|
+
return readable
|
|
105
|
+
}
|
|
100
106
|
return {
|
|
101
107
|
controlEnv: await readEnv(control.id),
|
|
102
108
|
proxyEnv: await readEnv(proxy.id),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Confirmations } from '@fabrika/installation-init'
|
|
2
2
|
import type { SchemaReconciler } from '@fabrika/provider-contract'
|
|
3
3
|
import type { Sleeper, ZeropsApi } from '../index'
|
|
4
|
+
import type { CheckerPublication } from './checker-release'
|
|
4
5
|
import type { PlatformSourcePreparer } from './installation-source'
|
|
5
6
|
import type { InitLog } from './log'
|
|
6
7
|
import type { PlatformUploadFetch } from './platform-upload'
|
|
@@ -11,6 +12,7 @@ export type InstallPrompts = Confirmations
|
|
|
11
12
|
/** Everything `runInstall` reaches the outside world through. */
|
|
12
13
|
export interface InstallCollaborators {
|
|
13
14
|
readonly prepareSource: PlatformSourcePreparer
|
|
15
|
+
readonly checkerPublished: CheckerPublication
|
|
14
16
|
readonly uploadFetch: PlatformUploadFetch
|
|
15
17
|
readonly api: ZeropsApi
|
|
16
18
|
readonly reconcileSchema: SchemaReconciler
|
|
@@ -18,6 +20,8 @@ export interface InstallCollaborators {
|
|
|
18
20
|
readonly log: InitLog
|
|
19
21
|
readonly prompts: InstallPrompts
|
|
20
22
|
readonly signal: AbortSignal
|
|
23
|
+
/** Create the local secrets file (`--secrets-file`) with this text. Never logged. */
|
|
24
|
+
readonly storeSecrets: (text: string) => Promise<void>
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export const assertRunning = (signal: AbortSignal): void => {
|
|
@@ -1,45 +1,61 @@
|
|
|
1
|
-
import { encodeProxyManifestJson
|
|
1
|
+
import { encodeProxyManifestJson } from '@fabrika/proxy-core/manifest'
|
|
2
2
|
import type { ZeropsNamespaceProxySource } from '../index'
|
|
3
3
|
import type { PlatformDeployService, ResolvedService } from './deploy'
|
|
4
4
|
import { type PlatformHosts, platformOrigin } from './hosts'
|
|
5
5
|
import { assertRunning, type InstallCollaborators } from './install-collaborators'
|
|
6
6
|
import { FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource } from './installation-source'
|
|
7
7
|
import type { InstallationSecrets } from './secrets'
|
|
8
|
+
import type { PlatformTier } from './topology/topology'
|
|
8
9
|
|
|
9
10
|
/** The private address the proxy dials IAM at inside the project, and pass 1's placeholder issuer. */
|
|
10
11
|
export const PRIVATE_IAM_UPSTREAM = 'iam:3000'
|
|
11
12
|
const PRIVATE_IAM_URL = `http://${PRIVATE_IAM_UPSTREAM}`
|
|
12
13
|
|
|
13
14
|
/** The canonical fabrika PostgreSQL DSN on Zerops — see `src/topology/setups.ts` for why both suffixes exist. */
|
|
14
|
-
const
|
|
15
|
+
const postgresUrl = (service: string): string => `\${${service}_connectionTlsString}/\${${service}_dbName}?sslmode=require`
|
|
15
16
|
|
|
16
|
-
/**
|
|
17
|
-
|
|
18
|
-
bucket:
|
|
19
|
-
accessKeyId:
|
|
20
|
-
secretAccessKey:
|
|
21
|
-
endpoint:
|
|
22
|
-
}
|
|
17
|
+
/** An object-storage service's coordinates, as the platform's own `${service_variable}` references. */
|
|
18
|
+
interface StorageReferences {
|
|
19
|
+
readonly bucket: string
|
|
20
|
+
readonly accessKeyId: string
|
|
21
|
+
readonly secretAccessKey: string
|
|
22
|
+
readonly endpoint: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const storageOf = (service: string): StorageReferences => ({
|
|
26
|
+
bucket: `\${${service}_bucketName}`,
|
|
27
|
+
accessKeyId: `\${${service}_accessKeyId}`,
|
|
28
|
+
secretAccessKey: `\${${service}_secretAccessKey}`,
|
|
29
|
+
endpoint: `\${${service}_apiUrl}`,
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
/** IAM and control share `db` and `storage` on both tiers; `standard` gives Operations its own pair. */
|
|
33
|
+
const OPERATIONS_DATA: Readonly<Record<PlatformTier, { readonly database: string; readonly storage: string }>> = {
|
|
34
|
+
light: { database: 'db', storage: 'storage' },
|
|
35
|
+
standard: { database: 'operationsdb', storage: 'operationsstorage' },
|
|
36
|
+
}
|
|
23
37
|
|
|
24
38
|
/**
|
|
25
|
-
* The
|
|
39
|
+
* The three variables pass 1 writes on the proxy, and nothing else.
|
|
26
40
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
* `deployPlatform` corrects it in pass 2, before the manifest it belongs to is applied.
|
|
41
|
+
* `FABRIKA_IAM_ISSUER` is the private address rather than the public issuer because the public one is not known
|
|
42
|
+
* yet; `deployPlatform` corrects it in pass 2, before the manifest it belongs to is applied. The pass-1 manifest is
|
|
43
|
+
* `PASS_ONE_PROXY_MANIFEST`, which the proxy's archive carries.
|
|
31
44
|
*/
|
|
32
45
|
export const passOneProxyEnv = (secrets: InstallationSecrets): ReadonlyMap<string, string> =>
|
|
33
46
|
new Map([
|
|
34
|
-
[FABRIKA_PROXY_MANIFEST_JSON, encodeProxyManifestJson({ apps: [] })],
|
|
35
47
|
['FABRIKA_IAM_ISSUER', PRIVATE_IAM_URL],
|
|
36
48
|
['FABRIKA_IAM_KEY', secrets.proxyKey],
|
|
37
49
|
['FABRIKA_PROXY_SESSION_OWNER_JSON', 'null'],
|
|
38
50
|
])
|
|
39
51
|
|
|
52
|
+
/** EMPTY on purpose: the only legal document before the hosts exist. The proxy comes up answering 404. */
|
|
53
|
+
export const PASS_ONE_PROXY_MANIFEST = encodeProxyManifestJson({ apps: [] })
|
|
54
|
+
|
|
40
55
|
/** What `installationServiceEnv` needs. Everything in it is settled before the first write. */
|
|
41
56
|
export interface InstallationEnvInput {
|
|
42
57
|
readonly environment: string
|
|
58
|
+
readonly tier: PlatformTier
|
|
43
59
|
readonly scheme: 'http' | 'https'
|
|
44
60
|
readonly hosts: PlatformHosts
|
|
45
61
|
readonly clientId: string
|
|
@@ -72,17 +88,21 @@ export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap
|
|
|
72
88
|
const { secrets } = input
|
|
73
89
|
const iamOrigin = platformOrigin(input.scheme, input.hosts.iam)
|
|
74
90
|
const operationsOrigin = platformOrigin(input.scheme, input.hosts.operations)
|
|
91
|
+
const platformDatabase = postgresUrl('db')
|
|
92
|
+
const runLogs = storageOf('storage')
|
|
93
|
+
const operationsData = OPERATIONS_DATA[input.tier]
|
|
94
|
+
const operationsBlobs = storageOf(operationsData.storage)
|
|
75
95
|
const control = new Map([
|
|
76
|
-
['FABRIKA_CONTROL_DATABASE_URL',
|
|
96
|
+
['FABRIKA_CONTROL_DATABASE_URL', platformDatabase],
|
|
77
97
|
['ENVIRONMENT', input.environment],
|
|
78
98
|
// A bare HOST, not an origin: `controlPublicOrigin` accepts either.
|
|
79
99
|
['FABRIKA_CONTROL_DOMAIN', input.hosts.control],
|
|
80
100
|
['FABRIKA_IAM_ISSUER', iamOrigin],
|
|
81
101
|
['OPERATIONS_ARTIFACT_ORIGIN', operationsOrigin],
|
|
82
|
-
['FABRIKA_CONTROL_RUN_LOGS_BUCKET',
|
|
83
|
-
['FABRIKA_CONTROL_RUN_LOGS_ACCESS_KEY_ID',
|
|
84
|
-
['FABRIKA_CONTROL_RUN_LOGS_SECRET_ACCESS_KEY',
|
|
85
|
-
['FABRIKA_CONTROL_RUN_LOGS_ENDPOINT',
|
|
102
|
+
['FABRIKA_CONTROL_RUN_LOGS_BUCKET', runLogs.bucket],
|
|
103
|
+
['FABRIKA_CONTROL_RUN_LOGS_ACCESS_KEY_ID', runLogs.accessKeyId],
|
|
104
|
+
['FABRIKA_CONTROL_RUN_LOGS_SECRET_ACCESS_KEY', runLogs.secretAccessKey],
|
|
105
|
+
['FABRIKA_CONTROL_RUN_LOGS_ENDPOINT', runLogs.endpoint],
|
|
86
106
|
['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
|
|
87
107
|
['OPERATIONS_SYNC_KEY', secrets.operationsSyncKey],
|
|
88
108
|
['FABRIKA_CONTROL_VAULT_KEY', secrets.vaultKey],
|
|
@@ -104,7 +124,7 @@ export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap
|
|
|
104
124
|
[
|
|
105
125
|
'iam',
|
|
106
126
|
new Map([
|
|
107
|
-
['FABRIKA_IAM_DATABASE_URL',
|
|
127
|
+
['FABRIKA_IAM_DATABASE_URL', platformDatabase],
|
|
108
128
|
['ENVIRONMENT', input.environment],
|
|
109
129
|
['ISSUER', iamOrigin],
|
|
110
130
|
// Password only, by decision: OIDC needs an identity provider the operator has not been
|
|
@@ -121,11 +141,11 @@ export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap
|
|
|
121
141
|
[
|
|
122
142
|
'operations',
|
|
123
143
|
new Map([
|
|
124
|
-
['FABRIKA_OPERATIONS_DATABASE_URL',
|
|
125
|
-
['FABRIKA_OPERATIONS_BLOB_BUCKET',
|
|
126
|
-
['FABRIKA_OPERATIONS_BLOB_ACCESS_KEY_ID',
|
|
127
|
-
['FABRIKA_OPERATIONS_BLOB_SECRET_ACCESS_KEY',
|
|
128
|
-
['FABRIKA_OPERATIONS_BLOB_ENDPOINT',
|
|
144
|
+
['FABRIKA_OPERATIONS_DATABASE_URL', postgresUrl(operationsData.database)],
|
|
145
|
+
['FABRIKA_OPERATIONS_BLOB_BUCKET', operationsBlobs.bucket],
|
|
146
|
+
['FABRIKA_OPERATIONS_BLOB_ACCESS_KEY_ID', operationsBlobs.accessKeyId],
|
|
147
|
+
['FABRIKA_OPERATIONS_BLOB_SECRET_ACCESS_KEY', operationsBlobs.secretAccessKey],
|
|
148
|
+
['FABRIKA_OPERATIONS_BLOB_ENDPOINT', operationsBlobs.endpoint],
|
|
129
149
|
['FABRIKA_OPERATIONS_PUBLIC_HOST', input.hosts.operations],
|
|
130
150
|
['ENVIRONMENT', input.environment],
|
|
131
151
|
['FABRIKA_IAM_ISSUER', iamOrigin],
|
|
@@ -175,18 +195,20 @@ export const GENERATED_SECRET_KEYS: ReadonlyMap<PlatformDeployService, readonly
|
|
|
175
195
|
/**
|
|
176
196
|
* Write a set of variables, reporting the KEYS — never a value, not even in a debug line.
|
|
177
197
|
*
|
|
178
|
-
* Write-first and unconditional:
|
|
179
|
-
*
|
|
180
|
-
*
|
|
198
|
+
* Write-first and unconditional: a generated secret cannot be read back to compare, and `PUT
|
|
199
|
+
* /user-data/{id}` replaces in place, so a repeat costs a call and nothing else. Exactly the generated
|
|
200
|
+
* secrets are written sensitive; everything else stays readable, because `platform deploy` compares
|
|
201
|
+
* and merges it later.
|
|
181
202
|
*/
|
|
182
203
|
export const writeServiceEnv = async (
|
|
183
204
|
service: ResolvedService,
|
|
184
205
|
desired: ReadonlyMap<string, string>,
|
|
185
206
|
{ api, log, signal }: InstallCollaborators,
|
|
186
207
|
): Promise<void> => {
|
|
208
|
+
const secrets = GENERATED_SECRET_KEYS.get(service.hostname) ?? []
|
|
187
209
|
for (const [key, value] of desired) {
|
|
188
210
|
assertRunning(signal)
|
|
189
|
-
await api.putServiceEnv({ serviceId: service.id, key, value, signal })
|
|
211
|
+
await api.putServiceEnv({ serviceId: service.id, key, value, sensitive: secrets.includes(key), signal })
|
|
190
212
|
}
|
|
191
213
|
log.ok(`${service.hostname}: wrote ${[...desired.keys()].join(', ')}`)
|
|
192
214
|
}
|
|
@@ -9,11 +9,12 @@
|
|
|
9
9
|
// and the variable that would supply it, so a mistyped install fails before it contacts anything.
|
|
10
10
|
|
|
11
11
|
import { describeArgument, readEnv, readFlag, requiredSetting, setting, UNATTENDED_FLAG } from '@fabrika/installation-init'
|
|
12
|
+
import { checkedSecretsFilePath, SECRETS_FILE_FLAG, SECRETS_FILE_VARIABLE } from './install-secrets'
|
|
12
13
|
import { parsePlatformScheme } from './placement-options'
|
|
13
14
|
import { readInstallationSourceDir } from './source-options'
|
|
15
|
+
import type { PlatformTier } from './topology/topology'
|
|
14
16
|
|
|
15
|
-
|
|
16
|
-
export type InstallablePlatformTier = 'light'
|
|
17
|
+
const PLATFORM_TIERS: readonly PlatformTier[] = ['light', 'standard']
|
|
17
18
|
|
|
18
19
|
/**
|
|
19
20
|
* Where the installation's project comes from.
|
|
@@ -43,7 +44,9 @@ export interface PlatformInstallInput {
|
|
|
43
44
|
/** The scheme a browser speaks to this installation. */
|
|
44
45
|
readonly scheme: 'http' | 'https'
|
|
45
46
|
readonly sourceDir: string
|
|
46
|
-
|
|
47
|
+
/** Where every credential this run creates is written, mode 0600, before the first is placed. */
|
|
48
|
+
readonly secretsFile: string
|
|
49
|
+
readonly tier: PlatformTier
|
|
47
50
|
/** `--yes`: answer every confirmation yes, for a bring-up with nobody at the keyboard. */
|
|
48
51
|
readonly unattended: boolean
|
|
49
52
|
}
|
|
@@ -51,7 +54,7 @@ export interface PlatformInstallInput {
|
|
|
51
54
|
/** The canonical public release repository used for namespace proxy source identities. */
|
|
52
55
|
export const FABRIKA_REPOSITORY_URL = 'https://github.com/contember/fabrika-platform'
|
|
53
56
|
|
|
54
|
-
const FLAGS = ['--project-id', '--project-name', '--client-id', '--env', '--scheme', '--source-dir', '--tier'] as const
|
|
57
|
+
const FLAGS = ['--project-id', '--project-name', '--client-id', '--env', '--scheme', '--source-dir', '--tier', SECRETS_FILE_FLAG] as const
|
|
55
58
|
|
|
56
59
|
/** `--create-project`: bring the project up too, instead of naming one the operator made. */
|
|
57
60
|
export const CREATE_PROJECT_FLAG = '--create-project'
|
|
@@ -74,22 +77,16 @@ const assertKnownArguments = (argv: readonly string[]): void => {
|
|
|
74
77
|
}
|
|
75
78
|
}
|
|
76
79
|
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
* Only `light`, and that is a property of the topology rather than a limitation of the flow: the light
|
|
81
|
-
* tier is the only one that emits a services-only provisioning document, because it is the only one an
|
|
82
|
-
* operator installs into a project they created themselves. The standard tier is TWO projects that
|
|
83
|
-
* fabrika creates, which is a different command with a different failure mode.
|
|
84
|
-
*/
|
|
85
|
-
const assertInstallableTier = (raw: string | undefined): InstallablePlatformTier => {
|
|
86
|
-
if (raw === undefined || raw === 'light') {
|
|
80
|
+
/** The platform tier to bring up. `light` stays the default, so an existing invocation installs what it did. */
|
|
81
|
+
const readTier = (raw: string | undefined): PlatformTier => {
|
|
82
|
+
if (raw === undefined) {
|
|
87
83
|
return 'light'
|
|
88
84
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
85
|
+
const tier = PLATFORM_TIERS.find((candidate) => candidate === raw)
|
|
86
|
+
if (tier === undefined) {
|
|
87
|
+
throw new Error(`--tier=${raw} is not a platform tier — pass --tier=light or --tier=standard`)
|
|
88
|
+
}
|
|
89
|
+
return tier
|
|
93
90
|
}
|
|
94
91
|
|
|
95
92
|
const readScheme = (argv: readonly string[], env: Record<string, string | undefined>): 'http' | 'https' => {
|
|
@@ -149,7 +146,11 @@ export const parsePlatformInstallArgs = (
|
|
|
149
146
|
environment: readEnvironment(argv, env),
|
|
150
147
|
scheme: readScheme(argv, env),
|
|
151
148
|
sourceDir,
|
|
152
|
-
|
|
149
|
+
secretsFile: checkedSecretsFilePath(
|
|
150
|
+
requiredSetting(setting(argv, env, SECRETS_FILE_FLAG, SECRETS_FILE_VARIABLE), SECRETS_FILE_FLAG, SECRETS_FILE_VARIABLE),
|
|
151
|
+
sourceDir,
|
|
152
|
+
),
|
|
153
|
+
tier: readTier(setting(argv, env, '--tier', 'FABRIKA_PLATFORM_TIER')),
|
|
153
154
|
unattended: argv.includes(UNATTENDED_FLAG),
|
|
154
155
|
...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
|
|
155
156
|
}
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { ENV_ISOLATION, renderYaml, type ZeropsProjectMode, type ZeropsProjectStatus } from '../index'
|
|
2
2
|
import { assertRunning, type InstallCollaborators } from './install-collaborators'
|
|
3
3
|
import type { PlatformInstallInput } from './install-options'
|
|
4
|
-
import {
|
|
4
|
+
import { installationTopology } from './install-provisioning'
|
|
5
|
+
import { assertPersonalAccessToken } from './install-token'
|
|
6
|
+
import type { PlatformTier } from './topology/topology'
|
|
5
7
|
|
|
6
|
-
/** The project compute tier each
|
|
7
|
-
const REQUIRED_PROJECT_MODE: ZeropsProjectMode = 'LIGHT'
|
|
8
|
+
/** The project compute tier each platform tier requires. `corePackage` is upgrade-only. */
|
|
9
|
+
const REQUIRED_PROJECT_MODE: Readonly<Record<PlatformTier, ZeropsProjectMode>> = { light: 'LIGHT', standard: 'SERIOUS' }
|
|
8
10
|
|
|
9
11
|
/** The one project state this command may install into. */
|
|
10
12
|
const PROJECT_ACTIVE: ZeropsProjectStatus = 'ACTIVE'
|
|
@@ -32,15 +34,16 @@ const PROJECT_READ_FAILURES = 3
|
|
|
32
34
|
* The document `--create-project` applies, carrying a `project:` block and NO services.
|
|
33
35
|
*
|
|
34
36
|
* Taken from the same declaration the provisioning import is compiled from, so `envIsolation: service`
|
|
35
|
-
* and `corePackage
|
|
37
|
+
* and `corePackage` — the two settings a project accepts at CREATION only and never afterwards —
|
|
36
38
|
* have exactly one source. Only the name is this run's. `renderImportYaml` is not the renderer here
|
|
37
39
|
* because it refuses a document with no services, and this one deliberately has none: the services arrive
|
|
38
40
|
* through the services-only import, into the project this creates.
|
|
39
41
|
*/
|
|
40
|
-
const createProjectYaml = (environment: string, projectName: string): string => {
|
|
41
|
-
const declared =
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
const createProjectYaml = (environment: string, tier: PlatformTier, projectName: string): string => {
|
|
43
|
+
const declared = installationTopology(environment, tier).provision.document.project
|
|
44
|
+
const mode = REQUIRED_PROJECT_MODE[tier]
|
|
45
|
+
if (declared === undefined || declared.corePackage !== mode || declared.envIsolation !== ENV_ISOLATION) {
|
|
46
|
+
throw new Error(`the ${tier} platform topology declares no \`${mode}\` project with \`envIsolation: ${ENV_ISOLATION}\` to create`)
|
|
44
47
|
}
|
|
45
48
|
return renderYaml({ project: { ...declared, name: projectName }, services: [] })
|
|
46
49
|
}
|
|
@@ -122,7 +125,7 @@ const createProject = async (
|
|
|
122
125
|
assertRunning(signal)
|
|
123
126
|
const result = await api.importProject({
|
|
124
127
|
clientId: input.clientId,
|
|
125
|
-
yaml: createProjectYaml(environment, projectName),
|
|
128
|
+
yaml: createProjectYaml(environment, input.tier, projectName),
|
|
126
129
|
signal,
|
|
127
130
|
})
|
|
128
131
|
if (result.projectId === '') {
|
|
@@ -143,18 +146,19 @@ const resolveProject = async (
|
|
|
143
146
|
{ api, log, signal }: InstallCollaborators,
|
|
144
147
|
): Promise<{ id: string; name: string }> => {
|
|
145
148
|
const project = await api.getProject({ projectId, signal })
|
|
149
|
+
const mode = REQUIRED_PROJECT_MODE[input.tier]
|
|
146
150
|
if (project.id === '') {
|
|
147
151
|
throw new Error(`Zerops project ${projectId} could not be read with this token`)
|
|
148
152
|
}
|
|
149
153
|
if (project.mode === undefined) {
|
|
150
154
|
// Not a mismatch, so not a refusal: the client narrows an unknown value to absent, and blocking an
|
|
151
155
|
// install on a field the API did not state would be refusing on an absence of evidence.
|
|
152
|
-
log.warn(`Zerops did not state project ${project.name}'s mode; the ${input.tier} tier expects ${
|
|
153
|
-
} else if (project.mode !==
|
|
156
|
+
log.warn(`Zerops did not state project ${project.name}'s mode; the ${input.tier} tier expects ${mode}`)
|
|
157
|
+
} else if (project.mode !== mode) {
|
|
154
158
|
throw new Error(
|
|
155
|
-
`Zerops project ${project.name} is ${project.mode} and the ${input.tier} tier needs ${
|
|
159
|
+
`Zerops project ${project.name} is ${project.mode} and the ${input.tier} tier needs ${mode}. \`corePackage\` is `
|
|
156
160
|
+ 'upgrade-only and its upgrade is partly destructive, so this is not something an install may change — create the project '
|
|
157
|
-
+ `with core package ${
|
|
161
|
+
+ `with core package ${mode}, or install a tier that matches`,
|
|
158
162
|
)
|
|
159
163
|
}
|
|
160
164
|
return { id: project.id, name: project.name }
|
|
@@ -163,7 +167,7 @@ const resolveProject = async (
|
|
|
163
167
|
/**
|
|
164
168
|
* Settle which project this run installs into — the operator's, or one created here.
|
|
165
169
|
*
|
|
166
|
-
* ONE confirmation either way, and both cover the read that follows: a created project is read back
|
|
170
|
+
* ONE confirmation either way, and both cover the token check and the read that follows: a created project is read back
|
|
167
171
|
* through the same `resolveProject` an operator-made one goes through, so the core package is checked on
|
|
168
172
|
* the project that will actually be installed into rather than on the document that asked for it.
|
|
169
173
|
*/
|
|
@@ -183,19 +187,23 @@ export const resolveInstallationProject = async (
|
|
|
183
187
|
) {
|
|
184
188
|
throw new Error('declined: nothing was contacted, and re-running the install is safe')
|
|
185
189
|
}
|
|
190
|
+
await assertPersonalAccessToken(collaborators)
|
|
186
191
|
return resolveProject(input, source.projectId, collaborators)
|
|
187
192
|
}
|
|
188
193
|
log.step('Create the project this installation is brought up in')
|
|
189
194
|
log.info('`envIsolation` and `corePackage` are settable at project CREATION only, which is why the project is created from here.')
|
|
190
195
|
if (
|
|
191
196
|
!(await prompts.confirm(
|
|
192
|
-
`CREATE Zerops project \`${source.projectName}\` (core package ${
|
|
197
|
+
`Check the token, then CREATE Zerops project \`${source.projectName}\` (core package ${
|
|
198
|
+
REQUIRED_PROJECT_MODE[input.tier]
|
|
199
|
+
}, envIsolation ${ENV_ISOLATION}) on client `
|
|
193
200
|
+ `${input.clientId}, then read it back?`,
|
|
194
201
|
true,
|
|
195
202
|
))
|
|
196
203
|
) {
|
|
197
204
|
throw new Error('declined: no project was created, and re-running the install is safe')
|
|
198
205
|
}
|
|
206
|
+
await assertPersonalAccessToken(collaborators)
|
|
199
207
|
const projectId = await createProject(input, environment, source.projectName, collaborators)
|
|
200
208
|
return resolveProject(input, projectId, collaborators)
|
|
201
209
|
}
|
|
@@ -2,24 +2,24 @@ import { waitForProcess, type ZeropsImportResult, type ZeropsService, type Zerop
|
|
|
2
2
|
import { PLATFORM_DEPLOY_ORDER, type PlatformDeployService, readServiceEnv, type ResolvedService } from './deploy'
|
|
3
3
|
import { assertRunning, type InstallCollaborators } from './install-collaborators'
|
|
4
4
|
import { GENERATED_SECRET_KEYS } from './install-environment'
|
|
5
|
-
import { type CompiledDocument, type CompiledTopology, compileTopology, platformTopology } from './topology/topology'
|
|
5
|
+
import { type CompiledDocument, type CompiledTopology, compileTopology, type PlatformTier, platformTopology } from './topology/topology'
|
|
6
6
|
|
|
7
|
-
/** The
|
|
8
|
-
export const
|
|
9
|
-
compileTopology(platformTopology({ env: environment, tier
|
|
7
|
+
/** The topology this command installs for a tier, so the project document and the services document cannot drift. */
|
|
8
|
+
export const installationTopology = (environment: string, tier: PlatformTier): CompiledTopology =>
|
|
9
|
+
compileTopology(platformTopology({ env: environment, tier, publicAccess: 'zerops-subdomain' }), environment)
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
12
|
* The services-only provisioning document (WU2), compiled from the declaration the committed artifact
|
|
13
13
|
* is rendered from.
|
|
14
14
|
*
|
|
15
15
|
* Not read off disk: `gen:check` already proves
|
|
16
|
-
* `packages/platform-artifacts/generated/platform
|
|
17
|
-
* value needs no filesystem in a published package.
|
|
16
|
+
* `packages/platform-artifacts/generated/platform{,-light}.services.provision.zerops-import.yaml` is this exact text,
|
|
17
|
+
* and a value needs no filesystem in a published package.
|
|
18
18
|
*/
|
|
19
|
-
export const provisioningDocument = (environment: string): CompiledDocument => {
|
|
20
|
-
const document =
|
|
19
|
+
export const provisioningDocument = (environment: string, tier: PlatformTier): CompiledDocument => {
|
|
20
|
+
const document = installationTopology(environment, tier).servicesProvision
|
|
21
21
|
if (document === undefined) {
|
|
22
|
-
throw new Error(
|
|
22
|
+
throw new Error(`the ${tier} platform topology declares no services-only provisioning document`)
|
|
23
23
|
}
|
|
24
24
|
return document
|
|
25
25
|
}
|
|
@@ -64,7 +64,7 @@ export const assertNoServiceIsBeingCreated = (existing: ReadonlyMap<string, Exis
|
|
|
64
64
|
if (creating.length > 0) {
|
|
65
65
|
throw new Error(
|
|
66
66
|
`Zerops is still creating ${creating.join(', ')} — a service reads NEW with no environment at all, so nothing can be `
|
|
67
|
-
+ 'read from it or written to it yet. The
|
|
67
|
+
+ 'read from it or written to it yet. The topology settles in a few minutes; run the install again then',
|
|
68
68
|
)
|
|
69
69
|
}
|
|
70
70
|
}
|
|
@@ -99,8 +99,8 @@ export const assertNotAlreadyInstalled = async (
|
|
|
99
99
|
`this project already holds an installation's generated secrets — ${placed.join(', ')} ${placed.length === 1 ? 'is' : 'are'} already set. `
|
|
100
100
|
+ '`platform install` is a BRING-UP and would roll a new vault key and new signing keys over them, which is unrecoverable '
|
|
101
101
|
+ 'for the vault and logs everyone out. If every bootstrap variable was placed, use `fabrika platform deploy --provider=zerops` '
|
|
102
|
-
+ 'to update or finish deploying the installation
|
|
103
|
-
+ '
|
|
102
|
+
+ 'to update or finish deploying the installation, with the credentials from the secrets file the first install wrote. '
|
|
103
|
+
+ 'Neither deploy nor init completes a partial bootstrap. '
|
|
104
104
|
+ 'For partial bootstrap, preserve the existing keys and arrange operator-owned recovery, or start a fresh installation in a separate empty project',
|
|
105
105
|
)
|
|
106
106
|
}
|