@fabrika/provider-zerops 0.4.2 → 0.4.4
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 +44 -5
- 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 +95 -37
- 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 +169 -52
- package/src/installation/index.ts +22 -15
- 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/platform-archive.ts +87 -0
- package/src/installation/platform-upload.ts +2 -2
- package/src/installation/secrets.ts +3 -4
- package/src/installation/sidecar.ts +38 -2
- package/src/installation/templates/README.md +1 -1
- package/src/installation/templates/platform.yml +23 -2
- package/src/installation/topology/setups.ts +15 -27
- package/src/installation/topology/topology.ts +105 -104
- package/src/installation/upgrade.ts +24 -7
- 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
- package/src/installation/manifest.ts +0 -95
|
@@ -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
|
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// The local copy of every credential `platform install` creates.
|
|
2
|
+
//
|
|
3
|
+
// The services hold the same values in Zerops, but stored SENSITIVE, and Zerops hands a sensitive value
|
|
4
|
+
// back only to a token in sudo mode (measured 2026-10-15). So the operator's copy is the one this
|
|
5
|
+
// command writes, before the first of them is placed: a run that fails later loses nothing.
|
|
6
|
+
|
|
7
|
+
import { existsSync } from 'node:fs'
|
|
8
|
+
import { open } from 'node:fs/promises'
|
|
9
|
+
import { isAbsolute, relative, resolve } from 'node:path'
|
|
10
|
+
import type { InstallationSecrets } from './secrets'
|
|
11
|
+
|
|
12
|
+
/** `--secrets-file`: where the local copy goes. A new file, outside the source checkout. */
|
|
13
|
+
export const SECRETS_FILE_FLAG = '--secrets-file'
|
|
14
|
+
export const SECRETS_FILE_VARIABLE = 'FABRIKA_PLATFORM_SECRETS_FILE'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Settle the path before anything is contacted.
|
|
18
|
+
*
|
|
19
|
+
* Refused when it exists, because overwriting one would destroy another installation's only copy, and
|
|
20
|
+
* when it is inside the source checkout, where it would dirty the checkout `platform deploy` uploads
|
|
21
|
+
* and could be committed.
|
|
22
|
+
*/
|
|
23
|
+
export const checkedSecretsFilePath = (path: string, sourceDir: string): string => {
|
|
24
|
+
const absolute = resolve(path)
|
|
25
|
+
const fromSource = relative(resolve(sourceDir), absolute)
|
|
26
|
+
if (fromSource === '' || (!fromSource.startsWith('..') && !isAbsolute(fromSource))) {
|
|
27
|
+
throw new Error(`${SECRETS_FILE_FLAG}=${path} is inside the source checkout — name a path outside it, so it is never uploaded or committed`)
|
|
28
|
+
}
|
|
29
|
+
if (existsSync(absolute)) {
|
|
30
|
+
throw new Error(`${SECRETS_FILE_FLAG}=${path} already exists — it may be another installation's only copy; name a new file`)
|
|
31
|
+
}
|
|
32
|
+
return absolute
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What the file records beside the credentials, so it identifies its installation on its own. */
|
|
36
|
+
export interface InstallationSecretsRecord {
|
|
37
|
+
readonly environment: string
|
|
38
|
+
readonly projectId: string
|
|
39
|
+
readonly clientId: string
|
|
40
|
+
readonly secrets: InstallationSecrets
|
|
41
|
+
/** The integration token minted for the control plane. */
|
|
42
|
+
readonly zeropsAccessToken: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The file's text: shell-sourceable assignments, named as the commands that read them name them.
|
|
47
|
+
*
|
|
48
|
+
* `FABRIKA_ZEROPS_ACCESS_TOKEN` is the INTEGRATION token, which is what `platform init` and
|
|
49
|
+
* `platform deploy` take; the personal token the install ran with is not written.
|
|
50
|
+
*/
|
|
51
|
+
export const renderSecretsFile = (record: InstallationSecretsRecord): string => {
|
|
52
|
+
const { secrets } = record
|
|
53
|
+
const assignments: readonly (readonly [string, string])[] = [
|
|
54
|
+
['FABRIKA_ZEROPS_PROJECT_ID', record.projectId],
|
|
55
|
+
['FABRIKA_ZEROPS_CLIENT_ID', record.clientId],
|
|
56
|
+
['FABRIKA_IAM_PROVISIONING_KEY', secrets.provisioningKey],
|
|
57
|
+
['FABRIKA_ZEROPS_ACCESS_TOKEN', record.zeropsAccessToken],
|
|
58
|
+
['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
|
|
59
|
+
['FABRIKA_IAM_PROXY_KEY', secrets.proxyKey],
|
|
60
|
+
['OPERATIONS_SYNC_KEY', secrets.operationsSyncKey],
|
|
61
|
+
['FABRIKA_CONTROL_VAULT_KEY', secrets.vaultKey],
|
|
62
|
+
['FABRIKA_IAM_SIGNING_KEYS', secrets.signingKeys],
|
|
63
|
+
]
|
|
64
|
+
const lines = assignments.map(([name, value]) => {
|
|
65
|
+
// Single quotes keep every character literal; none of these values can contain one.
|
|
66
|
+
if (value.includes("'") || value.includes('\n')) {
|
|
67
|
+
throw new Error(`${name} cannot be written to the secrets file as one quoted line`)
|
|
68
|
+
}
|
|
69
|
+
return `${name}='${value}'`
|
|
70
|
+
})
|
|
71
|
+
return [
|
|
72
|
+
`# fabrika on Zerops — installation \`${record.environment}\`, project ${record.projectId}.`,
|
|
73
|
+
'# Written by `fabrika platform install`. Every value below is a credential: keep this file private.',
|
|
74
|
+
'# Zerops holds the same values as sensitive variables and does not hand them back.',
|
|
75
|
+
'# Load it in a subshell: ( set -a && . <this file> && set +a && fabrika … )',
|
|
76
|
+
...lines,
|
|
77
|
+
'',
|
|
78
|
+
].join('\n')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Create the file with mode 0600, failing rather than replacing one that appeared since the check. */
|
|
82
|
+
export const writeSecretsFile = async (path: string, text: string): Promise<void> => {
|
|
83
|
+
const file = await open(path, 'wx', 0o600)
|
|
84
|
+
try {
|
|
85
|
+
await file.writeFile(text)
|
|
86
|
+
} finally {
|
|
87
|
+
await file.close()
|
|
88
|
+
}
|
|
89
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ZeropsApiError } from '../index'
|
|
2
|
+
import type { InstallCollaborators } from './install-collaborators'
|
|
3
|
+
|
|
4
|
+
/** The platform's answer when an integration token without delegation asks to mint another one. */
|
|
5
|
+
const MINT_REFUSED_TO_INTEGRATION_TOKEN = 'notAllowedForIntegrationTokenWithoutDelegation'
|
|
6
|
+
|
|
7
|
+
/** How `/user/info` names an integration token. Reported by an operator, not measured here. */
|
|
8
|
+
const INTEGRATION_TOKEN_EMAIL = /^token-[^@]+@zerops\.io$/i
|
|
9
|
+
|
|
10
|
+
const PERSONAL_TOKEN_REQUIRED = 'FABRIKA_ZEROPS_ACCESS_TOKEN is a Zerops integration token (`/user/info` names it `token-…@zerops.io`), '
|
|
11
|
+
+ 'and an integration token may not mint the one the control plane needs. Generate a personal access token in the Zerops GUI, '
|
|
12
|
+
+ 'as a user who is OWNER on the client'
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Refuse an integration token before anything is created.
|
|
16
|
+
*
|
|
17
|
+
* The install mints an integration token at step 4, and an integration token is refused that mint. Without
|
|
18
|
+
* this read the mistake costs a billed project and six imported services first.
|
|
19
|
+
*/
|
|
20
|
+
export const assertPersonalAccessToken = async ({ api, signal }: InstallCollaborators): Promise<void> => {
|
|
21
|
+
const identity = await api.getUserInfo({ signal })
|
|
22
|
+
if (INTEGRATION_TOKEN_EMAIL.test(identity.email)) {
|
|
23
|
+
throw new Error(`${PERSONAL_TOKEN_REQUIRED}. Nothing was created`)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Translate the mint's refusal of an integration token into the fix and the way back into this project. */
|
|
28
|
+
export const explainMintFailure = (failure: unknown, projectId: string): unknown => {
|
|
29
|
+
if (!(failure instanceof ZeropsApiError) || failure.code !== MINT_REFUSED_TO_INTEGRATION_TOKEN) {
|
|
30
|
+
return failure
|
|
31
|
+
}
|
|
32
|
+
return new Error(
|
|
33
|
+
`${PERSONAL_TOKEN_REQUIRED}. No generated secret was placed yet: re-run the install with the personal token and `
|
|
34
|
+
+ `--project-id=${projectId} instead of --create-project; it keeps the services already imported`,
|
|
35
|
+
{ cause: failure },
|
|
36
|
+
)
|
|
37
|
+
}
|