@ninedeploy/schemas 0.16.0 → 0.16.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/dist/multiNode.d.ts +2 -1
- package/dist/multiNode.d.ts.map +1 -1
- package/dist/multiNode.js +2 -0
- package/dist/multiNode.js.map +1 -1
- package/dist/service.d.ts +220 -0
- package/dist/service.d.ts.map +1 -1
- package/dist/service.js +274 -0
- package/dist/service.js.map +1 -1
- package/package.json +1 -1
- package/src/multiNode.ts +2 -0
- package/src/service.ts +281 -0
package/src/service.ts
CHANGED
|
@@ -4,6 +4,221 @@ import { containerPath, dockerVolumeName, envVarName, gitBranch, gitRepoUrl, htt
|
|
|
4
4
|
export const serviceType = z.enum(['pm2', 'docker', 'compose']);
|
|
5
5
|
export const buildPack = z.enum(['auto', 'nixpacks', 'dockerfile', 'railpack', 'static']);
|
|
6
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.2: the stage of a multi-stage Dockerfile to build (`docker build
|
|
17
|
+
* --target`), e.g. `runtime` or `prod`. Docker's own stage-name rule: a letter
|
|
18
|
+
* first, then letters, digits, `_`, `.` or `-` (so it can never read as a flag).
|
|
19
|
+
*/
|
|
20
|
+
export const dockerBuildTarget = z
|
|
21
|
+
.string()
|
|
22
|
+
.regex(/^[A-Za-z][A-Za-z0-9_.-]{0,127}$/, 'A stage name starts with a letter and holds letters, digits, _ . or -');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 0.16.1: capabilities a docker service may add. Deliberately short: the ones
|
|
26
|
+
* real workloads ask for (VPNs, packet capture, profilers, realtime audio,
|
|
27
|
+
* mlock). SYS_ADMIN, SYS_MODULE, DAC_READ_SEARCH and the like are left out —
|
|
28
|
+
* each is a known path out of the container.
|
|
29
|
+
*/
|
|
30
|
+
export const runtimeCapability = z.enum(['NET_ADMIN', 'NET_RAW', 'SYS_PTRACE', 'SYS_NICE', 'SYS_RESOURCE', 'IPC_LOCK', 'SYS_TIME']);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 0.16.1 advanced container options for docker services (operator only):
|
|
34
|
+
* structured fields, never free-form `docker run` flags, so nothing outside
|
|
35
|
+
* this list (privileged, host mounts, host network…) can be asked for.
|
|
36
|
+
*/
|
|
37
|
+
export const runtimeOptions = z
|
|
38
|
+
.object({
|
|
39
|
+
/** `--gpus all` or `--gpus <n>` (needs the NVIDIA container toolkit on the host). */
|
|
40
|
+
gpus: z.union([z.literal('all'), z.number().int().min(1).max(16)]).nullable().optional(),
|
|
41
|
+
/** `--shm-size` in MiB; browsers and PyTorch data loaders outgrow Docker's 64. */
|
|
42
|
+
shmSizeMb: z.number().int().min(1).max(65_536).nullable().optional(),
|
|
43
|
+
/** `--init`: reaps zombie processes and forwards signals to the app. */
|
|
44
|
+
init: z.boolean().optional(),
|
|
45
|
+
/** `--ulimit nofile=N:N`. */
|
|
46
|
+
ulimitNofile: z.number().int().min(1024).max(1_048_576).nullable().optional(),
|
|
47
|
+
/** `--cap-add`. */
|
|
48
|
+
capAdd: z.array(runtimeCapability).max(7).optional(),
|
|
49
|
+
})
|
|
50
|
+
.strict();
|
|
51
|
+
export type RuntimeOptions = z.infer<typeof runtimeOptions>;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* 0.16.1: the most host ports one service may publish through `portMappings`.
|
|
55
|
+
* Docker starts one userland proxy process per published port and protocol,
|
|
56
|
+
* so a 10 000-port range costs 10 000 processes on the host.
|
|
57
|
+
*/
|
|
58
|
+
export const MAX_MAPPED_PORTS = 1000;
|
|
59
|
+
/** Most port-mapping rules per service. */
|
|
60
|
+
export const MAX_PORT_MAPPINGS = 20;
|
|
61
|
+
|
|
62
|
+
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}$/;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* A literal IPv4 or IPv6 address a port can be bound to (no zone, no CIDR).
|
|
66
|
+
* The unspecified addresses are refused: an empty IP already means "every
|
|
67
|
+
* address", and two spellings of it would dodge the overlap check.
|
|
68
|
+
*/
|
|
69
|
+
export function isBindableIp(ip: string): boolean {
|
|
70
|
+
if (IPV4.test(ip)) return ip !== '0.0.0.0';
|
|
71
|
+
if (ip.length > 45) return false;
|
|
72
|
+
const halves = ip.split('::');
|
|
73
|
+
if (halves.length > 2) return false;
|
|
74
|
+
const groups = halves.flatMap((h) => (h === '' ? [] : h.split(':')));
|
|
75
|
+
let width = groups.length;
|
|
76
|
+
if (groups.length > 0 && IPV4.test(groups[groups.length - 1]!)) {
|
|
77
|
+
groups.pop();
|
|
78
|
+
width += 1; // an embedded IPv4 fills two groups
|
|
79
|
+
}
|
|
80
|
+
if (!groups.every((g) => /^[0-9A-Fa-f]{1,4}$/.test(g))) return false;
|
|
81
|
+
if (halves.length === 2 ? width > 7 : width !== 8) return false;
|
|
82
|
+
// `::`, `0::0`, `0:0:0:0:0:0:0:0`: the unspecified address.
|
|
83
|
+
return width !== groups.length || groups.some((g) => Number.parseInt(g, 16) !== 0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 0.16.1 extra host-port mappings of a docker service: one port or a range,
|
|
88
|
+
* TCP or UDP, on every host address or one dedicated IP
|
|
89
|
+
* (`docker run -p 203.0.113.7:27015-27030:27015-27030/udp`). The container
|
|
90
|
+
* range starts at `containerPort` and is as long as the host range.
|
|
91
|
+
*/
|
|
92
|
+
export const portMapping = z
|
|
93
|
+
.object({
|
|
94
|
+
/** Host address to bind; null or omitted = every address of the host. */
|
|
95
|
+
hostIp: z
|
|
96
|
+
.string()
|
|
97
|
+
.max(45)
|
|
98
|
+
.refine(isBindableIp, 'hostIp must be one IPv4 or IPv6 address of the host (leave it empty for every address)')
|
|
99
|
+
.nullable()
|
|
100
|
+
.optional(),
|
|
101
|
+
hostPort: z.number().int().min(1).max(65535),
|
|
102
|
+
/** Last host port of a range; null or omitted = the single `hostPort`. */
|
|
103
|
+
hostPortEnd: z.number().int().min(1).max(65535).nullable().optional(),
|
|
104
|
+
containerPort: z.number().int().min(1).max(65535),
|
|
105
|
+
protocol: z.enum(['tcp', 'udp']).default('tcp'),
|
|
106
|
+
/**
|
|
107
|
+
* TCP, one port: only these addresses or CIDR ranges may connect. The port
|
|
108
|
+
* is then published by a small Traefik proxy in front of the service
|
|
109
|
+
* instead of Docker's `-p`, which a host firewall cannot restrict.
|
|
110
|
+
*/
|
|
111
|
+
allowFrom: z.array(z.string().trim().min(1).max(64)).min(1).max(100).nullable().optional(),
|
|
112
|
+
})
|
|
113
|
+
.strict();
|
|
114
|
+
export type PortMapping = z.infer<typeof portMapping>;
|
|
115
|
+
type PortMappingLike = Pick<PortMapping, 'hostPort' | 'hostPortEnd' | 'containerPort'> & {
|
|
116
|
+
hostIp?: string | null;
|
|
117
|
+
protocol?: 'tcp' | 'udp';
|
|
118
|
+
allowFrom?: readonly string[] | null;
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
/** How many host ports a mapping publishes. */
|
|
122
|
+
export const mappedPortCount = (m: Pick<PortMapping, 'hostPort' | 'hostPortEnd'>): number =>
|
|
123
|
+
Math.max(1, (m.hostPortEnd ?? m.hostPort) - m.hostPort + 1);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Whether two mappings would bind the same host socket: same protocol,
|
|
127
|
+
* overlapping ports, and the same IP (or either on every address).
|
|
128
|
+
*/
|
|
129
|
+
export function portMappingsOverlap(a: PortMappingLike, b: PortMappingLike): boolean {
|
|
130
|
+
if ((a.protocol ?? 'tcp') !== (b.protocol ?? 'tcp')) return false;
|
|
131
|
+
if (a.hostIp && b.hostIp && a.hostIp.toLowerCase() !== b.hostIp.toLowerCase()) return false;
|
|
132
|
+
return a.hostPort <= (b.hostPortEnd ?? b.hostPort) && b.hostPort <= (a.hostPortEnd ?? a.hostPort);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Why a list of mappings cannot be published, or null. Shared by the server, the panel and the CLI. */
|
|
136
|
+
export function portMappingsProblem(list: readonly PortMappingLike[]): string | null {
|
|
137
|
+
let total = 0;
|
|
138
|
+
for (const [i, m] of list.entries()) {
|
|
139
|
+
const end = m.hostPortEnd ?? m.hostPort;
|
|
140
|
+
const label = formatPortMapping(m);
|
|
141
|
+
if (end < m.hostPort) return `Mapping ${i + 1} (${label}): the range ends before it starts.`;
|
|
142
|
+
if (m.containerPort + (end - m.hostPort) > 65535) return `Mapping ${i + 1} (${label}): the container range runs past 65535.`;
|
|
143
|
+
total += end - m.hostPort + 1;
|
|
144
|
+
if (m.allowFrom?.length && ((m.protocol ?? 'tcp') !== 'tcp' || end !== m.hostPort)) {
|
|
145
|
+
return `Mapping ${i + 1} (${label}): an allow list works on one TCP port (UDP and ranges cannot be filtered by source).`;
|
|
146
|
+
}
|
|
147
|
+
const other = list.slice(0, i).find((o) => portMappingsOverlap(o, m));
|
|
148
|
+
if (other) {
|
|
149
|
+
return `Mapping ${i + 1} (${label}) overlaps ${formatPortMapping(other)}: a host port can be published once per address and protocol.`;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (total > MAX_MAPPED_PORTS) {
|
|
153
|
+
return `${total} host ports requested; at most ${MAX_MAPPED_PORTS} per service (Docker runs one proxy process per published port).`;
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The `docker run -p` operand: `27015-27030:27015-27030/udp`, `203.0.113.7:53:53/udp`, `[2001:db8::7]:53:53/udp`. */
|
|
159
|
+
export function formatPortMapping(m: PortMappingLike): string {
|
|
160
|
+
const span = (m.hostPortEnd ?? m.hostPort) - m.hostPort;
|
|
161
|
+
const range = (start: number) => (span > 0 ? `${start}-${start + span}` : String(start));
|
|
162
|
+
const ip = m.hostIp ? (m.hostIp.includes(':') ? `[${m.hostIp}]:` : `${m.hostIp}:`) : '';
|
|
163
|
+
return `${ip}${range(m.hostPort)}:${range(m.containerPort)}/${m.protocol ?? 'tcp'}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Parse `27015-27030:27015/udp`, `8080:80`, `5432@10.0.0.0/8`, `53/udp`, `10000-10100/udp`
|
|
168
|
+
* (no colon = the same ports inside the container), optionally after an IP:
|
|
169
|
+
* `203.0.113.7:53/udp`, `[2001:db8::7]:8080:80`. A container range must be as
|
|
170
|
+
* long as the host range. Returns the mapping, or why the text is not one.
|
|
171
|
+
*/
|
|
172
|
+
export function parsePortMapping(text: string): PortMapping | string {
|
|
173
|
+
// `…@10.0.0.0/8,203.0.113.4`: the allow list of a TCP port.
|
|
174
|
+
const parts = text.trim().split('@');
|
|
175
|
+
const t = parts[0]!;
|
|
176
|
+
const allowText = parts[1];
|
|
177
|
+
if (parts.length > 2) return `"${text.trim()}" has more than one @.`;
|
|
178
|
+
const allowFrom = allowText === undefined ? null : allowText.split(',').map((e) => e.trim()).filter(Boolean);
|
|
179
|
+
if (allowFrom && allowFrom.length === 0) return `"${text.trim()}": list the addresses after @ (10.0.0.0/8,203.0.113.4).`;
|
|
180
|
+
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)
|
|
181
|
+
?? /^()()(\d{1,5})(?:-(\d{1,5}))?(?::(\d{1,5})(?:-(\d{1,5}))?)?(?:\/(tcp|udp))?$/i.exec(t);
|
|
182
|
+
if (!m) return `"${t}" is not a port mapping (examples: 8080:80, 27015-27030:27015/udp, 203.0.113.7:53/udp).`;
|
|
183
|
+
const hostIp = m[1] || m[2] || null;
|
|
184
|
+
if (hostIp && !isBindableIp(hostIp)) return `"${t}": ${hostIp} is not an address a port can be bound to.`;
|
|
185
|
+
const hostPort = Number(m[3]);
|
|
186
|
+
const hostPortEnd = m[4] ? Number(m[4]) : null;
|
|
187
|
+
const containerPort = m[5] ? Number(m[5]) : hostPort;
|
|
188
|
+
const ports = [hostPort, hostPortEnd ?? hostPort, containerPort];
|
|
189
|
+
if (ports.some((p) => p < 1 || p > 65535)) return `"${t}": ports run from 1 to 65535.`;
|
|
190
|
+
if (hostPortEnd !== null && hostPortEnd < hostPort) return `"${t}": the range ends before it starts.`;
|
|
191
|
+
if (m[6] && Number(m[6]) - containerPort !== (hostPortEnd ?? hostPort) - hostPort) {
|
|
192
|
+
return `"${t}": the container range must be as long as the host range.`;
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
hostIp,
|
|
196
|
+
hostPort,
|
|
197
|
+
hostPortEnd: hostPortEnd !== null && hostPortEnd !== hostPort ? hostPortEnd : null,
|
|
198
|
+
containerPort,
|
|
199
|
+
protocol: (m[7]?.toLowerCase() ?? 'tcp') as 'tcp' | 'udp',
|
|
200
|
+
...(allowFrom ? { allowFrom } : {}),
|
|
201
|
+
};
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The PATCH field: rules checked together (ranges, overlaps, the port budget). */
|
|
205
|
+
export const portMappings = z
|
|
206
|
+
.array(portMapping)
|
|
207
|
+
.max(MAX_PORT_MAPPINGS)
|
|
208
|
+
.superRefine((list, ctx) => {
|
|
209
|
+
const problem = portMappingsProblem(list);
|
|
210
|
+
if (problem) ctx.addIssue({ code: 'custom', message: problem });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* 0.16.1 deploy healthcheck: `http` probes the container port (an HTTP answer
|
|
215
|
+
* below 500, then a TCP connect); `running` only waits for the container to
|
|
216
|
+
* stay up, for services with no HTTP or TCP port to probe (UDP game servers,
|
|
217
|
+
* VoIP, queue workers).
|
|
218
|
+
*/
|
|
219
|
+
export const healthCheckMode = z.enum(['http', 'running']);
|
|
220
|
+
export type HealthCheckMode = z.infer<typeof healthCheckMode>;
|
|
221
|
+
|
|
7
222
|
/** Optional project/workspace/label tag IDs for new service creation. */
|
|
8
223
|
const tagIds = z.array(z.number().int().positive()).optional();
|
|
9
224
|
|
|
@@ -74,6 +289,10 @@ export const createService = z.object({
|
|
|
74
289
|
restartPolicy: z.string().regex(/^(no|always|unless-stopped|on-failure(?::\d{1,3})?)$/).optional(),
|
|
75
290
|
// Seconds between SIGTERM and SIGKILL on stop (docker stop -t).
|
|
76
291
|
stopGraceSeconds: z.number().int().min(0).max(300).optional(),
|
|
292
|
+
/** Env-var names passed to a Dockerfile build as `--build-arg`. */
|
|
293
|
+
buildArgs: buildArgNames.optional(),
|
|
294
|
+
/** Stage of a multi-stage Dockerfile to build (`--target`); absent = the last stage. */
|
|
295
|
+
buildTarget: dockerBuildTarget.optional(),
|
|
77
296
|
})
|
|
78
297
|
.default({ buildPack: 'auto', baseDir: '/' }),
|
|
79
298
|
}).superRefine((value, ctx) => {
|
|
@@ -134,8 +353,16 @@ export const updateService = z.object({
|
|
|
134
353
|
/** Horizontal replica count for docker services (Traefik load-balances). */
|
|
135
354
|
replicas: z.number().int().min(1).max(10).optional(),
|
|
136
355
|
healthPath: httpPath.optional(),
|
|
356
|
+
/** Seconds a deploy waits for the healthcheck (10–3600); null restores the 300 s default. */
|
|
357
|
+
healthTimeoutSeconds: z.number().int().min(10).max(3600).nullable().optional(),
|
|
358
|
+
/** 'running' = the deploy only waits for the container to stay up (UDP and other non-HTTP services); 'http' or null = the HTTP/TCP probe. */
|
|
359
|
+
healthCheck: healthCheckMode.nullable().optional(),
|
|
360
|
+
/** Operator only: advanced container options; null clears them all. */
|
|
361
|
+
runtimeOptions: runtimeOptions.nullable().optional(),
|
|
137
362
|
port: z.number().int().min(1).max(65535).optional(),
|
|
138
363
|
publishedPort: z.number().int().min(1).max(65535).nullable().optional(),
|
|
364
|
+
/** Extra host ports, TCP or UDP, single or ranges (docker services on the panel host); null or [] clears them. */
|
|
365
|
+
portMappings: portMappings.nullable().optional(),
|
|
139
366
|
previewDeploymentsEnabled: z.boolean().optional(),
|
|
140
367
|
previewAutoDestroyOnClose: z.boolean().optional(),
|
|
141
368
|
previewDomainPattern: z.string().nullable().optional(),
|
|
@@ -159,6 +386,10 @@ export const updateService = z.object({
|
|
|
159
386
|
preStopCmd: z.string().nullable().optional(),
|
|
160
387
|
restartPolicy: z.string().regex(/^(no|always|unless-stopped|on-failure(?::\d{1,3})?)$/).optional(),
|
|
161
388
|
stopGraceSeconds: z.number().int().min(0).max(300).optional(),
|
|
389
|
+
/** Env-var names passed to a Dockerfile build as `--build-arg`; [] clears. */
|
|
390
|
+
buildArgs: buildArgNames.optional(),
|
|
391
|
+
/** Stage of a multi-stage Dockerfile to build (`--target`); '' or null clears. */
|
|
392
|
+
buildTarget: z.union([dockerBuildTarget, z.literal('')]).nullable().optional(),
|
|
162
393
|
})
|
|
163
394
|
.optional(),
|
|
164
395
|
});
|
|
@@ -209,9 +440,17 @@ export const service = z.object({
|
|
|
209
440
|
commitSha: z.string().nullable(),
|
|
210
441
|
runtimeId: z.string().nullable(),
|
|
211
442
|
healthPath: z.string(),
|
|
443
|
+
/** Healthcheck deadline in seconds; null = the 300 s default (0.16.1). */
|
|
444
|
+
healthTimeoutSeconds: z.number().int().nullable().optional(),
|
|
445
|
+
/** How a deploy decides the new container is ready (0.16.1). */
|
|
446
|
+
healthCheck: healthCheckMode.optional(),
|
|
447
|
+
/** Advanced container options (0.16.1); null = none. */
|
|
448
|
+
runtimeOptions: runtimeOptions.nullable().optional(),
|
|
212
449
|
autoUrl: z.string().nullable(),
|
|
213
450
|
port: z.number().int().nullable(),
|
|
214
451
|
publishedPort: z.number().int().nullable().optional(),
|
|
452
|
+
/** Extra host-port mappings, TCP or UDP (0.16.1); [] = none. */
|
|
453
|
+
portMappings: z.array(portMapping).optional(),
|
|
215
454
|
cpuShares: z.number().int(),
|
|
216
455
|
cpuLimitMilli: z.number().int(),
|
|
217
456
|
memLimitMb: z.number().int(),
|
|
@@ -242,6 +481,10 @@ export const service = z.object({
|
|
|
242
481
|
preStopCmd: z.string().nullable().optional(),
|
|
243
482
|
restartPolicy: z.string(),
|
|
244
483
|
stopGraceSeconds: z.number().int(),
|
|
484
|
+
/** Env-var names a Dockerfile build receives as `--build-arg` (0.16.1). */
|
|
485
|
+
buildArgs: z.array(z.string()).optional(),
|
|
486
|
+
/** Stage of a multi-stage Dockerfile the build stops at; null = the last (0.16.2). */
|
|
487
|
+
buildTarget: z.string().nullable().optional(),
|
|
245
488
|
})
|
|
246
489
|
.nullable(),
|
|
247
490
|
createdAt: z.string().datetime(),
|
|
@@ -381,6 +624,40 @@ export const createDomain = z.object({
|
|
|
381
624
|
});
|
|
382
625
|
export type CreateDomainInput = z.input<typeof createDomain>;
|
|
383
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Why a redirect regex cannot go to Traefik, or null. Traefik compiles it
|
|
629
|
+
* with Go's RE2, which has no lookaround and no backreferences — and a regex
|
|
630
|
+
* Traefik refuses takes the whole dynamic config file down with it, so these
|
|
631
|
+
* are refused when saved. The rest is checked by compiling it here.
|
|
632
|
+
*/
|
|
633
|
+
export function redirectRegexProblem(regex: string): string | null {
|
|
634
|
+
if (/\(\?<?[=!]/.test(regex)) return 'lookahead / lookbehind are not supported (Traefik uses Go RE2)';
|
|
635
|
+
if (/\\[1-9]/.test(regex)) return 'backreferences such as \\1 are not supported (Traefik uses Go RE2)';
|
|
636
|
+
try {
|
|
637
|
+
new RegExp(regex);
|
|
638
|
+
} catch (err) {
|
|
639
|
+
// RegExp only ever throws a SyntaxError here.
|
|
640
|
+
return `not a valid regular expression (${(err as Error).message})`;
|
|
641
|
+
}
|
|
642
|
+
return null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** 0.16.1 one redirect rule of a domain (Dokploy's "Redirects"): Traefik `redirectRegex`. */
|
|
646
|
+
export const redirectRule = z
|
|
647
|
+
.object({
|
|
648
|
+
/** Matched against the full request URL, e.g. `^https://example.com/old/(.*)`. */
|
|
649
|
+
regex: z.string().min(1).max(1000),
|
|
650
|
+
/** The target; `${1}` inserts the first capture group. */
|
|
651
|
+
replacement: z.string().min(1).max(2000).regex(/^[^\r\n]*$/, 'one line'),
|
|
652
|
+
/** 301/308 when true, 302/307 when false. */
|
|
653
|
+
permanent: z.boolean().default(false),
|
|
654
|
+
})
|
|
655
|
+
.superRefine((r, ctx) => {
|
|
656
|
+
const problem = redirectRegexProblem(r.regex);
|
|
657
|
+
if (problem) ctx.addIssue({ code: 'custom', path: ['regex'], message: problem });
|
|
658
|
+
});
|
|
659
|
+
export type RedirectRule = z.infer<typeof redirectRule>;
|
|
660
|
+
|
|
384
661
|
/** PATCHable routing extras for an existing domain. */
|
|
385
662
|
export const domainPatch = z.object({
|
|
386
663
|
ssl: z.boolean().optional(),
|
|
@@ -390,6 +667,8 @@ export const domainPatch = z.object({
|
|
|
390
667
|
ipAllowlist: z.string().max(4000).optional().nullable(),
|
|
391
668
|
rateLimitAverage: z.number().int().min(0).max(100000).optional().nullable(),
|
|
392
669
|
rateLimitBurst: z.number().int().min(0).max(100000).optional().nullable(),
|
|
670
|
+
/** 0.16.1: redirect rules, applied in order; null or [] removes them. */
|
|
671
|
+
redirects: z.array(redirectRule).max(10).optional().nullable(),
|
|
393
672
|
});
|
|
394
673
|
export type DomainPatch = z.input<typeof domainPatch>;
|
|
395
674
|
|
|
@@ -405,6 +684,8 @@ export const domain = z.object({
|
|
|
405
684
|
ipAllowlist: z.string().nullable().optional(),
|
|
406
685
|
rateLimitAverage: z.number().nullable().optional(),
|
|
407
686
|
rateLimitBurst: z.number().nullable().optional(),
|
|
687
|
+
/** 0.16.1 redirect rules (absent on older panels). */
|
|
688
|
+
redirects: z.array(z.object({ regex: z.string(), replacement: z.string(), permanent: z.boolean() })).optional(),
|
|
408
689
|
status: z.string(),
|
|
409
690
|
createdAt: z.string().datetime(),
|
|
410
691
|
updatedAt: z.string().datetime(),
|