@ninedeploy/schemas 0.16.0 → 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/src/service.ts CHANGED
@@ -4,6 +4,212 @@ 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.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
+
7
213
  /** Optional project/workspace/label tag IDs for new service creation. */
8
214
  const tagIds = z.array(z.number().int().positive()).optional();
9
215
 
@@ -74,6 +280,8 @@ export const createService = z.object({
74
280
  restartPolicy: z.string().regex(/^(no|always|unless-stopped|on-failure(?::\d{1,3})?)$/).optional(),
75
281
  // Seconds between SIGTERM and SIGKILL on stop (docker stop -t).
76
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(),
77
285
  })
78
286
  .default({ buildPack: 'auto', baseDir: '/' }),
79
287
  }).superRefine((value, ctx) => {
@@ -134,8 +342,16 @@ export const updateService = z.object({
134
342
  /** Horizontal replica count for docker services (Traefik load-balances). */
135
343
  replicas: z.number().int().min(1).max(10).optional(),
136
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(),
137
351
  port: z.number().int().min(1).max(65535).optional(),
138
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(),
139
355
  previewDeploymentsEnabled: z.boolean().optional(),
140
356
  previewAutoDestroyOnClose: z.boolean().optional(),
141
357
  previewDomainPattern: z.string().nullable().optional(),
@@ -159,6 +375,8 @@ export const updateService = z.object({
159
375
  preStopCmd: z.string().nullable().optional(),
160
376
  restartPolicy: z.string().regex(/^(no|always|unless-stopped|on-failure(?::\d{1,3})?)$/).optional(),
161
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(),
162
380
  })
163
381
  .optional(),
164
382
  });
@@ -209,9 +427,17 @@ export const service = z.object({
209
427
  commitSha: z.string().nullable(),
210
428
  runtimeId: z.string().nullable(),
211
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(),
212
436
  autoUrl: z.string().nullable(),
213
437
  port: z.number().int().nullable(),
214
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(),
215
441
  cpuShares: z.number().int(),
216
442
  cpuLimitMilli: z.number().int(),
217
443
  memLimitMb: z.number().int(),
@@ -242,6 +468,8 @@ export const service = z.object({
242
468
  preStopCmd: z.string().nullable().optional(),
243
469
  restartPolicy: z.string(),
244
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(),
245
473
  })
246
474
  .nullable(),
247
475
  createdAt: z.string().datetime(),
@@ -381,6 +609,40 @@ export const createDomain = z.object({
381
609
  });
382
610
  export type CreateDomainInput = z.input<typeof createDomain>;
383
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
+
384
646
  /** PATCHable routing extras for an existing domain. */
385
647
  export const domainPatch = z.object({
386
648
  ssl: z.boolean().optional(),
@@ -390,6 +652,8 @@ export const domainPatch = z.object({
390
652
  ipAllowlist: z.string().max(4000).optional().nullable(),
391
653
  rateLimitAverage: z.number().int().min(0).max(100000).optional().nullable(),
392
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(),
393
657
  });
394
658
  export type DomainPatch = z.input<typeof domainPatch>;
395
659
 
@@ -405,6 +669,8 @@ export const domain = z.object({
405
669
  ipAllowlist: z.string().nullable().optional(),
406
670
  rateLimitAverage: z.number().nullable().optional(),
407
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(),
408
674
  status: z.string(),
409
675
  createdAt: z.string().datetime(),
410
676
  updatedAt: z.string().datetime(),