blob: 3eed93efd35452b2b820bc4dbc2c9b0c8db1f54e [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[];
gio91165612025-05-03 17:07:38 +000057 preBuildCommands?: 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: {
105 repository: nodes.filter((i) => i.type === "github").find((i) => i.id === n.data.repository.id)!.data.address,
106 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 },
124 auth: { enabled: false },
125 })),
gio5f2f1002025-03-20 18:38:48 +0400126 expose: findExpose(n),
gio91165612025-05-03 17:07:38 +0000127 preBuildCommands: [n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))],
gio5f2f1002025-03-20 18:38:48 +0400128 };
129 }),
130 volume: nodes.filter((n) => n.type === "volume").map((n): Volume => ({
131 name: n.data.label,
132 accessMode: n.data.type,
133 size: n.data.size,
134 })),
135 postgresql: nodes.filter((n) => n.type === "postgresql").map((n): PostgreSQL => ({
136 name: n.data.label,
137 size: "1Gi", // TODO(gio)
138 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
139 })),
140 mongodb: nodes.filter((n) => n.type === "mongodb").map((n): MongoDB => ({
141 name: n.data.label,
142 size: "1Gi", // TODO(gio)
143 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
144 })),
145 };
146 } catch (e) {
147 console.log(e);
148 return null;
149 }
150}
151
152export interface Validator {
153 (nodes: AppNode[]): Message[];
154}
155
156function CombineValidators(...v: Validator[]): Validator {
157 return (n) => v.flatMap((v) => v(n));
158}
159
160function MessageTypeToNumber(t: MessageType) {
161 switch (t) {
162 case "FATAL": return 0;
163 case "WARNING": return 1;
164 case "INFO": return 2;
165 }
166}
167
168function NodeTypeToNumber(t?: NodeType) {
169 switch (t) {
170 case "github": return 0;
171 case "app": return 1;
172 case "volume": return 2;
173 case "postgresql": return 3;
174 case "mongodb": return 4;
175 case "gateway-https": return 5;
176 case undefined: return 100;
177 }
178}
179
180function SortingValidator(v: Validator): Validator {
181 return (n) => {
182 const nt = new Map(n.map((n) => [n.id, NodeTypeToNumber(n.type)]))
183 return v(n).sort((a, b) => {
184 const at = MessageTypeToNumber(a.type);
185 const bt = MessageTypeToNumber(b.type);
186 if (a.nodeId === undefined && b.nodeId === undefined) {
187 if (at !== bt) {
188 return at - bt;
189 }
190 return a.id.localeCompare(b.id);
191 }
192 if (a.nodeId === undefined) {
193 return -1;
194 }
195 if (b.nodeId === undefined) {
196 return 1;
197 }
198 if (a.nodeId === b.nodeId) {
199 if (at !== bt) {
200 return at - bt;
201 }
202 return a.id.localeCompare(b.id);
203 }
204 const ant = nt.get(a.id)!;
205 const bnt = nt.get(b.id)!;
206 if (ant !== bnt) {
207 return ant - bnt;
208 }
209 return a.id.localeCompare(b.id);
210 });
211 };
212}
213
214export function CreateValidators(): Validator {
215 return SortingValidator(
216 CombineValidators(
217 EmptyValidator,
218 GitRepositoryValidator,
219 ServiceValidator,
220 GatewayHTTPSValidator,
221 GatewayTCPValidator,
222 )
223 );
224}
225
226function EmptyValidator(nodes: AppNode[]): Message[] {
giof8acc612025-04-26 08:20:55 +0400227 nodes = nodes.filter((n) => n.type !== "network");
gio5f2f1002025-03-20 18:38:48 +0400228 if (nodes.length > 0) {
229 return [];
230 }
231 return [{
232 id: "no-nodes",
233 type: "FATAL",
234 message: "Start by importing application source code",
235 onHighlight: (store) => store.setHighlightCategory("repository", true),
236 onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
237 }];
238}
239
240function GitRepositoryValidator(nodes: AppNode[]): Message[] {
241 const git = nodes.filter((n) => n.type === "github");
242 const noAddress: Message[] = git.filter((n) => n.data == null || n.data.address == null || n.data.address === "").map((n) => ({
243 id: `${n.id}-no-address`,
244 type: "FATAL",
245 nodeId: n.id,
246 message: "Configure repository address",
247 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
248 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
249 } satisfies Message));
250 const noApp = git.filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.id === n.id)).map((n) => ({
251 id: `${n.id}-no-app`,
252 type: "WARNING",
253 nodeId: n.id,
254 message: "Connect to service",
255 onHighlight: (store) => store.setHighlightCategory("Services", true),
256 onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
257} satisfies Message));
258 return noAddress.concat(noApp);
259}
260
261function ServiceValidator(nodes: AppNode[]): Message[] {
262 const apps = nodes.filter((n) => n.type === "app");
263 const noName = apps.filter((n) => n.data == null || n.data.label == null || n.data.label === "").map((n): Message => ({
264 id: `${n.id}-no-name`,
265 type: "FATAL",
266 nodeId: n.id,
267 message: "Name the service",
268 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
269 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
270 onClick: (store) => {
271 store.updateNode(n.id, { selected: true });
272 store.updateNodeData<"app">(n.id, {
273 activeField: "name" ,
274 });
275 },
276 }));
277 const noSource = apps.filter((n) => n.data == null || n.data.repository == null || n.data.repository.id === "").map((n): Message => ({
278 id: `${n.id}-no-repo`,
279 type: "FATAL",
280 nodeId: n.id,
281 message: "Connect to source repository",
282 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
283 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
284 }));
285 const noRuntime = apps.filter((n) => n.data == null || n.data.type == null).map((n): Message => ({
286 id: `${n.id}-no-runtime`,
287 type: "FATAL",
288 nodeId: n.id,
289 message: "Choose runtime",
290 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
291 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
292 onClick: (store) => {
293 store.updateNode(n.id, { selected: true });
294 store.updateNodeData<"app">(n.id, {
295 activeField: "type" ,
296 });
297 },
298 }));
299 const noPorts = apps.filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0).map((n): Message => ({
300 id: `${n.id}-no-ports`,
301 type: "INFO",
302 nodeId: n.id,
303 message: "Expose ports",
304 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
305 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
306 }));
307 const noIngress = apps.flatMap((n): Message[] => {
308 if (n.data == null) {
309 return [];
310 }
311 return (n.data.ports || []).filter((p) => !nodes.filter((i) => i.type === "gateway-https").some((i) => {
312 if (i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id) {
313 return true;
314 }
315 return false;
316 })).map((p): Message => ({
317 id: `${n.id}-${p.id}-no-ingress`,
318 type: "WARNING",
319 nodeId: n.id,
320 message: `Connect to ingress: ${p.name} - ${p.value}`,
321 onHighlight: (store) => {
322 store.updateNode(n.id, { selected: true });
323 store.setHighlightCategory("gateways", true);
324 },
325 onLooseHighlight: (store) => {
326 store.updateNode(n.id, { selected: false });
327 store.setHighlightCategory("gateways", false);
328 },
329 }));
330 });
331 const multipleIngress = apps.filter((n) => n.data != null && n.data.ports != null).flatMap((n) => n.data.ports.map((p): Message | undefined => {
332 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);
333 if (ing.length < 2) {
334 return undefined;
335 }
336 return {
337 id: `${n.id}-${p.id}-multiple-ingress`,
338 type: "FATAL",
339 nodeId: n.id,
340 message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
341 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
342 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
343 };
344 })).filter((m) => m !== undefined);
345 return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
346}
347
348function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
349 const ing = nodes.filter((n) => n.type === "gateway-https");
350 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 => ({
351 id: `${n.id}-no-network`,
352 type: "FATAL",
353 nodeId: n.id,
354 message: "Network and subdomain must be defined",
355 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
356 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
357 }));
358 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) => ({
359 id: `${n.id}-not-connected`,
360 type: "FATAL",
361 nodeId: n.id,
362 message: "Connect to a service port",
363 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
364 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
365 }));
366 return noNetwork.concat(notConnected);
367}
368
369function GatewayTCPValidator(nodes: AppNode[]): Message[] {
370 const ing = nodes.filter((n) => n.type === "gateway-tcp");
371 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 => ({
372 id: `${n.id}-no-network`,
373 type: "FATAL",
374 nodeId: n.id,
375 message: "Network and subdomain must be defined",
376 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
377 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
378 }));
379 const notConnected: Message[] = ing.filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0).map((n) => ({
380 id: `${n.id}-not-connected`,
381 type: "FATAL",
382 nodeId: n.id,
383 message: "Connect to a service port",
384 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
385 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
386 }));
387 return noNetwork.concat(notConnected);
giof8acc612025-04-26 08:20:55 +0400388}