@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.
- package/package.json +18 -5
- package/src/api/client.ts +522 -0
- package/src/api/errors.ts +50 -0
- package/src/api/polling.ts +59 -0
- package/src/api/readers.ts +185 -0
- package/src/api/service-env-response.ts +34 -0
- package/src/api/types.ts +602 -0
- package/src/api.ts +29 -1378
- package/src/authoring.ts +7 -10
- package/src/cli-args.ts +2 -6
- package/src/cli.ts +3 -28
- package/src/collaborators.ts +2 -4
- package/src/compile.ts +3 -1
- package/src/control.ts +203 -450
- package/src/descriptor.ts +179 -0
- package/src/index.ts +23 -127
- package/src/installation/admin-options.ts +100 -0
- package/src/installation/admin.ts +123 -0
- package/src/installation/deploy-options.ts +127 -0
- package/src/installation/deploy.ts +668 -0
- package/src/installation/generated/platform-console-schema.ts +41 -0
- package/src/installation/generated/platform-proxy-manifest.ts +72 -0
- package/src/installation/hosts.ts +117 -0
- package/src/installation/index.ts +390 -0
- package/src/installation/init-options.ts +148 -0
- package/src/installation/init.ts +394 -0
- package/src/installation/install-collaborators.ts +27 -0
- package/src/installation/install-environment.ts +193 -0
- package/src/installation/install-options.ts +156 -0
- package/src/installation/install-project.ts +201 -0
- package/src/installation/install-provisioning.ts +185 -0
- package/src/installation/install.ts +262 -0
- package/src/installation/installation-source.ts +42 -0
- package/src/installation/log.ts +77 -0
- package/src/installation/machine-key.ts +65 -0
- package/src/installation/manifest.ts +95 -0
- package/src/installation/placement-options.ts +33 -0
- package/src/installation/platform-source.ts +106 -0
- package/src/installation/platform-upload.ts +34 -0
- package/src/installation/proxy-manifest.ts +113 -0
- package/src/installation/secrets.ts +79 -0
- package/src/installation/sidecar.ts +125 -0
- package/src/installation/source-options.ts +12 -0
- package/src/installation/templates/README.md +116 -0
- package/src/installation/templates/fabrika.ref +1 -0
- package/src/installation/templates/gitignore +3 -0
- package/src/installation/templates/platform.yml +144 -0
- package/src/installation/topology/artifacts.ts +141 -0
- package/src/installation/topology/index.ts +13 -0
- package/src/installation/topology/installation-schemas.ts +225 -0
- package/src/installation/topology/invariants.ts +86 -0
- package/src/installation/topology/setups.ts +442 -0
- package/src/installation/topology/topology.ts +501 -0
- package/src/installation/topology/validate.ts +26 -0
- package/src/installation/upgrade-options.ts +99 -0
- package/src/installation/upgrade-output.ts +31 -0
- package/src/installation/upgrade.ts +373 -0
- package/src/managed-env.ts +113 -0
- package/src/manifest.ts +194 -344
- package/src/namespace/claims.ts +77 -0
- package/src/namespace/codec.ts +179 -0
- package/src/namespace/constants.ts +30 -0
- package/src/namespace/errors.ts +60 -0
- package/src/namespace/manifest.ts +28 -0
- package/src/namespace/operator.ts +151 -0
- package/src/namespace/provisioning.ts +482 -0
- package/src/namespace/proxy-attempt.ts +220 -0
- package/src/namespace/proxy-source.ts +22 -0
- package/src/namespace/topology.ts +115 -0
- package/src/namespace/types.ts +114 -0
- package/src/namespace.ts +34 -1271
- package/src/plan.ts +21 -23
- package/src/provider.ts +266 -241
- package/src/source/constants.ts +11 -0
- package/src/source/port.ts +49 -0
- package/src/source/types.ts +4 -0
- package/src/source/validation.ts +72 -0
- package/src/source.ts +2 -1220
- package/src/types.ts +8 -7
- package/src/version-attempt.ts +18 -0
- package/src/codec.ts +0 -108
- package/src/namespace-command.ts +0 -209
- package/src/source-connection.ts +0 -344
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON } from '@fabrika/proxy-core/manifest'
|
|
2
|
+
import type { ZeropsNamespaceProxySource } from '../index'
|
|
3
|
+
import type { PlatformDeployService, ResolvedService } from './deploy'
|
|
4
|
+
import { type PlatformHosts, platformOrigin } from './hosts'
|
|
5
|
+
import { assertRunning, type InstallCollaborators } from './install-collaborators'
|
|
6
|
+
import { FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource } from './installation-source'
|
|
7
|
+
import type { InstallationSecrets } from './secrets'
|
|
8
|
+
|
|
9
|
+
/** The private address the proxy dials IAM at inside the project, and pass 1's placeholder issuer. */
|
|
10
|
+
export const PRIVATE_IAM_UPSTREAM = 'iam:3000'
|
|
11
|
+
const PRIVATE_IAM_URL = `http://${PRIVATE_IAM_UPSTREAM}`
|
|
12
|
+
|
|
13
|
+
/** The canonical fabrika PostgreSQL DSN on Zerops — see `src/topology/setups.ts` for why both suffixes exist. */
|
|
14
|
+
const POSTGRES_URL = '${db_connectionTlsString}/${db_dbName}?sslmode=require'
|
|
15
|
+
|
|
16
|
+
/** The light tier's one object-storage service, as the platform's own `${service_variable}` references. */
|
|
17
|
+
const STORAGE = {
|
|
18
|
+
bucket: '${storage_bucketName}',
|
|
19
|
+
accessKeyId: '${storage_accessKeyId}',
|
|
20
|
+
secretAccessKey: '${storage_secretAccessKey}',
|
|
21
|
+
endpoint: '${storage_apiUrl}',
|
|
22
|
+
} as const
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The four variables pass 1 writes on the proxy, and nothing else.
|
|
26
|
+
*
|
|
27
|
+
* The manifest is EMPTY on purpose: it is the only legal document that can be written before the hosts
|
|
28
|
+
* exist, and the build bakes whatever is here (`src/topology/setups.ts`). `FABRIKA_IAM_ISSUER` is the private
|
|
29
|
+
* address rather than the public issuer for the same reason — the public one is not known yet — and
|
|
30
|
+
* `deployPlatform` corrects it in pass 2, before the manifest it belongs to is applied.
|
|
31
|
+
*/
|
|
32
|
+
export const passOneProxyEnv = (secrets: InstallationSecrets): ReadonlyMap<string, string> =>
|
|
33
|
+
new Map([
|
|
34
|
+
[FABRIKA_PROXY_MANIFEST_JSON, encodeProxyManifestJson({ apps: [] })],
|
|
35
|
+
['FABRIKA_IAM_ISSUER', PRIVATE_IAM_URL],
|
|
36
|
+
['FABRIKA_IAM_KEY', secrets.proxyKey],
|
|
37
|
+
['FABRIKA_PROXY_SESSION_OWNER_JSON', 'null'],
|
|
38
|
+
])
|
|
39
|
+
|
|
40
|
+
/** What `installationServiceEnv` needs. Everything in it is settled before the first write. */
|
|
41
|
+
export interface InstallationEnvInput {
|
|
42
|
+
readonly environment: string
|
|
43
|
+
readonly scheme: 'http' | 'https'
|
|
44
|
+
readonly hosts: PlatformHosts
|
|
45
|
+
readonly clientId: string
|
|
46
|
+
readonly proxySource: ZeropsNamespaceProxySource
|
|
47
|
+
readonly apiBaseUrl?: string
|
|
48
|
+
readonly secrets: InstallationSecrets
|
|
49
|
+
/** The Zerops integration token minted for the control plane. Never logged, never printed. */
|
|
50
|
+
readonly zeropsAccessToken: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* EVERY variable this command places, per service.
|
|
55
|
+
*
|
|
56
|
+
* Pure, and exported, because the matrix is the thing most worth pinning: a name that is right on
|
|
57
|
+
* Cloudflare and wrong on Bun is a silent no-op rather than a failure. Two spelling rules run through
|
|
58
|
+
* it, both verified against the runtimes rather than assumed:
|
|
59
|
+
*
|
|
60
|
+
* • IAM's Bun runtime reads `ISSUER` and `ENVIRONMENT` UNPREFIXED and everything else with the
|
|
61
|
+
* `FABRIKA_IAM_` prefix (`packages/iam/src/node/runtime.ts`). The prefixed spellings below are the
|
|
62
|
+
* ones that file actually reads; the unprefixed ones are the ones it actually reads too.
|
|
63
|
+
* • No key any service declares in its own `zerops.yaml` appears here — `PORT`,
|
|
64
|
+
* `FABRIKA_IAM_RPC_URL`, `FABRIKA_OPERATIONS_URL`, `FABRIKA_CONTROL_ASSETS_DIR`,
|
|
65
|
+
* `FABRIKA_CONTROL_URL`, `FABRIKA_PROXY_MANIFEST`, `FABRIKA_PROXY_PORT`. The env API refuses those
|
|
66
|
+
* (`provider-zerops/src/api.ts`), because the conflict cannot be resolved to a record id.
|
|
67
|
+
*
|
|
68
|
+
* The proxy manifest is deliberately absent: pass 1 writes an empty one and `deployPlatform` owns the
|
|
69
|
+
* real one, in the same run as the code it describes (ADR-0027).
|
|
70
|
+
*/
|
|
71
|
+
export const installationServiceEnv = (input: InstallationEnvInput): ReadonlyMap<PlatformDeployService, ReadonlyMap<string, string>> => {
|
|
72
|
+
const { secrets } = input
|
|
73
|
+
const iamOrigin = platformOrigin(input.scheme, input.hosts.iam)
|
|
74
|
+
const operationsOrigin = platformOrigin(input.scheme, input.hosts.operations)
|
|
75
|
+
const control = new Map([
|
|
76
|
+
['FABRIKA_CONTROL_DATABASE_URL', POSTGRES_URL],
|
|
77
|
+
['ENVIRONMENT', input.environment],
|
|
78
|
+
// A bare HOST, not an origin: `controlPublicOrigin` accepts either, and this is also what the
|
|
79
|
+
// same-origin check on both first-party gateways is decided against.
|
|
80
|
+
['FABRIKA_CONTROL_DOMAIN', input.hosts.control],
|
|
81
|
+
['FABRIKA_IAM_ISSUER', iamOrigin],
|
|
82
|
+
['OPERATIONS_ARTIFACT_ORIGIN', operationsOrigin],
|
|
83
|
+
['FABRIKA_CONTROL_RUN_LOGS_BUCKET', STORAGE.bucket],
|
|
84
|
+
['FABRIKA_CONTROL_RUN_LOGS_ACCESS_KEY_ID', STORAGE.accessKeyId],
|
|
85
|
+
['FABRIKA_CONTROL_RUN_LOGS_SECRET_ACCESS_KEY', STORAGE.secretAccessKey],
|
|
86
|
+
['FABRIKA_CONTROL_RUN_LOGS_ENDPOINT', STORAGE.endpoint],
|
|
87
|
+
['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
|
|
88
|
+
['OPERATIONS_SYNC_KEY', secrets.operationsSyncKey],
|
|
89
|
+
['FABRIKA_CONTROL_VAULT_KEY', secrets.vaultKey],
|
|
90
|
+
['FABRIKA_IAM_PROVISIONING_KEY', secrets.provisioningKey],
|
|
91
|
+
['FABRIKA_ZEROPS_CLIENT_ID', input.clientId],
|
|
92
|
+
[FABRIKA_ZEROPS_PROXY_SOURCE_JSON, serializeNamespaceProxySource(input.proxySource)],
|
|
93
|
+
// The PUBLIC IAM origin an application project's proxy reaches: there is no private network
|
|
94
|
+
// between Zerops projects (ADR-0006).
|
|
95
|
+
['FABRIKA_ZEROPS_PROXY_IAM_URL', iamOrigin],
|
|
96
|
+
['FABRIKA_ZEROPS_PROXY_IAM_KEY', secrets.proxyKey],
|
|
97
|
+
['FABRIKA_ZEROPS_ACCESS_TOKEN', input.zeropsAccessToken],
|
|
98
|
+
])
|
|
99
|
+
if (input.apiBaseUrl !== undefined) {
|
|
100
|
+
// Only when the installation is not on the default region. Control spells it `_API_BASE_URL` while
|
|
101
|
+
// the CLI reads `FABRIKA_ZEROPS_API_URL`; they are different processes reading different sources.
|
|
102
|
+
control.set('FABRIKA_ZEROPS_API_BASE_URL', input.apiBaseUrl)
|
|
103
|
+
}
|
|
104
|
+
return new Map<PlatformDeployService, ReadonlyMap<string, string>>([
|
|
105
|
+
[
|
|
106
|
+
'iam',
|
|
107
|
+
new Map([
|
|
108
|
+
['FABRIKA_IAM_DATABASE_URL', POSTGRES_URL],
|
|
109
|
+
['ENVIRONMENT', input.environment],
|
|
110
|
+
['ISSUER', iamOrigin],
|
|
111
|
+
// Password only, by decision: OIDC needs an identity provider the operator has not been
|
|
112
|
+
// asked for. Both are STATED because IAM's own defaults are the other way round — OIDC on,
|
|
113
|
+
// password off — and it refuses to boot with neither.
|
|
114
|
+
['FABRIKA_IAM_OIDC_ENABLED', 'false'],
|
|
115
|
+
['FABRIKA_IAM_PASSWORD_ENABLED', 'true'],
|
|
116
|
+
['FABRIKA_IAM_SIGNING_KEYS', secrets.signingKeys],
|
|
117
|
+
['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
|
|
118
|
+
['FABRIKA_IAM_PROXY_KEY', secrets.proxyKey],
|
|
119
|
+
['FABRIKA_IAM_PROVISIONING_KEY', secrets.provisioningKey],
|
|
120
|
+
]),
|
|
121
|
+
],
|
|
122
|
+
[
|
|
123
|
+
'operations',
|
|
124
|
+
new Map([
|
|
125
|
+
['FABRIKA_OPERATIONS_DATABASE_URL', POSTGRES_URL],
|
|
126
|
+
['FABRIKA_OPERATIONS_BLOB_BUCKET', STORAGE.bucket],
|
|
127
|
+
['FABRIKA_OPERATIONS_BLOB_ACCESS_KEY_ID', STORAGE.accessKeyId],
|
|
128
|
+
['FABRIKA_OPERATIONS_BLOB_SECRET_ACCESS_KEY', STORAGE.secretAccessKey],
|
|
129
|
+
['FABRIKA_OPERATIONS_BLOB_ENDPOINT', STORAGE.endpoint],
|
|
130
|
+
['FABRIKA_OPERATIONS_PUBLIC_HOST', input.hosts.operations],
|
|
131
|
+
['ENVIRONMENT', input.environment],
|
|
132
|
+
['FABRIKA_IAM_ISSUER', iamOrigin],
|
|
133
|
+
['OPERATIONS_SYNC_KEY', secrets.operationsSyncKey],
|
|
134
|
+
['FABRIKA_IAM_RPC_KEY', secrets.rpcKey],
|
|
135
|
+
]),
|
|
136
|
+
],
|
|
137
|
+
['control', control],
|
|
138
|
+
[
|
|
139
|
+
'proxy',
|
|
140
|
+
new Map([
|
|
141
|
+
['ENVIRONMENT', input.environment],
|
|
142
|
+
// Compared byte-for-byte against a token's `iss`, so this is the PUBLIC issuer and not the
|
|
143
|
+
// private `iam:3000` pass 1 wrote.
|
|
144
|
+
['FABRIKA_IAM_ISSUER', iamOrigin],
|
|
145
|
+
['FABRIKA_IAM_KEY', secrets.proxyKey],
|
|
146
|
+
['FABRIKA_PROXY_SESSION_OWNER_JSON', JSON.stringify({ issuer: iamOrigin, upstream: PRIVATE_IAM_UPSTREAM })],
|
|
147
|
+
]),
|
|
148
|
+
],
|
|
149
|
+
])
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The keys whose VALUE this command generates, per service.
|
|
154
|
+
*
|
|
155
|
+
* Their presence on a live service is what "this project already holds an installation" means, and it is
|
|
156
|
+
* checked by KEY only — a value is never read back for comparison. `__tests__/install.test.ts` pins this
|
|
157
|
+
* against the matrix above, so a new generated secret cannot be added without appearing here.
|
|
158
|
+
*/
|
|
159
|
+
export const GENERATED_SECRET_KEYS: ReadonlyMap<PlatformDeployService, readonly string[]> = new Map<PlatformDeployService, readonly string[]>([
|
|
160
|
+
['iam', ['FABRIKA_IAM_SIGNING_KEYS', 'FABRIKA_IAM_RPC_KEY', 'FABRIKA_IAM_PROXY_KEY', 'FABRIKA_IAM_PROVISIONING_KEY']],
|
|
161
|
+
['operations', ['OPERATIONS_SYNC_KEY', 'FABRIKA_IAM_RPC_KEY']],
|
|
162
|
+
[
|
|
163
|
+
'control',
|
|
164
|
+
[
|
|
165
|
+
'FABRIKA_IAM_RPC_KEY',
|
|
166
|
+
'OPERATIONS_SYNC_KEY',
|
|
167
|
+
'FABRIKA_CONTROL_VAULT_KEY',
|
|
168
|
+
'FABRIKA_IAM_PROVISIONING_KEY',
|
|
169
|
+
'FABRIKA_ZEROPS_PROXY_IAM_KEY',
|
|
170
|
+
'FABRIKA_ZEROPS_ACCESS_TOKEN',
|
|
171
|
+
],
|
|
172
|
+
],
|
|
173
|
+
['proxy', ['FABRIKA_IAM_KEY']],
|
|
174
|
+
])
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Write a set of variables, reporting the KEYS — never a value, not even in a debug line.
|
|
178
|
+
*
|
|
179
|
+
* Write-first and unconditional: half of these are generated secrets, and a read-compare-write over a
|
|
180
|
+
* value the platform may or may not hand back verbatim would make the outcome depend on something
|
|
181
|
+
* nobody has pinned. `PUT /user-data/{id}` replaces in place, so a repeat costs a call and nothing else.
|
|
182
|
+
*/
|
|
183
|
+
export const writeServiceEnv = async (
|
|
184
|
+
service: ResolvedService,
|
|
185
|
+
desired: ReadonlyMap<string, string>,
|
|
186
|
+
{ api, log, signal }: InstallCollaborators,
|
|
187
|
+
): Promise<void> => {
|
|
188
|
+
for (const [key, value] of desired) {
|
|
189
|
+
assertRunning(signal)
|
|
190
|
+
await api.putServiceEnv({ serviceId: service.id, key, value, signal })
|
|
191
|
+
}
|
|
192
|
+
log.ok(`${service.hostname}: wrote ${[...desired.keys()].join(', ')}`)
|
|
193
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// The CLI surface of `fabrika platform install --provider=zerops`, parsed once.
|
|
2
|
+
//
|
|
3
|
+
// The same two rules `deploy-options.ts` states, for the same reasons: every CREDENTIAL comes from the
|
|
4
|
+
// environment and only from the environment (there is no `--token`, and an unknown flag is an error, so
|
|
5
|
+
// one cannot arrive on a command line), and everything else may come from a flag OR the environment.
|
|
6
|
+
//
|
|
7
|
+
// Unlike a deploy this command is INTERACTIVE — it confirms before every step that leaves the
|
|
8
|
+
// operator's disk — but nothing here prompts: a missing required value is an error naming both the flag
|
|
9
|
+
// and the variable that would supply it, so a mistyped install fails before it contacts anything.
|
|
10
|
+
|
|
11
|
+
import { describeArgument, readEnv, readFlag, requiredSetting, setting, UNATTENDED_FLAG } from '@fabrika/installation-init'
|
|
12
|
+
import { parsePlatformScheme } from './placement-options'
|
|
13
|
+
import { readInstallationSourceDir } from './source-options'
|
|
14
|
+
|
|
15
|
+
/** The only tier a bootstrap can install. See `assertInstallableTier`. */
|
|
16
|
+
export type InstallablePlatformTier = 'light'
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where the installation's project comes from.
|
|
20
|
+
*
|
|
21
|
+
* A UNION rather than an optional id beside a boolean, because the two mistakes worth making
|
|
22
|
+
* unrepresentable are "neither" and "both": the parser is the only place that can tell them apart, and
|
|
23
|
+
* downstream code must not have to ask a second time.
|
|
24
|
+
*/
|
|
25
|
+
export type InstallProjectSource =
|
|
26
|
+
/** One the operator created, empty, by hand. */
|
|
27
|
+
| { readonly kind: 'existing'; readonly projectId: string }
|
|
28
|
+
/** One this command creates, from the same declaration the provisioning import is compiled from. */
|
|
29
|
+
| { readonly kind: 'create'; readonly projectName: string }
|
|
30
|
+
|
|
31
|
+
/** Every input `runInstall` takes from the operator. */
|
|
32
|
+
export interface PlatformInstallInput {
|
|
33
|
+
/** The project this installation is brought up in — named, or created here. */
|
|
34
|
+
readonly project: InstallProjectSource
|
|
35
|
+
/** The client the project belongs to — required, because the control plane's token is minted on it. */
|
|
36
|
+
readonly clientId: string
|
|
37
|
+
/** Zerops personal access token. Environment only. */
|
|
38
|
+
readonly accessToken: string
|
|
39
|
+
/** Region API base, when the installation is not on the default one. */
|
|
40
|
+
readonly apiBaseUrl?: string
|
|
41
|
+
/** The name this installation calls itself — written to every platform service as `ENVIRONMENT`. */
|
|
42
|
+
readonly environment: string
|
|
43
|
+
/** The scheme a browser speaks to this installation. */
|
|
44
|
+
readonly scheme: 'http' | 'https'
|
|
45
|
+
readonly sourceDir: string
|
|
46
|
+
readonly tier: InstallablePlatformTier
|
|
47
|
+
/** `--yes`: answer every confirmation yes, for a bring-up with nobody at the keyboard. */
|
|
48
|
+
readonly unattended: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The canonical public release repository used for namespace proxy source identities. */
|
|
52
|
+
export const FABRIKA_REPOSITORY_URL = 'https://github.com/contember/fabrika-platform'
|
|
53
|
+
|
|
54
|
+
const FLAGS = ['--project-id', '--project-name', '--client-id', '--env', '--scheme', '--source-dir', '--tier'] as const
|
|
55
|
+
|
|
56
|
+
/** `--create-project`: bring the project up too, instead of naming one the operator made. */
|
|
57
|
+
export const CREATE_PROJECT_FLAG = '--create-project'
|
|
58
|
+
|
|
59
|
+
/** The two flags that carry no value. Written with one, they are `unexpected argument` like any other. */
|
|
60
|
+
const BOOLEAN_FLAGS = [UNATTENDED_FLAG, CREATE_PROJECT_FLAG] as const
|
|
61
|
+
|
|
62
|
+
const assertKnownArguments = (argv: readonly string[]): void => {
|
|
63
|
+
for (const arg of argv) {
|
|
64
|
+
if (BOOLEAN_FLAGS.some((flag) => flag === arg)) {
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
const name = arg.split('=')[0] ?? arg
|
|
68
|
+
if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`unexpected argument ${describeArgument(arg)}. Credentials are read from the environment and have no flag; run `
|
|
71
|
+
+ '`fabrika platform install --provider=zerops --help` for the surface',
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The tier this command can bring up.
|
|
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') {
|
|
87
|
+
return 'light'
|
|
88
|
+
}
|
|
89
|
+
throw new Error(
|
|
90
|
+
`--tier=${raw} cannot be installed into a project you created: only the light tier emits a services-only `
|
|
91
|
+
+ 'provisioning document. The standard tier is two projects fabrika creates itself',
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const readScheme = (argv: readonly string[], env: Record<string, string | undefined>): 'http' | 'https' => {
|
|
96
|
+
const raw = setting(argv, env, '--scheme', 'FABRIKA_PLATFORM_SCHEME')
|
|
97
|
+
return parsePlatformScheme(raw)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const readEnvironment = (argv: readonly string[], env: Record<string, string | undefined>): string =>
|
|
101
|
+
requiredSetting(setting(argv, env, '--env', 'FABRIKA_PLATFORM_ENVIRONMENT'), '--env', 'FABRIKA_PLATFORM_ENVIRONMENT')
|
|
102
|
+
|
|
103
|
+
/** The name a created project takes when the operator did not choose one: the installation's own. */
|
|
104
|
+
const defaultProjectName = (environment: string): string => `fabrika-${environment}`
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Decide which project this run installs into, and refuse the two ways of asking for both.
|
|
108
|
+
*
|
|
109
|
+
* `--project-id` and `--create-project` are alternatives, not a pair — one of them names a project
|
|
110
|
+
* that exists and the other makes one, and a run that was given both cannot be resolved to the operator's
|
|
111
|
+
* intent. The refusal names the SOURCE the id came from, because an id inherited from a shell that
|
|
112
|
+
* exported `FABRIKA_ZEROPS_PROJECT_ID` is the case where the collision is invisible on the command line.
|
|
113
|
+
*/
|
|
114
|
+
const readProjectSource = (argv: readonly string[], env: Record<string, string | undefined>): InstallProjectSource => {
|
|
115
|
+
const projectId = setting(argv, env, '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID')
|
|
116
|
+
const projectName = readFlag(argv, '--project-name')
|
|
117
|
+
if (!argv.includes(CREATE_PROJECT_FLAG)) {
|
|
118
|
+
if (projectName !== undefined) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`--project-name names a project to CREATE, so it needs ${CREATE_PROJECT_FLAG} beside it. Installing into a project that already `
|
|
121
|
+
+ 'exists takes --project-id=<id>, which names it by id',
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
return { kind: 'existing', projectId: requiredSetting(projectId, '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID') }
|
|
125
|
+
}
|
|
126
|
+
if (projectId !== undefined) {
|
|
127
|
+
const source = readFlag(argv, '--project-id') === undefined ? 'FABRIKA_ZEROPS_PROJECT_ID' : '--project-id'
|
|
128
|
+
throw new Error(
|
|
129
|
+
`${CREATE_PROJECT_FLAG} creates the project and ${source} names one that already exists — pass one or the other, never both`,
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
return { kind: 'create', projectName: projectName ?? defaultProjectName(readEnvironment(argv, env)) }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Parse the command's arguments and environment into the one input `runInstall` takes. */
|
|
136
|
+
export const parsePlatformInstallArgs = (
|
|
137
|
+
argv: readonly string[],
|
|
138
|
+
env: Record<string, string | undefined>,
|
|
139
|
+
): PlatformInstallInput => {
|
|
140
|
+
const sourceDir = readInstallationSourceDir(argv, env)
|
|
141
|
+
assertKnownArguments(argv)
|
|
142
|
+
const apiBaseUrl = readEnv(env, 'FABRIKA_ZEROPS_API_URL')
|
|
143
|
+
return {
|
|
144
|
+
project: readProjectSource(argv, env),
|
|
145
|
+
// Named even when the project id is known: `POST /client/{id}/integration-token` is addressed by
|
|
146
|
+
// CLIENT, and the project read does not hand one back.
|
|
147
|
+
clientId: requiredSetting(setting(argv, env, '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'), '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'),
|
|
148
|
+
accessToken: requiredSetting(readEnv(env, 'FABRIKA_ZEROPS_ACCESS_TOKEN'), '(no flag)', 'FABRIKA_ZEROPS_ACCESS_TOKEN'),
|
|
149
|
+
environment: readEnvironment(argv, env),
|
|
150
|
+
scheme: readScheme(argv, env),
|
|
151
|
+
sourceDir,
|
|
152
|
+
tier: assertInstallableTier(setting(argv, env, '--tier', 'FABRIKA_PLATFORM_TIER')),
|
|
153
|
+
unattended: argv.includes(UNATTENDED_FLAG),
|
|
154
|
+
...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { ENV_ISOLATION, renderYaml, type ZeropsProjectMode, type ZeropsProjectStatus } from '../index'
|
|
2
|
+
import { assertRunning, type InstallCollaborators } from './install-collaborators'
|
|
3
|
+
import type { PlatformInstallInput } from './install-options'
|
|
4
|
+
import { lightTopology } from './install-provisioning'
|
|
5
|
+
|
|
6
|
+
/** The project compute tier each installable platform tier requires. `corePackage` is upgrade-only. */
|
|
7
|
+
const REQUIRED_PROJECT_MODE: ZeropsProjectMode = 'LIGHT'
|
|
8
|
+
|
|
9
|
+
/** The one project state this command may install into. */
|
|
10
|
+
const PROJECT_ACTIVE: ZeropsProjectStatus = 'ACTIVE'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Project states a bring-up cannot come back from, so waiting them out is only a slower failure.
|
|
14
|
+
*
|
|
15
|
+
* `NEW`, `CREATING` and `STARTING` are on the way to `ACTIVE` and are waited on; so is a status this
|
|
16
|
+
* build has never seen, which can therefore cost time and never be read as success. These four are the
|
|
17
|
+
* other direction, and the message must NOT offer `--project-id`: resuming into a `FAILED` or `DELETING`
|
|
18
|
+
* project installs nothing.
|
|
19
|
+
*/
|
|
20
|
+
const PROJECT_UNUSABLE: ReadonlySet<ZeropsProjectStatus> = new Set<ZeropsProjectStatus>(['FAILED', 'DELETING', 'STOPPING', 'STOPPED'])
|
|
21
|
+
|
|
22
|
+
/** Live-measured: a created project reads `NEW` at t+1 s and `ACTIVE` at about t+20 s. */
|
|
23
|
+
const PROJECT_POLL_INTERVAL_MS = 2_000
|
|
24
|
+
|
|
25
|
+
/** The number of SLEEPS, so the wait is 60 × 2 s = two minutes spread between 61 reads. */
|
|
26
|
+
const PROJECT_POLL_ATTEMPTS = (2 * 60_000) / PROJECT_POLL_INTERVAL_MS
|
|
27
|
+
|
|
28
|
+
/** Consecutive `getProject` failures ridden out before the wait gives the run back to the operator. */
|
|
29
|
+
const PROJECT_READ_FAILURES = 3
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The document `--create-project` applies, carrying a `project:` block and NO services.
|
|
33
|
+
*
|
|
34
|
+
* Taken from the same declaration the provisioning import is compiled from, so `envIsolation: service`
|
|
35
|
+
* and `corePackage: LIGHT` — the two settings a project accepts at CREATION only and never afterwards —
|
|
36
|
+
* have exactly one source. Only the name is this run's. `renderImportYaml` is not the renderer here
|
|
37
|
+
* because it refuses a document with no services, and this one deliberately has none: the services arrive
|
|
38
|
+
* through the services-only import, into the project this creates.
|
|
39
|
+
*/
|
|
40
|
+
const createProjectYaml = (environment: string, projectName: string): string => {
|
|
41
|
+
const declared = lightTopology(environment).provision.document.project
|
|
42
|
+
if (declared === undefined || declared.corePackage !== REQUIRED_PROJECT_MODE || declared.envIsolation !== ENV_ISOLATION) {
|
|
43
|
+
throw new Error(`the light platform topology declares no \`${REQUIRED_PROJECT_MODE}\` project with \`envIsolation: ${ENV_ISOLATION}\` to create`)
|
|
44
|
+
}
|
|
45
|
+
return renderYaml({ project: { ...declared, name: projectName }, services: [] })
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** One poll of the created project: its status, or the failure the read itself ended in. */
|
|
49
|
+
type ProjectRead =
|
|
50
|
+
| { readonly ok: true; readonly status: ZeropsProjectStatus | undefined }
|
|
51
|
+
| { readonly ok: false; readonly failure: unknown }
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Wait for a created project to be `ACTIVE`.
|
|
55
|
+
*
|
|
56
|
+
* Live-measured (2026-08-21): the import answers in about a second and the project then reads `NEW` →
|
|
57
|
+
* `CREATING` → `ACTIVE` at about t+20 s. Whether a service import into a `CREATING` project succeeds is
|
|
58
|
+
* NOT measured, so this waits rather than finds out.
|
|
59
|
+
*
|
|
60
|
+
* Three outcomes, and the difference between them is what the operator is told to do next. `ACTIVE`
|
|
61
|
+
* returns. A state in `PROJECT_UNUSABLE` throws WITHOUT a resume hint, because the project is spent. A
|
|
62
|
+
* transient state, an unknown one, or a read that fails outright is waited out — a 5xx on the read says
|
|
63
|
+
* nothing about the project, so a few in a row are ridden out before the run is handed back, and it is
|
|
64
|
+
* handed back with the id, because by then a project exists that the operator would otherwise leak.
|
|
65
|
+
*/
|
|
66
|
+
const waitForProjectActive = async (projectId: string, { api, sleep, signal }: InstallCollaborators): Promise<void> => {
|
|
67
|
+
let failures = 0
|
|
68
|
+
let last: ZeropsProjectStatus | 'unreadable' | undefined
|
|
69
|
+
for (let attempt = 0;; attempt += 1) {
|
|
70
|
+
assertRunning(signal)
|
|
71
|
+
const read: ProjectRead = await api.getProject({ projectId, signal }).then(
|
|
72
|
+
(project): ProjectRead => ({ ok: true, status: project.status }),
|
|
73
|
+
(failure: unknown): ProjectRead => ({ ok: false, failure }),
|
|
74
|
+
)
|
|
75
|
+
if (!read.ok) {
|
|
76
|
+
failures += 1
|
|
77
|
+
last = 'unreadable'
|
|
78
|
+
if (failures > PROJECT_READ_FAILURES) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`Zerops project ${projectId} was created but could not be read ${failures} times in a row. Re-run the install with `
|
|
81
|
+
+ `--project-id=${projectId} instead of --create-project`,
|
|
82
|
+
{ cause: read.failure },
|
|
83
|
+
)
|
|
84
|
+
}
|
|
85
|
+
} else {
|
|
86
|
+
failures = 0
|
|
87
|
+
last = read.status
|
|
88
|
+
if (read.status === PROJECT_ACTIVE) {
|
|
89
|
+
return
|
|
90
|
+
}
|
|
91
|
+
if (read.status !== undefined && PROJECT_UNUSABLE.has(read.status)) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Zerops project ${projectId} reads \`${read.status}\`, which a bring-up cannot recover from: the project exists and nothing `
|
|
94
|
+
+ 'can be installed into it. Delete it and run the install again — do NOT resume into this one',
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (attempt >= PROJECT_POLL_ATTEMPTS) {
|
|
99
|
+
throw new Error(
|
|
100
|
+
`Zerops project ${projectId} still reads \`${last ?? 'no status'}\` after two minutes (${PROJECT_POLL_ATTEMPTS + 1} reads, `
|
|
101
|
+
+ `${PROJECT_POLL_INTERVAL_MS / 1_000} s apart). It exists and may yet settle — re-run the install with `
|
|
102
|
+
+ `--project-id=${projectId} instead of --create-project`,
|
|
103
|
+
)
|
|
104
|
+
}
|
|
105
|
+
await sleep(PROJECT_POLL_INTERVAL_MS, signal)
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Create the project this installation is brought up in, and wait for it to be usable.
|
|
111
|
+
*
|
|
112
|
+
* The id is reported BEFORE the wait, and that ordering is the whole point of the line: an interrupted
|
|
113
|
+
* run has already spent a project on the account, and this id is the only way back to it.
|
|
114
|
+
*/
|
|
115
|
+
const createProject = async (
|
|
116
|
+
input: PlatformInstallInput,
|
|
117
|
+
environment: string,
|
|
118
|
+
projectName: string,
|
|
119
|
+
collaborators: InstallCollaborators,
|
|
120
|
+
): Promise<string> => {
|
|
121
|
+
const { api, log, signal } = collaborators
|
|
122
|
+
assertRunning(signal)
|
|
123
|
+
const result = await api.importProject({
|
|
124
|
+
clientId: input.clientId,
|
|
125
|
+
yaml: createProjectYaml(environment, projectName),
|
|
126
|
+
signal,
|
|
127
|
+
})
|
|
128
|
+
if (result.projectId === '') {
|
|
129
|
+
throw new Error(`Zerops created a project named ${projectName} with no id — nothing can be installed into it`)
|
|
130
|
+
}
|
|
131
|
+
log.ok(
|
|
132
|
+
`created Zerops project ${result.projectId} (${result.projectName ?? projectName}) — resume with --project-id=${result.projectId} if this `
|
|
133
|
+
+ 'run is interrupted',
|
|
134
|
+
)
|
|
135
|
+
await waitForProjectActive(result.projectId, collaborators)
|
|
136
|
+
return result.projectId
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Read the project and refuse anything the topology cannot be installed into. */
|
|
140
|
+
const resolveProject = async (
|
|
141
|
+
input: PlatformInstallInput,
|
|
142
|
+
projectId: string,
|
|
143
|
+
{ api, log, signal }: InstallCollaborators,
|
|
144
|
+
): Promise<{ id: string; name: string }> => {
|
|
145
|
+
const project = await api.getProject({ projectId, signal })
|
|
146
|
+
if (project.id === '') {
|
|
147
|
+
throw new Error(`Zerops project ${projectId} could not be read with this token`)
|
|
148
|
+
}
|
|
149
|
+
if (project.mode === undefined) {
|
|
150
|
+
// Not a mismatch, so not a refusal: the client narrows an unknown value to absent, and blocking an
|
|
151
|
+
// 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 ${REQUIRED_PROJECT_MODE}`)
|
|
153
|
+
} else if (project.mode !== REQUIRED_PROJECT_MODE) {
|
|
154
|
+
throw new Error(
|
|
155
|
+
`Zerops project ${project.name} is ${project.mode} and the ${input.tier} tier needs ${REQUIRED_PROJECT_MODE}. \`corePackage\` is `
|
|
156
|
+
+ 'upgrade-only and its upgrade is partly destructive, so this is not something an install may change — create the project '
|
|
157
|
+
+ `with core package ${REQUIRED_PROJECT_MODE}, or install a tier that matches`,
|
|
158
|
+
)
|
|
159
|
+
}
|
|
160
|
+
return { id: project.id, name: project.name }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Settle which project this run installs into — the operator's, or one created here.
|
|
165
|
+
*
|
|
166
|
+
* ONE confirmation either way, and both cover the read that follows: a created project is read back
|
|
167
|
+
* through the same `resolveProject` an operator-made one goes through, so the core package is checked on
|
|
168
|
+
* the project that will actually be installed into rather than on the document that asked for it.
|
|
169
|
+
*/
|
|
170
|
+
export const resolveInstallationProject = async (
|
|
171
|
+
input: PlatformInstallInput,
|
|
172
|
+
environment: string,
|
|
173
|
+
collaborators: InstallCollaborators,
|
|
174
|
+
): Promise<{ id: string; name: string }> => {
|
|
175
|
+
const { log, prompts } = collaborators
|
|
176
|
+
const source = input.project
|
|
177
|
+
if (source.kind === 'existing') {
|
|
178
|
+
log.step('Resolve the project the operator created')
|
|
179
|
+
// One confirmation covers every READ this step makes — the project, its services, and which variable
|
|
180
|
+
// KEYS those services already carry. None of them changes anything.
|
|
181
|
+
if (
|
|
182
|
+
!(await prompts.confirm(`Read Zerops project ${source.projectId} and its services? (the token, the id, the core package, what exists)`, true))
|
|
183
|
+
) {
|
|
184
|
+
throw new Error('declined: nothing was contacted, and re-running the install is safe')
|
|
185
|
+
}
|
|
186
|
+
return resolveProject(input, source.projectId, collaborators)
|
|
187
|
+
}
|
|
188
|
+
log.step('Create the project this installation is brought up in')
|
|
189
|
+
log.info('`envIsolation` and `corePackage` are settable at project CREATION only, which is why the project is created from here.')
|
|
190
|
+
if (
|
|
191
|
+
!(await prompts.confirm(
|
|
192
|
+
`CREATE Zerops project \`${source.projectName}\` (core package ${REQUIRED_PROJECT_MODE}, envIsolation ${ENV_ISOLATION}) on client `
|
|
193
|
+
+ `${input.clientId}, then read it back?`,
|
|
194
|
+
true,
|
|
195
|
+
))
|
|
196
|
+
) {
|
|
197
|
+
throw new Error('declined: no project was created, and re-running the install is safe')
|
|
198
|
+
}
|
|
199
|
+
const projectId = await createProject(input, environment, source.projectName, collaborators)
|
|
200
|
+
return resolveProject(input, projectId, collaborators)
|
|
201
|
+
}
|