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