@fabrika/provider-zerops 0.3.1 → 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 +6 -6
- package/src/cli-args.ts +2 -1
- package/src/descriptor.ts +45 -1
- package/src/installation/admin-options.ts +4 -32
- package/src/installation/deploy-options.ts +6 -34
- package/src/installation/index.ts +41 -17
- package/src/installation/init-options.ts +148 -0
- package/src/installation/init.ts +94 -221
- package/src/installation/install-collaborators.ts +2 -3
- package/src/installation/install-options.ts +6 -43
- package/src/installation/install.ts +7 -31
- package/src/installation/sidecar.ts +4 -1
- package/src/installation/upgrade-options.ts +3 -12
- package/src/managed-env.ts +11 -3
- package/src/manifest.ts +6 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fabrika/provider-zerops",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -43,11 +43,11 @@
|
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
45
|
"zod": "^3.25.76",
|
|
46
|
-
"@fabrika/auth": "^0.3.
|
|
47
|
-
"@fabrika/auth-core": "^0.3.
|
|
48
|
-
"@fabrika/installation-init": "^0.3.
|
|
49
|
-
"@fabrika/provider-contract": "^0.3.
|
|
50
|
-
"@fabrika/proxy-core": "^0.3.
|
|
46
|
+
"@fabrika/auth": "^0.3.2",
|
|
47
|
+
"@fabrika/auth-core": "^0.3.2",
|
|
48
|
+
"@fabrika/installation-init": "^0.3.2",
|
|
49
|
+
"@fabrika/provider-contract": "^0.3.2",
|
|
50
|
+
"@fabrika/proxy-core": "^0.3.2",
|
|
51
51
|
"yaml": "^2.9.1"
|
|
52
52
|
}
|
|
53
53
|
}
|
package/src/cli-args.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { describeArgument } from '@fabrika/installation-init'
|
|
1
2
|
export interface ZeropsCliArgs {
|
|
2
3
|
command: string | undefined
|
|
3
4
|
subcommand: string | undefined
|
|
@@ -71,7 +72,7 @@ export const parseZeropsCliArgs = (argv: readonly string[]): ZeropsCliArgs => {
|
|
|
71
72
|
} else if (subcommand === undefined && command === 'namespace') {
|
|
72
73
|
subcommand = arg
|
|
73
74
|
} else {
|
|
74
|
-
throw new Error(`Unexpected argument: ${arg}`)
|
|
75
|
+
throw new Error(`Unexpected argument: ${describeArgument(arg)}`)
|
|
75
76
|
}
|
|
76
77
|
}
|
|
77
78
|
|
package/src/descriptor.ts
CHANGED
|
@@ -128,8 +128,52 @@ export const selectZeropsDescriptorSetup = (parsed: ZeropsDescriptor, setup?: st
|
|
|
128
128
|
return selected
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
const inheritanceSchema = z.object({
|
|
132
|
+
zerops: z.array(z.intersection(
|
|
133
|
+
descriptorMappingSchema,
|
|
134
|
+
z.object({ setup: z.string(), extends: z.union([z.string(), z.array(z.string())]).optional() }),
|
|
135
|
+
)),
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The setup entries a deploy of `selected` applies: the selected one and, transitively, every setup it
|
|
140
|
+
* `extends`. Only these reach the running service; the other setups in the file never do.
|
|
141
|
+
*/
|
|
142
|
+
export const appliedZeropsSetups = (parsed: ZeropsDescriptor, selected: string): JsonValue[] => {
|
|
143
|
+
const document = inheritanceSchema.safeParse(parsed.document)
|
|
144
|
+
if (!document.success) return invalid('requires extends to name setups')
|
|
145
|
+
const applied: JsonValue[] = []
|
|
146
|
+
const visit = (name: string): void => {
|
|
147
|
+
const entry = document.data.zerops.find((candidate) => candidate.setup === name)
|
|
148
|
+
if (entry === undefined) return invalid(`extends unknown setup \`${name}\``)
|
|
149
|
+
if (applied.includes(entry)) return
|
|
150
|
+
applied.push(entry)
|
|
151
|
+
for (const parent of typeof entry.extends === 'string' ? [entry.extends] : entry.extends ?? []) visit(parent)
|
|
152
|
+
}
|
|
153
|
+
visit(selected)
|
|
154
|
+
return applied
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Refuse a selected setup that `extends` another without its own `run`: Zerops merges `extends` per
|
|
159
|
+
* whole section, and the deploy writes `FABRIKA_RELEASE` into `run`, which would replace the inherited one.
|
|
160
|
+
*/
|
|
161
|
+
export const assertOwnRunWhenExtending = (parsed: ZeropsDescriptor, selected: string): void => {
|
|
162
|
+
const [entry] = appliedZeropsSetups(parsed, selected)
|
|
163
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) return
|
|
164
|
+
if (entry['extends'] !== undefined && entry['run'] === undefined) {
|
|
165
|
+
invalid(`setup \`${selected}\` extends another setup, so it must declare its own run section — the deploy writes FABRIKA_RELEASE into it`)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** The document Zerops executes: the selected setup and what it extends, which is exactly what the ownership check read. */
|
|
170
|
+
export const executionZeropsDocument = (parsed: ZeropsDescriptor, selected: string): { readonly [key: string]: JsonValue } => ({
|
|
171
|
+
...parsed.document,
|
|
172
|
+
zerops: appliedZeropsSetups(parsed, selected),
|
|
173
|
+
})
|
|
174
|
+
|
|
131
175
|
export const prepareZeropsDescriptor = (contents: string, setup?: string): PreparedZeropsDescriptor => {
|
|
132
176
|
const parsed = parseZeropsDescriptor(contents)
|
|
133
177
|
const selected = selectZeropsDescriptorSetup(parsed, setup)
|
|
134
|
-
return { contents: renderYaml(parsed
|
|
178
|
+
return { contents: renderYaml(executionZeropsDocument(parsed, selected)), setup: selected }
|
|
135
179
|
}
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// and prompts for nothing — and a missing required value is an error naming both the flag and the
|
|
9
9
|
// variable that would supply it.
|
|
10
10
|
|
|
11
|
+
import { describeArgument, readEnv, requiredSetting, setting } from '@fabrika/installation-init'
|
|
11
12
|
import { parsePlatformHostname, parsePlatformScheme } from './placement-options'
|
|
12
13
|
|
|
13
14
|
/** Every input `runPlatformAdmin` takes from the operator. */
|
|
@@ -36,35 +37,6 @@ const FLAGS = ['--email', '--iam-host', '--scheme'] as const
|
|
|
36
37
|
*/
|
|
37
38
|
export const REISSUE_FLAG = '--reissue'
|
|
38
39
|
|
|
39
|
-
const readFlag = (argv: readonly string[], name: string): string | undefined => {
|
|
40
|
-
const prefix = `${name}=`
|
|
41
|
-
const matches = argv.filter((arg) => arg.startsWith(prefix))
|
|
42
|
-
if (matches.length > 1) {
|
|
43
|
-
throw new Error(`${name} was given more than once`)
|
|
44
|
-
}
|
|
45
|
-
const value = matches[0]?.slice(prefix.length)
|
|
46
|
-
return value === undefined || value.trim() === '' ? undefined : value.trim()
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const readEnv = (env: Record<string, string | undefined>, name: string): string | undefined => {
|
|
50
|
-
const value = env[name]
|
|
51
|
-
return value === undefined || value.trim() === '' ? undefined : value.trim()
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
const setting = (
|
|
55
|
-
argv: readonly string[],
|
|
56
|
-
env: Record<string, string | undefined>,
|
|
57
|
-
flag: string,
|
|
58
|
-
variable: string,
|
|
59
|
-
): string | undefined => readFlag(argv, flag) ?? readEnv(env, variable)
|
|
60
|
-
|
|
61
|
-
const required = (value: string | undefined, flag: string, variable: string): string => {
|
|
62
|
-
if (value === undefined) {
|
|
63
|
-
throw new Error(`${flag}=<value> or ${variable} is required`)
|
|
64
|
-
}
|
|
65
|
-
return value
|
|
66
|
-
}
|
|
67
|
-
|
|
68
40
|
const assertKnownArguments = (argv: readonly string[]): void => {
|
|
69
41
|
for (const arg of argv) {
|
|
70
42
|
if (arg === REISSUE_FLAG) {
|
|
@@ -73,7 +45,7 @@ const assertKnownArguments = (argv: readonly string[]): void => {
|
|
|
73
45
|
const name = arg.split('=')[0] ?? arg
|
|
74
46
|
if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
|
|
75
47
|
throw new Error(
|
|
76
|
-
`unexpected argument
|
|
48
|
+
`unexpected argument ${describeArgument(arg)}. Credentials are read from the environment and have no flag; run `
|
|
77
49
|
+ '`fabrika platform admin --provider=zerops --help` for the surface',
|
|
78
50
|
)
|
|
79
51
|
}
|
|
@@ -92,7 +64,7 @@ const readScheme = (argv: readonly string[], env: Record<string, string | undefi
|
|
|
92
64
|
* composed from it and `--scheme`, so a value carrying either would compose a second one.
|
|
93
65
|
*/
|
|
94
66
|
const readIamHost = (argv: readonly string[], env: Record<string, string | undefined>): string => {
|
|
95
|
-
const host =
|
|
67
|
+
const host = requiredSetting(setting(argv, env, '--iam-host', 'FABRIKA_PLATFORM_IAM_HOST'), '--iam-host', 'FABRIKA_PLATFORM_IAM_HOST')
|
|
96
68
|
return parsePlatformHostname(host)
|
|
97
69
|
}
|
|
98
70
|
|
|
@@ -119,7 +91,7 @@ export const parsePlatformAdminArgs = (
|
|
|
119
91
|
): PlatformAdminInput => {
|
|
120
92
|
assertKnownArguments(argv)
|
|
121
93
|
return {
|
|
122
|
-
email:
|
|
94
|
+
email: requiredSetting(setting(argv, env, '--email', 'FABRIKA_PLATFORM_ADMIN_EMAIL'), '--email', 'FABRIKA_PLATFORM_ADMIN_EMAIL'),
|
|
123
95
|
iamHost: readIamHost(argv, env),
|
|
124
96
|
scheme: readScheme(argv, env),
|
|
125
97
|
provisioningKey: provisioningKey(env),
|
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
// The command is unattended by construction: nothing here prompts, and a missing required value is an
|
|
13
13
|
// error naming both the flag and the variable that would supply it.
|
|
14
14
|
|
|
15
|
+
import { describeArgument, readEnv, requiredSetting, setting } from '@fabrika/installation-init'
|
|
15
16
|
import { parsePlatformHosts, parsePlatformScheme } from './placement-options'
|
|
16
17
|
import { readInstallationSourceDir } from './source-options'
|
|
17
18
|
|
|
@@ -52,35 +53,6 @@ const FLAGS = [
|
|
|
52
53
|
'--source-dir',
|
|
53
54
|
] as const
|
|
54
55
|
|
|
55
|
-
const readFlag = (argv: readonly string[], name: string): string | undefined => {
|
|
56
|
-
const prefix = `${name}=`
|
|
57
|
-
const matches = argv.filter((arg) => arg.startsWith(prefix))
|
|
58
|
-
if (matches.length > 1) {
|
|
59
|
-
throw new Error(`${name} was given more than once`)
|
|
60
|
-
}
|
|
61
|
-
const value = matches[0]?.slice(prefix.length)
|
|
62
|
-
return value === undefined || value.trim() === '' ? undefined : value.trim()
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const readEnv = (env: Record<string, string | undefined>, name: string): string | undefined => {
|
|
66
|
-
const value = env[name]
|
|
67
|
-
return value === undefined || value.trim() === '' ? undefined : value.trim()
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
const setting = (
|
|
71
|
-
argv: readonly string[],
|
|
72
|
-
env: Record<string, string | undefined>,
|
|
73
|
-
flag: string,
|
|
74
|
-
variable: string,
|
|
75
|
-
): string | undefined => readFlag(argv, flag) ?? readEnv(env, variable)
|
|
76
|
-
|
|
77
|
-
const required = (value: string | undefined, flag: string, variable: string): string => {
|
|
78
|
-
if (value === undefined) {
|
|
79
|
-
throw new Error(`${flag}=<value> or ${variable} is required`)
|
|
80
|
-
}
|
|
81
|
-
return value
|
|
82
|
-
}
|
|
83
|
-
|
|
84
56
|
const assertKnownArguments = (argv: readonly string[]): void => {
|
|
85
57
|
for (const arg of argv) {
|
|
86
58
|
if (arg === '--dry-run') {
|
|
@@ -89,7 +61,7 @@ const assertKnownArguments = (argv: readonly string[]): void => {
|
|
|
89
61
|
const name = arg.split('=')[0] ?? arg
|
|
90
62
|
if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
|
|
91
63
|
throw new Error(
|
|
92
|
-
`unexpected argument
|
|
64
|
+
`unexpected argument ${describeArgument(arg)}. Credentials are read from the environment and have no flag; run `
|
|
93
65
|
+ '`fabrika platform deploy --provider=zerops --help` for the surface',
|
|
94
66
|
)
|
|
95
67
|
}
|
|
@@ -123,7 +95,7 @@ const readProject = (argv: readonly string[], env: Record<string, string | undef
|
|
|
123
95
|
if (name === undefined) {
|
|
124
96
|
throw new Error('--project-id=<id> or FABRIKA_ZEROPS_PROJECT_ID is required (or --project-name with --client-id)')
|
|
125
97
|
}
|
|
126
|
-
const clientId =
|
|
98
|
+
const clientId = requiredSetting(setting(argv, env, '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID'), '--client-id', 'FABRIKA_ZEROPS_CLIENT_ID')
|
|
127
99
|
return { kind: 'name', clientId, name }
|
|
128
100
|
}
|
|
129
101
|
|
|
@@ -140,12 +112,12 @@ export const parsePlatformDeployArgs = (
|
|
|
140
112
|
return {
|
|
141
113
|
project: readProject(argv, env),
|
|
142
114
|
// A dry run reads, and reading still needs a token; it is the one credential it cannot do without.
|
|
143
|
-
accessToken:
|
|
144
|
-
environment:
|
|
115
|
+
accessToken: requiredSetting(readEnv(env, 'FABRIKA_ZEROPS_ACCESS_TOKEN'), '(no flag)', 'FABRIKA_ZEROPS_ACCESS_TOKEN'),
|
|
116
|
+
environment: requiredSetting(setting(argv, env, '--env', 'FABRIKA_PLATFORM_ENVIRONMENT'), '--env', 'FABRIKA_PLATFORM_ENVIRONMENT'),
|
|
145
117
|
// Not required for a dry run: it authenticates a write this mode never makes.
|
|
146
118
|
iamAdminKey: dryRun
|
|
147
119
|
? readEnv(env, 'FABRIKA_IAM_PROVISIONING_KEY') ?? ''
|
|
148
|
-
:
|
|
120
|
+
: requiredSetting(readEnv(env, 'FABRIKA_IAM_PROVISIONING_KEY'), '(no flag)', 'FABRIKA_IAM_PROVISIONING_KEY'),
|
|
149
121
|
scheme: readScheme(argv, env),
|
|
150
122
|
dryRun,
|
|
151
123
|
...(hosts === undefined ? {} : { hosts }),
|
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
import { reconcileSchema } from '@fabrika/auth'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
describeArgument,
|
|
4
|
+
info as consoleInfo,
|
|
5
|
+
ok as consoleOk,
|
|
6
|
+
step as consoleStep,
|
|
7
|
+
UNATTENDED_FLAG,
|
|
8
|
+
warn as consoleWarn,
|
|
9
|
+
} from '@fabrika/installation-init'
|
|
3
10
|
import type { SchemaReconciler } from '@fabrika/provider-contract'
|
|
4
11
|
import type { InstallationCli, InstallationCommand } from '@fabrika/provider-contract/installation'
|
|
5
12
|
import { createZeropsApi, defaultSleep } from '../index'
|
|
@@ -7,9 +14,10 @@ import { consoleAdminCollaborators, runPlatformAdmin } from './admin'
|
|
|
7
14
|
import { parsePlatformAdminArgs, REISSUE_FLAG } from './admin-options'
|
|
8
15
|
import { deployPlatform, PLATFORM_CONCURRENT_DEPLOY, PLATFORM_DEPLOY_ORDER, PLATFORM_SEQUENTIAL_DEPLOY } from './deploy'
|
|
9
16
|
import { parsePlatformDeployArgs } from './deploy-options'
|
|
10
|
-
import { consoleInitCollaborators,
|
|
17
|
+
import { consoleInitCollaborators, runInit } from './init'
|
|
18
|
+
import { parseInitArgs } from './init-options'
|
|
11
19
|
import { consoleInstallCollaborators, runInstall } from './install'
|
|
12
|
-
import { parsePlatformInstallArgs
|
|
20
|
+
import { parsePlatformInstallArgs } from './install-options'
|
|
13
21
|
import { prepareInstallationSource } from './installation-source'
|
|
14
22
|
import { consoleDeployLog } from './log'
|
|
15
23
|
import { generatedArtifacts } from './topology/artifacts'
|
|
@@ -34,7 +42,7 @@ const USAGE = `Zerops installation
|
|
|
34
42
|
|
|
35
43
|
Commands:
|
|
36
44
|
fabrika platform install --provider=zerops [options]
|
|
37
|
-
fabrika platform init --provider=zerops <installation>
|
|
45
|
+
fabrika platform init --provider=zerops <installation> --tag=<tag> --project-id=<id> [options]
|
|
38
46
|
fabrika platform plan --provider=zerops
|
|
39
47
|
fabrika platform deploy --provider=zerops [options]
|
|
40
48
|
fabrika platform admin --provider=zerops --email=<address> --iam-host=<host> [--scheme=<http|https>] [${REISSUE_FLAG}]
|
|
@@ -109,11 +117,12 @@ Creates and maintains the operator's SIDECAR REPOSITORY: the GitHub pipeline tha
|
|
|
109
117
|
credentials. ADR-0025 puts that pipeline in a repository the operator owns — fabrika ships the
|
|
110
118
|
generator, never the pipeline, because one public repository cannot hold every account's credentials.
|
|
111
119
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
120
|
+
Laptop-side and prompt-free: every value comes from a flag or the environment, so a pipeline or an
|
|
121
|
+
agent runs it end to end. It CONFIRMS before every step that leaves the operator's disk: reading the
|
|
122
|
+
project, reading its control and proxy services, creating or pushing the repository, writing the
|
|
123
|
+
Environment, and triggering the run. A stdin that is not a terminal is refused unless \`${UNATTENDED_FLAG}\` is given.
|
|
124
|
+
Declining a write stops there and prints the command that repeats the run; a re-run is safe. Declining
|
|
125
|
+
the read of control and proxy only leaves the console and IAM origins out of what init prints.
|
|
117
126
|
|
|
118
127
|
Init writes nothing to Zerops. It reads control and proxy only to print the console and IAM origins
|
|
119
128
|
the installation records, and prints none it cannot verify. Public repositories deploy without a
|
|
@@ -129,16 +138,30 @@ file afterwards.
|
|
|
129
138
|
<installation> names this installation: the GitHub Environment, the default
|
|
130
139
|
repository \`contember/fabrika-zerops-<installation>\`, and the
|
|
131
140
|
local checkout directory
|
|
132
|
-
|
|
141
|
+
|
|
142
|
+
Options (a flag beats the environment variable beside it):
|
|
143
|
+
|
|
144
|
+
--repo=<owner>/<name> a different sidecar repository
|
|
145
|
+
--checkout=<dir> the sidecar's local checkout, default
|
|
146
|
+
./fabrika-zerops-<installation>; refused
|
|
147
|
+
inside another git repository
|
|
148
|
+
--tag=<tag> FABRIKA_PLATFORM_TAG required; the published fabrika tag to pin
|
|
149
|
+
--project-id=<id> FABRIKA_ZEROPS_PROJECT_ID required; the project holding the platform
|
|
150
|
+
--env=<name> FABRIKA_PLATFORM_ENVIRONMENT default <installation>
|
|
151
|
+
--iam-host=<host> FABRIKA_PLATFORM_IAM_HOST custom domains: all three hosts or none;
|
|
152
|
+
--console-host=<host> FABRIKA_PLATFORM_CONSOLE_HOST none derives them from the proxy's Zerops
|
|
153
|
+
--operations-host=<host> FABRIKA_PLATFORM_OPERATIONS_HOST subdomains
|
|
154
|
+
${UNATTENDED_FLAG} answer every confirmation yes —
|
|
155
|
+
required when stdin is not a terminal
|
|
133
156
|
|
|
134
157
|
It writes TWO secrets into that Environment and GENERATES NEITHER — both already belong to the
|
|
135
|
-
installation, so a value invented here would not be one it accepts:
|
|
158
|
+
installation, so a value invented here would not be one it accepts. Both are environment only:
|
|
136
159
|
|
|
137
|
-
|
|
138
|
-
|
|
160
|
+
FABRIKA_ZEROPS_ACCESS_TOKEN required the INTEGRATION token install minted (the \`control\` service's env)
|
|
161
|
+
FABRIKA_IAM_PROVISIONING_KEY required the px_ key install printed
|
|
162
|
+
FABRIKA_ZEROPS_API_URL optional region API base, when not the default
|
|
139
163
|
|
|
140
|
-
Each is
|
|
141
|
-
over \`gh\` stdin, and never printed and never written to disk.
|
|
164
|
+
Each is sent to GitHub over \`gh\` stdin, and never printed and never written to disk.
|
|
142
165
|
|
|
143
166
|
The generated pipeline pins a published TAG of contember/fabrika-platform and refuses a branch, at
|
|
144
167
|
init and again in the workflow. It uploads that checkout's exact committed archive to every installation service.
|
|
@@ -273,7 +296,7 @@ const neverAborted = (): AbortSignal => new AbortController().signal
|
|
|
273
296
|
|
|
274
297
|
const runPlan = (argv: readonly string[]): void => {
|
|
275
298
|
if (argv.length > 0) {
|
|
276
|
-
throw new Error(`Unexpected Zerops plan arguments: ${argv.join(' ')}`)
|
|
299
|
+
throw new Error(`Unexpected Zerops plan arguments: ${argv.map(describeArgument).join(' ')}`)
|
|
277
300
|
}
|
|
278
301
|
const artifacts = generatedArtifacts()
|
|
279
302
|
for (const artifact of artifacts) {
|
|
@@ -342,7 +365,8 @@ export const installationCli: InstallationCli = {
|
|
|
342
365
|
return
|
|
343
366
|
}
|
|
344
367
|
if (command === 'init') {
|
|
345
|
-
|
|
368
|
+
const input = parseInitArgs(argv, process.env)
|
|
369
|
+
await runInit(input, consoleInitCollaborators(input))
|
|
346
370
|
return
|
|
347
371
|
}
|
|
348
372
|
if (command === 'plan') {
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// The CLI surface of `fabrika platform init --provider=zerops`, parsed once.
|
|
2
|
+
//
|
|
3
|
+
// The same two rules `deploy-options.ts` states: every CREDENTIAL comes from the environment and only
|
|
4
|
+
// from the environment, and everything else may come from a flag OR the environment, flag first.
|
|
5
|
+
// Nothing here prompts — a missing required value is an error naming both the flag and the variable —
|
|
6
|
+
// so a pipeline or an agent drives init end to end. Its confirmations stay, answered by `--yes`.
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
CHECKOUT_FLAG,
|
|
10
|
+
type CommandEnv,
|
|
11
|
+
describeArgument,
|
|
12
|
+
readEnv,
|
|
13
|
+
readFlag,
|
|
14
|
+
requiredSetting,
|
|
15
|
+
setting,
|
|
16
|
+
sidecarCheckoutDir,
|
|
17
|
+
UNATTENDED_FLAG,
|
|
18
|
+
} from '@fabrika/installation-init'
|
|
19
|
+
import { parsePlatformHosts } from './placement-options'
|
|
20
|
+
import { assertPinnedTag, defaultCheckoutName, defaultSidecarRepo } from './sidecar'
|
|
21
|
+
|
|
22
|
+
/** The name every bypass in this platform is gated on, and therefore the one name an installation cannot take. */
|
|
23
|
+
const LOCAL_ENVIRONMENT = 'local'
|
|
24
|
+
|
|
25
|
+
/** Every input `runInit` takes from the operator. */
|
|
26
|
+
export interface InitInput {
|
|
27
|
+
/** Names the GitHub Environment, the default repository and the local checkout directory. */
|
|
28
|
+
readonly installation: string
|
|
29
|
+
readonly repo: string
|
|
30
|
+
/** The sidecar's local checkout: `--checkout`, or `fabrika-zerops-<installation>` in the working directory. */
|
|
31
|
+
readonly checkoutDir: string
|
|
32
|
+
/** The published fabrika-platform tag the sidecar pins in `fabrika.ref`. */
|
|
33
|
+
readonly fabrikaRef: string
|
|
34
|
+
readonly projectId: string
|
|
35
|
+
/** The name this installation calls itself — written to every service as `ENVIRONMENT`. */
|
|
36
|
+
readonly environmentName: string
|
|
37
|
+
/** All three or none: none derives them from the proxy's Zerops subdomains. */
|
|
38
|
+
readonly hosts?: { readonly iam: string; readonly control: string; readonly operations: string }
|
|
39
|
+
/** Region API base, when the installation is not on the default one. */
|
|
40
|
+
readonly apiBaseUrl?: string
|
|
41
|
+
/** The Zerops INTEGRATION token `platform install` minted. Environment only. */
|
|
42
|
+
readonly accessToken: string
|
|
43
|
+
/** The `px_` key this installation's IAM was seeded with. Environment only. */
|
|
44
|
+
readonly provisioningKey: string
|
|
45
|
+
/** `--yes`: answer every confirmation yes. */
|
|
46
|
+
readonly unattended: boolean
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const FLAGS = ['--repo', CHECKOUT_FLAG, '--tag', '--project-id', '--env', '--iam-host', '--console-host', '--operations-host'] as const
|
|
50
|
+
|
|
51
|
+
const REPO_PATTERN = /^[^/\s]+\/[^/\s]+$/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The installation's environment name, refused when it claims to be local.
|
|
55
|
+
*
|
|
56
|
+
* The same refusal the services enforce at boot: `local` is the value `localDevLogin`, the ephemeral
|
|
57
|
+
* signing key and the credential-less caller path are all gated on, and a deployed installation is
|
|
58
|
+
* never a loopback address.
|
|
59
|
+
*/
|
|
60
|
+
export const checkedEnvironmentName = (declared: string): string => {
|
|
61
|
+
const trimmed = declared.trim()
|
|
62
|
+
if (trimmed === '') {
|
|
63
|
+
throw new Error('an installation environment name is required — it is written to every service as ENVIRONMENT')
|
|
64
|
+
}
|
|
65
|
+
if (trimmed === LOCAL_ENVIRONMENT) {
|
|
66
|
+
throw new Error(
|
|
67
|
+
`\`${LOCAL_ENVIRONMENT}\` is refused: it is the name every development bypass is gated on, and a deployed `
|
|
68
|
+
+ 'installation is not a loopback address. Name it after the installation instead',
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
return trimmed
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const assertKnownArguments = (argv: readonly string[]): void => {
|
|
75
|
+
for (const arg of argv) {
|
|
76
|
+
if (arg === UNATTENDED_FLAG || !arg.startsWith('-')) {
|
|
77
|
+
continue
|
|
78
|
+
}
|
|
79
|
+
const name = arg.split('=')[0] ?? arg
|
|
80
|
+
if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`unexpected argument ${describeArgument(arg)}. Credentials are read from the environment and have no flag; run `
|
|
83
|
+
+ '`fabrika platform init --provider=zerops --help` for the surface',
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const readInstallation = (argv: readonly string[]): string => {
|
|
90
|
+
const positional = argv.filter((arg) => !arg.startsWith('-')).map((arg) => arg.trim())
|
|
91
|
+
if (positional.length > 1) {
|
|
92
|
+
throw new Error('unexpected second positional argument: name ONE installation')
|
|
93
|
+
}
|
|
94
|
+
const installation = positional[0]
|
|
95
|
+
if (installation === undefined || installation === '') {
|
|
96
|
+
throw new Error('Zerops installation init requires an installation name, for example `fabrika platform init --provider=zerops test`')
|
|
97
|
+
}
|
|
98
|
+
return installation
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const readRepo = (argv: readonly string[], installation: string): string => {
|
|
102
|
+
const repo = readFlag(argv, '--repo')
|
|
103
|
+
if (repo === undefined) {
|
|
104
|
+
return defaultSidecarRepo(installation)
|
|
105
|
+
}
|
|
106
|
+
if (!REPO_PATTERN.test(repo)) {
|
|
107
|
+
throw new Error(`\`${repo}\` is not a <owner>/<name> repository`)
|
|
108
|
+
}
|
|
109
|
+
return repo
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A credential, with the one message that says why there is no flag for it. */
|
|
113
|
+
const credential = (env: CommandEnv, variable: string, what: string): string => {
|
|
114
|
+
const value = readEnv(env, variable)
|
|
115
|
+
if (value === undefined) {
|
|
116
|
+
throw new Error(`${variable} is required: ${what}. It has no flag — a credential on a command line lands in logs and shell history`)
|
|
117
|
+
}
|
|
118
|
+
return value
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Parse the command's arguments and environment into the one input `runInit` takes. */
|
|
122
|
+
export const parseInitArgs = (argv: readonly string[], env: CommandEnv, cwd: string = process.cwd()): InitInput => {
|
|
123
|
+
assertKnownArguments(argv)
|
|
124
|
+
const installation = readInstallation(argv)
|
|
125
|
+
const hosts = parsePlatformHosts({
|
|
126
|
+
iam: setting(argv, env, '--iam-host', 'FABRIKA_PLATFORM_IAM_HOST'),
|
|
127
|
+
control: setting(argv, env, '--console-host', 'FABRIKA_PLATFORM_CONSOLE_HOST'),
|
|
128
|
+
operations: setting(argv, env, '--operations-host', 'FABRIKA_PLATFORM_OPERATIONS_HOST'),
|
|
129
|
+
})
|
|
130
|
+
const apiBaseUrl = readEnv(env, 'FABRIKA_ZEROPS_API_URL')
|
|
131
|
+
return {
|
|
132
|
+
installation,
|
|
133
|
+
repo: readRepo(argv, installation),
|
|
134
|
+
checkoutDir: sidecarCheckoutDir(readFlag(argv, CHECKOUT_FLAG), defaultCheckoutName(installation), cwd),
|
|
135
|
+
fabrikaRef: assertPinnedTag(requiredSetting(setting(argv, env, '--tag', 'FABRIKA_PLATFORM_TAG'), '--tag', 'FABRIKA_PLATFORM_TAG')),
|
|
136
|
+
projectId: requiredSetting(setting(argv, env, '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID'), '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID'),
|
|
137
|
+
environmentName: checkedEnvironmentName(setting(argv, env, '--env', 'FABRIKA_PLATFORM_ENVIRONMENT') ?? installation),
|
|
138
|
+
accessToken: credential(
|
|
139
|
+
env,
|
|
140
|
+
'FABRIKA_ZEROPS_ACCESS_TOKEN',
|
|
141
|
+
"the Zerops INTEGRATION token `platform install` minted, read from the `control` service's environment",
|
|
142
|
+
),
|
|
143
|
+
provisioningKey: credential(env, 'FABRIKA_IAM_PROVISIONING_KEY', 'the px_ key `platform install` printed'),
|
|
144
|
+
unattended: argv.includes(UNATTENDED_FLAG),
|
|
145
|
+
...(hosts === undefined ? {} : { hosts }),
|
|
146
|
+
...(apiBaseUrl === undefined ? {} : { apiBaseUrl }),
|
|
147
|
+
}
|
|
148
|
+
}
|
package/src/installation/init.ts
CHANGED
|
@@ -23,18 +23,17 @@
|
|
|
23
23
|
import {
|
|
24
24
|
action as consoleAction,
|
|
25
25
|
configureEnvironment,
|
|
26
|
-
|
|
26
|
+
type Confirmations,
|
|
27
|
+
confirmations,
|
|
27
28
|
type EnvironmentConfig,
|
|
28
29
|
ghRepoExists,
|
|
29
30
|
info as consoleInfo,
|
|
30
31
|
ok as consoleOk,
|
|
31
32
|
scaffoldSidecarRepository,
|
|
32
|
-
|
|
33
|
-
select as promptSelect,
|
|
33
|
+
shellWord,
|
|
34
34
|
type SidecarScaffoldInput,
|
|
35
35
|
type SidecarScaffoldResult,
|
|
36
36
|
step as consoleStep,
|
|
37
|
-
text as promptText,
|
|
38
37
|
triggerPlatformWorkflow,
|
|
39
38
|
url,
|
|
40
39
|
warn as consoleWarn,
|
|
@@ -42,21 +41,10 @@ import {
|
|
|
42
41
|
import { createZeropsApi, type ZeropsApi, type ZeropsService } from '../index'
|
|
43
42
|
import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
|
|
44
43
|
import { derivePlatformHosts, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
|
|
44
|
+
import type { InitInput } from './init-options'
|
|
45
45
|
import type { InitLog } from './log'
|
|
46
46
|
import { MACHINE_KEY_NEXT_STEP_TITLE, machineKeyNextStepLines } from './machine-key'
|
|
47
|
-
import {
|
|
48
|
-
|
|
49
|
-
/** The name every bypass in this platform is gated on, and therefore the one name an installation cannot take. */
|
|
50
|
-
const LOCAL_ENVIRONMENT = 'local'
|
|
51
|
-
|
|
52
|
-
/** Everything `runInit` asks the operator. Injected so the flow can be exercised without a TTY. */
|
|
53
|
-
export interface InitPrompts {
|
|
54
|
-
text(question: string, fallback?: string): Promise<string>
|
|
55
|
-
confirm(question: string, defaultYes?: boolean): Promise<boolean>
|
|
56
|
-
select<T>(question: string, options: { label: string; value: T }[]): Promise<T>
|
|
57
|
-
/** A hidden prompt, or the named environment variable when it is already set. NEVER echoed. */
|
|
58
|
-
secret(variable: string, question: string): Promise<string>
|
|
59
|
-
}
|
|
47
|
+
import { materializeSidecarScaffold, readPinnedTag, SIDECAR_FILES } from './sidecar'
|
|
60
48
|
|
|
61
49
|
/** Everything `runInit` does outside the operator's own disk. Every one of them is confirmed first. */
|
|
62
50
|
export interface InitEffects {
|
|
@@ -72,80 +60,15 @@ export interface InitEffects {
|
|
|
72
60
|
|
|
73
61
|
export interface InitCollaborators {
|
|
74
62
|
readonly log: InitLog
|
|
75
|
-
readonly prompts:
|
|
63
|
+
readonly prompts: Confirmations
|
|
76
64
|
readonly effects: InitEffects
|
|
77
65
|
}
|
|
78
66
|
|
|
79
|
-
/** `<installation>` plus the one optional flag, so the repository can be named without a prompt. */
|
|
80
|
-
export interface InitArguments {
|
|
81
|
-
readonly installation: string
|
|
82
|
-
readonly repo?: string
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export const parseInitArgs = (argv: readonly string[]): InitArguments => {
|
|
86
|
-
let installation: string | undefined
|
|
87
|
-
let repo: string | undefined
|
|
88
|
-
for (const arg of argv) {
|
|
89
|
-
if (arg.startsWith('--repo=')) {
|
|
90
|
-
repo = arg.slice('--repo='.length).trim()
|
|
91
|
-
continue
|
|
92
|
-
}
|
|
93
|
-
if (arg.startsWith('-')) {
|
|
94
|
-
throw new Error(`unexpected argument \`${arg}\`. Usage: fabrika platform init --provider=zerops <installation> [--repo=<owner>/<name>]`)
|
|
95
|
-
}
|
|
96
|
-
if (installation !== undefined) {
|
|
97
|
-
throw new Error(`unexpected argument \`${arg}\`: name ONE installation`)
|
|
98
|
-
}
|
|
99
|
-
installation = arg.trim()
|
|
100
|
-
}
|
|
101
|
-
if (installation === undefined || installation === '') {
|
|
102
|
-
throw new Error('Zerops installation init requires an installation name, for example `fabrika platform init --provider=zerops test`')
|
|
103
|
-
}
|
|
104
|
-
if (repo !== undefined && !/^[^/\s]+\/[^/\s]+$/.test(repo)) {
|
|
105
|
-
throw new Error(`\`${repo}\` is not a <owner>/<name> repository`)
|
|
106
|
-
}
|
|
107
|
-
return { installation, ...(repo === undefined || repo === '' ? {} : { repo }) }
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
/**
|
|
111
|
-
* The installation's environment name, refused when it claims to be local.
|
|
112
|
-
*
|
|
113
|
-
* The same refusal the services enforce at boot: `local` is the value `localDevLogin`, the ephemeral
|
|
114
|
-
* signing key and the credential-less caller path are all gated on, and a deployed installation is
|
|
115
|
-
* never a loopback address. Refusing it here means the operator finds out at the prompt rather than in
|
|
116
|
-
* a failed readiness check.
|
|
117
|
-
*/
|
|
118
|
-
export const checkedEnvironmentName = (declared: string): string => {
|
|
119
|
-
const trimmed = declared.trim()
|
|
120
|
-
if (trimmed === '') {
|
|
121
|
-
throw new Error('an installation environment name is required — it is written to every service as ENVIRONMENT')
|
|
122
|
-
}
|
|
123
|
-
if (trimmed === LOCAL_ENVIRONMENT) {
|
|
124
|
-
throw new Error(
|
|
125
|
-
`\`${LOCAL_ENVIRONMENT}\` is refused: it is the name every development bypass is gated on, and a deployed `
|
|
126
|
-
+ 'installation is not a loopback address. Name it after the installation instead',
|
|
127
|
-
)
|
|
128
|
-
}
|
|
129
|
-
return trimmed
|
|
130
|
-
}
|
|
131
|
-
|
|
132
67
|
/** Where this installation answers, as far as `init` needs to know it. */
|
|
133
68
|
interface Placement {
|
|
134
69
|
readonly hosts?: { readonly iam: string; readonly control: string; readonly operations: string }
|
|
135
70
|
}
|
|
136
71
|
|
|
137
|
-
/** Everything collected before anything leaves this disk. */
|
|
138
|
-
interface Collected {
|
|
139
|
-
readonly repo: string
|
|
140
|
-
readonly fabrikaRef: string
|
|
141
|
-
readonly projectId: string
|
|
142
|
-
readonly environmentName: string
|
|
143
|
-
readonly placement: Placement
|
|
144
|
-
readonly apiBaseUrl?: string
|
|
145
|
-
readonly accessToken: string
|
|
146
|
-
readonly provisioningKey: string
|
|
147
|
-
}
|
|
148
|
-
|
|
149
72
|
export interface InspectPlatformInput {
|
|
150
73
|
readonly projectId: string
|
|
151
74
|
readonly accessToken: string
|
|
@@ -159,75 +82,6 @@ export interface PlatformInspection {
|
|
|
159
82
|
readonly proxyPublished: boolean
|
|
160
83
|
}
|
|
161
84
|
|
|
162
|
-
const requiredAnswer = async (prompts: InitPrompts, question: string, fallback?: string): Promise<string> => {
|
|
163
|
-
const answer = (await prompts.text(question, fallback)).trim()
|
|
164
|
-
if (answer === '') {
|
|
165
|
-
throw new Error(`${question} is required`)
|
|
166
|
-
}
|
|
167
|
-
return answer
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const bareHost = (value: string, label: string): string => {
|
|
171
|
-
const host = value.trim()
|
|
172
|
-
if (host === '' || host.includes('/') || host.includes(':')) {
|
|
173
|
-
throw new Error(`\`${value}\` is not a bare hostname — ${label} carries no scheme and no port`)
|
|
174
|
-
}
|
|
175
|
-
return host
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const collect = async (args: InitArguments, { log, prompts }: InitCollaborators): Promise<Collected> => {
|
|
179
|
-
log.step('The installation this repository will deploy')
|
|
180
|
-
log.info('Nothing leaves this machine until every question below is answered.')
|
|
181
|
-
const repo = args.repo ?? await requiredAnswer(prompts, 'Sidecar repository (<owner>/<name>)', defaultSidecarRepo(args.installation))
|
|
182
|
-
// Refused HERE, before the credential prompts and long before anything is created: a branch pin is a
|
|
183
|
-
// question for the operator, not a failure in their CI.
|
|
184
|
-
const fabrikaRef = assertPinnedTag(await requiredAnswer(prompts, 'fabrika-platform tag to pin (a published tag, never a branch)'))
|
|
185
|
-
const projectId = await requiredAnswer(prompts, 'Zerops project id (the project holding iam, operations, proxy and control)')
|
|
186
|
-
const environmentName = checkedEnvironmentName(await requiredAnswer(prompts, 'Installation environment name', args.installation))
|
|
187
|
-
|
|
188
|
-
const custom = await prompts.select('Where does this installation answer?', [
|
|
189
|
-
{ label: 'Zerops subdomains — derived from the proxy, and the deploy publishes them', value: false },
|
|
190
|
-
{ label: 'Custom domains — bound to the project balancer out of band', value: true },
|
|
191
|
-
])
|
|
192
|
-
const placement: Placement = custom
|
|
193
|
-
? {
|
|
194
|
-
hosts: {
|
|
195
|
-
iam: bareHost(await requiredAnswer(prompts, 'IAM hostname'), 'a platform host'),
|
|
196
|
-
control: bareHost(await requiredAnswer(prompts, 'Console hostname'), 'a platform host'),
|
|
197
|
-
operations: bareHost(await requiredAnswer(prompts, 'Operations ingest hostname'), 'a platform host'),
|
|
198
|
-
},
|
|
199
|
-
}
|
|
200
|
-
: {}
|
|
201
|
-
|
|
202
|
-
const apiBaseUrl = (await prompts.text('Zerops region API base (blank = the default region)', '')).trim()
|
|
203
|
-
|
|
204
|
-
log.step('Credentials')
|
|
205
|
-
log.info('Both already belong to the installation. Neither is generated here, printed, or written to disk;')
|
|
206
|
-
log.info('each is sent to GitHub over `gh` stdin and reaches the deploy as an Environment secret.')
|
|
207
|
-
const accessToken = await prompts.secret(
|
|
208
|
-
'FABRIKA_ZEROPS_ACCESS_TOKEN',
|
|
209
|
-
"Zerops access token (an INTEGRATION token scoped to this installation's projects, never a personal one)",
|
|
210
|
-
)
|
|
211
|
-
const provisioningKey = await prompts.secret(
|
|
212
|
-
'FABRIKA_IAM_PROVISIONING_KEY',
|
|
213
|
-
"IAM provisioning key (the px_ admin key this installation's IAM already holds)",
|
|
214
|
-
)
|
|
215
|
-
if (accessToken.trim() === '' || provisioningKey.trim() === '') {
|
|
216
|
-
throw new Error('both credentials are required: the deploy authenticates to Zerops and to IAM with them')
|
|
217
|
-
}
|
|
218
|
-
|
|
219
|
-
return {
|
|
220
|
-
repo,
|
|
221
|
-
fabrikaRef,
|
|
222
|
-
projectId,
|
|
223
|
-
environmentName,
|
|
224
|
-
placement,
|
|
225
|
-
accessToken: accessToken.trim(),
|
|
226
|
-
provisioningKey: provisioningKey.trim(),
|
|
227
|
-
...(apiBaseUrl === '' ? {} : { apiBaseUrl }),
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
85
|
const oneService = (services: readonly ZeropsService[], name: string): ZeropsService | undefined => {
|
|
232
86
|
const matches = services.filter((service) => service.name === name)
|
|
233
87
|
if (matches.length > 1) throw new Error(`Zerops project has ${matches.length} services named \`${name}\``)
|
|
@@ -318,47 +172,64 @@ export const sourceConnectionSettingsUrl = (placement: Placement, platform: Plat
|
|
|
318
172
|
}
|
|
319
173
|
|
|
320
174
|
/** The Environment the generated workflow reads. Secret VALUES appear here and nowhere else. */
|
|
321
|
-
const environmentConfig = (installation: string,
|
|
322
|
-
repo:
|
|
175
|
+
const environmentConfig = (installation: string, input: InitInput): EnvironmentConfig => ({
|
|
176
|
+
repo: input.repo,
|
|
323
177
|
environment: installation,
|
|
324
178
|
secrets: {
|
|
325
|
-
FABRIKA_ZEROPS_ACCESS_TOKEN:
|
|
326
|
-
FABRIKA_IAM_PROVISIONING_KEY:
|
|
179
|
+
FABRIKA_ZEROPS_ACCESS_TOKEN: input.accessToken,
|
|
180
|
+
FABRIKA_IAM_PROVISIONING_KEY: input.provisioningKey,
|
|
327
181
|
},
|
|
328
182
|
vars: {
|
|
329
|
-
FABRIKA_ZEROPS_PROJECT_ID:
|
|
330
|
-
FABRIKA_PLATFORM_ENVIRONMENT:
|
|
183
|
+
FABRIKA_ZEROPS_PROJECT_ID: input.projectId,
|
|
184
|
+
FABRIKA_PLATFORM_ENVIRONMENT: input.environmentName,
|
|
331
185
|
// Stated rather than defaulted, so the Environment describes the whole deploy.
|
|
332
186
|
FABRIKA_PLATFORM_SCHEME: 'https',
|
|
333
|
-
...(
|
|
334
|
-
FABRIKA_PLATFORM_IAM_HOST:
|
|
335
|
-
FABRIKA_PLATFORM_CONSOLE_HOST:
|
|
336
|
-
FABRIKA_PLATFORM_OPERATIONS_HOST:
|
|
187
|
+
...(input.hosts === undefined ? {} : {
|
|
188
|
+
FABRIKA_PLATFORM_IAM_HOST: input.hosts.iam,
|
|
189
|
+
FABRIKA_PLATFORM_CONSOLE_HOST: input.hosts.control,
|
|
190
|
+
FABRIKA_PLATFORM_OPERATIONS_HOST: input.hosts.operations,
|
|
337
191
|
}),
|
|
338
|
-
...(
|
|
192
|
+
...(input.apiBaseUrl === undefined ? {} : { FABRIKA_ZEROPS_API_URL: input.apiBaseUrl }),
|
|
339
193
|
},
|
|
340
194
|
})
|
|
341
195
|
|
|
196
|
+
/** The command that repeats this run, with every value but the two credentials, which stay in the environment. */
|
|
197
|
+
const rerunCommand = (input: InitInput): string =>
|
|
198
|
+
[
|
|
199
|
+
'fabrika platform init --provider=zerops',
|
|
200
|
+
...[
|
|
201
|
+
input.installation,
|
|
202
|
+
`--repo=${input.repo}`,
|
|
203
|
+
`--checkout=${input.checkoutDir}`,
|
|
204
|
+
`--tag=${input.fabrikaRef}`,
|
|
205
|
+
`--project-id=${input.projectId}`,
|
|
206
|
+
`--env=${input.environmentName}`,
|
|
207
|
+
...(input.hosts === undefined
|
|
208
|
+
? []
|
|
209
|
+
: [`--iam-host=${input.hosts.iam}`, `--console-host=${input.hosts.control}`, `--operations-host=${input.hosts.operations}`]),
|
|
210
|
+
].map(shellWord),
|
|
211
|
+
].join(' ')
|
|
212
|
+
|
|
342
213
|
/** Read the project back, so a wrong token or a mistyped id fails here rather than in someone's CI. */
|
|
343
|
-
const verifyProject = async (
|
|
214
|
+
const verifyProject = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<void> => {
|
|
344
215
|
log.step('Check the token against the project')
|
|
345
|
-
if (!(await prompts.confirm(`Read Zerops project ${
|
|
216
|
+
if (!(await prompts.confirm(`Read Zerops project ${input.projectId} to check the access token and the id?`, true))) {
|
|
346
217
|
log.info('Skipped. A wrong token or project id will surface in the first pipeline run instead.')
|
|
347
218
|
return
|
|
348
219
|
}
|
|
349
220
|
// A caught error is never re-thrown as itself: it may quote a request URL or a platform payload, and
|
|
350
221
|
// this is the one step holding both credentials. A short sentence naming the two causes is enough.
|
|
351
222
|
const name = await effects.describeProject({
|
|
352
|
-
projectId:
|
|
353
|
-
accessToken:
|
|
354
|
-
...(
|
|
223
|
+
projectId: input.projectId,
|
|
224
|
+
accessToken: input.accessToken,
|
|
225
|
+
...(input.apiBaseUrl === undefined ? {} : { apiBaseUrl: input.apiBaseUrl }),
|
|
355
226
|
}).catch(() => {
|
|
356
227
|
throw new Error(
|
|
357
|
-
`Zerops project ${
|
|
228
|
+
`Zerops project ${input.projectId} could not be read: the access token does not grant it, or the id is wrong. `
|
|
358
229
|
+ 'Nothing has been created — check both and run init again',
|
|
359
230
|
)
|
|
360
231
|
})
|
|
361
|
-
log.ok(`Zerops project ${name} (${
|
|
232
|
+
log.ok(`Zerops project ${name} (${input.projectId}) is readable with this token.`)
|
|
362
233
|
}
|
|
363
234
|
|
|
364
235
|
/**
|
|
@@ -366,16 +237,16 @@ const verifyProject = async (collected: Collected, { log, prompts, effects }: In
|
|
|
366
237
|
*
|
|
367
238
|
* Declining is not fatal: init then prints no origin rather than one it would have to guess.
|
|
368
239
|
*/
|
|
369
|
-
const readPlatform = async (
|
|
240
|
+
const readPlatform = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<PlatformInspection | undefined> => {
|
|
370
241
|
log.step('Read the installation')
|
|
371
|
-
if (!(await prompts.confirm(`Read \`control\` and \`proxy\` in Zerops project ${
|
|
242
|
+
if (!(await prompts.confirm(`Read \`control\` and \`proxy\` in Zerops project ${input.projectId} for the console and IAM origins?`, true))) {
|
|
372
243
|
log.info('Skipped. No console or IAM origin will be printed.')
|
|
373
244
|
return undefined
|
|
374
245
|
}
|
|
375
246
|
return effects.inspectPlatform({
|
|
376
|
-
projectId:
|
|
377
|
-
accessToken:
|
|
378
|
-
...(
|
|
247
|
+
projectId: input.projectId,
|
|
248
|
+
accessToken: input.accessToken,
|
|
249
|
+
...(input.apiBaseUrl === undefined ? {} : { apiBaseUrl: input.apiBaseUrl }),
|
|
379
250
|
}).catch(() => {
|
|
380
251
|
throw new Error('the `control` and `proxy` services could not be read; no credential value is shown. Inspect them in Zerops, then run init again')
|
|
381
252
|
})
|
|
@@ -388,52 +259,53 @@ const readPlatform = async (collected: Collected, { log, prompts, effects }: Ini
|
|
|
388
259
|
* outward step: an Environment cannot be written to a repository that does not exist, and a workflow
|
|
389
260
|
* cannot be triggered without one.
|
|
390
261
|
*/
|
|
391
|
-
const scaffold = async (
|
|
392
|
-
installation: string,
|
|
393
|
-
collected: Collected,
|
|
394
|
-
{ log, prompts, effects }: InitCollaborators,
|
|
395
|
-
): Promise<string | undefined> => {
|
|
262
|
+
const scaffold = async (input: InitInput, { log, prompts, effects }: InitCollaborators): Promise<string | undefined> => {
|
|
396
263
|
log.step('Scaffold the sidecar repository')
|
|
397
|
-
const exists = await effects.repositoryExists(
|
|
264
|
+
const exists = await effects.repositoryExists(input.repo)
|
|
398
265
|
const question = exists
|
|
399
|
-
? `Refresh the pipeline on ${
|
|
400
|
-
: `Create ${
|
|
266
|
+
? `Refresh the pipeline on ${input.repo} and push?`
|
|
267
|
+
: `Create ${input.repo} (private) on GitHub and push the pipeline?`
|
|
401
268
|
if (!(await prompts.confirm(question, true))) {
|
|
402
269
|
log.action('OPERATOR ACTION — nothing was created; run init again when ready', [
|
|
403
|
-
|
|
270
|
+
rerunCommand(input),
|
|
404
271
|
])
|
|
405
272
|
return undefined
|
|
406
273
|
}
|
|
407
|
-
const dir = defaultCheckoutDir(installation)
|
|
408
274
|
const result = await effects.scaffold({
|
|
409
|
-
repo:
|
|
410
|
-
dir,
|
|
275
|
+
repo: input.repo,
|
|
276
|
+
dir: input.checkoutDir,
|
|
411
277
|
files: [...SIDECAR_FILES],
|
|
412
278
|
materialize: (target) =>
|
|
413
279
|
materializeSidecarScaffold(target, {
|
|
414
|
-
installation,
|
|
415
|
-
repo:
|
|
416
|
-
fabrikaRef:
|
|
280
|
+
installation: input.installation,
|
|
281
|
+
repo: input.repo,
|
|
282
|
+
fabrikaRef: input.fabrikaRef,
|
|
417
283
|
}),
|
|
418
284
|
})
|
|
419
285
|
const pinned = await readPinnedTag(result.dir)
|
|
420
|
-
log.ok(`${
|
|
421
|
-
if (pinned !==
|
|
422
|
-
log.warn(`the existing pin ${pinned} was kept; ${
|
|
286
|
+
log.ok(`${input.repo} ${result.created ? 'created' : 'refreshed'} — pinned fabrika tag ${pinned}`)
|
|
287
|
+
if (pinned !== input.fabrikaRef) {
|
|
288
|
+
log.warn(`the existing pin ${pinned} was kept; ${input.fabrikaRef} was not written, because fabrika.ref is operator-owned`)
|
|
423
289
|
}
|
|
424
290
|
return result.dir
|
|
425
291
|
}
|
|
426
292
|
|
|
427
293
|
/** Run the whole thing for `<installation>`. */
|
|
428
|
-
export const runInit = async (
|
|
294
|
+
export const runInit = async (input: InitInput, collaborators: InitCollaborators): Promise<void> => {
|
|
429
295
|
const { log, prompts, effects } = collaborators
|
|
430
|
-
log.info(`fabrika platform init — the ${
|
|
296
|
+
log.info(`fabrika platform init — the ${input.installation} Zerops installation's sidecar repository`)
|
|
431
297
|
log.info('This UPDATES an installation that already exists. It does not create one.')
|
|
432
298
|
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
299
|
+
log.step('The installation this repository will deploy')
|
|
300
|
+
log.info(`repository ${input.repo}, pinned to ${input.fabrikaRef}; Zerops project ${input.projectId}, environment \`${input.environmentName}\``)
|
|
301
|
+
log.info(
|
|
302
|
+
input.hosts === undefined
|
|
303
|
+
? "hosts: derived from the proxy's Zerops subdomains"
|
|
304
|
+
: `hosts: iam ${input.hosts.iam}, console ${input.hosts.control}, operations ${input.hosts.operations}`,
|
|
305
|
+
)
|
|
306
|
+
await verifyProject(input, collaborators)
|
|
307
|
+
const platform = await readPlatform(input, collaborators)
|
|
308
|
+
const settingsUrl = platform === undefined ? undefined : sourceConnectionSettingsUrl(input, platform)
|
|
437
309
|
log.info('Public repositories deploy without a GitHub App. Connect private GitHub source in Control.')
|
|
438
310
|
log.action(
|
|
439
311
|
'OPERATOR ACTION — connect GitHub source in Control',
|
|
@@ -442,51 +314,51 @@ export const runInit = async (args: InitArguments, collaborators: InitCollaborat
|
|
|
442
314
|
: [url(settingsUrl)],
|
|
443
315
|
)
|
|
444
316
|
|
|
445
|
-
const dir = await scaffold(
|
|
317
|
+
const dir = await scaffold(input, collaborators)
|
|
446
318
|
if (dir === undefined) {
|
|
447
319
|
return
|
|
448
320
|
}
|
|
449
321
|
|
|
450
|
-
log.step(`Configure the ${
|
|
451
|
-
const config = environmentConfig(
|
|
452
|
-
log.info(`${Object.keys(config.secrets).length} secret(s) and ${Object.keys(config.vars).length} variable(s) on ${
|
|
453
|
-
if (!(await prompts.confirm(`Write them to the ${
|
|
322
|
+
log.step(`Configure the ${input.installation} GitHub Environment`)
|
|
323
|
+
const config = environmentConfig(input.installation, input)
|
|
324
|
+
log.info(`${Object.keys(config.secrets).length} secret(s) and ${Object.keys(config.vars).length} variable(s) on ${input.repo}.`)
|
|
325
|
+
if (!(await prompts.confirm(`Write them to the ${input.installation} Environment now (secret VALUES go to GitHub over \`gh\` stdin)?`, true))) {
|
|
454
326
|
log.action('OPERATOR ACTION — the pipeline cannot run until the Environment holds these', [
|
|
455
|
-
|
|
327
|
+
rerunCommand(input),
|
|
456
328
|
])
|
|
457
329
|
return
|
|
458
330
|
}
|
|
459
331
|
await effects.configureEnvironment(config)
|
|
460
|
-
log.ok(`Environment ${
|
|
332
|
+
log.ok(`Environment ${input.installation} written: ${Object.keys(config.secrets).join(', ')} and ${Object.keys(config.vars).join(', ')}.`)
|
|
461
333
|
|
|
462
334
|
log.step('Trigger the deploy')
|
|
463
335
|
log.info("The pipeline runs the whole ordered sequence in the operator's CI; this machine deploys nothing.")
|
|
464
|
-
if (!(await prompts.confirm(`Run the platform workflow on ${
|
|
336
|
+
if (!(await prompts.confirm(`Run the platform workflow on ${input.repo} now?`, true))) {
|
|
465
337
|
log.action('OPERATOR ACTION — run it when ready', [
|
|
466
|
-
`gh workflow run platform.yml --repo ${
|
|
467
|
-
`or: ${url(`https://github.com/${
|
|
338
|
+
`gh workflow run platform.yml --repo ${input.repo}`,
|
|
339
|
+
`or: ${url(`https://github.com/${input.repo}/actions`)} → platform → Run workflow`,
|
|
468
340
|
])
|
|
469
341
|
} else {
|
|
470
|
-
await effects.triggerWorkflow(
|
|
342
|
+
await effects.triggerWorkflow(input.repo)
|
|
471
343
|
log.ok('Platform workflow triggered.')
|
|
472
|
-
log.info(`Watch: ${url(`https://github.com/${
|
|
344
|
+
log.info(`Watch: ${url(`https://github.com/${input.repo}/actions`)} (or: gh run watch --repo ${input.repo})`)
|
|
473
345
|
}
|
|
474
346
|
|
|
475
347
|
log.step('Done')
|
|
476
|
-
log.ok(`Sidecar repository: ${
|
|
348
|
+
log.ok(`Sidecar repository: ${input.repo} (local checkout: ${dir})`)
|
|
477
349
|
log.info('Roll this installation forward by bumping fabrika.ref to a newer published tag and pushing.')
|
|
478
350
|
log.info("Who may administer the installation is IAM's to say: this repository holds no admission list to close later.")
|
|
479
351
|
// The origin comes off the live control service, never from what was typed here: an operator who
|
|
480
352
|
// pastes a guessed hostname into FABRIKA_IAM_RPC_URL gets "Unable to connect" and no explanation.
|
|
481
|
-
const iamOrigin = platform === undefined ? undefined : platformIamOrigin(
|
|
353
|
+
const iamOrigin = platform === undefined ? undefined : platformIamOrigin(input, platform)
|
|
482
354
|
log.action(
|
|
483
355
|
MACHINE_KEY_NEXT_STEP_TITLE,
|
|
484
|
-
machineKeyNextStepLines({ sidecarEnvironment:
|
|
356
|
+
machineKeyNextStepLines({ sidecarEnvironment: input.installation, ...(iamOrigin === undefined ? {} : { iamOrigin }) }),
|
|
485
357
|
)
|
|
486
358
|
}
|
|
487
359
|
|
|
488
360
|
/** The real collaborators: a TTY, `gh`, git, and one read of the Zerops API. */
|
|
489
|
-
export const consoleInitCollaborators = (): InitCollaborators => ({
|
|
361
|
+
export const consoleInitCollaborators = (input: InitInput): InitCollaborators => ({
|
|
490
362
|
log: {
|
|
491
363
|
step: (title) => consoleStep(title),
|
|
492
364
|
info: (message) => consoleInfo(message),
|
|
@@ -494,12 +366,13 @@ export const consoleInitCollaborators = (): InitCollaborators => ({
|
|
|
494
366
|
ok: (message) => consoleOk(message),
|
|
495
367
|
action: (title, lines) => consoleAction(title, [...lines]),
|
|
496
368
|
},
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
369
|
+
// First, before anything is contacted: a run nobody can confirm does not start.
|
|
370
|
+
prompts: confirmations(
|
|
371
|
+
input.unattended,
|
|
372
|
+
process.stdin,
|
|
373
|
+
(line) => consoleInfo(line),
|
|
374
|
+
'creates or pushes the sidecar repository, writes its GitHub Environment secrets and triggers the platform workflow',
|
|
375
|
+
),
|
|
503
376
|
effects: {
|
|
504
377
|
repositoryExists: (repo) => ghRepoExists(repo),
|
|
505
378
|
scaffold: (input) => scaffoldSidecarRepository(input),
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { Confirmations } from '@fabrika/installation-init'
|
|
1
2
|
import type { SchemaReconciler } from '@fabrika/provider-contract'
|
|
2
3
|
import type { Sleeper, ZeropsApi } from '../index'
|
|
3
4
|
import type { PlatformSourcePreparer } from './installation-source'
|
|
@@ -5,9 +6,7 @@ import type { InitLog } from './log'
|
|
|
5
6
|
import type { PlatformUploadFetch } from './platform-upload'
|
|
6
7
|
|
|
7
8
|
/** The one question this command asks. Injected so the flow can be exercised without a TTY. */
|
|
8
|
-
export
|
|
9
|
-
confirm(question: string, defaultYes?: boolean): Promise<boolean>
|
|
10
|
-
}
|
|
9
|
+
export type InstallPrompts = Confirmations
|
|
11
10
|
|
|
12
11
|
/** Everything `runInstall` reaches the outside world through. */
|
|
13
12
|
export interface InstallCollaborators {
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// operator's disk — but nothing here prompts: a missing required value is an error naming both the flag
|
|
9
9
|
// and the variable that would supply it, so a mistyped install fails before it contacts anything.
|
|
10
10
|
|
|
11
|
+
import { describeArgument, readEnv, readFlag, requiredSetting, setting, UNATTENDED_FLAG } from '@fabrika/installation-init'
|
|
11
12
|
import { parsePlatformScheme } from './placement-options'
|
|
12
13
|
import { readInstallationSourceDir } from './source-options'
|
|
13
14
|
|
|
@@ -52,50 +53,12 @@ export const FABRIKA_REPOSITORY_URL = 'https://github.com/contember/fabrika-plat
|
|
|
52
53
|
|
|
53
54
|
const FLAGS = ['--project-id', '--project-name', '--client-id', '--env', '--scheme', '--source-dir', '--tier'] as const
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
-
* The one way this command runs with nobody watching it.
|
|
57
|
-
*
|
|
58
|
-
* Every confirmation defaults to yes, so a pipe carrying blank lines answers all six of them with
|
|
59
|
-
* nobody reading what they agree to — which is why saying yes unattended is a FLAG and never an
|
|
60
|
-
* inference: it makes the choice explicit, and it puts it in the shell history of whoever ran it.
|
|
61
|
-
*/
|
|
62
|
-
export const UNATTENDED_FLAG = '--yes'
|
|
63
|
-
|
|
64
56
|
/** `--create-project`: bring the project up too, instead of naming one the operator made. */
|
|
65
57
|
export const CREATE_PROJECT_FLAG = '--create-project'
|
|
66
58
|
|
|
67
59
|
/** The two flags that carry no value. Written with one, they are `unexpected argument` like any other. */
|
|
68
60
|
const BOOLEAN_FLAGS = [UNATTENDED_FLAG, CREATE_PROJECT_FLAG] as const
|
|
69
61
|
|
|
70
|
-
const readFlag = (argv: readonly string[], name: string): string | undefined => {
|
|
71
|
-
const prefix = `${name}=`
|
|
72
|
-
const matches = argv.filter((arg) => arg.startsWith(prefix))
|
|
73
|
-
if (matches.length > 1) {
|
|
74
|
-
throw new Error(`${name} was given more than once`)
|
|
75
|
-
}
|
|
76
|
-
const value = matches[0]?.slice(prefix.length)
|
|
77
|
-
return value === undefined || value.trim() === '' ? undefined : value.trim()
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const readEnv = (env: Record<string, string | undefined>, name: string): string | undefined => {
|
|
81
|
-
const value = env[name]
|
|
82
|
-
return value === undefined || value.trim() === '' ? undefined : value.trim()
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
const setting = (
|
|
86
|
-
argv: readonly string[],
|
|
87
|
-
env: Record<string, string | undefined>,
|
|
88
|
-
flag: string,
|
|
89
|
-
variable: string,
|
|
90
|
-
): string | undefined => readFlag(argv, flag) ?? readEnv(env, variable)
|
|
91
|
-
|
|
92
|
-
const required = (value: string | undefined, flag: string, variable: string): string => {
|
|
93
|
-
if (value === undefined) {
|
|
94
|
-
throw new Error(`${flag}=<value> or ${variable} is required`)
|
|
95
|
-
}
|
|
96
|
-
return value
|
|
97
|
-
}
|
|
98
|
-
|
|
99
62
|
const assertKnownArguments = (argv: readonly string[]): void => {
|
|
100
63
|
for (const arg of argv) {
|
|
101
64
|
if (BOOLEAN_FLAGS.some((flag) => flag === arg)) {
|
|
@@ -104,7 +67,7 @@ const assertKnownArguments = (argv: readonly string[]): void => {
|
|
|
104
67
|
const name = arg.split('=')[0] ?? arg
|
|
105
68
|
if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
|
|
106
69
|
throw new Error(
|
|
107
|
-
`unexpected argument
|
|
70
|
+
`unexpected argument ${describeArgument(arg)}. Credentials are read from the environment and have no flag; run `
|
|
108
71
|
+ '`fabrika platform install --provider=zerops --help` for the surface',
|
|
109
72
|
)
|
|
110
73
|
}
|
|
@@ -135,7 +98,7 @@ const readScheme = (argv: readonly string[], env: Record<string, string | undefi
|
|
|
135
98
|
}
|
|
136
99
|
|
|
137
100
|
const readEnvironment = (argv: readonly string[], env: Record<string, string | undefined>): string =>
|
|
138
|
-
|
|
101
|
+
requiredSetting(setting(argv, env, '--env', 'FABRIKA_PLATFORM_ENVIRONMENT'), '--env', 'FABRIKA_PLATFORM_ENVIRONMENT')
|
|
139
102
|
|
|
140
103
|
/** The name a created project takes when the operator did not choose one: the installation's own. */
|
|
141
104
|
const defaultProjectName = (environment: string): string => `fabrika-${environment}`
|
|
@@ -158,7 +121,7 @@ const readProjectSource = (argv: readonly string[], env: Record<string, string |
|
|
|
158
121
|
+ 'exists takes --project-id=<id>, which names it by id',
|
|
159
122
|
)
|
|
160
123
|
}
|
|
161
|
-
return { kind: 'existing', projectId:
|
|
124
|
+
return { kind: 'existing', projectId: requiredSetting(projectId, '--project-id', 'FABRIKA_ZEROPS_PROJECT_ID') }
|
|
162
125
|
}
|
|
163
126
|
if (projectId !== undefined) {
|
|
164
127
|
const source = readFlag(argv, '--project-id') === undefined ? 'FABRIKA_ZEROPS_PROJECT_ID' : '--project-id'
|
|
@@ -181,8 +144,8 @@ export const parsePlatformInstallArgs = (
|
|
|
181
144
|
project: readProjectSource(argv, env),
|
|
182
145
|
// Named even when the project id is known: `POST /client/{id}/integration-token` is addressed by
|
|
183
146
|
// CLIENT, and the project read does not hand one back.
|
|
184
|
-
clientId:
|
|
185
|
-
accessToken:
|
|
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'),
|
|
186
149
|
environment: readEnvironment(argv, env),
|
|
187
150
|
scheme: readScheme(argv, env),
|
|
188
151
|
sourceDir,
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
import {
|
|
6
6
|
action as consoleAction,
|
|
7
|
-
|
|
7
|
+
confirmations,
|
|
8
8
|
info as consoleInfo,
|
|
9
9
|
ok as consoleOk,
|
|
10
10
|
step as consoleStep,
|
|
@@ -24,10 +24,10 @@ import {
|
|
|
24
24
|
} from './deploy'
|
|
25
25
|
import { PLATFORM_PROXY_MANIFEST_TEMPLATE } from './generated/platform-proxy-manifest'
|
|
26
26
|
import { derivePlatformHosts, type PlatformHosts, platformOrigin, ZEROPS_SUBDOMAIN_VARIABLE } from './hosts'
|
|
27
|
-
import { checkedEnvironmentName } from './init'
|
|
27
|
+
import { checkedEnvironmentName } from './init-options'
|
|
28
28
|
import { assertRunning, type InstallCollaborators, type InstallPrompts } from './install-collaborators'
|
|
29
29
|
import { installationServiceEnv, passOneProxyEnv, writeServiceEnv } from './install-environment'
|
|
30
|
-
import {
|
|
30
|
+
import type { PlatformInstallInput } from './install-options'
|
|
31
31
|
import { resolveInstallationProject } from './install-project'
|
|
32
32
|
import {
|
|
33
33
|
assertNoServiceIsBeingCreated,
|
|
@@ -226,39 +226,15 @@ const installPreparedPlatform = async (
|
|
|
226
226
|
}
|
|
227
227
|
|
|
228
228
|
/**
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* The confirmations are the only thing standing between a stray invocation and an installation: this
|
|
232
|
-
* command generates six credentials, mints a Zerops token on the account and deploys. Every one of
|
|
233
|
-
* them defaults to yes, so a pipe carrying blank lines answers all six of them with nobody reading
|
|
234
|
-
* what they agree to. So a run with no terminal is refused unless the operator asked for it by name.
|
|
229
|
+
* The confirmations stand between a stray invocation and an installation: this command generates six
|
|
230
|
+
* credentials, mints a Zerops token on the account and deploys.
|
|
235
231
|
*/
|
|
236
232
|
export const installPrompts = (
|
|
237
233
|
input: PlatformInstallInput,
|
|
238
234
|
stdin: { readonly isTTY?: boolean },
|
|
239
235
|
announce: (line: string) => void,
|
|
240
|
-
): InstallPrompts =>
|
|
241
|
-
|
|
242
|
-
// The flag IS the yes, given once for the whole sequence; nothing reads stdin after it. Each
|
|
243
|
-
// question is still printed with the answer it was given, so an unattended run leaves the same
|
|
244
|
-
// record of what was agreed to that an interactive one does.
|
|
245
|
-
return {
|
|
246
|
-
confirm: (question) => {
|
|
247
|
-
announce(`${question} yes (${UNATTENDED_FLAG})`)
|
|
248
|
-
return Promise.resolve(true)
|
|
249
|
-
},
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
if (stdin.isTTY !== true) {
|
|
253
|
-
throw new Error(
|
|
254
|
-
"stdin is not a terminal, so nobody is here to confirm what this command does — it generates this installation's "
|
|
255
|
-
+ 'credentials, mints a Zerops integration token and deploys. Run it from a terminal, or pass `'
|
|
256
|
-
+ UNATTENDED_FLAG
|
|
257
|
-
+ '` to answer every confirmation yes unattended',
|
|
258
|
-
)
|
|
259
|
-
}
|
|
260
|
-
return { confirm: (question, defaultYes) => promptConfirm(question, defaultYes) }
|
|
261
|
-
}
|
|
236
|
+
): InstallPrompts =>
|
|
237
|
+
confirmations(input.unattended, stdin, announce, "generates this installation's credentials, mints a Zerops integration token and deploys")
|
|
262
238
|
|
|
263
239
|
/** The real collaborators: a TTY for the confirmations, the Zerops API, and IAM's own schema reconciler. */
|
|
264
240
|
export const consoleInstallCollaborators = (input: PlatformInstallInput, reconcileSchema: SchemaReconciler): InstallCollaborators => {
|
|
@@ -119,4 +119,7 @@ export const readPinnedTag = async (dir: string): Promise<string> => {
|
|
|
119
119
|
export const defaultSidecarRepo = (installation: string): string => `contember/fabrika-zerops-${installation}`
|
|
120
120
|
|
|
121
121
|
/** Where the sidecar is checked out locally: `./fabrika-zerops-<installation>` under the cwd. */
|
|
122
|
-
|
|
122
|
+
/** The sidecar checkout's directory name when `--checkout` is not given; `init` creates it and `upgrade` reads it. */
|
|
123
|
+
export const defaultCheckoutName = (installation: string): string => `fabrika-zerops-${installation}`
|
|
124
|
+
|
|
125
|
+
export const defaultCheckoutDir = (installation: string): string => resolve(process.cwd(), defaultCheckoutName(installation))
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
// The pin is checked BEFORE anything leaves this machine: `assertPinnedTag` refuses a branch or a SHA
|
|
9
9
|
// (ADR-0025) without a network call, so `--to=main` fails on the spot rather than after a clone.
|
|
10
10
|
|
|
11
|
+
import { describeArgument, readFlag } from '@fabrika/installation-init'
|
|
11
12
|
import { assertPinnedTag, defaultCheckoutDir } from './sidecar'
|
|
12
13
|
|
|
13
14
|
/** Where the sidecar repository this command edits is found. */
|
|
@@ -43,16 +44,6 @@ export const UPGRADE_DRY_RUN_FLAG = '--dry-run'
|
|
|
43
44
|
*/
|
|
44
45
|
const REPO_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
45
46
|
|
|
46
|
-
const readFlag = (argv: readonly string[], name: string): string | undefined => {
|
|
47
|
-
const prefix = `${name}=`
|
|
48
|
-
const matches = argv.filter((arg) => arg.startsWith(prefix))
|
|
49
|
-
if (matches.length > 1) {
|
|
50
|
-
throw new Error(`${name} was given more than once`)
|
|
51
|
-
}
|
|
52
|
-
const value = matches[0]?.slice(prefix.length)
|
|
53
|
-
return value === undefined || value.trim() === '' ? undefined : value.trim()
|
|
54
|
-
}
|
|
55
|
-
|
|
56
47
|
const assertKnownArguments = (argv: readonly string[]): void => {
|
|
57
48
|
for (const arg of argv) {
|
|
58
49
|
if (arg === UPGRADE_DRY_RUN_FLAG || !arg.startsWith('-')) {
|
|
@@ -61,7 +52,7 @@ const assertKnownArguments = (argv: readonly string[]): void => {
|
|
|
61
52
|
const name = arg.split('=')[0] ?? arg
|
|
62
53
|
if (!FLAGS.some((flag) => flag === name) || !arg.includes('=')) {
|
|
63
54
|
throw new Error(
|
|
64
|
-
`unexpected argument
|
|
55
|
+
`unexpected argument ${describeArgument(arg)}. Run \`fabrika platform upgrade --provider=zerops --help\` for the surface`,
|
|
65
56
|
)
|
|
66
57
|
}
|
|
67
58
|
}
|
|
@@ -71,7 +62,7 @@ const assertKnownArguments = (argv: readonly string[]): void => {
|
|
|
71
62
|
const readInstallation = (argv: readonly string[]): string | undefined => {
|
|
72
63
|
const positional = argv.filter((arg) => !arg.startsWith('-'))
|
|
73
64
|
if (positional.length > 1) {
|
|
74
|
-
throw new Error(
|
|
65
|
+
throw new Error('unexpected second positional argument: name ONE installation')
|
|
75
66
|
}
|
|
76
67
|
const installation = positional[0]?.trim()
|
|
77
68
|
return installation === undefined || installation === '' ? undefined : installation
|
package/src/managed-env.ts
CHANGED
|
@@ -1,7 +1,13 @@
|
|
|
1
1
|
import type { JsonValue } from '@fabrika/provider-contract'
|
|
2
2
|
import { z } from 'zod'
|
|
3
3
|
import { renderYaml } from './compile'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
assertOwnRunWhenExtending,
|
|
6
|
+
executionZeropsDocument,
|
|
7
|
+
parseZeropsDescriptor,
|
|
8
|
+
type PreparedZeropsDescriptor,
|
|
9
|
+
selectZeropsDescriptorSetup,
|
|
10
|
+
} from './descriptor'
|
|
5
11
|
|
|
6
12
|
export type ManagedKey =
|
|
7
13
|
| 'FABRIKA_RELEASE'
|
|
@@ -93,7 +99,9 @@ export function overlayRelease(contents: string, setup: string | undefined, rele
|
|
|
93
99
|
const parsed = parseZeropsDescriptor(contents)
|
|
94
100
|
assertAuthoredManagedDocument(parsed.document)
|
|
95
101
|
const selected = selectZeropsDescriptorSetup(parsed, setup)
|
|
96
|
-
|
|
102
|
+
assertOwnRunWhenExtending(parsed, selected)
|
|
103
|
+
const execution = executionZeropsDocument(parsed, selected)
|
|
104
|
+
const entries = execution['zerops']
|
|
97
105
|
if (!Array.isArray(entries)) throw new Error('zerops: descriptor requires setup entries')
|
|
98
106
|
const overlaid = release === null ? entries : entries.map((entry) => {
|
|
99
107
|
if (!isMapping(entry) || entry['setup'] !== selected) return entry
|
|
@@ -101,5 +109,5 @@ export function overlayRelease(contents: string, setup: string | undefined, rele
|
|
|
101
109
|
const env = isMapping(run['envVariables']) ? run['envVariables'] : {}
|
|
102
110
|
return { ...entry, run: { ...run, envVariables: { ...env, FABRIKA_RELEASE: release } } }
|
|
103
111
|
})
|
|
104
|
-
return { contents: renderYaml({ ...
|
|
112
|
+
return { contents: renderYaml({ ...execution, zerops: overlaid }), setup: selected }
|
|
105
113
|
}
|
package/src/manifest.ts
CHANGED
|
@@ -4,7 +4,7 @@ import type { JsonValue, ProviderCodec } from '@fabrika/provider-contract'
|
|
|
4
4
|
import { normalizeGateRule, proxyGateRuleFieldsSchema } from '@fabrika/proxy-core/manifest'
|
|
5
5
|
import { z } from 'zod'
|
|
6
6
|
import { compileImport, ENV_ISOLATION, OVERRIDE, renderYaml } from './compile'
|
|
7
|
-
import { parseZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
|
|
7
|
+
import { assertOwnRunWhenExtending, executionZeropsDocument, parseZeropsDescriptor, selectZeropsDescriptorSetup } from './descriptor'
|
|
8
8
|
import { assertAuthoredManagedDocument, assertAuthoredManagedInputs } from './managed-env'
|
|
9
9
|
import { allowedReferenceHosts, foreignReferenceHost } from './service-references'
|
|
10
10
|
import type { ZeropsAppConfig, ZeropsNamespaceResourceRequirement, ZeropsProxySpec, ZeropsSharedPostgresBinding } from './types'
|
|
@@ -288,14 +288,17 @@ export function parseFabrikaManifest(value: unknown, expected: ManifestExpectati
|
|
|
288
288
|
if (!serviceHostnames.includes(deployService)) {
|
|
289
289
|
throw new Error('invalid fabrika manifest Zerops deploy service')
|
|
290
290
|
}
|
|
291
|
+
// Only the selected setup and what it extends reach this app's service, so a sibling setup for another
|
|
292
|
+
// placement may reference services this app does not own.
|
|
293
|
+
const selectedSetup = selectZeropsDescriptorSetup(parsedDescriptor, zeropsSetup)
|
|
294
|
+
assertOwnRunWhenExtending(parsedDescriptor, selectedSetup)
|
|
291
295
|
const foreignInDescriptor = foreignReferenceInValue(
|
|
292
|
-
parsedDescriptor
|
|
296
|
+
executionZeropsDocument(parsedDescriptor, selectedSetup),
|
|
293
297
|
allowedReferenceHosts(serviceHostnames),
|
|
294
298
|
)
|
|
295
299
|
if (foreignInDescriptor !== null) {
|
|
296
300
|
throw new Error(`invalid fabrika manifest: zerops.yaml references service \`${foreignInDescriptor}\`, which this app does not own`)
|
|
297
301
|
}
|
|
298
|
-
selectZeropsDescriptorSetup(parsedDescriptor, zeropsSetup)
|
|
299
302
|
if (proxy !== undefined) {
|
|
300
303
|
const separator = proxy.upstream.indexOf(':')
|
|
301
304
|
const upstreamHostname = separator === -1 ? proxy.upstream : proxy.upstream.slice(0, separator)
|