blob: 63f6642218486b986fbaeb3fbe558489838f2252 [file] [log] [blame]
gio48fde052025-05-14 09:48:08 +00001import { AppNode, Env, Message, MessageType, NodeType, ServiceType, VolumeType } from "./state";
gio5f2f1002025-03-20 18:38:48 +04002
3export type AuthDisabled = {
giod0026612025-05-08 13:00:36 +00004 enabled: false;
gio5f2f1002025-03-20 18:38:48 +04005};
6
7export type AuthEnabled = {
giod0026612025-05-08 13:00:36 +00008 enabled: true;
9 groups: string[];
10 noAuthPathPatterns: string[];
gio5f2f1002025-03-20 18:38:48 +040011};
12
13export type Auth = AuthDisabled | AuthEnabled;
14
15export type Ingress = {
giod0026612025-05-08 13:00:36 +000016 network: string;
17 subdomain: string;
18 port: { name: string } | { value: string };
19 auth: Auth;
gio5f2f1002025-03-20 18:38:48 +040020};
21
22export type Domain = {
giod0026612025-05-08 13:00:36 +000023 network: string;
24 subdomain: string;
gio5f2f1002025-03-20 18:38:48 +040025};
26
giod0026612025-05-08 13:00:36 +000027export type PortValue =
28 | {
29 name: string;
30 }
31 | {
32 value: number;
33 };
gio5f2f1002025-03-20 18:38:48 +040034
35export type PortDomain = Domain & {
giod0026612025-05-08 13:00:36 +000036 port: PortValue;
37};
gio5f2f1002025-03-20 18:38:48 +040038
39export type Service = {
giod0026612025-05-08 13:00:36 +000040 type: ServiceType;
41 name: string;
42 source: {
43 repository: string;
44 branch: string;
45 rootDir: string;
46 };
47 ports?: {
48 name: string;
49 value: number;
50 protocol: "TCP" | "UDP";
51 }[];
52 env?: {
53 name: string;
54 alias?: string;
55 }[];
56 ingress?: Ingress[];
57 expose?: PortDomain[];
58 volume?: string[];
59 preBuildCommands?: { bin: string }[];
gio48fde052025-05-14 09:48:08 +000060 dev?: {
61 enabled: boolean;
gio3ed59592025-05-14 16:51:09 +000062 username?: string;
gio48fde052025-05-14 09:48:08 +000063 ssh?: Domain;
64 codeServer?: Domain;
65 };
gio5f2f1002025-03-20 18:38:48 +040066};
67
68export type Volume = {
giod0026612025-05-08 13:00:36 +000069 name: string;
70 accessMode: VolumeType;
71 size: string;
gio5f2f1002025-03-20 18:38:48 +040072};
73
74export type PostgreSQL = {
giod0026612025-05-08 13:00:36 +000075 name: string;
76 size: string;
77 expose?: Domain[];
gio5f2f1002025-03-20 18:38:48 +040078};
79
80export type MongoDB = {
giod0026612025-05-08 13:00:36 +000081 name: string;
82 size: string;
83 expose?: Domain[];
gio5f2f1002025-03-20 18:38:48 +040084};
85
86export type Config = {
gio7d813702025-05-08 18:29:52 +000087 input: {
88 appId: string;
89 managerAddr: string;
90 };
giod0026612025-05-08 13:00:36 +000091 service?: Service[];
92 volume?: Volume[];
93 postgresql?: PostgreSQL[];
94 mongodb?: MongoDB[];
gio5f2f1002025-03-20 18:38:48 +040095};
96
gio7d813702025-05-08 18:29:52 +000097export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): Config | null {
giod0026612025-05-08 13:00:36 +000098 try {
gio7d813702025-05-08 18:29:52 +000099 if (appId == null || env.managerAddr == null) {
100 return null;
101 }
giod0026612025-05-08 13:00:36 +0000102 const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
gio3ed59592025-05-14 16:51:09 +0000103 const ingressNodes = nodes
104 .filter((n) => n.type === "gateway-https")
105 .filter((n) => n.data.https !== undefined && !n.data.readonly);
106 const tcpNodes = nodes
107 .filter((n) => n.type === "gateway-tcp")
108 .filter((n) => n.data.exposed !== undefined && !n.data.readonly);
giod0026612025-05-08 13:00:36 +0000109 const findExpose = (n: AppNode): PortDomain[] => {
110 return n.data.ports
111 .map((p) => [n.id, p.id, p.name])
112 .flatMap((sp) => {
113 return tcpNodes.flatMap((i) =>
114 (i.data.exposed || [])
115 .filter((t) => t.serviceId === sp[0] && t.portId === sp[1])
116 .map(() => ({
117 network: networkMap.get(i.data.network!)!,
118 subdomain: i.data.subdomain!,
119 port: { name: sp[2] },
120 })),
121 );
122 });
123 };
124 return {
gio7d813702025-05-08 18:29:52 +0000125 input: {
126 appId: appId,
127 managerAddr: env.managerAddr,
128 },
giod0026612025-05-08 13:00:36 +0000129 service: nodes
130 .filter((n) => n.type === "app")
131 .map((n): Service => {
132 return {
133 type: n.data.type,
134 name: n.data.label,
135 source: {
136 repository: nodes
137 .filter((i) => i.type === "github")
138 .find((i) => i.id === n.data.repository.id)!.data.repository!.sshURL,
139 branch: n.data.repository.branch,
140 rootDir: n.data.repository.rootDir,
141 },
gio3ed59592025-05-14 16:51:09 +0000142 ports: (n.data.ports || [])
143 .filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
144 .map((p) => ({
145 name: p.name,
146 value: p.value,
147 protocol: "TCP", // TODO(gio)
148 })),
giod0026612025-05-08 13:00:36 +0000149 env: (n.data.envVars || [])
150 .filter((e) => "name" in e)
151 .map((e) => ({
152 name: e.name,
153 alias: "alias" in e ? e.alias : undefined,
154 })),
155 ingress: ingressNodes
156 .filter((i) => i.data.https!.serviceId === n.id)
157 .map(
gio48fde052025-05-14 09:48:08 +0000158 (i): Ingress => ({
giod0026612025-05-08 13:00:36 +0000159 network: networkMap.get(i.data.network!)!,
160 subdomain: i.data.subdomain!,
161 port: {
162 name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
163 },
164 auth:
165 i.data.auth?.enabled || false
166 ? {
167 enabled: true,
168 groups: i.data.auth!.groups,
169 noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
170 }
171 : {
172 enabled: false,
173 },
174 }),
175 ),
176 expose: findExpose(n),
177 preBuildCommands: n.data.preBuildCommands
178 ? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
179 : [],
gio48fde052025-05-14 09:48:08 +0000180 dev: {
181 enabled: n.data.dev ? n.data.dev.enabled : false,
gio3ed59592025-05-14 16:51:09 +0000182 username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
gio48fde052025-05-14 09:48:08 +0000183 codeServer:
184 n.data.dev?.enabled && n.data.dev.expose != null
185 ? {
gio3ed59592025-05-14 16:51:09 +0000186 network: networkMap.get(n.data.dev.expose.network)!,
gio48fde052025-05-14 09:48:08 +0000187 subdomain: n.data.dev.expose.subdomain,
188 }
189 : undefined,
190 ssh:
191 n.data.dev?.enabled && n.data.dev.expose != null
192 ? {
gio3ed59592025-05-14 16:51:09 +0000193 network: networkMap.get(n.data.dev.expose.network)!,
gio48fde052025-05-14 09:48:08 +0000194 subdomain: n.data.dev.expose.subdomain,
195 }
196 : undefined,
197 },
giod0026612025-05-08 13:00:36 +0000198 };
199 }),
200 volume: nodes
201 .filter((n) => n.type === "volume")
202 .map(
203 (n): Volume => ({
204 name: n.data.label,
205 accessMode: n.data.type,
206 size: n.data.size,
207 }),
208 ),
209 postgresql: nodes
210 .filter((n) => n.type === "postgresql")
211 .map(
212 (n): PostgreSQL => ({
213 name: n.data.label,
214 size: "1Gi", // TODO(gio)
215 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
216 }),
217 ),
218 mongodb: nodes
219 .filter((n) => n.type === "mongodb")
220 .map(
221 (n): MongoDB => ({
222 name: n.data.label,
223 size: "1Gi", // TODO(gio)
224 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
225 }),
226 ),
227 };
228 } catch (e) {
229 console.log(e);
230 return null;
231 }
gio5f2f1002025-03-20 18:38:48 +0400232}
233
234export interface Validator {
giod0026612025-05-08 13:00:36 +0000235 (nodes: AppNode[]): Message[];
gio5f2f1002025-03-20 18:38:48 +0400236}
237
238function CombineValidators(...v: Validator[]): Validator {
giod0026612025-05-08 13:00:36 +0000239 return (n) => v.flatMap((v) => v(n));
gio5f2f1002025-03-20 18:38:48 +0400240}
241
242function MessageTypeToNumber(t: MessageType) {
giod0026612025-05-08 13:00:36 +0000243 switch (t) {
244 case "FATAL":
245 return 0;
246 case "WARNING":
247 return 1;
248 case "INFO":
249 return 2;
250 }
gio5f2f1002025-03-20 18:38:48 +0400251}
252
253function NodeTypeToNumber(t?: NodeType) {
giod0026612025-05-08 13:00:36 +0000254 switch (t) {
255 case "github":
256 return 0;
257 case "app":
258 return 1;
259 case "volume":
260 return 2;
261 case "postgresql":
262 return 3;
263 case "mongodb":
264 return 4;
265 case "gateway-https":
266 return 5;
267 case undefined:
268 return 100;
269 }
gio5f2f1002025-03-20 18:38:48 +0400270}
271
272function SortingValidator(v: Validator): Validator {
giod0026612025-05-08 13:00:36 +0000273 return (n) => {
274 const nt = new Map(n.map((n) => [n.id, NodeTypeToNumber(n.type)]));
275 return v(n).sort((a, b) => {
276 const at = MessageTypeToNumber(a.type);
277 const bt = MessageTypeToNumber(b.type);
278 if (a.nodeId === undefined && b.nodeId === undefined) {
279 if (at !== bt) {
280 return at - bt;
281 }
282 return a.id.localeCompare(b.id);
283 }
284 if (a.nodeId === undefined) {
285 return -1;
286 }
287 if (b.nodeId === undefined) {
288 return 1;
289 }
290 if (a.nodeId === b.nodeId) {
291 if (at !== bt) {
292 return at - bt;
293 }
294 return a.id.localeCompare(b.id);
295 }
296 const ant = nt.get(a.id)!;
297 const bnt = nt.get(b.id)!;
298 if (ant !== bnt) {
299 return ant - bnt;
300 }
301 return a.id.localeCompare(b.id);
302 });
303 };
gio5f2f1002025-03-20 18:38:48 +0400304}
305
306export function CreateValidators(): Validator {
giod0026612025-05-08 13:00:36 +0000307 return SortingValidator(
308 CombineValidators(
309 EmptyValidator,
310 GitRepositoryValidator,
311 ServiceValidator,
312 GatewayHTTPSValidator,
313 GatewayTCPValidator,
314 ),
315 );
gio5f2f1002025-03-20 18:38:48 +0400316}
317
318function EmptyValidator(nodes: AppNode[]): Message[] {
gio5cf364c2025-05-08 16:01:21 +0000319 if (nodes.some((n) => n.type !== "network")) {
giod0026612025-05-08 13:00:36 +0000320 return [];
321 }
322 return [
323 {
324 id: "no-nodes",
325 type: "FATAL",
326 message: "Start by importing application source code",
327 onHighlight: (store) => store.setHighlightCategory("repository", true),
328 onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
329 },
330 ];
gio5f2f1002025-03-20 18:38:48 +0400331}
332
333function GitRepositoryValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000334 const git = nodes.filter((n) => n.type === "github");
335 const noAddress: Message[] = git
336 .filter((n) => n.data == null || n.data.repository == null)
337 .map(
338 (n) =>
339 ({
340 id: `${n.id}-no-address`,
341 type: "FATAL",
342 nodeId: n.id,
343 message: "Configure repository address",
344 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
345 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
346 }) satisfies Message,
347 );
348 const noApp = git
349 .filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.id === n.id))
350 .map(
351 (n) =>
352 ({
353 id: `${n.id}-no-app`,
354 type: "WARNING",
355 nodeId: n.id,
356 message: "Connect to service",
357 onHighlight: (store) => store.setHighlightCategory("Services", true),
358 onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
359 }) satisfies Message,
360 );
361 return noAddress.concat(noApp);
gio5f2f1002025-03-20 18:38:48 +0400362}
363
364function ServiceValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000365 const apps = nodes.filter((n) => n.type === "app");
366 const noName = apps
367 .filter((n) => n.data == null || n.data.label == null || n.data.label === "")
368 .map(
369 (n): Message => ({
370 id: `${n.id}-no-name`,
371 type: "FATAL",
372 nodeId: n.id,
373 message: "Name the service",
374 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
375 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
376 onClick: (store) => {
377 store.updateNode(n.id, { selected: true });
378 store.updateNodeData<"app">(n.id, {
379 activeField: "name",
380 });
381 },
382 }),
383 );
384 const noSource = apps
385 .filter((n) => n.data == null || n.data.repository == null || n.data.repository.id === "")
386 .map(
387 (n): Message => ({
388 id: `${n.id}-no-repo`,
389 type: "FATAL",
390 nodeId: n.id,
391 message: "Connect to source repository",
392 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
393 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
394 }),
395 );
396 const noRuntime = apps
397 .filter((n) => n.data == null || n.data.type == null)
398 .map(
399 (n): Message => ({
400 id: `${n.id}-no-runtime`,
401 type: "FATAL",
402 nodeId: n.id,
403 message: "Choose runtime",
404 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
405 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
406 onClick: (store) => {
407 store.updateNode(n.id, { selected: true });
408 store.updateNodeData<"app">(n.id, {
409 activeField: "type",
410 });
411 },
412 }),
413 );
414 const noPorts = apps
415 .filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0)
416 .map(
417 (n): Message => ({
418 id: `${n.id}-no-ports`,
419 type: "INFO",
420 nodeId: n.id,
421 message: "Expose ports",
422 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
423 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
424 }),
425 );
426 const noIngress = apps.flatMap((n): Message[] => {
427 if (n.data == null) {
428 return [];
429 }
430 return (n.data.ports || [])
431 .filter(
432 (p) =>
433 !nodes
434 .filter((i) => i.type === "gateway-https")
435 .some((i) => {
436 if (
437 i.data &&
438 i.data.https &&
439 i.data.https.serviceId === n.id &&
440 i.data.https.portId === p.id
441 ) {
442 return true;
443 }
444 return false;
445 }),
446 )
447 .map(
448 (p): Message => ({
449 id: `${n.id}-${p.id}-no-ingress`,
450 type: "WARNING",
451 nodeId: n.id,
452 message: `Connect to ingress: ${p.name} - ${p.value}`,
453 onHighlight: (store) => {
454 store.updateNode(n.id, { selected: true });
455 store.setHighlightCategory("gateways", true);
456 },
457 onLooseHighlight: (store) => {
458 store.updateNode(n.id, { selected: false });
459 store.setHighlightCategory("gateways", false);
460 },
461 }),
462 );
463 });
464 const multipleIngress = apps
465 .filter((n) => n.data != null && n.data.ports != null)
466 .flatMap((n) =>
467 n.data.ports.map((p): Message | undefined => {
468 const ing = nodes
469 .filter((i) => i.type === "gateway-https")
470 .filter(
471 (i) =>
472 i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id,
473 );
474 if (ing.length < 2) {
475 return undefined;
476 }
477 return {
478 id: `${n.id}-${p.id}-multiple-ingress`,
479 type: "FATAL",
480 nodeId: n.id,
481 message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
482 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
483 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
484 };
485 }),
486 )
487 .filter((m) => m !== undefined);
488 return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
gio5f2f1002025-03-20 18:38:48 +0400489}
490
491function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000492 const ing = nodes.filter((n) => n.type === "gateway-https");
493 const noNetwork: Message[] = ing
494 .filter(
495 (n) =>
496 n.data == null ||
497 n.data.network == null ||
498 n.data.network == "" ||
499 n.data.subdomain == null ||
500 n.data.subdomain == "",
501 )
502 .map(
503 (n): Message => ({
504 id: `${n.id}-no-network`,
505 type: "FATAL",
506 nodeId: n.id,
507 message: "Network and subdomain must be defined",
508 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
509 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
510 }),
511 );
512 const notConnected: Message[] = ing
513 .filter(
514 (n) =>
515 n.data == null ||
516 n.data.https == null ||
517 n.data.https.serviceId == null ||
518 n.data.https.serviceId == "" ||
519 n.data.https.portId == null ||
520 n.data.https.portId == "",
521 )
522 .map((n) => ({
523 id: `${n.id}-not-connected`,
524 type: "FATAL",
525 nodeId: n.id,
526 message: "Connect to a service port",
527 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
528 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
529 }));
530 return noNetwork.concat(notConnected);
gio5f2f1002025-03-20 18:38:48 +0400531}
532
533function GatewayTCPValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000534 const ing = nodes.filter((n) => n.type === "gateway-tcp");
535 const noNetwork: Message[] = ing
536 .filter(
537 (n) =>
538 n.data == null ||
539 n.data.network == null ||
540 n.data.network == "" ||
541 n.data.subdomain == null ||
542 n.data.subdomain == "",
543 )
544 .map(
545 (n): Message => ({
546 id: `${n.id}-no-network`,
547 type: "FATAL",
548 nodeId: n.id,
549 message: "Network and subdomain must be defined",
550 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
551 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
552 }),
553 );
554 const notConnected: Message[] = ing
555 .filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0)
556 .map((n) => ({
557 id: `${n.id}-not-connected`,
558 type: "FATAL",
559 nodeId: n.id,
560 message: "Connect to a service port",
561 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
562 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
563 }));
564 return noNetwork.concat(notConnected);
giof8acc612025-04-26 08:20:55 +0400565}