blob: 60d99665e2e4c43aa3b155cd2efa0dd37da4442d [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;
62 ssh?: Domain;
63 codeServer?: Domain;
64 };
gio5f2f1002025-03-20 18:38:48 +040065};
66
67export type Volume = {
giod0026612025-05-08 13:00:36 +000068 name: string;
69 accessMode: VolumeType;
70 size: string;
gio5f2f1002025-03-20 18:38:48 +040071};
72
73export type PostgreSQL = {
giod0026612025-05-08 13:00:36 +000074 name: string;
75 size: string;
76 expose?: Domain[];
gio5f2f1002025-03-20 18:38:48 +040077};
78
79export type MongoDB = {
giod0026612025-05-08 13:00:36 +000080 name: string;
81 size: string;
82 expose?: Domain[];
gio5f2f1002025-03-20 18:38:48 +040083};
84
85export type Config = {
gio7d813702025-05-08 18:29:52 +000086 input: {
87 appId: string;
88 managerAddr: string;
89 };
giod0026612025-05-08 13:00:36 +000090 service?: Service[];
91 volume?: Volume[];
92 postgresql?: PostgreSQL[];
93 mongodb?: MongoDB[];
gio5f2f1002025-03-20 18:38:48 +040094};
95
gio7d813702025-05-08 18:29:52 +000096export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): Config | null {
giod0026612025-05-08 13:00:36 +000097 try {
gio7d813702025-05-08 18:29:52 +000098 if (appId == null || env.managerAddr == null) {
99 return null;
100 }
giod0026612025-05-08 13:00:36 +0000101 const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
102 const ingressNodes = nodes.filter((n) => n.type === "gateway-https").filter((n) => n.data.https !== undefined);
103 const tcpNodes = nodes.filter((n) => n.type === "gateway-tcp").filter((n) => n.data.exposed !== undefined);
104 const findExpose = (n: AppNode): PortDomain[] => {
105 return n.data.ports
106 .map((p) => [n.id, p.id, p.name])
107 .flatMap((sp) => {
108 return tcpNodes.flatMap((i) =>
109 (i.data.exposed || [])
110 .filter((t) => t.serviceId === sp[0] && t.portId === sp[1])
111 .map(() => ({
112 network: networkMap.get(i.data.network!)!,
113 subdomain: i.data.subdomain!,
114 port: { name: sp[2] },
115 })),
116 );
117 });
118 };
119 return {
gio7d813702025-05-08 18:29:52 +0000120 input: {
121 appId: appId,
122 managerAddr: env.managerAddr,
123 },
giod0026612025-05-08 13:00:36 +0000124 service: nodes
125 .filter((n) => n.type === "app")
126 .map((n): Service => {
127 return {
128 type: n.data.type,
129 name: n.data.label,
130 source: {
131 repository: nodes
132 .filter((i) => i.type === "github")
133 .find((i) => i.id === n.data.repository.id)!.data.repository!.sshURL,
134 branch: n.data.repository.branch,
135 rootDir: n.data.repository.rootDir,
136 },
137 ports: (n.data.ports || []).map((p) => ({
138 name: p.name,
139 value: p.value,
140 protocol: "TCP", // TODO(gio)
141 })),
142 env: (n.data.envVars || [])
143 .filter((e) => "name" in e)
144 .map((e) => ({
145 name: e.name,
146 alias: "alias" in e ? e.alias : undefined,
147 })),
148 ingress: ingressNodes
149 .filter((i) => i.data.https!.serviceId === n.id)
150 .map(
gio48fde052025-05-14 09:48:08 +0000151 (i): Ingress => ({
giod0026612025-05-08 13:00:36 +0000152 network: networkMap.get(i.data.network!)!,
153 subdomain: i.data.subdomain!,
154 port: {
155 name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
156 },
157 auth:
158 i.data.auth?.enabled || false
159 ? {
160 enabled: true,
161 groups: i.data.auth!.groups,
162 noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
163 }
164 : {
165 enabled: false,
166 },
167 }),
168 ),
169 expose: findExpose(n),
170 preBuildCommands: n.data.preBuildCommands
171 ? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
172 : [],
gio48fde052025-05-14 09:48:08 +0000173 dev: {
174 enabled: n.data.dev ? n.data.dev.enabled : false,
175 codeServer:
176 n.data.dev?.enabled && n.data.dev.expose != null
177 ? {
178 network: n.data.dev.expose.network,
179 subdomain: n.data.dev.expose.subdomain,
180 }
181 : undefined,
182 ssh:
183 n.data.dev?.enabled && n.data.dev.expose != null
184 ? {
185 network: n.data.dev.expose.network,
186 subdomain: n.data.dev.expose.subdomain,
187 }
188 : undefined,
189 },
giod0026612025-05-08 13:00:36 +0000190 };
191 }),
192 volume: nodes
193 .filter((n) => n.type === "volume")
194 .map(
195 (n): Volume => ({
196 name: n.data.label,
197 accessMode: n.data.type,
198 size: n.data.size,
199 }),
200 ),
201 postgresql: nodes
202 .filter((n) => n.type === "postgresql")
203 .map(
204 (n): PostgreSQL => ({
205 name: n.data.label,
206 size: "1Gi", // TODO(gio)
207 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
208 }),
209 ),
210 mongodb: nodes
211 .filter((n) => n.type === "mongodb")
212 .map(
213 (n): MongoDB => ({
214 name: n.data.label,
215 size: "1Gi", // TODO(gio)
216 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
217 }),
218 ),
219 };
220 } catch (e) {
221 console.log(e);
222 return null;
223 }
gio5f2f1002025-03-20 18:38:48 +0400224}
225
226export interface Validator {
giod0026612025-05-08 13:00:36 +0000227 (nodes: AppNode[]): Message[];
gio5f2f1002025-03-20 18:38:48 +0400228}
229
230function CombineValidators(...v: Validator[]): Validator {
giod0026612025-05-08 13:00:36 +0000231 return (n) => v.flatMap((v) => v(n));
gio5f2f1002025-03-20 18:38:48 +0400232}
233
234function MessageTypeToNumber(t: MessageType) {
giod0026612025-05-08 13:00:36 +0000235 switch (t) {
236 case "FATAL":
237 return 0;
238 case "WARNING":
239 return 1;
240 case "INFO":
241 return 2;
242 }
gio5f2f1002025-03-20 18:38:48 +0400243}
244
245function NodeTypeToNumber(t?: NodeType) {
giod0026612025-05-08 13:00:36 +0000246 switch (t) {
247 case "github":
248 return 0;
249 case "app":
250 return 1;
251 case "volume":
252 return 2;
253 case "postgresql":
254 return 3;
255 case "mongodb":
256 return 4;
257 case "gateway-https":
258 return 5;
259 case undefined:
260 return 100;
261 }
gio5f2f1002025-03-20 18:38:48 +0400262}
263
264function SortingValidator(v: Validator): Validator {
giod0026612025-05-08 13:00:36 +0000265 return (n) => {
266 const nt = new Map(n.map((n) => [n.id, NodeTypeToNumber(n.type)]));
267 return v(n).sort((a, b) => {
268 const at = MessageTypeToNumber(a.type);
269 const bt = MessageTypeToNumber(b.type);
270 if (a.nodeId === undefined && b.nodeId === undefined) {
271 if (at !== bt) {
272 return at - bt;
273 }
274 return a.id.localeCompare(b.id);
275 }
276 if (a.nodeId === undefined) {
277 return -1;
278 }
279 if (b.nodeId === undefined) {
280 return 1;
281 }
282 if (a.nodeId === b.nodeId) {
283 if (at !== bt) {
284 return at - bt;
285 }
286 return a.id.localeCompare(b.id);
287 }
288 const ant = nt.get(a.id)!;
289 const bnt = nt.get(b.id)!;
290 if (ant !== bnt) {
291 return ant - bnt;
292 }
293 return a.id.localeCompare(b.id);
294 });
295 };
gio5f2f1002025-03-20 18:38:48 +0400296}
297
298export function CreateValidators(): Validator {
giod0026612025-05-08 13:00:36 +0000299 return SortingValidator(
300 CombineValidators(
301 EmptyValidator,
302 GitRepositoryValidator,
303 ServiceValidator,
304 GatewayHTTPSValidator,
305 GatewayTCPValidator,
306 ),
307 );
gio5f2f1002025-03-20 18:38:48 +0400308}
309
310function EmptyValidator(nodes: AppNode[]): Message[] {
gio5cf364c2025-05-08 16:01:21 +0000311 if (nodes.some((n) => n.type !== "network")) {
giod0026612025-05-08 13:00:36 +0000312 return [];
313 }
314 return [
315 {
316 id: "no-nodes",
317 type: "FATAL",
318 message: "Start by importing application source code",
319 onHighlight: (store) => store.setHighlightCategory("repository", true),
320 onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
321 },
322 ];
gio5f2f1002025-03-20 18:38:48 +0400323}
324
325function GitRepositoryValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000326 const git = nodes.filter((n) => n.type === "github");
327 const noAddress: Message[] = git
328 .filter((n) => n.data == null || n.data.repository == null)
329 .map(
330 (n) =>
331 ({
332 id: `${n.id}-no-address`,
333 type: "FATAL",
334 nodeId: n.id,
335 message: "Configure repository address",
336 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
337 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
338 }) satisfies Message,
339 );
340 const noApp = git
341 .filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.id === n.id))
342 .map(
343 (n) =>
344 ({
345 id: `${n.id}-no-app`,
346 type: "WARNING",
347 nodeId: n.id,
348 message: "Connect to service",
349 onHighlight: (store) => store.setHighlightCategory("Services", true),
350 onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
351 }) satisfies Message,
352 );
353 return noAddress.concat(noApp);
gio5f2f1002025-03-20 18:38:48 +0400354}
355
356function ServiceValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000357 const apps = nodes.filter((n) => n.type === "app");
358 const noName = apps
359 .filter((n) => n.data == null || n.data.label == null || n.data.label === "")
360 .map(
361 (n): Message => ({
362 id: `${n.id}-no-name`,
363 type: "FATAL",
364 nodeId: n.id,
365 message: "Name the service",
366 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
367 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
368 onClick: (store) => {
369 store.updateNode(n.id, { selected: true });
370 store.updateNodeData<"app">(n.id, {
371 activeField: "name",
372 });
373 },
374 }),
375 );
376 const noSource = apps
377 .filter((n) => n.data == null || n.data.repository == null || n.data.repository.id === "")
378 .map(
379 (n): Message => ({
380 id: `${n.id}-no-repo`,
381 type: "FATAL",
382 nodeId: n.id,
383 message: "Connect to source repository",
384 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
385 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
386 }),
387 );
388 const noRuntime = apps
389 .filter((n) => n.data == null || n.data.type == null)
390 .map(
391 (n): Message => ({
392 id: `${n.id}-no-runtime`,
393 type: "FATAL",
394 nodeId: n.id,
395 message: "Choose runtime",
396 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
397 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
398 onClick: (store) => {
399 store.updateNode(n.id, { selected: true });
400 store.updateNodeData<"app">(n.id, {
401 activeField: "type",
402 });
403 },
404 }),
405 );
406 const noPorts = apps
407 .filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0)
408 .map(
409 (n): Message => ({
410 id: `${n.id}-no-ports`,
411 type: "INFO",
412 nodeId: n.id,
413 message: "Expose ports",
414 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
415 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
416 }),
417 );
418 const noIngress = apps.flatMap((n): Message[] => {
419 if (n.data == null) {
420 return [];
421 }
422 return (n.data.ports || [])
423 .filter(
424 (p) =>
425 !nodes
426 .filter((i) => i.type === "gateway-https")
427 .some((i) => {
428 if (
429 i.data &&
430 i.data.https &&
431 i.data.https.serviceId === n.id &&
432 i.data.https.portId === p.id
433 ) {
434 return true;
435 }
436 return false;
437 }),
438 )
439 .map(
440 (p): Message => ({
441 id: `${n.id}-${p.id}-no-ingress`,
442 type: "WARNING",
443 nodeId: n.id,
444 message: `Connect to ingress: ${p.name} - ${p.value}`,
445 onHighlight: (store) => {
446 store.updateNode(n.id, { selected: true });
447 store.setHighlightCategory("gateways", true);
448 },
449 onLooseHighlight: (store) => {
450 store.updateNode(n.id, { selected: false });
451 store.setHighlightCategory("gateways", false);
452 },
453 }),
454 );
455 });
456 const multipleIngress = apps
457 .filter((n) => n.data != null && n.data.ports != null)
458 .flatMap((n) =>
459 n.data.ports.map((p): Message | undefined => {
460 const ing = nodes
461 .filter((i) => i.type === "gateway-https")
462 .filter(
463 (i) =>
464 i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id,
465 );
466 if (ing.length < 2) {
467 return undefined;
468 }
469 return {
470 id: `${n.id}-${p.id}-multiple-ingress`,
471 type: "FATAL",
472 nodeId: n.id,
473 message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
474 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
475 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
476 };
477 }),
478 )
479 .filter((m) => m !== undefined);
480 return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
gio5f2f1002025-03-20 18:38:48 +0400481}
482
483function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000484 const ing = nodes.filter((n) => n.type === "gateway-https");
485 const noNetwork: Message[] = ing
486 .filter(
487 (n) =>
488 n.data == null ||
489 n.data.network == null ||
490 n.data.network == "" ||
491 n.data.subdomain == null ||
492 n.data.subdomain == "",
493 )
494 .map(
495 (n): Message => ({
496 id: `${n.id}-no-network`,
497 type: "FATAL",
498 nodeId: n.id,
499 message: "Network and subdomain must be defined",
500 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
501 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
502 }),
503 );
504 const notConnected: Message[] = ing
505 .filter(
506 (n) =>
507 n.data == null ||
508 n.data.https == null ||
509 n.data.https.serviceId == null ||
510 n.data.https.serviceId == "" ||
511 n.data.https.portId == null ||
512 n.data.https.portId == "",
513 )
514 .map((n) => ({
515 id: `${n.id}-not-connected`,
516 type: "FATAL",
517 nodeId: n.id,
518 message: "Connect to a service port",
519 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
520 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
521 }));
522 return noNetwork.concat(notConnected);
gio5f2f1002025-03-20 18:38:48 +0400523}
524
525function GatewayTCPValidator(nodes: AppNode[]): Message[] {
giod0026612025-05-08 13:00:36 +0000526 const ing = nodes.filter((n) => n.type === "gateway-tcp");
527 const noNetwork: Message[] = ing
528 .filter(
529 (n) =>
530 n.data == null ||
531 n.data.network == null ||
532 n.data.network == "" ||
533 n.data.subdomain == null ||
534 n.data.subdomain == "",
535 )
536 .map(
537 (n): Message => ({
538 id: `${n.id}-no-network`,
539 type: "FATAL",
540 nodeId: n.id,
541 message: "Network and subdomain must be defined",
542 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
543 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
544 }),
545 );
546 const notConnected: Message[] = ing
547 .filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0)
548 .map((n) => ({
549 id: `${n.id}-not-connected`,
550 type: "FATAL",
551 nodeId: n.id,
552 message: "Connect to a service port",
553 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
554 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
555 }));
556 return noNetwork.concat(notConnected);
giof8acc612025-04-26 08:20:55 +0400557}