blob: 3ea29b65d6f8011d5deb5dd5aecd19f3ad96d877 [file] [log] [blame]
gio5f2f1002025-03-20 18:38:48 +04001import { AppNode, Env, GatewayHttpsNode, Message, MessageType, NodeType, ServiceType, VolumeType } from "./state";
2
3export type AuthDisabled = {
4 enabled: false;
5};
6
7export type AuthEnabled = {
8 enabled: true;
9 groups: string[];
10 noAuthPathPatterns: string[];
11};
12
13export type Auth = AuthDisabled | AuthEnabled;
14
15export type Ingress = {
16 network: string;
17 subdomain: string;
18 port: { name: string; } | { value: string; };
19 auth: Auth;
20};
21
22export type Domain = {
23 network: string;
24 subdomain: string;
25};
26
27export type PortValue = {
28 name: string;
29} | {
30 value: number;
31};
32
33export type PortDomain = Domain & {
34 port: PortValue;
35}
36
37export type Service = {
38 type: ServiceType;
39 name: string;
40 source: {
41 repository: string;
42 branch: string;
43 rootDir: string;
44 };
45 ports?: {
46 name: string;
47 value: number;
48 protocol: "TCP" | "UDP";
49 }[];
50 env?: {
51 name: string;
52 alias?: string;
53 }[]
gio218e8132025-04-22 17:11:58 +000054 ingress?: Ingress[];
gio5f2f1002025-03-20 18:38:48 +040055 expose?: PortDomain[];
56 volume?: string[];
gio33990c62025-05-06 07:51:24 +000057 preBuildCommands?: { bin: string }[];
gio5f2f1002025-03-20 18:38:48 +040058};
59
60export type Volume = {
61 name: string;
62 accessMode: VolumeType;
63 size: string;
64};
65
66export type PostgreSQL = {
67 name: string;
68 size: string;
69 expose?: Domain[];
70};
71
72export type MongoDB = {
73 name: string;
74 size: string;
75 expose?: Domain[];
76};
77
78export type Config = {
79 service?: Service[];
80 volume?: Volume[];
81 postgresql?: PostgreSQL[];
82 mongodb?: MongoDB[];
83};
84
85export function generateDodoConfig(nodes: AppNode[], env: Env): Config | null {
86 try {
87 const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
88 const ingressNodes = nodes.filter((n) => n.type === "gateway-https").filter((n) => n.data.https !== undefined);
89 const tcpNodes = nodes.filter((n) => n.type === "gateway-tcp").filter((n) => n.data.exposed !== undefined);
90 const findExpose = (n: AppNode): PortDomain[] => {
91 return n.data.ports.map((p) => [n.id, p.id, p.name]).flatMap((sp) => {
92 return tcpNodes.flatMap((i) => (i.data.exposed || []).filter((t) => t.serviceId === sp[0] && t.portId === sp[1]).map(() => ({
93 network: networkMap.get(i.data.network!)!,
94 subdomain: i.data.subdomain!,
95 port: { name: sp[2] },
96 })));
97 });
98 };
99 return {
100 service: nodes.filter((n) => n.type === "app").map((n): Service => {
101 return {
102 type: n.data.type,
103 name: n.data.label,
104 source: {
gio7f98e772025-05-07 11:00:14 +0000105 repository: nodes.filter((i) => i.type === "github").find((i) => i.id === n.data.repository.id)!.data.repository!.sshURL,
gio5f2f1002025-03-20 18:38:48 +0400106 branch: n.data.repository.branch,
107 rootDir: n.data.repository.rootDir,
108 },
109 ports: (n.data.ports || []).map((p) => ({
110 name: p.name,
111 value: p.value,
112 protocol: "TCP", // TODO(gio)
113 })),
114 env: (n.data.envVars || []).filter((e) => "name" in e).map((e) => ({
115 name: e.name,
116 alias: "alias" in e ? e.alias : undefined,
117 })),
gio218e8132025-04-22 17:11:58 +0000118 ingress: ingressNodes.filter((i) => i.data.https!.serviceId === n.id).map((i: GatewayHttpsNode): Ingress => ({
119 network: networkMap.get(i.data.network!)!,
120 subdomain: i.data.subdomain!,
121 port: {
122 name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
123 },
gio9b2d4962025-05-07 04:59:39 +0000124 auth: (i.data.auth?.enabled || false ? {
125 enabled: true,
126 groups: i.data.auth!.groups,
127 noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
128 } : {
129 enabled: false,
130 }),
gio218e8132025-04-22 17:11:58 +0000131 })),
gio5f2f1002025-03-20 18:38:48 +0400132 expose: findExpose(n),
gio33990c62025-05-06 07:51:24 +0000133 preBuildCommands: n.data.preBuildCommands ? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd })) : [],
gio5f2f1002025-03-20 18:38:48 +0400134 };
135 }),
136 volume: nodes.filter((n) => n.type === "volume").map((n): Volume => ({
137 name: n.data.label,
138 accessMode: n.data.type,
139 size: n.data.size,
140 })),
141 postgresql: nodes.filter((n) => n.type === "postgresql").map((n): PostgreSQL => ({
142 name: n.data.label,
143 size: "1Gi", // TODO(gio)
144 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
145 })),
146 mongodb: nodes.filter((n) => n.type === "mongodb").map((n): MongoDB => ({
147 name: n.data.label,
148 size: "1Gi", // TODO(gio)
149 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
150 })),
151 };
152 } catch (e) {
153 console.log(e);
154 return null;
155 }
156}
157
158export interface Validator {
159 (nodes: AppNode[]): Message[];
160}
161
162function CombineValidators(...v: Validator[]): Validator {
163 return (n) => v.flatMap((v) => v(n));
164}
165
166function MessageTypeToNumber(t: MessageType) {
167 switch (t) {
168 case "FATAL": return 0;
169 case "WARNING": return 1;
170 case "INFO": return 2;
171 }
172}
173
174function NodeTypeToNumber(t?: NodeType) {
175 switch (t) {
176 case "github": return 0;
177 case "app": return 1;
178 case "volume": return 2;
179 case "postgresql": return 3;
180 case "mongodb": return 4;
181 case "gateway-https": return 5;
182 case undefined: return 100;
183 }
184}
185
186function SortingValidator(v: Validator): Validator {
187 return (n) => {
188 const nt = new Map(n.map((n) => [n.id, NodeTypeToNumber(n.type)]))
189 return v(n).sort((a, b) => {
190 const at = MessageTypeToNumber(a.type);
191 const bt = MessageTypeToNumber(b.type);
192 if (a.nodeId === undefined && b.nodeId === undefined) {
193 if (at !== bt) {
194 return at - bt;
195 }
196 return a.id.localeCompare(b.id);
197 }
198 if (a.nodeId === undefined) {
199 return -1;
200 }
201 if (b.nodeId === undefined) {
202 return 1;
203 }
204 if (a.nodeId === b.nodeId) {
205 if (at !== bt) {
206 return at - bt;
207 }
208 return a.id.localeCompare(b.id);
209 }
210 const ant = nt.get(a.id)!;
211 const bnt = nt.get(b.id)!;
212 if (ant !== bnt) {
213 return ant - bnt;
214 }
215 return a.id.localeCompare(b.id);
216 });
217 };
218}
219
220export function CreateValidators(): Validator {
221 return SortingValidator(
222 CombineValidators(
gio33990c62025-05-06 07:51:24 +0000223 EmptyValidator,
gio5f2f1002025-03-20 18:38:48 +0400224 GitRepositoryValidator,
225 ServiceValidator,
226 GatewayHTTPSValidator,
227 GatewayTCPValidator,
228 )
229 );
230}
231
232function EmptyValidator(nodes: AppNode[]): Message[] {
giof8acc612025-04-26 08:20:55 +0400233 nodes = nodes.filter((n) => n.type !== "network");
gio5f2f1002025-03-20 18:38:48 +0400234 if (nodes.length > 0) {
235 return [];
236 }
gio33990c62025-05-06 07:51:24 +0000237 return [{
gio5f2f1002025-03-20 18:38:48 +0400238 id: "no-nodes",
239 type: "FATAL",
240 message: "Start by importing application source code",
241 onHighlight: (store) => store.setHighlightCategory("repository", true),
242 onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
243 }];
244}
245
246function GitRepositoryValidator(nodes: AppNode[]): Message[] {
247 const git = nodes.filter((n) => n.type === "github");
gio7f98e772025-05-07 11:00:14 +0000248 const noAddress: Message[] = git.filter((n) => n.data == null || n.data.repository == null).map((n) => ({
gio5f2f1002025-03-20 18:38:48 +0400249 id: `${n.id}-no-address`,
250 type: "FATAL",
251 nodeId: n.id,
252 message: "Configure repository address",
253 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
254 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
255 } satisfies Message));
256 const noApp = git.filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.id === n.id)).map((n) => ({
257 id: `${n.id}-no-app`,
258 type: "WARNING",
259 nodeId: n.id,
260 message: "Connect to service",
261 onHighlight: (store) => store.setHighlightCategory("Services", true),
262 onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
gio33990c62025-05-06 07:51:24 +0000263 } satisfies Message));
gio5f2f1002025-03-20 18:38:48 +0400264 return noAddress.concat(noApp);
265}
266
267function ServiceValidator(nodes: AppNode[]): Message[] {
268 const apps = nodes.filter((n) => n.type === "app");
269 const noName = apps.filter((n) => n.data == null || n.data.label == null || n.data.label === "").map((n): Message => ({
270 id: `${n.id}-no-name`,
271 type: "FATAL",
272 nodeId: n.id,
273 message: "Name the service",
274 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
275 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
276 onClick: (store) => {
277 store.updateNode(n.id, { selected: true });
gio33990c62025-05-06 07:51:24 +0000278 store.updateNodeData<"app">(n.id, {
279 activeField: "name",
gio5f2f1002025-03-20 18:38:48 +0400280 });
281 },
282 }));
283 const noSource = apps.filter((n) => n.data == null || n.data.repository == null || n.data.repository.id === "").map((n): Message => ({
284 id: `${n.id}-no-repo`,
285 type: "FATAL",
286 nodeId: n.id,
287 message: "Connect to source repository",
288 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
289 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
290 }));
291 const noRuntime = apps.filter((n) => n.data == null || n.data.type == null).map((n): Message => ({
292 id: `${n.id}-no-runtime`,
293 type: "FATAL",
294 nodeId: n.id,
295 message: "Choose runtime",
296 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
297 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
298 onClick: (store) => {
299 store.updateNode(n.id, { selected: true });
gio33990c62025-05-06 07:51:24 +0000300 store.updateNodeData<"app">(n.id, {
301 activeField: "type",
gio5f2f1002025-03-20 18:38:48 +0400302 });
303 },
304 }));
305 const noPorts = apps.filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0).map((n): Message => ({
306 id: `${n.id}-no-ports`,
307 type: "INFO",
308 nodeId: n.id,
309 message: "Expose ports",
310 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
311 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
312 }));
313 const noIngress = apps.flatMap((n): Message[] => {
314 if (n.data == null) {
315 return [];
316 }
317 return (n.data.ports || []).filter((p) => !nodes.filter((i) => i.type === "gateway-https").some((i) => {
318 if (i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id) {
319 return true;
320 }
321 return false;
322 })).map((p): Message => ({
323 id: `${n.id}-${p.id}-no-ingress`,
324 type: "WARNING",
325 nodeId: n.id,
326 message: `Connect to ingress: ${p.name} - ${p.value}`,
327 onHighlight: (store) => {
328 store.updateNode(n.id, { selected: true });
329 store.setHighlightCategory("gateways", true);
330 },
331 onLooseHighlight: (store) => {
332 store.updateNode(n.id, { selected: false });
gio33990c62025-05-06 07:51:24 +0000333 store.setHighlightCategory("gateways", false);
gio5f2f1002025-03-20 18:38:48 +0400334 },
335 }));
336 });
337 const multipleIngress = apps.filter((n) => n.data != null && n.data.ports != null).flatMap((n) => n.data.ports.map((p): Message | undefined => {
338 const ing = nodes.filter((i) => i.type === "gateway-https").filter((i) => i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id);
339 if (ing.length < 2) {
340 return undefined;
341 }
342 return {
343 id: `${n.id}-${p.id}-multiple-ingress`,
344 type: "FATAL",
345 nodeId: n.id,
346 message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
347 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
gio33990c62025-05-06 07:51:24 +0000348 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
gio5f2f1002025-03-20 18:38:48 +0400349 };
350 })).filter((m) => m !== undefined);
351 return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
352}
353
354function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
355 const ing = nodes.filter((n) => n.type === "gateway-https");
356 const noNetwork: Message[] = ing.filter((n) => n.data == null || n.data.network == null || n.data.network == "" || n.data.subdomain == null || n.data.subdomain == "").map((n): Message => ({
357 id: `${n.id}-no-network`,
358 type: "FATAL",
359 nodeId: n.id,
360 message: "Network and subdomain must be defined",
361 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
362 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
363 }));
364 const notConnected: Message[] = ing.filter((n) => n.data == null || n.data.https == null || n.data.https.serviceId == null || n.data.https.serviceId == "" || n.data.https.portId == null || n.data.https.portId == "").map((n) => ({
365 id: `${n.id}-not-connected`,
366 type: "FATAL",
367 nodeId: n.id,
368 message: "Connect to a service port",
369 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
370 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
371 }));
372 return noNetwork.concat(notConnected);
373}
374
375function GatewayTCPValidator(nodes: AppNode[]): Message[] {
376 const ing = nodes.filter((n) => n.type === "gateway-tcp");
377 const noNetwork: Message[] = ing.filter((n) => n.data == null || n.data.network == null || n.data.network == "" || n.data.subdomain == null || n.data.subdomain == "").map((n): Message => ({
378 id: `${n.id}-no-network`,
379 type: "FATAL",
380 nodeId: n.id,
381 message: "Network and subdomain must be defined",
382 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
383 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
384 }));
385 const notConnected: Message[] = ing.filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0).map((n) => ({
386 id: `${n.id}-not-connected`,
387 type: "FATAL",
388 nodeId: n.id,
389 message: "Connect to a service port",
390 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
391 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
392 }));
393 return noNetwork.concat(notConnected);
giof8acc612025-04-26 08:20:55 +0400394}