@fabrika/provider-zerops 0.0.26 → 0.3.1
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 +0 -5
- package/src/cli.ts +3 -28
- package/src/collaborators.ts +2 -4
- package/src/compile.ts +75 -20
- package/src/control.ts +217 -447
- package/src/descriptor.ts +135 -0
- package/src/index.ts +24 -128
- package/src/installation/admin-options.ts +128 -0
- package/src/installation/admin.ts +123 -0
- package/src/installation/deploy-options.ts +155 -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 +366 -0
- package/src/installation/init.ts +521 -0
- package/src/installation/install-collaborators.ts +28 -0
- package/src/installation/install-environment.ts +193 -0
- package/src/installation/install-options.ts +193 -0
- package/src/installation/install-project.ts +201 -0
- package/src/installation/install-provisioning.ts +185 -0
- package/src/installation/install.ts +286 -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 +122 -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 +108 -0
- package/src/installation/upgrade-output.ts +31 -0
- package/src/installation/upgrade.ts +373 -0
- package/src/managed-env.ts +105 -0
- package/src/manifest.ts +208 -315
- 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 -1260
- package/src/plan.ts +21 -23
- package/src/provider.ts +399 -254
- package/src/service-references.ts +28 -0
- 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,220 @@
|
|
|
1
|
+
import { followLeaseLoss, isLeaseLoss, ProviderNamespaceError } from '@fabrika/provider-contract'
|
|
2
|
+
import { encodeProxyManifestJson, FABRIKA_PROXY_MANIFEST_JSON, type ProxyManifest } from '@fabrika/proxy-core/manifest'
|
|
3
|
+
import { ZEROPS_ACTIVE, ZEROPS_TERMINAL, type ZeropsApi, ZeropsApiError, type ZeropsAppVersion } from '../api'
|
|
4
|
+
import type { Sleeper } from '../collaborators'
|
|
5
|
+
import { prepareZeropsDescriptor } from '../descriptor'
|
|
6
|
+
import { isZeropsUploadDestination } from '../source'
|
|
7
|
+
import type { ZeropsSourcePort } from '../source/port'
|
|
8
|
+
import { deleteVersion } from '../version-attempt'
|
|
9
|
+
import { ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE, ZEROPS_NAMESPACE_IAM_KEY_VARIABLE } from './constants'
|
|
10
|
+
import { verifyZeropsNamespaceProxySource } from './proxy-source'
|
|
11
|
+
import type { ZeropsNamespaceProxyBuild, ZeropsNamespaceTarget } from './types'
|
|
12
|
+
|
|
13
|
+
export type ZeropsProxyApi = Pick<
|
|
14
|
+
ZeropsApi,
|
|
15
|
+
'listServiceEnv' | 'putServiceEnv' | 'createAppVersion' | 'deleteAppVersion' | 'buildAndDeployAppVersion' | 'getAppVersion'
|
|
16
|
+
>
|
|
17
|
+
|
|
18
|
+
export interface ZeropsProxyRepublishInput {
|
|
19
|
+
readonly target: ZeropsNamespaceTarget
|
|
20
|
+
readonly manifest: ProxyManifest
|
|
21
|
+
readonly source?: ZeropsSourcePort
|
|
22
|
+
readonly api: ZeropsProxyApi
|
|
23
|
+
readonly iamUrl: string
|
|
24
|
+
readonly iamKey: string
|
|
25
|
+
/** Aborts with `lease_lost` when the namespace lease goes, and with `cancel_requested` when a deploy is cancelled. */
|
|
26
|
+
readonly signal: AbortSignal
|
|
27
|
+
readonly wait: 'started' | 'active'
|
|
28
|
+
/** Persist the target; the caller fences the write on the namespace lease. */
|
|
29
|
+
readonly checkpoint: (target: ZeropsNamespaceTarget) => Promise<void>
|
|
30
|
+
readonly boundary: () => Promise<void>
|
|
31
|
+
readonly sleep: Sleeper
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const POLL_INTERVAL_MS = 3000
|
|
35
|
+
const POLL_DEADLINE_MS = 70 * 60 * 1000
|
|
36
|
+
const USER_DATA_SYNC_ATTEMPTS = 10
|
|
37
|
+
const SESSION_OWNER_VARIABLE = 'FABRIKA_PROXY_SESSION_OWNER_JSON'
|
|
38
|
+
|
|
39
|
+
const unresolved = (id: string): ProviderNamespaceError =>
|
|
40
|
+
new ProviderNamespaceError(`Proxy version ${id} remains unresolved; reconcile this namespace before another build`, 'proxyAttemptUnresolved', true)
|
|
41
|
+
|
|
42
|
+
const digest = async (text: string): Promise<string> =>
|
|
43
|
+
Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))), byte => byte.toString(16).padStart(2, '0')).join(
|
|
44
|
+
'',
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const writeProxyIam = async (input: ZeropsProxyRepublishInput, serviceId: string): Promise<void> => {
|
|
48
|
+
const { api, signal } = input
|
|
49
|
+
const variables = await api.listServiceEnv({ serviceId, signal })
|
|
50
|
+
if (variables.find(variable => variable.key === SESSION_OWNER_VARIABLE)?.content !== 'null') {
|
|
51
|
+
await api.putServiceEnv({ serviceId, key: SESSION_OWNER_VARIABLE, value: 'null', signal })
|
|
52
|
+
}
|
|
53
|
+
if (variables.find(variable => variable.key === ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE)?.content !== input.iamUrl) {
|
|
54
|
+
await api.putServiceEnv({ serviceId, key: ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE, value: input.iamUrl, signal })
|
|
55
|
+
}
|
|
56
|
+
// Secret read-back is not trustworthy, so overwrite it without inspecting its value.
|
|
57
|
+
await api.putServiceEnv({ serviceId, key: ZEROPS_NAMESPACE_IAM_KEY_VARIABLE, value: input.iamKey, signal })
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Rebuild the namespace proxy with `manifest` (ADR-0044). The attempt is recorded as `proxyBuild` and never
|
|
62
|
+
* resumed: an inherited attempt not yet `triggered` is deleted, a triggered one is polled to a terminal state.
|
|
63
|
+
* A lost lease deletes nothing; a cancelled deploy leaves a triggered attempt recorded for reconciliation.
|
|
64
|
+
*/
|
|
65
|
+
export async function republishProxy(input: ZeropsProxyRepublishInput): Promise<ZeropsNamespaceTarget> {
|
|
66
|
+
const pin = input.target.proxySource
|
|
67
|
+
const serviceId = input.target.proxyServiceId
|
|
68
|
+
if (serviceId === undefined) throw new Error('Zerops namespace has no proxy service id')
|
|
69
|
+
await verifyZeropsNamespaceProxySource(pin)
|
|
70
|
+
const manifest = encodeProxyManifestJson(input.manifest)
|
|
71
|
+
// The attempt's identity covers the IAM configuration too, so a rotated issuer or key forces a rebuild.
|
|
72
|
+
const manifestSha256 = await digest(`${manifest}\n${input.iamUrl}\n${await digest(input.iamKey)}`)
|
|
73
|
+
let target = input.target
|
|
74
|
+
|
|
75
|
+
const save = async (next: ZeropsNamespaceTarget): Promise<void> => {
|
|
76
|
+
await input.checkpoint(next)
|
|
77
|
+
target = next
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Observe one version until terminal; with `started`, a running build of this manifest is answer enough. */
|
|
81
|
+
const settle = async (build: ZeropsNamespaceProxyBuild): Promise<'settled' | 'running'> => {
|
|
82
|
+
const deadline = Date.now() + POLL_DEADLINE_MS
|
|
83
|
+
for (;;) {
|
|
84
|
+
await input.boundary()
|
|
85
|
+
input.signal.throwIfAborted()
|
|
86
|
+
let version: ZeropsAppVersion
|
|
87
|
+
try {
|
|
88
|
+
version = await input.api.getAppVersion({ appVersionId: build.appVersionId, signal: input.signal })
|
|
89
|
+
} catch {
|
|
90
|
+
input.signal.throwIfAborted()
|
|
91
|
+
throw unresolved(build.appVersionId)
|
|
92
|
+
}
|
|
93
|
+
if (
|
|
94
|
+
version.id !== build.appVersionId || (version.serviceStackId !== undefined && version.serviceStackId !== serviceId)
|
|
95
|
+
|| (version.projectId !== undefined && target.projectId !== undefined && version.projectId !== target.projectId)
|
|
96
|
+
) throw unresolved(build.appVersionId)
|
|
97
|
+
if (version.status !== undefined && ZEROPS_TERMINAL.has(version.status)) {
|
|
98
|
+
const active = version.status === ZEROPS_ACTIVE
|
|
99
|
+
await save({
|
|
100
|
+
...target,
|
|
101
|
+
proxyBuild: undefined,
|
|
102
|
+
...(active
|
|
103
|
+
? {
|
|
104
|
+
proxyDeployment: {
|
|
105
|
+
appVersionId: version.id,
|
|
106
|
+
repository: pin.repository,
|
|
107
|
+
commitSha: pin.commitSha,
|
|
108
|
+
descriptorSha256: pin.descriptor.sha256,
|
|
109
|
+
...(build.manifestSha256 === undefined ? {} : { manifestSha256: build.manifestSha256 }),
|
|
110
|
+
},
|
|
111
|
+
}
|
|
112
|
+
: { ready: false }),
|
|
113
|
+
})
|
|
114
|
+
if (!active) throw new ProviderNamespaceError(`Proxy version ${version.id} finished as ${version.status}`, 'proxyDeployFailed', false)
|
|
115
|
+
return 'settled'
|
|
116
|
+
}
|
|
117
|
+
if (input.wait === 'started') {
|
|
118
|
+
if (build.manifestSha256 === manifestSha256 && version.status !== 'UPLOADING') return 'running'
|
|
119
|
+
throw unresolved(build.appVersionId)
|
|
120
|
+
}
|
|
121
|
+
if (Date.now() >= deadline) throw unresolved(build.appVersionId)
|
|
122
|
+
await input.sleep(POLL_INTERVAL_MS, input.signal)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const inherited = target.proxyBuild
|
|
127
|
+
if (inherited !== undefined && !inherited.triggered) {
|
|
128
|
+
input.signal.throwIfAborted()
|
|
129
|
+
try {
|
|
130
|
+
await deleteVersion(input.api, inherited.appVersionId, input.signal)
|
|
131
|
+
} catch {
|
|
132
|
+
input.signal.throwIfAborted()
|
|
133
|
+
throw unresolved(inherited.appVersionId)
|
|
134
|
+
}
|
|
135
|
+
await save({ ...target, proxyBuild: undefined })
|
|
136
|
+
} else if (inherited !== undefined) {
|
|
137
|
+
if (await settle(inherited) === 'running') return target
|
|
138
|
+
if (inherited.manifestSha256 === manifestSha256) return target
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (input.source === undefined) throw new Error('Zerops namespace source client is required')
|
|
142
|
+
const runId = crypto.randomUUID()
|
|
143
|
+
const resolved = await input.source.resolve({
|
|
144
|
+
repository: pin.repository,
|
|
145
|
+
requestedRef: pin.commitSha,
|
|
146
|
+
expectedCommitSha: pin.commitSha,
|
|
147
|
+
descriptorSha256: pin.descriptor.sha256,
|
|
148
|
+
signal: input.signal,
|
|
149
|
+
})
|
|
150
|
+
if (resolved.commitSha !== pin.commitSha || resolved.descriptorSha256 !== pin.descriptor.sha256) {
|
|
151
|
+
throw new Error('Proxy source resolution identity mismatch')
|
|
152
|
+
}
|
|
153
|
+
await input.boundary()
|
|
154
|
+
await input.api.putServiceEnv({ serviceId, key: FABRIKA_PROXY_MANIFEST_JSON, value: manifest, signal: input.signal })
|
|
155
|
+
await writeProxyIam(input, serviceId)
|
|
156
|
+
input.signal.throwIfAborted()
|
|
157
|
+
const reserved = await input.api.createAppVersion({ serviceId, name: runId, signal: input.signal })
|
|
158
|
+
let build: ZeropsNamespaceProxyBuild = { appVersionId: reserved.id, triggered: false, manifestSha256 }
|
|
159
|
+
let accepted = false
|
|
160
|
+
try {
|
|
161
|
+
await save({ ...target, proxyBuild: build })
|
|
162
|
+
input.signal.throwIfAborted()
|
|
163
|
+
if (!isZeropsUploadDestination(reserved.uploadUrl)) throw new Error(`Proxy version ${reserved.id} has an unexpected upload destination`)
|
|
164
|
+
const uploaded = await input.source.upload({
|
|
165
|
+
repository: pin.repository,
|
|
166
|
+
commitSha: pin.commitSha,
|
|
167
|
+
descriptorSha256: pin.descriptor.sha256,
|
|
168
|
+
archive: { kind: 'platform' },
|
|
169
|
+
uploadUrl: reserved.uploadUrl,
|
|
170
|
+
signal: input.signal,
|
|
171
|
+
})
|
|
172
|
+
if (uploaded.commitSha !== pin.commitSha || uploaded.descriptorSha256 !== pin.descriptor.sha256) {
|
|
173
|
+
throw new Error('Proxy source upload identity mismatch')
|
|
174
|
+
}
|
|
175
|
+
const descriptor = prepareZeropsDescriptor(pin.descriptor.contents, 'proxy')
|
|
176
|
+
// Once triggered, only a lost lease stops the build request; a cancelled deploy leaves it to reconcile.
|
|
177
|
+
const buildSignal = followLeaseLoss(input.signal)
|
|
178
|
+
for (let attempt = 1;; attempt++) {
|
|
179
|
+
await input.boundary()
|
|
180
|
+
build = { ...build, triggered: true }
|
|
181
|
+
await save({ ...target, proxyBuild: build })
|
|
182
|
+
buildSignal.throwIfAborted()
|
|
183
|
+
try {
|
|
184
|
+
const process = await input.api.buildAndDeployAppVersion({
|
|
185
|
+
appVersionId: reserved.id,
|
|
186
|
+
zeropsYaml: descriptor.contents,
|
|
187
|
+
zeropsYamlSetup: 'proxy',
|
|
188
|
+
signal: buildSignal,
|
|
189
|
+
})
|
|
190
|
+
if (process.appVersionId !== undefined && process.appVersionId !== reserved.id) throw unresolved(reserved.id)
|
|
191
|
+
accepted = true
|
|
192
|
+
break
|
|
193
|
+
} catch (cause) {
|
|
194
|
+
buildSignal.throwIfAborted()
|
|
195
|
+
if (!(cause instanceof ZeropsApiError && cause.status === 400 && cause.code === 'userDataSyncRunning')) break
|
|
196
|
+
// A definitive refusal: the version is inert again, so a failure from here deletes it.
|
|
197
|
+
build = { ...build, triggered: false }
|
|
198
|
+
await save({ ...target, proxyBuild: build })
|
|
199
|
+
if (attempt >= USER_DATA_SYNC_ATTEMPTS) {
|
|
200
|
+
throw new ProviderNamespaceError(`Proxy version ${reserved.id}: service variables are still synchronizing`, 'userDataSyncRunning', true)
|
|
201
|
+
}
|
|
202
|
+
await input.sleep(POLL_INTERVAL_MS, input.signal)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch (cause) {
|
|
206
|
+
if (!build.triggered && !isLeaseLoss(input.signal.reason) && !isLeaseLoss(cause)) {
|
|
207
|
+
const cleanup = AbortSignal.any([followLeaseLoss(input.signal), AbortSignal.timeout(30_000)])
|
|
208
|
+
try {
|
|
209
|
+
await deleteVersion(input.api, reserved.id, cleanup)
|
|
210
|
+
await save({ ...target, proxyBuild: undefined })
|
|
211
|
+
} catch {
|
|
212
|
+
// The recorded attempt is deleted by the next owner.
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
throw cause
|
|
216
|
+
}
|
|
217
|
+
if (input.wait === 'started' && accepted) return target
|
|
218
|
+
await settle(build)
|
|
219
|
+
return target
|
|
220
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { z } from 'zod'
|
|
2
|
+
import { prepareZeropsDescriptor } from '../descriptor'
|
|
3
|
+
import { verifyZeropsArtifactSourceDescriptor, zeropsArtifactSourceDescriptorSchema } from '../manifest'
|
|
4
|
+
import { commitShaSchema, parseSourceWire, repositorySchema } from '../source/validation'
|
|
5
|
+
import type { ZeropsNamespaceProxySource } from './types'
|
|
6
|
+
|
|
7
|
+
export const namespaceProxySourceSchema = z.object({
|
|
8
|
+
repository: repositorySchema,
|
|
9
|
+
commitSha: commitShaSchema,
|
|
10
|
+
descriptor: zeropsArtifactSourceDescriptorSchema,
|
|
11
|
+
}).strict().transform((source) => {
|
|
12
|
+
prepareZeropsDescriptor(source.descriptor.contents, 'proxy')
|
|
13
|
+
return source
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
export const decodeZeropsNamespaceProxySource = (value: unknown): ZeropsNamespaceProxySource =>
|
|
17
|
+
parseSourceWire(namespaceProxySourceSchema, value, 'Zerops namespace proxy source')
|
|
18
|
+
|
|
19
|
+
export const verifyZeropsNamespaceProxySource = async (source: ZeropsNamespaceProxySource): Promise<void> => {
|
|
20
|
+
const parsed = decodeZeropsNamespaceProxySource(source)
|
|
21
|
+
await verifyZeropsArtifactSourceDescriptor(parsed.descriptor)
|
|
22
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import type { ProviderDeploymentNamespace } from '@fabrika/provider-contract'
|
|
2
|
+
import { compileImportYaml, compileProvisioningYaml } from '../compile'
|
|
3
|
+
import type { ZeropsServiceSpec, ZeropsSourceTarget } from '../types'
|
|
4
|
+
import { ZEROPS_NAMESPACE_POSTGRES_HOSTNAME, ZEROPS_NAMESPACE_PROXY_HOSTNAME } from './constants'
|
|
5
|
+
import { decodeZeropsNamespaceProxySource } from './proxy-source'
|
|
6
|
+
import type {
|
|
7
|
+
ZeropsNamespacePolicy,
|
|
8
|
+
ZeropsNamespacePostgres,
|
|
9
|
+
ZeropsNamespacePresetInput,
|
|
10
|
+
ZeropsNamespacePublicAccess,
|
|
11
|
+
ZeropsNamespaceTarget,
|
|
12
|
+
ZeropsNamespaceTopology,
|
|
13
|
+
} from './types'
|
|
14
|
+
|
|
15
|
+
export const PROXY_TYPE = 'alpine@3.21'
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The namespace-owned PostgreSQL service: the cheapest shape that runs, on EVERY environment.
|
|
19
|
+
*
|
|
20
|
+
* A profile sets the FLOOR and the tuning preset, never the cap — `oltp-hobby` and `oltp-production`
|
|
21
|
+
* share the same 8-core / 48 GB / 250 GB ceiling and the same vertical autoscaler, so the production
|
|
22
|
+
* preset does not buy headroom, it buys an idle bill. `oltp-hobby` starts at one SHARED core and
|
|
23
|
+
* 0.25 GB and grows under load; the shape is proven by a live app with dozens of tables.
|
|
24
|
+
*
|
|
25
|
+
* Keying this off `env === 'prod'` is what this default used to do, and it was wrong: most apps are
|
|
26
|
+
* small on every environment they have, and a name is not a size. HA and a bigger floor stay
|
|
27
|
+
* available and become a DELIBERATE act — `--postgres-type` and `--postgres-profile` on the namespace
|
|
28
|
+
* command — because the failure mode of over-provisioning is silent and monthly, while the failure
|
|
29
|
+
* mode of under-provisioning is visible and fixable at any time.
|
|
30
|
+
*
|
|
31
|
+
* Omitting `profile` would not be neutral: an HA service silently gets `oltp-production` (two
|
|
32
|
+
* DEDICATED cores and 4 GB per container, three containers) and a single one `oltp-staging`. Both
|
|
33
|
+
* read back off a live service.
|
|
34
|
+
*/
|
|
35
|
+
const defaultPostgres = (): ZeropsNamespacePostgres => ({ type: 'postgresql:single@18', profile: 'oltp-hobby' })
|
|
36
|
+
|
|
37
|
+
/** A preset's project and service policy, without the proxy source pin (ADR-0045). */
|
|
38
|
+
export const zeropsNamespacePolicy = (input: Omit<ZeropsNamespacePresetInput, 'proxySource'>): ZeropsNamespacePolicy => ({
|
|
39
|
+
projectName: input.projectName,
|
|
40
|
+
// `LIGHT` on every environment. An APP namespace is not the platform project — the argument for
|
|
41
|
+
// `SERIOUS` in the platform installation topology is that the platform repairs the apps project, which no app
|
|
42
|
+
// namespace does. `corePackage` is upgrade-only, so this stays the caller's explicit choice.
|
|
43
|
+
corePackage: input.corePackage ?? 'LIGHT',
|
|
44
|
+
publicAccess: input.publicAccess ?? 'custom-domain',
|
|
45
|
+
managed: true,
|
|
46
|
+
ready: false,
|
|
47
|
+
...(input.preset === 'cheap' ? { postgres: input.postgres ?? defaultPostgres() } : {}),
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
export const zeropsNamespacePreset = (input: ZeropsNamespacePresetInput): ZeropsNamespaceTarget => ({
|
|
51
|
+
proxySource: decodeZeropsNamespaceProxySource(input.proxySource),
|
|
52
|
+
...zeropsNamespacePolicy(input),
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
export const projectMarker = (namespace: ProviderDeploymentNamespace): string => `Managed by Fabrika namespace ${namespace.id} (${namespace.env}).`
|
|
56
|
+
|
|
57
|
+
// Imports provision services only. The reserved upload-backed build selects the proxy setup.
|
|
58
|
+
//
|
|
59
|
+
// `enableSubdomainAccess` is a DECLARATION here and not a mechanism: the platform accepts it and drops it,
|
|
60
|
+
// including on the service this document creates. `ensureSubdomainAccess` below is what establishes the
|
|
61
|
+
// subdomain, after the proxy's first deploy. Written anyway because it is what ADR-0022's
|
|
62
|
+
// `assertOnlyPublicService` reads to prove no other service is claimed public.
|
|
63
|
+
const proxyService = (publicAccess: ZeropsNamespacePublicAccess): ZeropsServiceSpec => ({
|
|
64
|
+
hostname: ZEROPS_NAMESPACE_PROXY_HOSTNAME,
|
|
65
|
+
type: PROXY_TYPE,
|
|
66
|
+
priority: 10,
|
|
67
|
+
enableSubdomainAccess: publicAccess === 'zerops-subdomain',
|
|
68
|
+
// One container is a real availability trade — the proxy is the only enforcement point (ADR-0022),
|
|
69
|
+
// so a restart is a short outage rather than a rolling one — taken because it is a whole core of
|
|
70
|
+
// idle floor on every namespace, and most namespaces hold one small app. `maxContainers` is
|
|
71
|
+
// unchanged, so load still scales out.
|
|
72
|
+
minContainers: 1,
|
|
73
|
+
maxContainers: 6,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const postgresService = (postgres: ZeropsNamespacePostgres): ZeropsServiceSpec => ({
|
|
77
|
+
hostname: ZEROPS_NAMESPACE_POSTGRES_HOSTNAME,
|
|
78
|
+
type: postgres.type,
|
|
79
|
+
priority: 100,
|
|
80
|
+
...(postgres.profile !== undefined ? { profile: postgres.profile } : {}),
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
/** Compile the one project and its namespace-owned services without depending on private deploy code. */
|
|
84
|
+
export const compileZeropsNamespaceTopology = (
|
|
85
|
+
namespace: ProviderDeploymentNamespace,
|
|
86
|
+
target: ZeropsNamespacePolicy,
|
|
87
|
+
): ZeropsNamespaceTopology => {
|
|
88
|
+
const projectName = target.projectName
|
|
89
|
+
const corePackage = target.corePackage
|
|
90
|
+
const publicAccess = target.publicAccess
|
|
91
|
+
if (projectName === undefined || corePackage === undefined || publicAccess === undefined) {
|
|
92
|
+
throw new Error('Zerops namespace topology requires normalized project policy')
|
|
93
|
+
}
|
|
94
|
+
const services = (): ZeropsServiceSpec[] => [
|
|
95
|
+
...(target.postgres === undefined ? [] : [postgresService(target.postgres)]),
|
|
96
|
+
proxyService(publicAccess),
|
|
97
|
+
]
|
|
98
|
+
const source: ZeropsSourceTarget = {
|
|
99
|
+
platform: 'zerops',
|
|
100
|
+
project: {
|
|
101
|
+
name: projectName,
|
|
102
|
+
description: projectMarker(namespace),
|
|
103
|
+
corePackage,
|
|
104
|
+
tags: ['fabrika', 'namespace', namespace.id, namespace.env],
|
|
105
|
+
},
|
|
106
|
+
services,
|
|
107
|
+
}
|
|
108
|
+
const serviceSource: ZeropsSourceTarget = { platform: 'zerops', services }
|
|
109
|
+
return {
|
|
110
|
+
source,
|
|
111
|
+
createYaml: compileImportYaml({ target: source, ctx: { env: namespace.env } }).yaml,
|
|
112
|
+
servicesProvisionYaml: compileProvisioningYaml({ target: serviceSource, ctx: { env: namespace.env } }).yaml,
|
|
113
|
+
servicesSteadyYaml: compileImportYaml({ target: serviceSource, ctx: { env: namespace.env } }).yaml,
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { ProviderNamespaceHost } from '@fabrika/provider-contract'
|
|
2
|
+
import type { ZeropsApi, ZeropsProjectMode } from '../api'
|
|
3
|
+
import type { Sleeper } from '../collaborators'
|
|
4
|
+
import type { ZeropsArtifactSourceDescriptor } from '../manifest'
|
|
5
|
+
import type { ZeropsSourceRepository } from '../source'
|
|
6
|
+
import type { ZeropsSourcePort } from '../source/port'
|
|
7
|
+
import type { ZeropsSourceTarget } from '../types'
|
|
8
|
+
|
|
9
|
+
export type ZeropsNamespacePublicAccess = 'custom-domain' | 'zerops-subdomain'
|
|
10
|
+
|
|
11
|
+
export interface ZeropsNamespaceProxySource {
|
|
12
|
+
readonly repository: ZeropsSourceRepository
|
|
13
|
+
readonly commitSha: string
|
|
14
|
+
readonly descriptor: ZeropsArtifactSourceDescriptor
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** The proxy attempt in flight (ADR-0044): a version not `triggered` is deleted, a triggered one is polled. */
|
|
18
|
+
export interface ZeropsNamespaceProxyBuild {
|
|
19
|
+
readonly appVersionId: string
|
|
20
|
+
readonly triggered: boolean
|
|
21
|
+
readonly manifestSha256?: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface ZeropsNamespaceProxyDeployment {
|
|
25
|
+
readonly manifestSha256?: string
|
|
26
|
+
readonly appVersionId: string
|
|
27
|
+
readonly repository: ZeropsSourceRepository
|
|
28
|
+
readonly commitSha: string
|
|
29
|
+
readonly descriptorSha256: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ZeropsNamespacePostgres =
|
|
33
|
+
| {
|
|
34
|
+
readonly type: 'postgresql:ha@18'
|
|
35
|
+
readonly profile?: 'oltp-staging' | 'oltp-production' | 'oltp-enterprise' | 'olap-production' | 'writeheavy-production'
|
|
36
|
+
}
|
|
37
|
+
| {
|
|
38
|
+
readonly type: 'postgresql:single@18'
|
|
39
|
+
readonly profile?: 'oltp-hobby' | 'oltp-staging' | 'oltp-production' | 'olap-production' | 'writeheavy-production'
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Provider-owned namespace state. Optional fields are durable progress checkpoints; values that are
|
|
44
|
+
* absent are resolved by `normalize()` before a mutation starts.
|
|
45
|
+
*/
|
|
46
|
+
export interface ZeropsNamespaceTarget {
|
|
47
|
+
readonly proxySource: ZeropsNamespaceProxySource
|
|
48
|
+
readonly proxyBuild?: ZeropsNamespaceProxyBuild
|
|
49
|
+
readonly proxyDeployment?: ZeropsNamespaceProxyDeployment
|
|
50
|
+
readonly projectName?: string
|
|
51
|
+
readonly corePackage?: ZeropsProjectMode
|
|
52
|
+
readonly publicAccess?: ZeropsNamespacePublicAccess
|
|
53
|
+
readonly postgres?: ZeropsNamespacePostgres
|
|
54
|
+
readonly managed?: boolean
|
|
55
|
+
readonly projectId?: string
|
|
56
|
+
readonly proxyServiceId?: string
|
|
57
|
+
readonly postgresServiceId?: string
|
|
58
|
+
readonly proxyConfigured?: boolean
|
|
59
|
+
readonly proxyBaselineSequence?: number
|
|
60
|
+
readonly proxyAppVersionId?: string
|
|
61
|
+
/** The hosts the published proxy answers on, recorded once so a namespace read needs no platform call. */
|
|
62
|
+
readonly proxyHosts?: readonly ProviderNamespaceHost[]
|
|
63
|
+
readonly ready?: boolean
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ZeropsNamespacePolicy = Omit<ZeropsNamespaceTarget, 'proxySource'>
|
|
67
|
+
|
|
68
|
+
export interface NormalizedZeropsNamespaceTarget extends ZeropsNamespaceTarget {
|
|
69
|
+
readonly projectName: string
|
|
70
|
+
readonly corePackage: ZeropsProjectMode
|
|
71
|
+
readonly publicAccess: ZeropsNamespacePublicAccess
|
|
72
|
+
readonly managed: boolean
|
|
73
|
+
readonly ready: boolean
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ZeropsNamespaceOptions {
|
|
77
|
+
readonly proxySource: ZeropsNamespaceProxySource
|
|
78
|
+
readonly source?: ZeropsSourcePort
|
|
79
|
+
readonly clientId: string
|
|
80
|
+
/** Public IAM origin reachable from an app project. */
|
|
81
|
+
readonly iamUrl: string
|
|
82
|
+
/** Same credential IAM receives as `FABRIKA_IAM_PROXY_KEY`. */
|
|
83
|
+
readonly iamKey: string
|
|
84
|
+
readonly api: ZeropsApi
|
|
85
|
+
readonly sleep?: Sleeper
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface ZeropsNamespaceTopology {
|
|
89
|
+
readonly source: ZeropsSourceTarget
|
|
90
|
+
readonly createYaml: string
|
|
91
|
+
readonly servicesProvisionYaml: string
|
|
92
|
+
readonly servicesSteadyYaml: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ZeropsNamespacePresetInput {
|
|
96
|
+
readonly proxySource: ZeropsNamespaceProxySource
|
|
97
|
+
readonly preset: 'cheap' | 'mid' | 'full'
|
|
98
|
+
readonly env: string
|
|
99
|
+
readonly projectName: string
|
|
100
|
+
readonly corePackage?: ZeropsProjectMode
|
|
101
|
+
readonly publicAccess?: ZeropsNamespacePublicAccess
|
|
102
|
+
readonly postgres?: ZeropsNamespacePostgres
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface ZeropsNamespaceOperatorOptions {
|
|
106
|
+
readonly proxySource: ZeropsNamespaceProxySource
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export interface ZeropsNamespacePlanOptions {
|
|
110
|
+
readonly projectName?: string
|
|
111
|
+
readonly corePackage?: ZeropsProjectMode
|
|
112
|
+
readonly publicAccess?: ZeropsNamespacePublicAccess
|
|
113
|
+
readonly postgres?: ZeropsNamespacePostgres
|
|
114
|
+
}
|