@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,77 @@
|
|
|
1
|
+
import type { ProviderDeploymentNamespace, ProviderRegistration } from '@fabrika/provider-contract'
|
|
2
|
+
import { isProxyHost } from '@fabrika/proxy-core/manifest'
|
|
3
|
+
import { assertFabrikaManifestCoordinates, manifestServiceHostnames, zeropsArtifactCodec } from '../manifest'
|
|
4
|
+
import { zeropsSharedServicePrefix } from '../service-names'
|
|
5
|
+
import { decodeEnvelope } from './codec'
|
|
6
|
+
import { ZEROPS_NAMESPACE_POSTGRES_HOSTNAME, ZEROPS_NAMESPACE_PROXY_HOSTNAME } from './constants'
|
|
7
|
+
|
|
8
|
+
export const namespaceResourceClaims = (namespace: ProviderDeploymentNamespace): string[] => {
|
|
9
|
+
const target = decodeEnvelope(namespace.target)
|
|
10
|
+
return [
|
|
11
|
+
`service:${ZEROPS_NAMESPACE_PROXY_HOSTNAME}`,
|
|
12
|
+
...(target.postgres === undefined ? [] : [`service:${ZEROPS_NAMESPACE_POSTGRES_HOSTNAME}`]),
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const registrationResourceClaims = (registration: ProviderRegistration): string[] => {
|
|
17
|
+
const namespace = registration.environment.namespace
|
|
18
|
+
if (namespace === undefined) {
|
|
19
|
+
throw new Error('Zerops registration requires a deployment namespace')
|
|
20
|
+
}
|
|
21
|
+
if (
|
|
22
|
+
namespace.exclusiveAppId !== undefined
|
|
23
|
+
&& namespace.exclusiveAppId !== registration.app.id
|
|
24
|
+
) {
|
|
25
|
+
throw new Error(`Zerops namespace is exclusive to app \`${namespace.exclusiveAppId}\``)
|
|
26
|
+
}
|
|
27
|
+
const artifactEnvelope = registration.environment.artifact
|
|
28
|
+
if (artifactEnvelope.provider !== 'zerops' || artifactEnvelope.version !== zeropsArtifactCodec.version) {
|
|
29
|
+
throw new Error('Zerops registration artifact envelope is not supported')
|
|
30
|
+
}
|
|
31
|
+
const manifest = zeropsArtifactCodec.decode(artifactEnvelope.payload)
|
|
32
|
+
assertFabrikaManifestCoordinates(manifest.app, {
|
|
33
|
+
appId: registration.app.id,
|
|
34
|
+
env: registration.environment.env,
|
|
35
|
+
})
|
|
36
|
+
// A proxy target is routed by HOST, so a registration with no domain has nothing the proxy can serve:
|
|
37
|
+
// refuse it here, where nothing has been created yet, instead of at the first deploy's manifest compile.
|
|
38
|
+
if (manifest.target.proxy !== undefined) {
|
|
39
|
+
const domain = registration.environment.domain ?? ''
|
|
40
|
+
if (domain.trim() === '') {
|
|
41
|
+
throw new Error(
|
|
42
|
+
`Zerops proxy target \`${registration.app.id}/${registration.environment.env}\` requires a public domain: `
|
|
43
|
+
+ `pass --domain with a host this namespace serves (\`namespaces get ${namespace.id}\` lists them)`,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
// One Caddy fronts the whole namespace and its host matcher reads `*` as a wildcard label, so a
|
|
47
|
+
// pattern here would answer for every sibling app. The manifest parser refuses it either way.
|
|
48
|
+
if (!isProxyHost(domain.toLowerCase())) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`Zerops proxy target \`${registration.app.id}/${registration.environment.env}\` domain \`${domain}\` is not a literal hostname: `
|
|
51
|
+
+ 'use dot-separated labels of letters, digits and `-` (no wildcard, no port, no placeholder)',
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const namespaceClaims = new Set(namespaceResourceClaims(namespace))
|
|
56
|
+
for (const requirement of manifest.target.namespaceResources ?? []) {
|
|
57
|
+
if (!namespaceClaims.has(requirement.resourceKey)) {
|
|
58
|
+
throw new Error(`Zerops namespace does not provide required resource \`${requirement.resourceKey}\``)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const hostnames = manifestServiceHostnames(manifest)
|
|
62
|
+
for (const hostname of hostnames) {
|
|
63
|
+
const key = `service:${hostname}`
|
|
64
|
+
if (namespaceClaims.has(key)) {
|
|
65
|
+
throw new Error(`Zerops service hostname \`${hostname}\` is reserved by the namespace`)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (namespace.exclusiveAppId === undefined) {
|
|
69
|
+
const prefix = zeropsSharedServicePrefix(registration.app.id)
|
|
70
|
+
for (const hostname of hostnames) {
|
|
71
|
+
if (!hostname.startsWith(prefix) || hostname.length === prefix.length) {
|
|
72
|
+
throw new Error(`Zerops shared namespace service \`${hostname}\` must use app prefix \`${prefix}\``)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return hostnames.map((hostname) => `service:${hostname}`).sort()
|
|
77
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
JsonValue,
|
|
3
|
+
ProviderCodec,
|
|
4
|
+
ProviderDeploymentNamespace,
|
|
5
|
+
ProviderEnvelope,
|
|
6
|
+
ProviderNamespaceMutationInput,
|
|
7
|
+
} from '@fabrika/provider-contract'
|
|
8
|
+
import { z } from 'zod'
|
|
9
|
+
import { commitShaSchema, identifierSchema, rejectExplicitUndefined, repositorySchema, sha256Schema } from '../source/validation'
|
|
10
|
+
import { namespaceProxySourceSchema } from './proxy-source'
|
|
11
|
+
import type { NormalizedZeropsNamespaceTarget, ZeropsNamespaceOptions, ZeropsNamespaceProxySource, ZeropsNamespaceTarget } from './types'
|
|
12
|
+
|
|
13
|
+
const encodeSource = (source: ZeropsNamespaceProxySource): JsonValue => ({
|
|
14
|
+
repository: { ...source.repository },
|
|
15
|
+
commitSha: source.commitSha,
|
|
16
|
+
descriptor: { ...source.descriptor },
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
const proxyBuildSchema = z.object({
|
|
20
|
+
appVersionId: identifierSchema,
|
|
21
|
+
triggered: z.boolean(),
|
|
22
|
+
manifestSha256: sha256Schema.optional(),
|
|
23
|
+
}).strict().superRefine(rejectExplicitUndefined)
|
|
24
|
+
|
|
25
|
+
const proxyDeploymentSchema = z.object({
|
|
26
|
+
manifestSha256: sha256Schema.optional(),
|
|
27
|
+
appVersionId: identifierSchema,
|
|
28
|
+
repository: repositorySchema,
|
|
29
|
+
commitSha: commitShaSchema,
|
|
30
|
+
descriptorSha256: sha256Schema,
|
|
31
|
+
}).strict().superRefine(rejectExplicitUndefined)
|
|
32
|
+
|
|
33
|
+
export const postgresSchema = z.discriminatedUnion('type', [
|
|
34
|
+
z.object({
|
|
35
|
+
type: z.literal('postgresql:ha@18'),
|
|
36
|
+
profile: z.enum(['oltp-staging', 'oltp-production', 'oltp-enterprise', 'olap-production', 'writeheavy-production']).optional(),
|
|
37
|
+
}),
|
|
38
|
+
z.object({
|
|
39
|
+
type: z.literal('postgresql:single@18'),
|
|
40
|
+
profile: z.enum(['oltp-hobby', 'oltp-staging', 'oltp-production', 'olap-production', 'writeheavy-production']).optional(),
|
|
41
|
+
}),
|
|
42
|
+
]).transform((value) => {
|
|
43
|
+
if (value.profile === undefined) delete value.profile
|
|
44
|
+
return value
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const namespaceTargetSchema = z.object({
|
|
48
|
+
proxyDeployment: proxyDeploymentSchema.optional(),
|
|
49
|
+
proxySource: namespaceProxySourceSchema,
|
|
50
|
+
proxyBuild: proxyBuildSchema.optional(),
|
|
51
|
+
projectName: z.string().min(1).optional(),
|
|
52
|
+
corePackage: z.enum(['LIGHT', 'SERIOUS']).optional(),
|
|
53
|
+
publicAccess: z.enum(['custom-domain', 'zerops-subdomain']).optional(),
|
|
54
|
+
postgres: postgresSchema.optional(),
|
|
55
|
+
managed: z.boolean().optional(),
|
|
56
|
+
projectId: z.string().min(1).optional(),
|
|
57
|
+
proxyServiceId: z.string().min(1).optional(),
|
|
58
|
+
postgresServiceId: z.string().min(1).optional(),
|
|
59
|
+
proxyConfigured: z.boolean().optional(),
|
|
60
|
+
proxyBaselineSequence: z.number().int().min(-1).optional(),
|
|
61
|
+
proxyAppVersionId: z.string().min(1).optional(),
|
|
62
|
+
proxyHosts: z.array(z.object({ host: z.string().min(1), port: z.number().int().positive() })).optional(),
|
|
63
|
+
ready: z.boolean().optional(),
|
|
64
|
+
}).transform((target) => {
|
|
65
|
+
for (const key of Object.keys(target)) {
|
|
66
|
+
if (Reflect.get(target, key) === undefined) Reflect.deleteProperty(target, key)
|
|
67
|
+
}
|
|
68
|
+
return target
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
export const zeropsNamespaceTargetCodec: ProviderCodec<ZeropsNamespaceTarget> = {
|
|
72
|
+
version: 1,
|
|
73
|
+
encode: (target) => ({
|
|
74
|
+
...(target.proxyDeployment === undefined
|
|
75
|
+
? {}
|
|
76
|
+
: {
|
|
77
|
+
proxyDeployment: {
|
|
78
|
+
appVersionId: target.proxyDeployment.appVersionId,
|
|
79
|
+
repository: { ...target.proxyDeployment.repository },
|
|
80
|
+
commitSha: target.proxyDeployment.commitSha,
|
|
81
|
+
descriptorSha256: target.proxyDeployment.descriptorSha256,
|
|
82
|
+
...(target.proxyDeployment.manifestSha256 === undefined ? {} : { manifestSha256: target.proxyDeployment.manifestSha256 }),
|
|
83
|
+
},
|
|
84
|
+
}),
|
|
85
|
+
proxySource: encodeSource(target.proxySource),
|
|
86
|
+
...(target.proxyBuild === undefined ? {} : {
|
|
87
|
+
proxyBuild: {
|
|
88
|
+
appVersionId: target.proxyBuild.appVersionId,
|
|
89
|
+
triggered: target.proxyBuild.triggered,
|
|
90
|
+
...(target.proxyBuild.manifestSha256 === undefined ? {} : { manifestSha256: target.proxyBuild.manifestSha256 }),
|
|
91
|
+
},
|
|
92
|
+
}),
|
|
93
|
+
...(target.projectName !== undefined ? { projectName: target.projectName } : {}),
|
|
94
|
+
...(target.corePackage !== undefined ? { corePackage: target.corePackage } : {}),
|
|
95
|
+
...(target.publicAccess !== undefined ? { publicAccess: target.publicAccess } : {}),
|
|
96
|
+
...(target.postgres !== undefined
|
|
97
|
+
? { postgres: { type: target.postgres.type, ...(target.postgres.profile !== undefined ? { profile: target.postgres.profile } : {}) } }
|
|
98
|
+
: {}),
|
|
99
|
+
...(target.managed !== undefined ? { managed: target.managed } : {}),
|
|
100
|
+
...(target.projectId !== undefined ? { projectId: target.projectId } : {}),
|
|
101
|
+
...(target.proxyServiceId !== undefined ? { proxyServiceId: target.proxyServiceId } : {}),
|
|
102
|
+
...(target.postgresServiceId !== undefined ? { postgresServiceId: target.postgresServiceId } : {}),
|
|
103
|
+
...(target.proxyConfigured !== undefined ? { proxyConfigured: target.proxyConfigured } : {}),
|
|
104
|
+
...(target.proxyBaselineSequence !== undefined ? { proxyBaselineSequence: target.proxyBaselineSequence } : {}),
|
|
105
|
+
...(target.proxyAppVersionId !== undefined ? { proxyAppVersionId: target.proxyAppVersionId } : {}),
|
|
106
|
+
...(target.proxyHosts === undefined ? {} : { proxyHosts: target.proxyHosts.map((entry) => ({ host: entry.host, port: entry.port })) }),
|
|
107
|
+
...(target.ready !== undefined ? { ready: target.ready } : {}),
|
|
108
|
+
}),
|
|
109
|
+
decode: (payload) => {
|
|
110
|
+
const result = namespaceTargetSchema.safeParse(payload)
|
|
111
|
+
if (!result.success) {
|
|
112
|
+
const path = result.error.issues[0]?.path ?? []
|
|
113
|
+
if (path[0] === 'postgres') {
|
|
114
|
+
if (path[1] === 'type') throw new Error('Zerops namespace PostgreSQL type must be postgresql:ha@18 or postgresql:single@18')
|
|
115
|
+
throw new Error('Zerops namespace PostgreSQL profile is not supported')
|
|
116
|
+
}
|
|
117
|
+
if (path[0] === 'proxyHosts' && path.at(-1) === 'port') throw new Error('Zerops namespace target proxyHosts port must be a positive integer')
|
|
118
|
+
throw new Error(`Zerops namespace target ${path.join('.')} is invalid`)
|
|
119
|
+
}
|
|
120
|
+
return result.data
|
|
121
|
+
},
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const envelope = (target: ZeropsNamespaceTarget): ProviderEnvelope => ({
|
|
125
|
+
provider: 'zerops',
|
|
126
|
+
version: zeropsNamespaceTargetCodec.version,
|
|
127
|
+
payload: zeropsNamespaceTargetCodec.encode(target),
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
export const decodeEnvelope = (value: ProviderEnvelope): ZeropsNamespaceTarget => {
|
|
131
|
+
if (value.provider !== 'zerops') {
|
|
132
|
+
throw new Error(`namespace belongs to provider "${value.provider}", expected "zerops"`)
|
|
133
|
+
}
|
|
134
|
+
if (value.version !== zeropsNamespaceTargetCodec.version) {
|
|
135
|
+
throw new Error(`namespace schema version ${value.version} is not supported by provider "zerops"`)
|
|
136
|
+
}
|
|
137
|
+
return zeropsNamespaceTargetCodec.decode(value.payload)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export const normalizeTarget = (
|
|
141
|
+
namespace: ProviderDeploymentNamespace,
|
|
142
|
+
options: ZeropsNamespaceOptions,
|
|
143
|
+
): NormalizedZeropsNamespaceTarget => {
|
|
144
|
+
if (namespace.id.trim() === '' || namespace.env.trim() === '') {
|
|
145
|
+
throw new Error('Zerops namespace id and env must be non-empty')
|
|
146
|
+
}
|
|
147
|
+
const decoded = decodeEnvelope(namespace.target)
|
|
148
|
+
return {
|
|
149
|
+
...decoded,
|
|
150
|
+
projectName: decoded.projectName ?? namespace.id,
|
|
151
|
+
corePackage: decoded.corePackage ?? 'LIGHT',
|
|
152
|
+
publicAccess: decoded.publicAccess ?? 'custom-domain',
|
|
153
|
+
managed: decoded.managed ?? decoded.projectId === undefined,
|
|
154
|
+
ready: decoded.ready ?? false,
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export const normalizedNamespace = (
|
|
159
|
+
namespace: ProviderDeploymentNamespace,
|
|
160
|
+
options: ZeropsNamespaceOptions,
|
|
161
|
+
): ProviderDeploymentNamespace => ({
|
|
162
|
+
...namespace,
|
|
163
|
+
target: envelope(normalizeTarget(namespace, options)),
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const withTarget = (
|
|
167
|
+
namespace: ProviderDeploymentNamespace,
|
|
168
|
+
target: ZeropsNamespaceTarget,
|
|
169
|
+
): ProviderDeploymentNamespace => ({ ...namespace, target: envelope(target) })
|
|
170
|
+
|
|
171
|
+
export const checkpoint = async (
|
|
172
|
+
input: ProviderNamespaceMutationInput,
|
|
173
|
+
namespace: ProviderDeploymentNamespace,
|
|
174
|
+
target: ZeropsNamespaceTarget,
|
|
175
|
+
): Promise<ProviderDeploymentNamespace> => {
|
|
176
|
+
const updated = withTarget(namespace, target)
|
|
177
|
+
await input.events.checkpoint(updated)
|
|
178
|
+
return updated
|
|
179
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const ZEROPS_NAMESPACE_PROXY_HOSTNAME = 'proxy'
|
|
2
|
+
|
|
3
|
+
export const ZEROPS_NAMESPACE_POSTGRES_HOSTNAME = 'postgres'
|
|
4
|
+
|
|
5
|
+
export const ZEROPS_NAMESPACE_IAM_ISSUER_VARIABLE = 'FABRIKA_IAM_ISSUER'
|
|
6
|
+
|
|
7
|
+
export const ZEROPS_NAMESPACE_IAM_KEY_VARIABLE = 'FABRIKA_IAM_KEY'
|
|
8
|
+
|
|
9
|
+
/** The platform-owned variable naming one URL per published HTTP port. READ_ONLY, written by Zerops. */
|
|
10
|
+
export const ZEROPS_NAMESPACE_SUBDOMAIN_VARIABLE = 'zeropsSubdomain'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* How an app reaches the namespace-owned PostgreSQL service — port, database and TLS mode NAMED, not
|
|
14
|
+
* defaulted. Every part settled against a live account, none of it safe to infer:
|
|
15
|
+
*
|
|
16
|
+
* • `${x_connectionTlsString}` is `postgresql://${user}:${password}@${hostname}:${portTls}` — no
|
|
17
|
+
* database path. With none, the libpq/postgres.js convention falls back to the USER name.
|
|
18
|
+
* • `dbName` and `user` are both literally `db` on EVERY PostgreSQL service, whatever its hostname
|
|
19
|
+
* (checked on a service named `wu2db`). So the fallback lands right today — by construction rather
|
|
20
|
+
* than by luck, but still by a rule nobody wrote down. `/${x_dbName}` says it out loud.
|
|
21
|
+
* • THE TLS PORT, NOT THE DIRECT ONE, and this is the one that changed. `postgresql:single@18`
|
|
22
|
+
* speaks TLS on both; `postgresql:ha@18` speaks it ONLY on `portTls` and answers `sslmode=require`
|
|
23
|
+
* on the direct port with "Server does not support SSL". A namespace picks `ha` for `prod` and
|
|
24
|
+
* `single` everywhere else, so a DSN naming the direct port is one preset away from failing.
|
|
25
|
+
* • `portTls` preserves SESSION state on both — same backend pid across statements, `SET SESSION`
|
|
26
|
+
* kept, advisory lock held and visible — so the migration runner's session-level lock is safe
|
|
27
|
+
* there. It is a TLS endpoint, not the transaction pool the port number suggests.
|
|
28
|
+
* • `verify-full` fails (`self signed certificate`), so `require` is the strongest mode available.
|
|
29
|
+
*/
|
|
30
|
+
export const ZEROPS_SHARED_POSTGRES_CONNECTION_STRING = '${postgres_connectionTlsString}/${postgres_dbName}?sslmode=require'
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { ProviderNamespaceError } from '@fabrika/provider-contract'
|
|
2
|
+
import { ZeropsApiError } from '../api'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Lifecycle codes this provider defines where the platform has none of its own. They are an
|
|
6
|
+
* operator-facing vocabulary, so they are stable identifiers rather than prose (backlog 72): a
|
|
7
|
+
* platform code such as `insufficientPermissions` and one of these read the same way downstream.
|
|
8
|
+
*/
|
|
9
|
+
export const ZEROPS_NAMESPACE_CANCELLED = 'namespaceCancelled'
|
|
10
|
+
|
|
11
|
+
export const ZEROPS_NAMESPACE_LIFECYCLE = 'namespaceLifecycle'
|
|
12
|
+
|
|
13
|
+
export const ZEROPS_PROXY_DEPLOY_TIMEOUT = 'proxyDeployTimeout'
|
|
14
|
+
|
|
15
|
+
export const ZEROPS_PROXY_DEPLOY_FAILED = 'proxyDeployFailed'
|
|
16
|
+
|
|
17
|
+
export const ZEROPS_SUBDOMAIN_NOT_PUBLISHED = 'subdomainNotPublished'
|
|
18
|
+
|
|
19
|
+
export const namespaceError = (code: string, message: string, retryable: boolean, detail?: string): ProviderNamespaceError =>
|
|
20
|
+
new ProviderNamespaceError(message, code, retryable, detail)
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `apiError` composes `zerops: <label> failed (<status>) — <code>: <server message>`. The half before the
|
|
24
|
+
* first separator is OURS (a literal label and the status); the half after is the platform's, and it
|
|
25
|
+
* REPEATS the code, which travels separately. Splitting them keeps the summary safe to show while the
|
|
26
|
+
* upstream words survive in `detail`, where the control plane redacts them before they are stored or
|
|
27
|
+
* logged — discarding them is what left three different live failures indistinguishable (backlog 72).
|
|
28
|
+
*
|
|
29
|
+
* A `redactDetail: true` call carries the code and nothing else, so it yields no detail at all.
|
|
30
|
+
*/
|
|
31
|
+
const splitApiMessage = (message: string, code: string): { summary: string; detail?: string } => {
|
|
32
|
+
const at = message.indexOf(' — ')
|
|
33
|
+
if (at === -1) return { summary: message }
|
|
34
|
+
const summary = message.slice(0, at)
|
|
35
|
+
const rest = message.slice(at + 3)
|
|
36
|
+
if (code === '' || rest === code) return rest === code ? { summary } : { summary, detail: rest }
|
|
37
|
+
const detail = rest.startsWith(`${code}: `) ? rest.slice(code.length + 2) : rest
|
|
38
|
+
return detail === '' ? { summary } : { summary, detail }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Every namespace lifecycle failure leaves this provider as one typed, coded error. */
|
|
42
|
+
export const asNamespaceError = (cause: unknown, mode: 'provision' | 'reconcile'): ProviderNamespaceError => {
|
|
43
|
+
if (cause instanceof ProviderNamespaceError) return cause
|
|
44
|
+
if (cause instanceof ZeropsApiError) {
|
|
45
|
+
const { summary, detail } = splitApiMessage(cause.message, cause.code)
|
|
46
|
+
return namespaceError(
|
|
47
|
+
cause.code === '' ? `zeropsHttp${cause.status}` : cause.code,
|
|
48
|
+
summary,
|
|
49
|
+
cause.status === 429 || cause.status >= 500,
|
|
50
|
+
detail,
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
if (cause instanceof Error && cause.name === 'AbortError') {
|
|
54
|
+
return namespaceError(ZEROPS_NAMESPACE_CANCELLED, `Zerops namespace ${mode} was cancelled`, true)
|
|
55
|
+
}
|
|
56
|
+
if (cause instanceof Error) {
|
|
57
|
+
return namespaceError(ZEROPS_NAMESPACE_LIFECYCLE, cause.message, false)
|
|
58
|
+
}
|
|
59
|
+
return namespaceError(ZEROPS_NAMESPACE_LIFECYCLE, `Zerops namespace ${mode} failed`, false)
|
|
60
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { decodeEnvelope, type ProviderNamespaceMember } from '@fabrika/provider-contract'
|
|
2
|
+
import type { ProxyManifest } from '@fabrika/proxy-core/manifest'
|
|
3
|
+
import { assertFabrikaManifestCoordinates, zeropsArtifactCodec } from '../manifest'
|
|
4
|
+
|
|
5
|
+
/** Compile every public member of one deployment namespace into its strict proxy manifest. */
|
|
6
|
+
export function compileNamespaceProxyManifest(namespaceId: string, members: readonly ProviderNamespaceMember[]): ProxyManifest {
|
|
7
|
+
const apps: ProxyManifest['apps'] = []
|
|
8
|
+
const ids = new Set<string>()
|
|
9
|
+
const hosts = new Set<string>()
|
|
10
|
+
for (const member of members) {
|
|
11
|
+
const manifest = decodeEnvelope('artifact', 'zerops', member.artifact, zeropsArtifactCodec)
|
|
12
|
+
assertFabrikaManifestCoordinates(manifest.app, { appId: member.appId, env: member.env })
|
|
13
|
+
const proxy = manifest.target.proxy
|
|
14
|
+
if (proxy === undefined) continue
|
|
15
|
+
if (member.domain === undefined || member.domain === '') {
|
|
16
|
+
throw new Error(`Zerops proxy target ${member.appId}/${member.env} requires a public domain`)
|
|
17
|
+
}
|
|
18
|
+
const host = member.domain.toLowerCase()
|
|
19
|
+
if (ids.has(member.appId)) throw new Error(`duplicate proxy app id \`${member.appId}\` in deployment namespace ${namespaceId}`)
|
|
20
|
+
if (hosts.has(host)) throw new Error(`duplicate proxy host \`${host}\` in deployment namespace ${namespaceId}`)
|
|
21
|
+
ids.add(member.appId)
|
|
22
|
+
hosts.add(host)
|
|
23
|
+
// A Zerops public domain is always served over the project's TLS-terminating L7 balancer, so the
|
|
24
|
+
// browser speaks https even though the proxy itself is reached over plain HTTP (ADR-0022).
|
|
25
|
+
apps.push({ id: member.appId, hosts: [host], upstream: proxy.upstream, gates: proxy.gates, scheme: 'https' })
|
|
26
|
+
}
|
|
27
|
+
return { apps }
|
|
28
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
JsonValue,
|
|
3
|
+
ProviderDeploymentNamespace,
|
|
4
|
+
ProviderNamespaceFact,
|
|
5
|
+
ProviderNamespaceOperator,
|
|
6
|
+
ProviderNamespacePlanInput,
|
|
7
|
+
ProviderNamespacePresentation,
|
|
8
|
+
ProviderNamespacePreset,
|
|
9
|
+
} from '@fabrika/provider-contract'
|
|
10
|
+
import { z } from 'zod'
|
|
11
|
+
import { decodeEnvelope, envelope, postgresSchema } from './codec'
|
|
12
|
+
import { zeropsNamespacePreset } from './topology'
|
|
13
|
+
import type { ZeropsNamespaceOperatorOptions, ZeropsNamespacePlanOptions, ZeropsNamespaceTarget } from './types'
|
|
14
|
+
|
|
15
|
+
const ZEROPS_NAMESPACE_PRESETS: readonly ProviderNamespacePreset[] = [
|
|
16
|
+
{
|
|
17
|
+
id: 'cheap',
|
|
18
|
+
label: 'Cheap',
|
|
19
|
+
description: 'Several apps share one project, proxy, and namespace-owned PostgreSQL service.',
|
|
20
|
+
requiresExclusiveApp: false,
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'mid',
|
|
24
|
+
label: 'Mid',
|
|
25
|
+
description: 'Several apps share one project and proxy; each app owns its prefixed database services.',
|
|
26
|
+
requiresExclusiveApp: false,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'full',
|
|
30
|
+
label: 'Full',
|
|
31
|
+
description: 'One project and proxy are reserved for exactly one app.',
|
|
32
|
+
requiresExclusiveApp: true,
|
|
33
|
+
},
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
const planOptionsSchema = z.object({
|
|
37
|
+
projectName: z.string().min(1).optional(),
|
|
38
|
+
corePackage: z.enum(['LIGHT', 'SERIOUS']).optional(),
|
|
39
|
+
publicAccess: z.enum(['custom-domain', 'zerops-subdomain']).optional(),
|
|
40
|
+
postgres: postgresSchema.optional(),
|
|
41
|
+
}).strict().transform((options) => {
|
|
42
|
+
for (const key of Object.keys(options)) {
|
|
43
|
+
if (Reflect.get(options, key) === undefined) Reflect.deleteProperty(options, key)
|
|
44
|
+
}
|
|
45
|
+
return options
|
|
46
|
+
})
|
|
47
|
+
const parsePlanOptions = (value: JsonValue | undefined): ZeropsNamespacePlanOptions => {
|
|
48
|
+
const result = planOptionsSchema.safeParse(value === undefined ? {} : value)
|
|
49
|
+
if (!result.success) {
|
|
50
|
+
const issue = result.error.issues[0]
|
|
51
|
+
if (issue?.code === 'unrecognized_keys') throw new Error(`Zerops namespace plan option ${issue.keys[0]} is not supported`)
|
|
52
|
+
throw new Error(`Zerops namespace plan ${issue?.path.join('.') ?? 'options'} is not supported`)
|
|
53
|
+
}
|
|
54
|
+
return result.data
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const namespacePresentation = (
|
|
58
|
+
namespace: ProviderDeploymentNamespace,
|
|
59
|
+
target: ZeropsNamespaceTarget,
|
|
60
|
+
): ProviderNamespacePresentation => {
|
|
61
|
+
const preset = target.postgres !== undefined ? 'cheap' : namespace.exclusiveAppId === undefined ? 'mid' : 'full'
|
|
62
|
+
const projectName = target.projectName ?? namespace.id
|
|
63
|
+
const corePackage = target.corePackage ?? 'LIGHT'
|
|
64
|
+
const publicAccess = target.publicAccess ?? 'custom-domain'
|
|
65
|
+
const postgres = target.postgres === undefined
|
|
66
|
+
? 'App-owned database services'
|
|
67
|
+
: `Shared ${target.postgres.type}${target.postgres.profile === undefined ? '' : ` (${target.postgres.profile})`}`
|
|
68
|
+
// Only a published subdomain namespace knows its own hosts; custom domains are bound out of band, so
|
|
69
|
+
// the provider names none and an operator reads them where they were bound.
|
|
70
|
+
const hosts = publicAccess === 'zerops-subdomain' && target.ready === true ? target.proxyHosts ?? [] : []
|
|
71
|
+
const instructions = [
|
|
72
|
+
...(publicAccess === 'custom-domain'
|
|
73
|
+
? [`Bind each application domain to the \`proxy\` service in Zerops project \`${projectName}\`; keep every app service private.`]
|
|
74
|
+
: ['Use the proxy Zerops subdomain only for non-production access; app services must remain private.']),
|
|
75
|
+
...(hosts.length === 0 ? [] : ['Register a proxy-target environment with one of the hosts below as its domain; each host serves one app.']),
|
|
76
|
+
...(target.postgres === undefined
|
|
77
|
+
? []
|
|
78
|
+
: ['Every app consuming the shared PostgreSQL binding shares its physical service and provider-issued credential.']),
|
|
79
|
+
]
|
|
80
|
+
// The project is the one fact that names a LIVE resource: it exists at the provider, it outlives the
|
|
81
|
+
// namespace row, and removal does not delete it (ADR-0034). Marked only once it actually exists.
|
|
82
|
+
const project: ProviderNamespaceFact = target.projectId === undefined
|
|
83
|
+
? { label: 'Project', value: projectName }
|
|
84
|
+
: { label: 'Project', value: `${projectName} (${target.projectId})`, resource: true }
|
|
85
|
+
return {
|
|
86
|
+
preset,
|
|
87
|
+
title: `${ZEROPS_NAMESPACE_PRESETS.find((candidate) => candidate.id === preset)?.label ?? preset} namespace`,
|
|
88
|
+
facts: [
|
|
89
|
+
{
|
|
90
|
+
label: 'Proxy source',
|
|
91
|
+
value:
|
|
92
|
+
`${target.proxySource.repository.owner}/${target.proxySource.repository.name}@${target.proxySource.commitSha} (zerops.yaml sha256:${target.proxySource.descriptor.sha256})`,
|
|
93
|
+
},
|
|
94
|
+
project,
|
|
95
|
+
{ label: 'Environment', value: namespace.env },
|
|
96
|
+
{ label: 'Core package', value: corePackage },
|
|
97
|
+
{ label: 'Placement', value: namespace.exclusiveAppId === undefined ? 'Shared' : `Exclusive to ${namespace.exclusiveAppId}` },
|
|
98
|
+
{ label: 'PostgreSQL', value: postgres },
|
|
99
|
+
{ label: 'Public access', value: publicAccess },
|
|
100
|
+
],
|
|
101
|
+
instructions,
|
|
102
|
+
...(hosts.length === 0 ? {} : { hosts }),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Build the provider-owned namespace planning surface without any network collaborators. */
|
|
107
|
+
export const createZeropsNamespaceOperator = (
|
|
108
|
+
options: ZeropsNamespaceOperatorOptions,
|
|
109
|
+
): ProviderNamespaceOperator => {
|
|
110
|
+
return {
|
|
111
|
+
presets: ZEROPS_NAMESPACE_PRESETS,
|
|
112
|
+
plan(input: ProviderNamespacePlanInput) {
|
|
113
|
+
if (input.id.trim() === '' || input.env.trim() === '') {
|
|
114
|
+
throw new Error('Zerops namespace plan id and env must be non-empty')
|
|
115
|
+
}
|
|
116
|
+
if (input.preset !== 'cheap' && input.preset !== 'mid' && input.preset !== 'full') {
|
|
117
|
+
throw new Error(`Zerops namespace preset ${input.preset} is not supported`)
|
|
118
|
+
}
|
|
119
|
+
const exclusiveAppId = input.exclusiveAppId?.trim()
|
|
120
|
+
if (input.preset === 'full' && (exclusiveAppId === undefined || exclusiveAppId === '')) {
|
|
121
|
+
throw new Error('Zerops full namespace plan requires exclusiveAppId')
|
|
122
|
+
}
|
|
123
|
+
if (input.preset !== 'full' && exclusiveAppId !== undefined) {
|
|
124
|
+
throw new Error(`Zerops ${input.preset} namespace plan cannot reserve an exclusive app`)
|
|
125
|
+
}
|
|
126
|
+
const planOptions = parsePlanOptions(input.options)
|
|
127
|
+
if (input.preset !== 'cheap' && planOptions.postgres !== undefined) {
|
|
128
|
+
throw new Error(`Zerops ${input.preset} namespace plan cannot own PostgreSQL`)
|
|
129
|
+
}
|
|
130
|
+
const target = zeropsNamespacePreset({
|
|
131
|
+
preset: input.preset,
|
|
132
|
+
env: input.env,
|
|
133
|
+
projectName: planOptions.projectName ?? input.id,
|
|
134
|
+
proxySource: options.proxySource,
|
|
135
|
+
...(planOptions.corePackage === undefined ? {} : { corePackage: planOptions.corePackage }),
|
|
136
|
+
...(planOptions.publicAccess === undefined ? {} : { publicAccess: planOptions.publicAccess }),
|
|
137
|
+
...(planOptions.postgres === undefined ? {} : { postgres: planOptions.postgres }),
|
|
138
|
+
})
|
|
139
|
+
const namespace: ProviderDeploymentNamespace = {
|
|
140
|
+
id: input.id,
|
|
141
|
+
env: input.env,
|
|
142
|
+
...(exclusiveAppId === undefined ? {} : { exclusiveAppId }),
|
|
143
|
+
target: envelope(target),
|
|
144
|
+
}
|
|
145
|
+
return { namespace, presentation: namespacePresentation(namespace, target) }
|
|
146
|
+
},
|
|
147
|
+
present(namespace) {
|
|
148
|
+
return namespacePresentation(namespace, decodeEnvelope(namespace.target))
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
}
|