@ninedeploy/schemas 0.10.6 → 0.16.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/LICENSE +21 -21
- package/dist/accessGrants.d.ts +156 -0
- package/dist/accessGrants.d.ts.map +1 -0
- package/dist/accessGrants.js +100 -0
- package/dist/accessGrants.js.map +1 -0
- package/dist/agentCommand.d.ts.map +1 -1
- package/dist/agentCommand.js +11 -1
- package/dist/agentCommand.js.map +1 -1
- package/dist/auth.d.ts +53 -0
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +26 -0
- package/dist/auth.js.map +1 -1
- package/dist/backupPolicy.d.ts +37 -0
- package/dist/backupPolicy.d.ts.map +1 -0
- package/dist/backupPolicy.js +50 -0
- package/dist/backupPolicy.js.map +1 -0
- package/dist/common.d.ts +21 -5
- package/dist/common.d.ts.map +1 -1
- package/dist/common.js +35 -6
- package/dist/common.js.map +1 -1
- package/dist/databaseImport.d.ts +145 -0
- package/dist/databaseImport.d.ts.map +1 -0
- package/dist/databaseImport.js +115 -0
- package/dist/databaseImport.js.map +1 -0
- package/dist/doctor.d.ts +16 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +25 -0
- package/dist/doctor.js.map +1 -1
- package/dist/githubApp.d.ts +236 -0
- package/dist/githubApp.d.ts.map +1 -0
- package/dist/githubApp.js +223 -0
- package/dist/githubApp.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -1
- package/dist/kernel.d.ts +5 -2
- package/dist/kernel.d.ts.map +1 -1
- package/dist/kernel.js +11 -2
- package/dist/kernel.js.map +1 -1
- package/dist/management.d.ts +46 -2
- package/dist/management.d.ts.map +1 -1
- package/dist/management.js +68 -15
- package/dist/management.js.map +1 -1
- package/dist/multiNode.d.ts +326 -0
- package/dist/multiNode.d.ts.map +1 -0
- package/dist/multiNode.js +296 -0
- package/dist/multiNode.js.map +1 -0
- package/dist/ninedeployManifest.d.ts.map +1 -1
- package/dist/ninedeployManifest.js +6 -3
- package/dist/ninedeployManifest.js.map +1 -1
- package/dist/panelBackup.d.ts +73 -0
- package/dist/panelBackup.d.ts.map +1 -0
- package/dist/panelBackup.js +45 -0
- package/dist/panelBackup.js.map +1 -0
- package/dist/proxyConfig.d.ts +132 -0
- package/dist/proxyConfig.d.ts.map +1 -0
- package/dist/proxyConfig.js +105 -0
- package/dist/proxyConfig.js.map +1 -0
- package/dist/publicAccess.d.ts +64 -0
- package/dist/publicAccess.d.ts.map +1 -0
- package/dist/publicAccess.js +63 -0
- package/dist/publicAccess.js.map +1 -0
- package/dist/secretProviders.d.ts +119 -0
- package/dist/secretProviders.d.ts.map +1 -0
- package/dist/secretProviders.js +130 -0
- package/dist/secretProviders.js.map +1 -0
- package/dist/secretScan.js +1 -1
- package/dist/service.d.ts +235 -17
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +331 -26
- package/dist/service.js.map +1 -1
- package/dist/terminals.d.ts +284 -0
- package/dist/terminals.d.ts.map +1 -0
- package/dist/terminals.js +177 -0
- package/dist/terminals.js.map +1 -0
- package/dist/traffic.d.ts +276 -0
- package/dist/traffic.d.ts.map +1 -0
- package/dist/traffic.js +103 -0
- package/dist/traffic.js.map +1 -0
- package/dist/workspaces.d.ts +19 -1
- package/dist/workspaces.d.ts.map +1 -1
- package/dist/workspaces.js +26 -0
- package/dist/workspaces.js.map +1 -1
- package/package.json +5 -4
- package/src/accessGrants.ts +117 -0
- package/src/agentCommand.ts +67 -0
- package/src/ai.ts +32 -0
- package/src/auth.ts +323 -0
- package/src/backupPolicy.ts +55 -0
- package/src/common.ts +179 -0
- package/src/databaseImport.ts +138 -0
- package/src/doctor.ts +161 -0
- package/src/environments.ts +23 -0
- package/src/githubApp.ts +268 -0
- package/src/index.ts +30 -0
- package/src/insights.ts +78 -0
- package/src/kernel.ts +180 -0
- package/src/labels.ts +86 -0
- package/src/management.ts +620 -0
- package/src/multiNode.ts +356 -0
- package/src/ninedeployManifest.ts +363 -0
- package/src/panelBackup.ts +90 -0
- package/src/project.ts +42 -0
- package/src/proxyConfig.ts +139 -0
- package/src/publicAccess.ts +80 -0
- package/src/runtimeVersions.ts +232 -0
- package/src/secretProviders.ts +159 -0
- package/src/secretScan.ts +169 -0
- package/src/service.ts +1310 -0
- package/src/terminals.ts +212 -0
- package/src/traffic.ts +130 -0
- package/src/workspaces.ts +215 -0
package/src/service.ts
ADDED
|
@@ -0,0 +1,1310 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { containerPath, dockerVolumeName, envVarName, gitBranch, gitRepoUrl, httpPath, providerBaseUrl, repoBaseDir, repoRelativePath, slug } from './common.js';
|
|
3
|
+
|
|
4
|
+
export const serviceType = z.enum(['pm2', 'docker', 'compose']);
|
|
5
|
+
export const buildPack = z.enum(['auto', 'nixpacks', 'dockerfile', 'railpack', 'static']);
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 0.16.1: names of the service's environment variables a Dockerfile build
|
|
9
|
+
* receives as `--build-arg` (the Dockerfile reads them with `ARG`). Opt-in per
|
|
10
|
+
* name: a build gets none unless listed. Nixpacks and Railpack builds already
|
|
11
|
+
* see the whole environment.
|
|
12
|
+
*/
|
|
13
|
+
export const buildArgNames = z.array(envVarName.max(100)).max(100);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* 0.16.1: capabilities a docker service may add. Deliberately short: the ones
|
|
17
|
+
* real workloads ask for (VPNs, packet capture, profilers, realtime audio,
|
|
18
|
+
* mlock). SYS_ADMIN, SYS_MODULE, DAC_READ_SEARCH and the like are left out —
|
|
19
|
+
* each is a known path out of the container.
|
|
20
|
+
*/
|
|
21
|
+
export const runtimeCapability = z.enum(['NET_ADMIN', 'NET_RAW', 'SYS_PTRACE', 'SYS_NICE', 'SYS_RESOURCE', 'IPC_LOCK', 'SYS_TIME']);
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 0.16.1 advanced container options for docker services (operator only):
|
|
25
|
+
* structured fields, never free-form `docker run` flags, so nothing outside
|
|
26
|
+
* this list (privileged, host mounts, host network…) can be asked for.
|
|
27
|
+
*/
|
|
28
|
+
export const runtimeOptions = z
|
|
29
|
+
.object({
|
|
30
|
+
/** `--gpus all` or `--gpus <n>` (needs the NVIDIA container toolkit on the host). */
|
|
31
|
+
gpus: z.union([z.literal('all'), z.number().int().min(1).max(16)]).nullable().optional(),
|
|
32
|
+
/** `--shm-size` in MiB; browsers and PyTorch data loaders outgrow Docker's 64. */
|
|
33
|
+
shmSizeMb: z.number().int().min(1).max(65_536).nullable().optional(),
|
|
34
|
+
/** `--init`: reaps zombie processes and forwards signals to the app. */
|
|
35
|
+
init: z.boolean().optional(),
|
|
36
|
+
/** `--ulimit nofile=N:N`. */
|
|
37
|
+
ulimitNofile: z.number().int().min(1024).max(1_048_576).nullable().optional(),
|
|
38
|
+
/** `--cap-add`. */
|
|
39
|
+
capAdd: z.array(runtimeCapability).max(7).optional(),
|
|
40
|
+
})
|
|
41
|
+
.strict();
|
|
42
|
+
export type RuntimeOptions = z.infer<typeof runtimeOptions>;
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 0.16.1: the most host ports one service may publish through `portMappings`.
|
|
46
|
+
* Docker starts one userland proxy process per published port and protocol,
|
|
47
|
+
* so a 10 000-port range costs 10 000 processes on the host.
|
|
48
|
+
*/
|
|
49
|
+
export const MAX_MAPPED_PORTS = 1000;
|
|
50
|
+
/** Most port-mapping rules per service. */
|
|
51
|
+
export const MAX_PORT_MAPPINGS = 20;
|
|
52
|
+
|
|
53
|
+
const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A literal IPv4 or IPv6 address a port can be bound to (no zone, no CIDR).
|
|
57
|
+
* The unspecified addresses are refused: an empty IP already means "every
|
|
58
|
+
* address", and two spellings of it would dodge the overlap check.
|
|
59
|
+
*/
|
|
60
|
+
export function isBindableIp(ip: string): boolean {
|
|
61
|
+
if (IPV4.test(ip)) return ip !== '0.0.0.0';
|
|
62
|
+
if (ip.length > 45) return false;
|
|
63
|
+
const halves = ip.split('::');
|
|
64
|
+
if (halves.length > 2) return false;
|
|
65
|
+
const groups = halves.flatMap((h) => (h === '' ? [] : h.split(':')));
|
|
66
|
+
let width = groups.length;
|
|
67
|
+
if (groups.length > 0 && IPV4.test(groups[groups.length - 1]!)) {
|
|
68
|
+
groups.pop();
|
|
69
|
+
width += 1; // an embedded IPv4 fills two groups
|
|
70
|
+
}
|
|
71
|
+
if (!groups.every((g) => /^[0-9A-Fa-f]{1,4}$/.test(g))) return false;
|
|
72
|
+
if (halves.length === 2 ? width > 7 : width !== 8) return false;
|
|
73
|
+
// `::`, `0::0`, `0:0:0:0:0:0:0:0`: the unspecified address.
|
|
74
|
+
return width !== groups.length || groups.some((g) => Number.parseInt(g, 16) !== 0);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 0.16.1 extra host-port mappings of a docker service: one port or a range,
|
|
79
|
+
* TCP or UDP, on every host address or one dedicated IP
|
|
80
|
+
* (`docker run -p 203.0.113.7:27015-27030:27015-27030/udp`). The container
|
|
81
|
+
* range starts at `containerPort` and is as long as the host range.
|
|
82
|
+
*/
|
|
83
|
+
export const portMapping = z
|
|
84
|
+
.object({
|
|
85
|
+
/** Host address to bind; null or omitted = every address of the host. */
|
|
86
|
+
hostIp: z
|
|
87
|
+
.string()
|
|
88
|
+
.max(45)
|
|
89
|
+
.refine(isBindableIp, 'hostIp must be one IPv4 or IPv6 address of the host (leave it empty for every address)')
|
|
90
|
+
.nullable()
|
|
91
|
+
.optional(),
|
|
92
|
+
hostPort: z.number().int().min(1).max(65535),
|
|
93
|
+
/** Last host port of a range; null or omitted = the single `hostPort`. */
|
|
94
|
+
hostPortEnd: z.number().int().min(1).max(65535).nullable().optional(),
|
|
95
|
+
containerPort: z.number().int().min(1).max(65535),
|
|
96
|
+
protocol: z.enum(['tcp', 'udp']).default('tcp'),
|
|
97
|
+
/**
|
|
98
|
+
* TCP, one port: only these addresses or CIDR ranges may connect. The port
|
|
99
|
+
* is then published by a small Traefik proxy in front of the service
|
|
100
|
+
* instead of Docker's `-p`, which a host firewall cannot restrict.
|
|
101
|
+
*/
|
|
102
|
+
allowFrom: z.array(z.string().trim().min(1).max(64)).min(1).max(100).nullable().optional(),
|
|
103
|
+
})
|
|
104
|
+
.strict();
|
|
105
|
+
export type PortMapping = z.infer<typeof portMapping>;
|
|
106
|
+
type PortMappingLike = Pick<PortMapping, 'hostPort' | 'hostPortEnd' | 'containerPort'> & {
|
|
107
|
+
hostIp?: string | null;
|
|
108
|
+
protocol?: 'tcp' | 'udp';
|
|
109
|
+
allowFrom?: readonly string[] | null;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** How many host ports a mapping publishes. */
|
|
113
|
+
export const mappedPortCount = (m: Pick<PortMapping, 'hostPort' | 'hostPortEnd'>): number =>
|
|
114
|
+
Math.max(1, (m.hostPortEnd ?? m.hostPort) - m.hostPort + 1);
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Whether two mappings would bind the same host socket: same protocol,
|
|
118
|
+
* overlapping ports, and the same IP (or either on every address).
|
|
119
|
+
*/
|
|
120
|
+
export function portMappingsOverlap(a: PortMappingLike, b: PortMappingLike): boolean {
|
|
121
|
+
if ((a.protocol ?? 'tcp') !== (b.protocol ?? 'tcp')) return false;
|
|
122
|
+
if (a.hostIp && b.hostIp && a.hostIp.toLowerCase() !== b.hostIp.toLowerCase()) return false;
|
|
123
|
+
return a.hostPort <= (b.hostPortEnd ?? b.hostPort) && b.hostPort <= (a.hostPortEnd ?? a.hostPort);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Why a list of mappings cannot be published, or null. Shared by the server, the panel and the CLI. */
|
|
127
|
+
export function portMappingsProblem(list: readonly PortMappingLike[]): string | null {
|
|
128
|
+
let total = 0;
|
|
129
|
+
for (const [i, m] of list.entries()) {
|
|
130
|
+
const end = m.hostPortEnd ?? m.hostPort;
|
|
131
|
+
const label = formatPortMapping(m);
|
|
132
|
+
if (end < m.hostPort) return `Mapping ${i + 1} (${label}): the range ends before it starts.`;
|
|
133
|
+
if (m.containerPort + (end - m.hostPort) > 65535) return `Mapping ${i + 1} (${label}): the container range runs past 65535.`;
|
|
134
|
+
total += end - m.hostPort + 1;
|
|
135
|
+
if (m.allowFrom?.length && ((m.protocol ?? 'tcp') !== 'tcp' || end !== m.hostPort)) {
|
|
136
|
+
return `Mapping ${i + 1} (${label}): an allow list works on one TCP port (UDP and ranges cannot be filtered by source).`;
|
|
137
|
+
}
|
|
138
|
+
const other = list.slice(0, i).find((o) => portMappingsOverlap(o, m));
|
|
139
|
+
if (other) {
|
|
140
|
+
return `Mapping ${i + 1} (${label}) overlaps ${formatPortMapping(other)}: a host port can be published once per address and protocol.`;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (total > MAX_MAPPED_PORTS) {
|
|
144
|
+
return `${total} host ports requested; at most ${MAX_MAPPED_PORTS} per service (Docker runs one proxy process per published port).`;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The `docker run -p` operand: `27015-27030:27015-27030/udp`, `203.0.113.7:53:53/udp`, `[2001:db8::7]:53:53/udp`. */
|
|
150
|
+
export function formatPortMapping(m: PortMappingLike): string {
|
|
151
|
+
const span = (m.hostPortEnd ?? m.hostPort) - m.hostPort;
|
|
152
|
+
const range = (start: number) => (span > 0 ? `${start}-${start + span}` : String(start));
|
|
153
|
+
const ip = m.hostIp ? (m.hostIp.includes(':') ? `[${m.hostIp}]:` : `${m.hostIp}:`) : '';
|
|
154
|
+
return `${ip}${range(m.hostPort)}:${range(m.containerPort)}/${m.protocol ?? 'tcp'}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Parse `27015-27030:27015/udp`, `8080:80`, `5432@10.0.0.0/8`, `53/udp`, `10000-10100/udp`
|
|
159
|
+
* (no colon = the same ports inside the container), optionally after an IP:
|
|
160
|
+
* `203.0.113.7:53/udp`, `[2001:db8::7]:8080:80`. A container range must be as
|
|
161
|
+
* long as the host range. Returns the mapping, or why the text is not one.
|
|
162
|
+
*/
|
|
163
|
+
export function parsePortMapping(text: string): PortMapping | string {
|
|
164
|
+
// `…@10.0.0.0/8,203.0.113.4`: the allow list of a TCP port.
|
|
165
|
+
const parts = text.trim().split('@');
|
|
166
|
+
const t = parts[0]!;
|
|
167
|
+
const allowText = parts[1];
|
|
168
|
+
if (parts.length > 2) return `"${text.trim()}" has more than one @.`;
|
|
169
|
+
const allowFrom = allowText === undefined ? null : allowText.split(',').map((e) => e.trim()).filter(Boolean);
|
|
170
|
+
if (allowFrom && allowFrom.length === 0) return `"${text.trim()}": list the addresses after @ (10.0.0.0/8,203.0.113.4).`;
|
|
171
|
+
const m = /^(?:(\d{1,3}(?:\.\d{1,3}){3})|\[([0-9A-Fa-f:.]+)\]):(\d{1,5})(?:-(\d{1,5}))?(?::(\d{1,5})(?:-(\d{1,5}))?)?(?:\/(tcp|udp))?$/i.exec(t)
|
|
172
|
+
?? /^()()(\d{1,5})(?:-(\d{1,5}))?(?::(\d{1,5})(?:-(\d{1,5}))?)?(?:\/(tcp|udp))?$/i.exec(t);
|
|
173
|
+
if (!m) return `"${t}" is not a port mapping (examples: 8080:80, 27015-27030:27015/udp, 203.0.113.7:53/udp).`;
|
|
174
|
+
const hostIp = m[1] || m[2] || null;
|
|
175
|
+
if (hostIp && !isBindableIp(hostIp)) return `"${t}": ${hostIp} is not an address a port can be bound to.`;
|
|
176
|
+
const hostPort = Number(m[3]);
|
|
177
|
+
const hostPortEnd = m[4] ? Number(m[4]) : null;
|
|
178
|
+
const containerPort = m[5] ? Number(m[5]) : hostPort;
|
|
179
|
+
const ports = [hostPort, hostPortEnd ?? hostPort, containerPort];
|
|
180
|
+
if (ports.some((p) => p < 1 || p > 65535)) return `"${t}": ports run from 1 to 65535.`;
|
|
181
|
+
if (hostPortEnd !== null && hostPortEnd < hostPort) return `"${t}": the range ends before it starts.`;
|
|
182
|
+
if (m[6] && Number(m[6]) - containerPort !== (hostPortEnd ?? hostPort) - hostPort) {
|
|
183
|
+
return `"${t}": the container range must be as long as the host range.`;
|
|
184
|
+
}
|
|
185
|
+
return {
|
|
186
|
+
hostIp,
|
|
187
|
+
hostPort,
|
|
188
|
+
hostPortEnd: hostPortEnd !== null && hostPortEnd !== hostPort ? hostPortEnd : null,
|
|
189
|
+
containerPort,
|
|
190
|
+
protocol: (m[7]?.toLowerCase() ?? 'tcp') as 'tcp' | 'udp',
|
|
191
|
+
...(allowFrom ? { allowFrom } : {}),
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The PATCH field: rules checked together (ranges, overlaps, the port budget). */
|
|
196
|
+
export const portMappings = z
|
|
197
|
+
.array(portMapping)
|
|
198
|
+
.max(MAX_PORT_MAPPINGS)
|
|
199
|
+
.superRefine((list, ctx) => {
|
|
200
|
+
const problem = portMappingsProblem(list);
|
|
201
|
+
if (problem) ctx.addIssue({ code: 'custom', message: problem });
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* 0.16.1 deploy healthcheck: `http` probes the container port (an HTTP answer
|
|
206
|
+
* below 500, then a TCP connect); `running` only waits for the container to
|
|
207
|
+
* stay up, for services with no HTTP or TCP port to probe (UDP game servers,
|
|
208
|
+
* VoIP, queue workers).
|
|
209
|
+
*/
|
|
210
|
+
export const healthCheckMode = z.enum(['http', 'running']);
|
|
211
|
+
export type HealthCheckMode = z.infer<typeof healthCheckMode>;
|
|
212
|
+
|
|
213
|
+
/** Optional project/workspace/label tag IDs for new service creation. */
|
|
214
|
+
const tagIds = z.array(z.number().int().positive()).optional();
|
|
215
|
+
|
|
216
|
+
export const createService = z.object({
|
|
217
|
+
/** Bundled/remote Hub template ID. The server resolves privileged template
|
|
218
|
+
* settings (cmd, Docker socket and database env mapping) from the trusted
|
|
219
|
+
* registry; clients cannot submit those settings directly. */
|
|
220
|
+
templateId: z.string().min(1).max(100).optional(),
|
|
221
|
+
/**
|
|
222
|
+
* Project / workspace / label tagging (N-N). `tagProjectIds` replaces the
|
|
223
|
+
* removed `projectId` field. `tagWorkspaceIds` defaults to all workspaces
|
|
224
|
+
* the caller belongs to when omitted (so a freshly-created service is
|
|
225
|
+
* visible from the operator's workspaces). `tagLabelIds` is optional.
|
|
226
|
+
*/
|
|
227
|
+
tagProjectIds: tagIds,
|
|
228
|
+
tagWorkspaceIds: tagIds,
|
|
229
|
+
tagLabelIds: tagIds,
|
|
230
|
+
name: z.string().min(1).max(100),
|
|
231
|
+
slug: slug.optional(), // derived from name if omitted
|
|
232
|
+
/** Resume an idle, caller-owned service left by an interrupted Hub deploy. */
|
|
233
|
+
reuseExisting: z.boolean().optional(),
|
|
234
|
+
type: serviceType.default('docker'),
|
|
235
|
+
repoUrl: gitRepoUrl.optional(),
|
|
236
|
+
branch: gitBranch.default('main'),
|
|
237
|
+
sourceId: z.number().int().positive().optional(),
|
|
238
|
+
serverId: z.number().int().positive().nullable().optional(),
|
|
239
|
+
image: z.string().optional(),
|
|
240
|
+
volumeMount: z.string().optional(),
|
|
241
|
+
/** Compose deploys: the main service in the compose file (health/routing
|
|
242
|
+
* target). Defaults to the service slug when omitted — or, for an inline
|
|
243
|
+
* `composeContent` stack, to the main service the server derives from the
|
|
244
|
+
* YAML itself. */
|
|
245
|
+
composeService: z.string().min(1).max(200).optional(),
|
|
246
|
+
/** Inline Docker Compose stack: the whole YAML, pasted instead of cloned.
|
|
247
|
+
* The server stores it on the service row and re-materialises it into the
|
|
248
|
+
* workspace before every deploy, so there is no repository to check out.
|
|
249
|
+
* Same 256 KiB cap as a Hub template's `composeContent`. */
|
|
250
|
+
composeContent: z.string().min(1).max(262_144).optional(),
|
|
251
|
+
cpuShares: z.number().int().min(0).max(262144).optional(),
|
|
252
|
+
/** Hard CPU cap in millicores (500 = 0.5 cores); maps to docker --cpus. */
|
|
253
|
+
cpuLimitMilli: z.number().int().min(0).max(512_000).optional(),
|
|
254
|
+
memLimitMb: z.number().int().min(0).optional(),
|
|
255
|
+
/** Horizontal replica count for docker services (Traefik load-balances). */
|
|
256
|
+
replicas: z.number().int().min(1).max(10).optional(),
|
|
257
|
+
healthPath: httpPath.optional(),
|
|
258
|
+
port: z.number().int().min(1).max(65535).optional(),
|
|
259
|
+
publishedPort: z.number().int().min(1).max(65535).nullable().optional(),
|
|
260
|
+
previewDeploymentsEnabled: z.boolean().optional(),
|
|
261
|
+
previewAutoDestroyOnClose: z.boolean().optional(),
|
|
262
|
+
previewDomainPattern: z.string().nullable().optional(),
|
|
263
|
+
previewMaxActive: z.number().int().min(1).max(50).optional(),
|
|
264
|
+
/** Deployment lane at create time (production / staging / …). */
|
|
265
|
+
environmentId: z.number().int().positive().optional(),
|
|
266
|
+
build: z
|
|
267
|
+
.object({
|
|
268
|
+
buildPack: buildPack.default('auto'),
|
|
269
|
+
baseDir: repoBaseDir.default('/'),
|
|
270
|
+
installCmd: z.string().optional(),
|
|
271
|
+
buildCmd: z.string().optional(),
|
|
272
|
+
startCmd: z.string().optional(),
|
|
273
|
+
dockerfilePath: repoRelativePath.optional(),
|
|
274
|
+
/** Static build pack: build output dir (relative to baseDir). */
|
|
275
|
+
outputDir: repoRelativePath.optional(),
|
|
276
|
+
preDeployCmd: z.string().nullable().optional(),
|
|
277
|
+
postDeployCmd: z.string().nullable().optional(),
|
|
278
|
+
preStopCmd: z.string().nullable().optional(),
|
|
279
|
+
// docker --restart policy: fixed values plus on-failure:N (restart-loop cap).
|
|
280
|
+
restartPolicy: z.string().regex(/^(no|always|unless-stopped|on-failure(?::\d{1,3})?)$/).optional(),
|
|
281
|
+
// Seconds between SIGTERM and SIGKILL on stop (docker stop -t).
|
|
282
|
+
stopGraceSeconds: z.number().int().min(0).max(300).optional(),
|
|
283
|
+
/** Env-var names passed to a Dockerfile build as `--build-arg`. */
|
|
284
|
+
buildArgs: buildArgNames.optional(),
|
|
285
|
+
})
|
|
286
|
+
.default({ buildPack: 'auto', baseDir: '/' }),
|
|
287
|
+
}).superRefine((value, ctx) => {
|
|
288
|
+
if (!value.composeContent) return;
|
|
289
|
+
// An inline stack IS a compose deploy — every other type ignores the field
|
|
290
|
+
// entirely, so accepting it would store YAML that never runs.
|
|
291
|
+
if (value.type !== 'compose') {
|
|
292
|
+
ctx.addIssue({
|
|
293
|
+
code: 'custom',
|
|
294
|
+
path: ['composeContent'],
|
|
295
|
+
message: "composeContent requires type 'compose'",
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
// A repository would be cloned into the same workspace the YAML is written
|
|
299
|
+
// to, and the first clone wipes that directory (lib/git.ts) — the pasted
|
|
300
|
+
// stack would silently disappear. One source of truth or the other.
|
|
301
|
+
if (value.repoUrl) {
|
|
302
|
+
ctx.addIssue({
|
|
303
|
+
code: 'custom',
|
|
304
|
+
path: ['repoUrl'],
|
|
305
|
+
message: 'composeContent and repoUrl are mutually exclusive — a git checkout would overwrite the pasted stack',
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
export type CreateService = z.infer<typeof createService>;
|
|
310
|
+
|
|
311
|
+
/** PATCH shape — every field optional, including individual build-config keys.
|
|
312
|
+
* Built explicitly (NOT via createService.partial()): in Zod v4 `partial()`
|
|
313
|
+
* still applies `.default()` values for absent keys, which would silently
|
|
314
|
+
* rewrite `type` back to 'docker' and `branch` to 'main' on every PATCH. */
|
|
315
|
+
export const updateService = z.object({
|
|
316
|
+
// r335: no tag fields. Tags change only through `PUT /v1/services/:id/tags`
|
|
317
|
+
// (admin-gated, with the r156 foreign-link rules). The PATCH route used to
|
|
318
|
+
// accept tagProjectIds/tagWorkspaceIds/tagLabelIds here and then silently
|
|
319
|
+
// drop them; it now answers 400 `tags_via_put` when a body carries them.
|
|
320
|
+
name: z.string().min(1).max(100).optional(),
|
|
321
|
+
slug: slug.optional(),
|
|
322
|
+
type: serviceType.optional(),
|
|
323
|
+
repoUrl: gitRepoUrl.optional(),
|
|
324
|
+
branch: gitBranch.optional(),
|
|
325
|
+
/** Attached Git credential (source) for private-repo cloning. null clears it. */
|
|
326
|
+
sourceId: z.number().int().positive().nullable().optional(),
|
|
327
|
+
serverId: z.number().int().positive().nullable().optional(),
|
|
328
|
+
image: z.string().optional(),
|
|
329
|
+
volumeMount: z.string().optional(),
|
|
330
|
+
composeService: z.string().min(1).max(200).optional(),
|
|
331
|
+
/** Watchtower-style image auto-update. Only meaningful on image-based
|
|
332
|
+
* docker services — the route refuses it otherwise. */
|
|
333
|
+
autoUpdate: z.boolean().optional(),
|
|
334
|
+
/** Replace an inline stack's YAML. Only meaningful on a service that
|
|
335
|
+
* already stores one — the route refuses it otherwise, because `type`
|
|
336
|
+
* alone cannot tell an inline stack from a git-repo compose service. */
|
|
337
|
+
composeContent: z.string().min(1).max(262_144).optional(),
|
|
338
|
+
cpuShares: z.number().int().min(0).max(262144).optional(),
|
|
339
|
+
/** Hard CPU cap in millicores (500 = 0.5 cores); maps to docker --cpus. */
|
|
340
|
+
cpuLimitMilli: z.number().int().min(0).max(512_000).optional(),
|
|
341
|
+
memLimitMb: z.number().int().min(0).optional(),
|
|
342
|
+
/** Horizontal replica count for docker services (Traefik load-balances). */
|
|
343
|
+
replicas: z.number().int().min(1).max(10).optional(),
|
|
344
|
+
healthPath: httpPath.optional(),
|
|
345
|
+
/** Seconds a deploy waits for the healthcheck (10–3600); null restores the 300 s default. */
|
|
346
|
+
healthTimeoutSeconds: z.number().int().min(10).max(3600).nullable().optional(),
|
|
347
|
+
/** 'running' = the deploy only waits for the container to stay up (UDP and other non-HTTP services); 'http' or null = the HTTP/TCP probe. */
|
|
348
|
+
healthCheck: healthCheckMode.nullable().optional(),
|
|
349
|
+
/** Operator only: advanced container options; null clears them all. */
|
|
350
|
+
runtimeOptions: runtimeOptions.nullable().optional(),
|
|
351
|
+
port: z.number().int().min(1).max(65535).optional(),
|
|
352
|
+
publishedPort: z.number().int().min(1).max(65535).nullable().optional(),
|
|
353
|
+
/** Extra host ports, TCP or UDP, single or ranges (docker services on the panel host); null or [] clears them. */
|
|
354
|
+
portMappings: portMappings.nullable().optional(),
|
|
355
|
+
previewDeploymentsEnabled: z.boolean().optional(),
|
|
356
|
+
previewAutoDestroyOnClose: z.boolean().optional(),
|
|
357
|
+
previewDomainPattern: z.string().nullable().optional(),
|
|
358
|
+
previewMaxActive: z.number().int().min(1).max(50).optional(),
|
|
359
|
+
/** Deployment lane (production / staging / …). null clears the assignment. */
|
|
360
|
+
environmentId: z.number().int().positive().nullable().optional(),
|
|
361
|
+
build: z
|
|
362
|
+
.object({
|
|
363
|
+
buildPack: buildPack.optional(),
|
|
364
|
+
baseDir: repoBaseDir.optional(),
|
|
365
|
+
installCmd: z.string().optional(),
|
|
366
|
+
buildCmd: z.string().optional(),
|
|
367
|
+
startCmd: z.string().optional(),
|
|
368
|
+
dockerfilePath: repoRelativePath.optional(),
|
|
369
|
+
/** Static build pack: build output dir, relative to baseDir. */
|
|
370
|
+
outputDir: repoRelativePath.optional(),
|
|
371
|
+
/** Static build pack: SPA history fallback in the generated nginx conf. */
|
|
372
|
+
staticSpa: z.boolean().optional(),
|
|
373
|
+
preDeployCmd: z.string().nullable().optional(),
|
|
374
|
+
postDeployCmd: z.string().nullable().optional(),
|
|
375
|
+
preStopCmd: z.string().nullable().optional(),
|
|
376
|
+
restartPolicy: z.string().regex(/^(no|always|unless-stopped|on-failure(?::\d{1,3})?)$/).optional(),
|
|
377
|
+
stopGraceSeconds: z.number().int().min(0).max(300).optional(),
|
|
378
|
+
/** Env-var names passed to a Dockerfile build as `--build-arg`; [] clears. */
|
|
379
|
+
buildArgs: buildArgNames.optional(),
|
|
380
|
+
})
|
|
381
|
+
.optional(),
|
|
382
|
+
});
|
|
383
|
+
export type UpdateService = z.infer<typeof updateService>;
|
|
384
|
+
|
|
385
|
+
/** Input shapes (what a client sends) — defaults make `build` optional. */
|
|
386
|
+
export type CreateServiceInput = z.input<typeof createService>;
|
|
387
|
+
export type UpdateServiceInput = z.input<typeof updateService>;
|
|
388
|
+
|
|
389
|
+
export const service = z.object({
|
|
390
|
+
id: z.number().int(),
|
|
391
|
+
/**
|
|
392
|
+
* Project / workspace / label tag lists (N-N). The legacy single
|
|
393
|
+
* `projectId` field is gone; consumers compose the three arrays to decide
|
|
394
|
+
* what shows up in the top-bar filter chips.
|
|
395
|
+
*/
|
|
396
|
+
projectIds: z.array(z.number().int()),
|
|
397
|
+
workspaceIds: z.array(z.number().int()),
|
|
398
|
+
labelIds: z.array(z.number().int()),
|
|
399
|
+
/** Resolved display objects — present on detail responses, may be omitted on list. */
|
|
400
|
+
projects: z
|
|
401
|
+
.array(z.object({ id: z.number().int(), name: z.string(), slug: z.string() }))
|
|
402
|
+
.optional(),
|
|
403
|
+
workspaces: z
|
|
404
|
+
.array(z.object({ id: z.number().int(), name: z.string(), slug: z.string() }))
|
|
405
|
+
.optional(),
|
|
406
|
+
labels: z
|
|
407
|
+
.array(z.object({ id: z.number().int(), name: z.string(), color: z.string() }))
|
|
408
|
+
.optional(),
|
|
409
|
+
serverId: z.number().int().nullable().optional(),
|
|
410
|
+
name: z.string(),
|
|
411
|
+
slug: z.string(),
|
|
412
|
+
type: serviceType,
|
|
413
|
+
status: z.enum(['idle', 'deploying', 'running', 'stopped', 'error', 'deleting']),
|
|
414
|
+
repoUrl: z.string().nullable(),
|
|
415
|
+
branch: z.string(),
|
|
416
|
+
sourceId: z.number().int().nullable(),
|
|
417
|
+
/** Display name of the attached Git credential (source), for the UI. */
|
|
418
|
+
sourceName: z.string().nullable().optional(),
|
|
419
|
+
image: z.string().nullable(),
|
|
420
|
+
/** Watchtower-style digest watch (image-based docker services only). */
|
|
421
|
+
autoUpdate: z.boolean().optional(),
|
|
422
|
+
volumeMount: z.string().nullable(),
|
|
423
|
+
composeService: z.string().nullable(),
|
|
424
|
+
/** The stored YAML of an inline compose stack; null for every other
|
|
425
|
+
* service (a git-repo compose service keeps its file in the repository). */
|
|
426
|
+
composeContent: z.string().nullable().optional(),
|
|
427
|
+
commitSha: z.string().nullable(),
|
|
428
|
+
runtimeId: z.string().nullable(),
|
|
429
|
+
healthPath: z.string(),
|
|
430
|
+
/** Healthcheck deadline in seconds; null = the 300 s default (0.16.1). */
|
|
431
|
+
healthTimeoutSeconds: z.number().int().nullable().optional(),
|
|
432
|
+
/** How a deploy decides the new container is ready (0.16.1). */
|
|
433
|
+
healthCheck: healthCheckMode.optional(),
|
|
434
|
+
/** Advanced container options (0.16.1); null = none. */
|
|
435
|
+
runtimeOptions: runtimeOptions.nullable().optional(),
|
|
436
|
+
autoUrl: z.string().nullable(),
|
|
437
|
+
port: z.number().int().nullable(),
|
|
438
|
+
publishedPort: z.number().int().nullable().optional(),
|
|
439
|
+
/** Extra host-port mappings, TCP or UDP (0.16.1); [] = none. */
|
|
440
|
+
portMappings: z.array(portMapping).optional(),
|
|
441
|
+
cpuShares: z.number().int(),
|
|
442
|
+
cpuLimitMilli: z.number().int(),
|
|
443
|
+
memLimitMb: z.number().int(),
|
|
444
|
+
replicas: z.number().int(),
|
|
445
|
+
previewDeploymentsEnabled: z.boolean().optional(),
|
|
446
|
+
previewAutoDestroyOnClose: z.boolean().optional(),
|
|
447
|
+
previewDomainPattern: z.string().nullable().optional(),
|
|
448
|
+
previewMaxActive: z.number().int().optional(),
|
|
449
|
+
isEphemeralPreview: z.boolean().optional(),
|
|
450
|
+
previewParentServiceId: z.number().int().nullable().optional(),
|
|
451
|
+
prNumber: z.number().int().nullable().optional(),
|
|
452
|
+
/** Deployment lane id (production / staging / …). Null = ungrouped. */
|
|
453
|
+
environmentId: z.number().int().nullable().optional(),
|
|
454
|
+
build: z
|
|
455
|
+
.object({
|
|
456
|
+
buildPack: buildPack,
|
|
457
|
+
baseDir: z.string(),
|
|
458
|
+
installCmd: z.string().nullable(),
|
|
459
|
+
buildCmd: z.string().nullable(),
|
|
460
|
+
startCmd: z.string().nullable(),
|
|
461
|
+
dockerfilePath: z.string().nullable(),
|
|
462
|
+
/** Static build pack: build output dir (relative to baseDir). */
|
|
463
|
+
outputDir: z.string().nullable().optional(),
|
|
464
|
+
/** Static build pack: SPA history fallback in the generated nginx conf. */
|
|
465
|
+
staticSpa: z.boolean().optional(),
|
|
466
|
+
preDeployCmd: z.string().nullable().optional(),
|
|
467
|
+
postDeployCmd: z.string().nullable().optional(),
|
|
468
|
+
preStopCmd: z.string().nullable().optional(),
|
|
469
|
+
restartPolicy: z.string(),
|
|
470
|
+
stopGraceSeconds: z.number().int(),
|
|
471
|
+
/** Env-var names a Dockerfile build receives as `--build-arg` (0.16.1). */
|
|
472
|
+
buildArgs: z.array(z.string()).optional(),
|
|
473
|
+
})
|
|
474
|
+
.nullable(),
|
|
475
|
+
createdAt: z.string().datetime(),
|
|
476
|
+
updatedAt: z.string().datetime(),
|
|
477
|
+
});
|
|
478
|
+
export type Service = z.infer<typeof service>;
|
|
479
|
+
|
|
480
|
+
// ── Sources (private repo credentials) ─────────────────────────────────────
|
|
481
|
+
/** r512: a registry host a `registry` credential may be sent to (`ghcr.io`, `registry.local:5000`). */
|
|
482
|
+
export const registryHost = z
|
|
483
|
+
.string()
|
|
484
|
+
.max(255)
|
|
485
|
+
.regex(/^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?(:\d{1,5})?$/, 'Expected a registry host such as ghcr.io or registry.local:5000');
|
|
486
|
+
export const createSource = z.object({
|
|
487
|
+
name: z.string().min(1).max(100),
|
|
488
|
+
type: z.enum(['github', 'gitlab', 'gitea', 'bitbucket', 'custom', 'registry']),
|
|
489
|
+
token: z.string().optional(),
|
|
490
|
+
deployKey: z.string().optional(),
|
|
491
|
+
// Registry-type sources: username for `docker login` (token = password).
|
|
492
|
+
registryUsername: z.string().max(255).optional(),
|
|
493
|
+
/** r512: registry hosts this credential may be sent to (Docker Hub = docker.io). */
|
|
494
|
+
registryHosts: z.array(registryHost).max(20).optional(),
|
|
495
|
+
defaultBranch: z.string().optional(),
|
|
496
|
+
/**
|
|
497
|
+
* 0.13: self-hosted provider base (Gitea), e.g. `https://git.example.com`.
|
|
498
|
+
* The server refuses `http:` unless private egress is allowed.
|
|
499
|
+
* `github_app` is deliberately NOT in `type`: only installation sync creates those sources.
|
|
500
|
+
*/
|
|
501
|
+
baseUrl: providerBaseUrl.optional(),
|
|
502
|
+
});
|
|
503
|
+
export type CreateSourceInput = z.input<typeof createSource>;
|
|
504
|
+
|
|
505
|
+
export const source = z.object({
|
|
506
|
+
id: z.number().int(),
|
|
507
|
+
name: z.string(),
|
|
508
|
+
type: z.string(),
|
|
509
|
+
hasToken: z.boolean(),
|
|
510
|
+
hasDeployKey: z.boolean(),
|
|
511
|
+
registryUsername: z.string().nullable().optional(),
|
|
512
|
+
/** r512: hosts a registry credential is bound to (registry sources only). */
|
|
513
|
+
registryHosts: z.array(z.string()).optional(),
|
|
514
|
+
defaultBranch: z.string().nullable(),
|
|
515
|
+
/** 0.13: self-hosted provider base URL (Gitea); null/absent = not set. */
|
|
516
|
+
baseUrl: z.string().nullable().optional(),
|
|
517
|
+
// ── 0.16 T8 surfaces ── multi-node (0.15.2, additive): this PAT or deploy key
|
|
518
|
+
// may be sent to a node for one clone. Absent on older panels.
|
|
519
|
+
allowOnNodes: z.boolean().optional(),
|
|
520
|
+
// ── end 0.16 T8 ──
|
|
521
|
+
/** 0.15.8: the panel pins this source's git host SSH keys (absent on older panels). */
|
|
522
|
+
pinHostKeys: z.boolean().optional(),
|
|
523
|
+
/** 0.15.8: what the pin holds, as fingerprints only. */
|
|
524
|
+
pinnedHostKeys: z.array(z.object({ host: z.string(), type: z.string(), fingerprint: z.string().nullable() })).optional(),
|
|
525
|
+
createdAt: z.string().datetime(),
|
|
526
|
+
updatedAt: z.string().datetime().optional(),
|
|
527
|
+
});
|
|
528
|
+
export type Source = z.infer<typeof source>;
|
|
529
|
+
|
|
530
|
+
export const triggerDeploy = z.object({
|
|
531
|
+
/**
|
|
532
|
+
* @deprecated r334: IGNORED by `POST /v1/services/:id/deploys` — a manual
|
|
533
|
+
* trigger always builds the configured branch head. Pinning a commit here
|
|
534
|
+
* was never wired (the route reads no body). To redeploy an exact commit,
|
|
535
|
+
* use `deploys.promote` (another lane's running SHA) or `deploys.rollback`.
|
|
536
|
+
*/
|
|
537
|
+
commitSha: z.string().optional(),
|
|
538
|
+
});
|
|
539
|
+
export type TriggerDeploy = z.infer<typeof triggerDeploy>;
|
|
540
|
+
|
|
541
|
+
export const deployment = z.object({
|
|
542
|
+
id: z.number().int(),
|
|
543
|
+
status: z.enum(['queued', 'building', 'deploying', 'running', 'superseded', 'failed', 'cancelled']),
|
|
544
|
+
commitSha: z.string().nullable(),
|
|
545
|
+
message: z.string().nullable(),
|
|
546
|
+
author: z.string().nullable(),
|
|
547
|
+
trigger: z.string(),
|
|
548
|
+
startedAt: z.string().datetime().nullable(),
|
|
549
|
+
finishedAt: z.string().datetime().nullable(),
|
|
550
|
+
createdAt: z.string().datetime(),
|
|
551
|
+
});
|
|
552
|
+
export type Deployment = z.infer<typeof deployment>;
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* A single row in the global deploy queue (`GET /v1/services/queue`).
|
|
556
|
+
*
|
|
557
|
+
* Same fields as `Deployment` plus the service + project metadata the
|
|
558
|
+
* panel needs to render "what is happening and what is coming next"
|
|
559
|
+
* without a follow-up round-trip. `imageDigest` is populated for image
|
|
560
|
+
* deploys where `commitSha` is null.
|
|
561
|
+
*/
|
|
562
|
+
export const queueItem = z.object({
|
|
563
|
+
id: z.number().int(),
|
|
564
|
+
serviceId: z.number().int(),
|
|
565
|
+
serviceName: z.string(),
|
|
566
|
+
status: z.enum(['queued', 'building', 'deploying']),
|
|
567
|
+
commitSha: z.string().nullable(),
|
|
568
|
+
imageDigest: z.string().nullable(),
|
|
569
|
+
message: z.string().nullable(),
|
|
570
|
+
author: z.string().nullable(),
|
|
571
|
+
trigger: z.string(),
|
|
572
|
+
startedAt: z.string().datetime().nullable(),
|
|
573
|
+
finishedAt: z.string().datetime().nullable(),
|
|
574
|
+
createdAt: z.string().datetime(),
|
|
575
|
+
});
|
|
576
|
+
export type QueueItem = z.infer<typeof queueItem>;
|
|
577
|
+
|
|
578
|
+
export const queueResponse = z.object({
|
|
579
|
+
items: z.array(queueItem),
|
|
580
|
+
count: z.number().int(),
|
|
581
|
+
byStatus: z.object({
|
|
582
|
+
queued: z.number().int(),
|
|
583
|
+
building: z.number().int(),
|
|
584
|
+
deploying: z.number().int(),
|
|
585
|
+
}),
|
|
586
|
+
});
|
|
587
|
+
export type QueueResponse = z.infer<typeof queueResponse>;
|
|
588
|
+
|
|
589
|
+
// ── Domains (Traefik routing) ─────────────────────────────────────────────
|
|
590
|
+
export const createDomain = z.object({
|
|
591
|
+
hostname: z.string().min(3).max(253),
|
|
592
|
+
// r473: aligned with the manifest's route.path rule — the consumer strips
|
|
593
|
+
// it downstream anyway, but the boundary should not depend on that.
|
|
594
|
+
path: z.string().min(1).max(200).regex(/^\/.*$/).default('/'),
|
|
595
|
+
// Default matches the DB column default and the CLI (`ssl !== false`):
|
|
596
|
+
// HTTPS everywhere unless explicitly disabled.
|
|
597
|
+
ssl: z.boolean().default(true),
|
|
598
|
+
redirectWww: z.boolean().optional(),
|
|
599
|
+
/** JSON array [{name, value}] of custom response headers. */
|
|
600
|
+
headers: z.string().max(8000).optional(),
|
|
601
|
+
/** Basic Auth credentials (JSON array of "user:htpasswd_hash" or "user:pass"). */
|
|
602
|
+
basicAuth: z.string().max(8000).optional().nullable(),
|
|
603
|
+
/** IP Allowlist (comma-separated CIDRs e.g. "1.2.3.4/32, 10.0.0.0/8"). */
|
|
604
|
+
ipAllowlist: z.string().max(4000).optional().nullable(),
|
|
605
|
+
/** Rate limit: average requests/second. */
|
|
606
|
+
rateLimitAverage: z.number().int().min(0).max(100000).optional().nullable(),
|
|
607
|
+
/** Rate limit: burst peak requests allowed. */
|
|
608
|
+
rateLimitBurst: z.number().int().min(0).max(100000).optional().nullable(),
|
|
609
|
+
});
|
|
610
|
+
export type CreateDomainInput = z.input<typeof createDomain>;
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* Why a redirect regex cannot go to Traefik, or null. Traefik compiles it
|
|
614
|
+
* with Go's RE2, which has no lookaround and no backreferences — and a regex
|
|
615
|
+
* Traefik refuses takes the whole dynamic config file down with it, so these
|
|
616
|
+
* are refused when saved. The rest is checked by compiling it here.
|
|
617
|
+
*/
|
|
618
|
+
export function redirectRegexProblem(regex: string): string | null {
|
|
619
|
+
if (/\(\?<?[=!]/.test(regex)) return 'lookahead / lookbehind are not supported (Traefik uses Go RE2)';
|
|
620
|
+
if (/\\[1-9]/.test(regex)) return 'backreferences such as \\1 are not supported (Traefik uses Go RE2)';
|
|
621
|
+
try {
|
|
622
|
+
new RegExp(regex);
|
|
623
|
+
} catch (err) {
|
|
624
|
+
// RegExp only ever throws a SyntaxError here.
|
|
625
|
+
return `not a valid regular expression (${(err as Error).message})`;
|
|
626
|
+
}
|
|
627
|
+
return null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** 0.16.1 one redirect rule of a domain (Dokploy's "Redirects"): Traefik `redirectRegex`. */
|
|
631
|
+
export const redirectRule = z
|
|
632
|
+
.object({
|
|
633
|
+
/** Matched against the full request URL, e.g. `^https://example.com/old/(.*)`. */
|
|
634
|
+
regex: z.string().min(1).max(1000),
|
|
635
|
+
/** The target; `${1}` inserts the first capture group. */
|
|
636
|
+
replacement: z.string().min(1).max(2000).regex(/^[^\r\n]*$/, 'one line'),
|
|
637
|
+
/** 301/308 when true, 302/307 when false. */
|
|
638
|
+
permanent: z.boolean().default(false),
|
|
639
|
+
})
|
|
640
|
+
.superRefine((r, ctx) => {
|
|
641
|
+
const problem = redirectRegexProblem(r.regex);
|
|
642
|
+
if (problem) ctx.addIssue({ code: 'custom', path: ['regex'], message: problem });
|
|
643
|
+
});
|
|
644
|
+
export type RedirectRule = z.infer<typeof redirectRule>;
|
|
645
|
+
|
|
646
|
+
/** PATCHable routing extras for an existing domain. */
|
|
647
|
+
export const domainPatch = z.object({
|
|
648
|
+
ssl: z.boolean().optional(),
|
|
649
|
+
redirectWww: z.boolean().optional(),
|
|
650
|
+
headers: z.string().max(8000).optional().nullable(),
|
|
651
|
+
basicAuth: z.string().max(8000).optional().nullable(),
|
|
652
|
+
ipAllowlist: z.string().max(4000).optional().nullable(),
|
|
653
|
+
rateLimitAverage: z.number().int().min(0).max(100000).optional().nullable(),
|
|
654
|
+
rateLimitBurst: z.number().int().min(0).max(100000).optional().nullable(),
|
|
655
|
+
/** 0.16.1: redirect rules, applied in order; null or [] removes them. */
|
|
656
|
+
redirects: z.array(redirectRule).max(10).optional().nullable(),
|
|
657
|
+
});
|
|
658
|
+
export type DomainPatch = z.input<typeof domainPatch>;
|
|
659
|
+
|
|
660
|
+
export const domain = z.object({
|
|
661
|
+
id: z.number().int(),
|
|
662
|
+
serviceId: z.number().int(),
|
|
663
|
+
hostname: z.string(),
|
|
664
|
+
path: z.string(),
|
|
665
|
+
ssl: z.boolean(),
|
|
666
|
+
redirectWww: z.boolean(),
|
|
667
|
+
headers: z.string(),
|
|
668
|
+
basicAuth: z.string().nullable().optional(),
|
|
669
|
+
ipAllowlist: z.string().nullable().optional(),
|
|
670
|
+
rateLimitAverage: z.number().nullable().optional(),
|
|
671
|
+
rateLimitBurst: z.number().nullable().optional(),
|
|
672
|
+
/** 0.16.1 redirect rules (absent on older panels). */
|
|
673
|
+
redirects: z.array(z.object({ regex: z.string(), replacement: z.string(), permanent: z.boolean() })).optional(),
|
|
674
|
+
status: z.string(),
|
|
675
|
+
createdAt: z.string().datetime(),
|
|
676
|
+
updatedAt: z.string().datetime(),
|
|
677
|
+
});
|
|
678
|
+
export type Domain = z.infer<typeof domain>;
|
|
679
|
+
|
|
680
|
+
// ── Webhooks (auto-deploy) ────────────────────────────────────────────────
|
|
681
|
+
// Webhook CREATION is validated by `webhookCreate` in management.ts (the
|
|
682
|
+
// route's schema). The former duplicate here (r473: `createWebhook`) was used
|
|
683
|
+
// by no route and had drifted from the live one — removed rather than left
|
|
684
|
+
// as a trap for someone "hardening" the wrong twin.
|
|
685
|
+
|
|
686
|
+
export const webhook = z.object({
|
|
687
|
+
id: z.number().int(),
|
|
688
|
+
branch: z.string(),
|
|
689
|
+
active: z.boolean(),
|
|
690
|
+
watchPaths: z.string(),
|
|
691
|
+
sourceId: z.number().int().nullable().optional(),
|
|
692
|
+
url: z.string(),
|
|
693
|
+
createdAt: z.string().datetime(),
|
|
694
|
+
});
|
|
695
|
+
export type Webhook = z.infer<typeof webhook>;
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Returned once at creation time — the secret is never retrievable again.
|
|
699
|
+
* r334: the create route answers id/branch/active/sourceId/url/secret only;
|
|
700
|
+
* `watchPaths` and `createdAt` come from `webhooks.list`.
|
|
701
|
+
*/
|
|
702
|
+
export const createdWebhook = webhook.omit({ watchPaths: true, createdAt: true }).extend({ secret: z.string() });
|
|
703
|
+
export type CreatedWebhook = z.infer<typeof createdWebhook>;
|
|
704
|
+
|
|
705
|
+
// ── Managed databases ─────────────────────────────────────────────────────
|
|
706
|
+
export const createDatabase = z.object({
|
|
707
|
+
name: z.string().min(1).max(100),
|
|
708
|
+
engine: z.enum(['postgres', 'mysql', 'mariadb', 'redis', 'mongo', 'valkey', 'clickhouse', 'meilisearch', 'rabbitmq', 'keydb', 'dragonfly']),
|
|
709
|
+
version: z.string().optional(),
|
|
710
|
+
projectId: z.number().int().optional(),
|
|
711
|
+
/** Hub/template provisioning is retryable: resume a matching database owned
|
|
712
|
+
* by the caller instead of colliding with the globally unique slug. */
|
|
713
|
+
reuseExisting: z.boolean().optional(),
|
|
714
|
+
existingVolume: z.string().optional(),
|
|
715
|
+
extensions: z.array(z.string()).optional(),
|
|
716
|
+
webGuiEnabled: z.boolean().optional(),
|
|
717
|
+
/**
|
|
718
|
+
* Multi-node (additive): the node to run the database on. null or absent =
|
|
719
|
+
* the panel host (today). Operator only when non-null; placement is fixed
|
|
720
|
+
* at create time.
|
|
721
|
+
*/
|
|
722
|
+
serverId: z.number().int().positive().nullable().optional(),
|
|
723
|
+
});
|
|
724
|
+
export type CreateDatabaseInput = z.input<typeof createDatabase>;
|
|
725
|
+
|
|
726
|
+
export const managedDatabase = z.object({
|
|
727
|
+
id: z.number().int(),
|
|
728
|
+
projectId: z.number().int().nullable(),
|
|
729
|
+
name: z.string(),
|
|
730
|
+
slug: z.string(),
|
|
731
|
+
engine: z.string(),
|
|
732
|
+
version: z.string().nullable(),
|
|
733
|
+
status: z.string(),
|
|
734
|
+
host: z.string().nullable(),
|
|
735
|
+
port: z.number().int().nullable(),
|
|
736
|
+
username: z.string().nullable(),
|
|
737
|
+
database: z.string().nullable(),
|
|
738
|
+
connectionString: z.string().nullable(),
|
|
739
|
+
webGuiEnabled: z.boolean().optional().default(false),
|
|
740
|
+
webGuiPort: z.number().int().nullable().optional(),
|
|
741
|
+
extensions: z.array(z.string()).optional().default([]),
|
|
742
|
+
attachedServices: z.array(z.object({ id: z.number().int(), name: z.string(), slug: z.string() })).optional().default([]),
|
|
743
|
+
// ── 0.16 T8 surfaces: the node fields the 0.15.3 routes return (additive) ──
|
|
744
|
+
/** The node the database runs on; null = the panel host. Absent before 0.15.3. */
|
|
745
|
+
serverId: z.number().int().nullable().optional(),
|
|
746
|
+
/** That node's name; null for a panel-host database. */
|
|
747
|
+
serverName: z.string().nullable().optional(),
|
|
748
|
+
/** The node's last reachability probe; null for a panel-host database or a node not probed yet. */
|
|
749
|
+
reachable: z.boolean().nullable().optional(),
|
|
750
|
+
// ── end 0.16 T8 ──
|
|
751
|
+
createdAt: z.string().datetime(),
|
|
752
|
+
updatedAt: z.string().datetime(),
|
|
753
|
+
});
|
|
754
|
+
export type ManagedDatabase = z.infer<typeof managedDatabase>;
|
|
755
|
+
|
|
756
|
+
/** Input for attaching a managed database to a service. The env alias, when
|
|
757
|
+
* provided, must be a valid environment-variable name (trimmed) — it is
|
|
758
|
+
* injected verbatim into the service's runtime env at deploy time. */
|
|
759
|
+
export const createAttachment = z.object({
|
|
760
|
+
databaseId: z.number().int().positive(),
|
|
761
|
+
envAlias: envVarName.optional(),
|
|
762
|
+
reuseExisting: z.boolean().optional(),
|
|
763
|
+
});
|
|
764
|
+
export type CreateAttachmentInput = z.input<typeof createAttachment>;
|
|
765
|
+
|
|
766
|
+
export const attachment = z.object({
|
|
767
|
+
id: z.number().int(),
|
|
768
|
+
databaseId: z.number().int(),
|
|
769
|
+
envAlias: z.string(),
|
|
770
|
+
database: z.object({ name: z.string(), engine: z.string(), status: z.string() }).nullable(),
|
|
771
|
+
});
|
|
772
|
+
export type Attachment = z.infer<typeof attachment>;
|
|
773
|
+
|
|
774
|
+
// ── Environment variables ──────────────────────────────────────────────────
|
|
775
|
+
export const upsertEnvVar = z.object({
|
|
776
|
+
key: envVarName.max(100),
|
|
777
|
+
// r473: same 32 KB cap the Hub deployTemplate path applies — the sink
|
|
778
|
+
// escapes newlines either way, but the twins should not disagree.
|
|
779
|
+
value: z.string().max(32 * 1024),
|
|
780
|
+
isSecret: z.boolean().optional(),
|
|
781
|
+
overwriteExisting: z.boolean().optional(),
|
|
782
|
+
});
|
|
783
|
+
export type UpsertEnvVarInput = z.input<typeof upsertEnvVar>;
|
|
784
|
+
|
|
785
|
+
/** .env bulk import — parse the content server-side and upsert each var. */
|
|
786
|
+
export const envImport = z.object({
|
|
787
|
+
content: z.string().min(1).max(100_000),
|
|
788
|
+
isSecret: z.boolean().optional(),
|
|
789
|
+
overwriteExisting: z.boolean().optional(),
|
|
790
|
+
});
|
|
791
|
+
export type EnvImportInput = z.infer<typeof envImport>;
|
|
792
|
+
|
|
793
|
+
export const envImportResult = z.object({
|
|
794
|
+
imported: z.number().int(),
|
|
795
|
+
skipped: z.number().int(),
|
|
796
|
+
errors: z.array(z.object({ line: z.number().int(), message: z.string() })),
|
|
797
|
+
});
|
|
798
|
+
export type EnvImportResult = z.infer<typeof envImportResult>;
|
|
799
|
+
|
|
800
|
+
export const envVar = z.object({
|
|
801
|
+
id: z.number().int(),
|
|
802
|
+
key: z.string(),
|
|
803
|
+
value: z.string(),
|
|
804
|
+
isSecret: z.boolean(),
|
|
805
|
+
});
|
|
806
|
+
export type EnvVar = z.infer<typeof envVar>;
|
|
807
|
+
|
|
808
|
+
// ── Service volume attachments ────────────────────────────────────────────
|
|
809
|
+
// A service can attach additional named Docker volumes in addition to its
|
|
810
|
+
// `volumeMount` primary. Either an existing managed volume (by name) or a
|
|
811
|
+
// fresh one created on demand. The volume persists across redeploys and
|
|
812
|
+
// container recreations — detaching only removes the link, not the data.
|
|
813
|
+
/** Create a new attachment. Exactly one of `volumeName` or `create.label`
|
|
814
|
+
* must be present — the former attaches an existing managed volume, the
|
|
815
|
+
* latter provisions a new one (system-generated name) and immediately
|
|
816
|
+
* attaches it. */
|
|
817
|
+
export const createServiceVolumeAttachment = z
|
|
818
|
+
.object({
|
|
819
|
+
// Attach an existing managed volume (must start with nd-svc- / nd-db-).
|
|
820
|
+
volumeName: dockerVolumeName.optional(),
|
|
821
|
+
// Provision a new named volume on attach. The label is a short
|
|
822
|
+
// human-friendly suffix (e.g. "uploads"); the server prepends the
|
|
823
|
+
// service-prefix to produce a unique managed name.
|
|
824
|
+
create: z.object({ label: z.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9-]*$/i, 'invalid label') }).optional(),
|
|
825
|
+
containerPath: containerPath,
|
|
826
|
+
readOnly: z.boolean().optional(),
|
|
827
|
+
})
|
|
828
|
+
.refine((v) => Boolean(v.volumeName) !== Boolean(v.create?.label), {
|
|
829
|
+
message: 'Provide exactly one of volumeName or create.label',
|
|
830
|
+
});
|
|
831
|
+
export type CreateServiceVolumeAttachmentInput = z.input<typeof createServiceVolumeAttachment>;
|
|
832
|
+
|
|
833
|
+
/** PATCH shape — path and readOnly are optional, but at least one must change. */
|
|
834
|
+
export const updateServiceVolumeAttachment = z
|
|
835
|
+
.object({
|
|
836
|
+
containerPath: containerPath.optional(),
|
|
837
|
+
readOnly: z.boolean().optional(),
|
|
838
|
+
})
|
|
839
|
+
.refine((v) => v.containerPath !== undefined || v.readOnly !== undefined, {
|
|
840
|
+
message: 'Provide at least one of containerPath or readOnly',
|
|
841
|
+
});
|
|
842
|
+
export type UpdateServiceVolumeAttachmentInput = z.input<typeof updateServiceVolumeAttachment>;
|
|
843
|
+
|
|
844
|
+
/** API representation of a service's volume attachment. */
|
|
845
|
+
export const serviceVolumeAttachment = z.object({
|
|
846
|
+
id: z.number().int(),
|
|
847
|
+
serviceId: z.number().int(),
|
|
848
|
+
volumeName: z.string(),
|
|
849
|
+
containerPath: z.string(),
|
|
850
|
+
readOnly: z.boolean(),
|
|
851
|
+
createdAt: z.string().datetime(),
|
|
852
|
+
updatedAt: z.string().datetime(),
|
|
853
|
+
});
|
|
854
|
+
export type ServiceVolumeAttachment = z.infer<typeof serviceVolumeAttachment>;
|
|
855
|
+
|
|
856
|
+
// ── Resource limits ───────────────────────────────────────────────────────
|
|
857
|
+
export const setLimits = z.object({
|
|
858
|
+
cpuShares: z.number().int().min(0).max(262144).nullable().optional(),
|
|
859
|
+
memLimitMb: z.number().int().min(0).nullable().optional(),
|
|
860
|
+
/** Hard CPU cap in millicores (500 = 0.5 cores), unlike the relative
|
|
861
|
+
* cpuShares weight. Maps to docker --cpus. */
|
|
862
|
+
cpuLimitMilli: z.number().int().min(0).max(512_000).nullable().optional(),
|
|
863
|
+
});
|
|
864
|
+
export type SetLimitsInput = z.infer<typeof setLimits>;
|
|
865
|
+
|
|
866
|
+
// ── Monitoring stats ───────────────────────────────────────────────────────
|
|
867
|
+
export const hostStat = z.object({
|
|
868
|
+
cpuCores: z.number().int(),
|
|
869
|
+
load1: z.number(),
|
|
870
|
+
memTotalBytes: z.number().int(),
|
|
871
|
+
memUsedBytes: z.number().int(),
|
|
872
|
+
diskTotalBytes: z.number().int(),
|
|
873
|
+
diskUsedBytes: z.number().int(),
|
|
874
|
+
});
|
|
875
|
+
export type HostStat = z.infer<typeof hostStat>;
|
|
876
|
+
|
|
877
|
+
export const containerStat = z.object({
|
|
878
|
+
name: z.string(),
|
|
879
|
+
kind: z.enum(['service', 'database']),
|
|
880
|
+
refId: z.number().int(),
|
|
881
|
+
refName: z.string(),
|
|
882
|
+
engine: z.string().optional(),
|
|
883
|
+
cpuPct: z.number(),
|
|
884
|
+
memMb: z.number(),
|
|
885
|
+
memLimitMb: z.number().int(),
|
|
886
|
+
});
|
|
887
|
+
export type ContainerStat = z.infer<typeof containerStat>;
|
|
888
|
+
|
|
889
|
+
export const statsSnapshot = z.object({
|
|
890
|
+
host: hostStat.nullable(),
|
|
891
|
+
containers: z.array(containerStat),
|
|
892
|
+
});
|
|
893
|
+
export type StatsSnapshot = z.infer<typeof statsSnapshot>;
|
|
894
|
+
|
|
895
|
+
export const metricSeries = z.object({
|
|
896
|
+
kind: z.string(),
|
|
897
|
+
points: z.array(z.object({ ts: z.string().datetime(), value: z.number() })),
|
|
898
|
+
});
|
|
899
|
+
export type MetricSeries = z.infer<typeof metricSeries>;
|
|
900
|
+
|
|
901
|
+
// ── Topology graph ─────────────────────────────────────────────────────────
|
|
902
|
+
export const topologyGraph = z.object({
|
|
903
|
+
services: z.array(
|
|
904
|
+
z.object({
|
|
905
|
+
id: z.number().int(),
|
|
906
|
+
name: z.string(),
|
|
907
|
+
slug: z.string(),
|
|
908
|
+
type: z.string(),
|
|
909
|
+
status: z.string(),
|
|
910
|
+
image: z.string().nullable(),
|
|
911
|
+
port: z.number().int().nullable(),
|
|
912
|
+
runtimeId: z.string().nullable(),
|
|
913
|
+
volumeMount: z.string().nullable(),
|
|
914
|
+
}),
|
|
915
|
+
),
|
|
916
|
+
databases: z.array(z.object({ id: z.number().int(), name: z.string(), engine: z.string(), status: z.string(), host: z.string().nullable() })),
|
|
917
|
+
attachments: z.array(z.object({ id: z.number().int(), serviceId: z.number().int(), databaseId: z.number().int(), envAlias: z.string() })),
|
|
918
|
+
domains: z.array(z.object({ id: z.number().int(), serviceId: z.number().int(), hostname: z.string(), ssl: z.boolean() })),
|
|
919
|
+
/** Managed docker volumes with their owner link (null = orphaned). */
|
|
920
|
+
volumes: z.array(
|
|
921
|
+
z.object({
|
|
922
|
+
name: z.string(),
|
|
923
|
+
owner: z.object({ kind: z.string(), refId: z.number().int(), name: z.string(), engine: z.string().optional() }).nullable(),
|
|
924
|
+
}),
|
|
925
|
+
),
|
|
926
|
+
/** User-defined docker networks; `containers` is filled for the shared mesh only. */
|
|
927
|
+
networks: z.array(z.object({ name: z.string(), driver: z.string(), containers: z.array(z.string()) })),
|
|
928
|
+
/** The Traefik gateway fronting every routed service. */
|
|
929
|
+
gateway: z.object({ name: z.string(), network: z.string(), running: z.boolean() }),
|
|
930
|
+
});
|
|
931
|
+
export type TopologyGraph = z.infer<typeof topologyGraph>;
|
|
932
|
+
|
|
933
|
+
// ── Backups + storage ──────────────────────────────────────────────────────
|
|
934
|
+
export const backup = z.object({
|
|
935
|
+
id: z.number().int(),
|
|
936
|
+
// Exactly one of (databaseId, volumeName) is set — encoded by `scope`.
|
|
937
|
+
databaseId: z.number().int().nullable(),
|
|
938
|
+
volumeName: z.string().nullable(),
|
|
939
|
+
scope: z.enum(['db', 'volumes']),
|
|
940
|
+
status: z.string(),
|
|
941
|
+
sizeBytes: z.number().int(),
|
|
942
|
+
/** Snapshot name ('manual', 'schedule-…' or an operator tag); volume rows only. */
|
|
943
|
+
label: z.string().nullable(),
|
|
944
|
+
hasRemoteCopy: z.boolean().optional(),
|
|
945
|
+
createdAt: z.string().datetime(),
|
|
946
|
+
});
|
|
947
|
+
export type Backup = z.infer<typeof backup>;
|
|
948
|
+
|
|
949
|
+
export const backupWithDb = backup.extend({ databaseName: z.string().nullable() });
|
|
950
|
+
export type BackupWithDb = z.infer<typeof backupWithDb>;
|
|
951
|
+
|
|
952
|
+
/** Create a new volume backup. The `volumeName` is the route param; the
|
|
953
|
+
* body only carries the `label` for human-friendly file naming. */
|
|
954
|
+
export const createVolumeBackup = z.object({
|
|
955
|
+
label: z.string().min(1).max(80).optional(),
|
|
956
|
+
});
|
|
957
|
+
export type CreateVolumeBackupInput = z.input<typeof createVolumeBackup>;
|
|
958
|
+
|
|
959
|
+
// ── Template hub ───────────────────────────────────────────────────────────
|
|
960
|
+
export const templateSummary = z.object({
|
|
961
|
+
id: z.string(),
|
|
962
|
+
name: z.string(),
|
|
963
|
+
tagline: z.string(),
|
|
964
|
+
category: z.string(),
|
|
965
|
+
emoji: z.string(),
|
|
966
|
+
featured: z.boolean().optional(),
|
|
967
|
+
runtimeVerified: z.boolean().optional(),
|
|
968
|
+
verifiedAt: z.iso.date().optional(),
|
|
969
|
+
});
|
|
970
|
+
export type TemplateSummary = z.infer<typeof templateSummary>;
|
|
971
|
+
|
|
972
|
+
export const template = z.object({
|
|
973
|
+
id: z.string(),
|
|
974
|
+
name: z.string(),
|
|
975
|
+
tagline: z.string(),
|
|
976
|
+
description: z.string(),
|
|
977
|
+
category: z.string(),
|
|
978
|
+
emoji: z.string(),
|
|
979
|
+
image: z.string(),
|
|
980
|
+
port: z.number().int(),
|
|
981
|
+
/** Override for the isolated smoke run's readiness window (seconds).
|
|
982
|
+
* Upstream images whose cold start legitimately exceeds the 300s default
|
|
983
|
+
* (model-loading services) set this so re-verification stays honest
|
|
984
|
+
* instead of timing out a working image. */
|
|
985
|
+
startTimeoutSeconds: z.number().int().min(30).max(1800).optional(),
|
|
986
|
+
volumeMount: z.string().nullable().optional(),
|
|
987
|
+
env: z.array(z.object({ key: z.string(), value: z.string(), secret: z.boolean().optional() })).optional(),
|
|
988
|
+
website: z.string().optional(),
|
|
989
|
+
docs: z.string().optional(),
|
|
990
|
+
featured: z.boolean().optional(),
|
|
991
|
+
/** Set only after the complete template contract has passed an isolated
|
|
992
|
+
* container startup and declared-port smoke test. */
|
|
993
|
+
runtimeVerified: z.boolean().optional(),
|
|
994
|
+
verifiedAt: z.iso.date().optional(),
|
|
995
|
+
/** Human hint about extra setup this template needs (shown in the Hub). */
|
|
996
|
+
requires: z.string().optional(),
|
|
997
|
+
/** When set, the wizard can auto-provision + attach a managed database of
|
|
998
|
+
* this engine. databaseEnv controls the application-specific injection. */
|
|
999
|
+
dbEngine: z.enum(['postgres', 'mysql', 'mariadb', 'redis', 'valkey', 'mongo', 'clickhouse', 'meilisearch', 'rabbitmq']).optional(),
|
|
1000
|
+
/** Map application-specific environment keys to managed database connection
|
|
1001
|
+
* fields. Without this, attachments expose only their legacy URL alias. */
|
|
1002
|
+
databaseEnv: z.record(
|
|
1003
|
+
envVarName,
|
|
1004
|
+
z.enum(['url', 'host', 'hostPort', 'port', 'username', 'password', 'database']),
|
|
1005
|
+
).optional(),
|
|
1006
|
+
/** Container command (argv) appended after the image — needed by images
|
|
1007
|
+
* whose default entrypoint prints help and exits (e.g. minio). */
|
|
1008
|
+
cmd: z.array(z.string()).min(1).optional(),
|
|
1009
|
+
/** Bind-mount the host Docker socket into the container (docker control —
|
|
1010
|
+
* only settable from the admin-controlled template registry, never via
|
|
1011
|
+
* the create-service API). */
|
|
1012
|
+
dockerSocket: z.boolean().optional(),
|
|
1013
|
+
/** Full Docker Compose stack definition. When present the template deploys
|
|
1014
|
+
* as a multi-container compose project (`type: 'compose'`) instead of the
|
|
1015
|
+
* single-container image+port model; `image`/`port` then describe the MAIN
|
|
1016
|
+
* (routed) service for Hub display and health routing. */
|
|
1017
|
+
composeContent: z.string().min(1).max(262_144).optional(),
|
|
1018
|
+
/** Compose service name the router points at and that owns `# port`. */
|
|
1019
|
+
composeService: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_.-]*$/).optional(),
|
|
1020
|
+
}).superRefine((value, ctx) => {
|
|
1021
|
+
if (value.dbEngine && !value.databaseEnv) {
|
|
1022
|
+
ctx.addIssue({
|
|
1023
|
+
code: 'custom',
|
|
1024
|
+
path: ['databaseEnv'],
|
|
1025
|
+
message: 'databaseEnv is required when dbEngine is set',
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
if (value.composeContent && !value.composeService) {
|
|
1029
|
+
ctx.addIssue({
|
|
1030
|
+
code: 'custom',
|
|
1031
|
+
path: ['composeService'],
|
|
1032
|
+
message: 'composeService is required when composeContent is set',
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
});
|
|
1036
|
+
export type Template = z.infer<typeof template>;
|
|
1037
|
+
|
|
1038
|
+
/**
|
|
1039
|
+
* Community template contribution (G-13). A `*.json`
|
|
1040
|
+
* file dropped into `<dataDir>/community-templates/` is
|
|
1041
|
+
* parsed against the `template` schema and surfaced in
|
|
1042
|
+
* the `community.list` response. Files that fail to
|
|
1043
|
+
* parse are reported in `errors` (one entry per file)
|
|
1044
|
+
* so a single bad contribution does not hide the rest.
|
|
1045
|
+
*/
|
|
1046
|
+
export const communityTemplateEntrySchema = z.object({
|
|
1047
|
+
id: z.string(),
|
|
1048
|
+
template: template,
|
|
1049
|
+
file: z.string(),
|
|
1050
|
+
bytes: z.number().int().nonnegative(),
|
|
1051
|
+
mtime: z.number(),
|
|
1052
|
+
});
|
|
1053
|
+
export type CommunityTemplateEntry = z.infer<typeof communityTemplateEntrySchema>;
|
|
1054
|
+
|
|
1055
|
+
export const communityTemplateListResultSchema = z.object({
|
|
1056
|
+
entries: z.array(communityTemplateEntrySchema),
|
|
1057
|
+
errors: z.array(z.object({ file: z.string(), error: z.string() })),
|
|
1058
|
+
totalBytes: z.number().int().nonnegative(),
|
|
1059
|
+
});
|
|
1060
|
+
export type CommunityTemplateListResult = z.infer<typeof communityTemplateListResultSchema>;
|
|
1061
|
+
|
|
1062
|
+
/**
|
|
1063
|
+
* One canonical Hub installation request. Internal port, volume, command,
|
|
1064
|
+
* Docker socket and database mapping are intentionally absent: they are
|
|
1065
|
+
* privileged registry-owned fields resolved by the server.
|
|
1066
|
+
*/
|
|
1067
|
+
export const deployTemplate = z.object({
|
|
1068
|
+
name: z.string().trim().min(1).max(100).optional(),
|
|
1069
|
+
projectId: z.number().int().positive().optional(),
|
|
1070
|
+
serverId: z.number().int().positive().nullable().optional(),
|
|
1071
|
+
publishedPort: z.number().int().min(1).max(65535).nullable().optional(),
|
|
1072
|
+
healthPath: httpPath.optional(),
|
|
1073
|
+
cpuShares: z.number().int().min(0).max(262144).optional(),
|
|
1074
|
+
/** Hard CPU cap in millicores (500 = 0.5 cores); maps to docker --cpus. */
|
|
1075
|
+
cpuLimitMilli: z.number().int().min(0).max(512_000).optional(),
|
|
1076
|
+
memLimitMb: z.number().int().min(0).optional(),
|
|
1077
|
+
/** Optional tag override for the template's image — SAME repository only
|
|
1078
|
+
* (e.g. directus/directus:11.5 instead of :latest). Validated server-side
|
|
1079
|
+
* against the template image; digest references and cross-repository
|
|
1080
|
+
* swaps are rejected. */
|
|
1081
|
+
image: z.string().trim().min(1).max(500).optional(),
|
|
1082
|
+
env: z.array(z.object({
|
|
1083
|
+
key: envVarName,
|
|
1084
|
+
value: z.string().max(32_768),
|
|
1085
|
+
isSecret: z.boolean().optional().default(false),
|
|
1086
|
+
})).max(200).optional(),
|
|
1087
|
+
/** Reconcile an interrupted caller-owned install with the same slug. */
|
|
1088
|
+
reuseExisting: z.boolean().optional().default(true),
|
|
1089
|
+
});
|
|
1090
|
+
export type DeployTemplateInput = z.input<typeof deployTemplate>;
|
|
1091
|
+
export type DeployTemplate = z.infer<typeof deployTemplate>;
|
|
1092
|
+
|
|
1093
|
+
/**
|
|
1094
|
+
* Dry-run analysis of a pasted compose file. The wizard calls this while the
|
|
1095
|
+
* user is still typing so blocking problems, host-privilege warnings, the
|
|
1096
|
+
* routable service list and the variables the stack will ask for all appear
|
|
1097
|
+
* inline — instead of arriving as a 400 after the service row already exists.
|
|
1098
|
+
* Reads only: nothing is written and no service is created.
|
|
1099
|
+
*/
|
|
1100
|
+
export const composePreviewRequest = z.object({
|
|
1101
|
+
content: z.string().min(1).max(262_144),
|
|
1102
|
+
/** The container port the wizard is about to route to, used to pick the
|
|
1103
|
+
* main service by port reference. 0/omitted falls back to name heuristics. */
|
|
1104
|
+
port: z.number().int().min(1).max(65535).optional(),
|
|
1105
|
+
});
|
|
1106
|
+
export type ComposePreviewRequestInput = z.input<typeof composePreviewRequest>;
|
|
1107
|
+
|
|
1108
|
+
export const composePreviewResponse = z.object({
|
|
1109
|
+
/** False when `reasons` is non-empty — the stack cannot run here at all. */
|
|
1110
|
+
ok: z.boolean(),
|
|
1111
|
+
reasons: z.array(z.string()),
|
|
1112
|
+
warnings: z.array(z.string()),
|
|
1113
|
+
/** Every service key in the file, in declaration order. */
|
|
1114
|
+
services: z.array(z.string()),
|
|
1115
|
+
/** Best guess at the routed service; null when the file declares none. */
|
|
1116
|
+
suggestedService: z.string().nullable(),
|
|
1117
|
+
/** `SERVICE_*` tokens the platform will generate values for. */
|
|
1118
|
+
magicTokens: z.array(z.string()),
|
|
1119
|
+
/** `${VAR}` references with no default — the user must supply these. */
|
|
1120
|
+
openPlaceholders: z.array(z.string()),
|
|
1121
|
+
/** `${VAR:-default}` references, offered as prefilled env rows. */
|
|
1122
|
+
configurableEnv: z.array(z.object({ key: z.string(), value: z.string() })),
|
|
1123
|
+
});
|
|
1124
|
+
export type ComposePreviewResponse = z.infer<typeof composePreviewResponse>;
|
|
1125
|
+
|
|
1126
|
+
/**
|
|
1127
|
+
* Whether an image reference keeps the template's registry repository while
|
|
1128
|
+
* changing only the tag (`directus/directus:latest` → `directus/directus:11.5`).
|
|
1129
|
+
* Digest references and cross-repository swaps are refused: the Hub override
|
|
1130
|
+
* exists to pin a version, not to run an arbitrary image under a template's name.
|
|
1131
|
+
*/
|
|
1132
|
+
export function sameImageRepository(templateImage: string, override: string): boolean {
|
|
1133
|
+
if (override.includes('@')) return false;
|
|
1134
|
+
const repo = (ref: string): string => {
|
|
1135
|
+
const at = ref.lastIndexOf('@');
|
|
1136
|
+
if (at > ref.lastIndexOf('/')) return ref.slice(0, at);
|
|
1137
|
+
const colon = ref.lastIndexOf(':');
|
|
1138
|
+
return colon > ref.lastIndexOf('/') ? ref.slice(0, colon) : ref;
|
|
1139
|
+
};
|
|
1140
|
+
return repo(templateImage) === repo(override);
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
// ── Domain routing index + volumes ─────────────────────────────────────────
|
|
1144
|
+
export const domainEntry = z.object({
|
|
1145
|
+
id: z.number().int(),
|
|
1146
|
+
hostname: z.string(),
|
|
1147
|
+
path: z.string(),
|
|
1148
|
+
ssl: z.boolean(),
|
|
1149
|
+
status: z.string(),
|
|
1150
|
+
serviceId: z.number().int(),
|
|
1151
|
+
serviceName: z.string().nullable(),
|
|
1152
|
+
container: z.string().nullable(),
|
|
1153
|
+
port: z.number().int().nullable(),
|
|
1154
|
+
certExpiresAt: z.string().datetime().nullable(),
|
|
1155
|
+
createdAt: z.string().datetime(),
|
|
1156
|
+
updatedAt: z.string().datetime(),
|
|
1157
|
+
});
|
|
1158
|
+
export type DomainEntry = z.infer<typeof domainEntry>;
|
|
1159
|
+
|
|
1160
|
+
export const volumeEntry = z.object({
|
|
1161
|
+
name: z.string(),
|
|
1162
|
+
sizeBytes: z.number().int(),
|
|
1163
|
+
owner: z.object({ kind: z.string(), id: z.number().int().optional(), name: z.string(), engine: z.string().optional() }).nullable(),
|
|
1164
|
+
/** True when the owner's container is running — deletion is refused (409). */
|
|
1165
|
+
inUse: z.boolean(),
|
|
1166
|
+
});
|
|
1167
|
+
export type VolumeEntry = z.infer<typeof volumeEntry>;
|
|
1168
|
+
|
|
1169
|
+
// ── Volume file manager ────────────────────────────────────────────────────
|
|
1170
|
+
export const volumeFileEntry = z.object({
|
|
1171
|
+
name: z.string(),
|
|
1172
|
+
type: z.enum(['file', 'dir']),
|
|
1173
|
+
sizeBytes: z.number().int(),
|
|
1174
|
+
mode: z.string().nullable().optional(),
|
|
1175
|
+
modifiedAt: z.string().datetime().nullable(),
|
|
1176
|
+
});
|
|
1177
|
+
export type VolumeFileEntry = z.infer<typeof volumeFileEntry>;
|
|
1178
|
+
|
|
1179
|
+
export const volumeFileWrite = z.object({
|
|
1180
|
+
/** Relative path inside the volume. */
|
|
1181
|
+
path: z.string().min(1).max(1024),
|
|
1182
|
+
/** Base64-encoded file content (schemas validate; argv never sees it). */
|
|
1183
|
+
contentBase64: z.string().max(8 * 1024 * 1024).refine((value) => {
|
|
1184
|
+
const compact = value.replace(/\s/g, '');
|
|
1185
|
+
return compact.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(compact);
|
|
1186
|
+
}, { message: 'must be valid base64 content' }),
|
|
1187
|
+
});
|
|
1188
|
+
export type VolumeFileWriteInput = z.input<typeof volumeFileWrite>;
|
|
1189
|
+
|
|
1190
|
+
export const volumePathCreate = z.object({
|
|
1191
|
+
path: z.string().min(1).max(1024),
|
|
1192
|
+
});
|
|
1193
|
+
export type VolumePathCreateInput = z.input<typeof volumePathCreate>;
|
|
1194
|
+
|
|
1195
|
+
// ── Container file manager ─────────────────────────────────────────────────
|
|
1196
|
+
export const containerFileEntry = volumeFileEntry;
|
|
1197
|
+
export type ContainerFileEntry = VolumeFileEntry;
|
|
1198
|
+
|
|
1199
|
+
export const containerFileWrite = volumeFileWrite;
|
|
1200
|
+
export type ContainerFileWriteInput = VolumeFileWriteInput;
|
|
1201
|
+
|
|
1202
|
+
export const containerPathCreate = volumePathCreate;
|
|
1203
|
+
export type ContainerPathCreateInput = VolumePathCreateInput;
|
|
1204
|
+
|
|
1205
|
+
// ── Docker resource accounting ─────────────────────────────────────────────
|
|
1206
|
+
export const dockerResources = z.object({
|
|
1207
|
+
network: z.string(),
|
|
1208
|
+
containers: z.number().int(),
|
|
1209
|
+
volumes: z.number().int(),
|
|
1210
|
+
imagesSummary: z.object({ total: z.string(), active: z.string(), size: z.string(), reclaimable: z.string() }),
|
|
1211
|
+
images: z.array(z.object({ repo: z.string(), tag: z.string(), size: z.string() })),
|
|
1212
|
+
});
|
|
1213
|
+
export type DockerResources = z.infer<typeof dockerResources>;
|
|
1214
|
+
|
|
1215
|
+
// ── Cloudflare Tunnels ─────────────────────────────────────────────────────
|
|
1216
|
+
export const createTunnel = z.object({ name: z.string().min(1).max(100), token: z.string().min(1) });
|
|
1217
|
+
export type CreateTunnelInput = z.input<typeof createTunnel>;
|
|
1218
|
+
|
|
1219
|
+
export const tunnelEntry = z.object({
|
|
1220
|
+
id: z.number().int(),
|
|
1221
|
+
name: z.string(),
|
|
1222
|
+
slug: z.string(),
|
|
1223
|
+
status: z.string(),
|
|
1224
|
+
containerName: z.string(),
|
|
1225
|
+
createdAt: z.string().datetime(),
|
|
1226
|
+
});
|
|
1227
|
+
export type TunnelEntry = z.infer<typeof tunnelEntry>;
|
|
1228
|
+
|
|
1229
|
+
// ── Log Drains ─────────────────────────────────────────────────────────────
|
|
1230
|
+
export const logDrainType = z.enum(['syslog', 'loki', 'vector', 'datadog', 'http']);
|
|
1231
|
+
export type LogDrainType = z.infer<typeof logDrainType>;
|
|
1232
|
+
|
|
1233
|
+
export const logDrainFormat = z.enum(['json', 'raw', 'rfc5424']);
|
|
1234
|
+
export type LogDrainFormat = z.infer<typeof logDrainFormat>;
|
|
1235
|
+
|
|
1236
|
+
export const logDrain = z.object({
|
|
1237
|
+
id: z.number().int(),
|
|
1238
|
+
name: z.string(),
|
|
1239
|
+
type: logDrainType,
|
|
1240
|
+
url: z.string(),
|
|
1241
|
+
serviceId: z.number().int().nullable(),
|
|
1242
|
+
serviceName: z.string().nullable().optional(),
|
|
1243
|
+
enabled: z.boolean(),
|
|
1244
|
+
format: logDrainFormat,
|
|
1245
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
1246
|
+
hasApiKey: z.boolean().optional(),
|
|
1247
|
+
createdAt: z.string().datetime(),
|
|
1248
|
+
updatedAt: z.string().datetime(),
|
|
1249
|
+
});
|
|
1250
|
+
export type LogDrain = z.infer<typeof logDrain>;
|
|
1251
|
+
|
|
1252
|
+
export const logDrainCreate = z.object({
|
|
1253
|
+
name: z.string().min(1).max(100),
|
|
1254
|
+
type: logDrainType,
|
|
1255
|
+
url: z.string().min(1),
|
|
1256
|
+
apiKey: z.string().optional(),
|
|
1257
|
+
serviceId: z.number().int().optional().nullable(),
|
|
1258
|
+
enabled: z.boolean().optional(),
|
|
1259
|
+
format: logDrainFormat.optional(),
|
|
1260
|
+
headers: z.record(z.string(), z.string()).optional(),
|
|
1261
|
+
});
|
|
1262
|
+
export type LogDrainCreateInput = z.input<typeof logDrainCreate>;
|
|
1263
|
+
|
|
1264
|
+
export const logDrainUpdate = logDrainCreate.partial();
|
|
1265
|
+
export type LogDrainUpdateInput = z.input<typeof logDrainUpdate>;
|
|
1266
|
+
|
|
1267
|
+
export const logDrainTestResult = z.object({
|
|
1268
|
+
ok: z.boolean(),
|
|
1269
|
+
latencyMs: z.number(),
|
|
1270
|
+
message: z.string().optional(),
|
|
1271
|
+
});
|
|
1272
|
+
export type LogDrainTestResult = z.infer<typeof logDrainTestResult>;
|
|
1273
|
+
|
|
1274
|
+
// ── Auto-Prune & Disk Maintenance ──────────────────────────────────────────
|
|
1275
|
+
export const autoPruneConfig = z.object({
|
|
1276
|
+
enabled: z.boolean(),
|
|
1277
|
+
thresholdPercent: z.number().min(10).max(99),
|
|
1278
|
+
pruneImages: z.boolean(),
|
|
1279
|
+
pruneVolumes: z.boolean(),
|
|
1280
|
+
pruneContainers: z.boolean(),
|
|
1281
|
+
pruneBuildCache: z.boolean(),
|
|
1282
|
+
maxAgeHours: z.number().min(1).max(720),
|
|
1283
|
+
});
|
|
1284
|
+
export type AutoPruneConfig = z.infer<typeof autoPruneConfig>;
|
|
1285
|
+
|
|
1286
|
+
export const autoPruneConfigUpdate = autoPruneConfig.partial();
|
|
1287
|
+
export type AutoPruneConfigUpdateInput = z.input<typeof autoPruneConfigUpdate>;
|
|
1288
|
+
|
|
1289
|
+
export const autoPruneStatus = autoPruneConfig.extend({
|
|
1290
|
+
diskUsedPercent: z.number(),
|
|
1291
|
+
diskTotalBytes: z.number(),
|
|
1292
|
+
diskFreeBytes: z.number(),
|
|
1293
|
+
lastPrunedAt: z.string().datetime().nullable(),
|
|
1294
|
+
lastFreedBytes: z.number().nullable(),
|
|
1295
|
+
});
|
|
1296
|
+
export type AutoPruneStatus = z.infer<typeof autoPruneStatus>;
|
|
1297
|
+
|
|
1298
|
+
export const autoPruneRunResult = z.object({
|
|
1299
|
+
ok: z.boolean(),
|
|
1300
|
+
freedBytes: z.number(),
|
|
1301
|
+
diskUsedPercentAfter: z.number(),
|
|
1302
|
+
details: z.object({
|
|
1303
|
+
imagesFreed: z.string().optional(),
|
|
1304
|
+
buildCacheFreed: z.string().optional(),
|
|
1305
|
+
containersFreed: z.string().optional(),
|
|
1306
|
+
volumesFreed: z.string().optional(),
|
|
1307
|
+
}),
|
|
1308
|
+
});
|
|
1309
|
+
export type AutoPruneRunResult = z.infer<typeof autoPruneRunResult>;
|
|
1310
|
+
|