blob: f8d82ed0d8d4271d6cf14c46e31417aeb18f8888 [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[];
57};
58
59export type Volume = {
60 name: string;
61 accessMode: VolumeType;
62 size: string;
63};
64
65export type PostgreSQL = {
66 name: string;
67 size: string;
68 expose?: Domain[];
69};
70
71export type MongoDB = {
72 name: string;
73 size: string;
74 expose?: Domain[];
75};
76
77export type Config = {
78 service?: Service[];
79 volume?: Volume[];
80 postgresql?: PostgreSQL[];
81 mongodb?: MongoDB[];
82};
83
84export function generateDodoConfig(nodes: AppNode[], env: Env): Config | null {
85 try {
86 const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
87 const ingressNodes = nodes.filter((n) => n.type === "gateway-https").filter((n) => n.data.https !== undefined);
88 const tcpNodes = nodes.filter((n) => n.type === "gateway-tcp").filter((n) => n.data.exposed !== undefined);
89 const findExpose = (n: AppNode): PortDomain[] => {
90 return n.data.ports.map((p) => [n.id, p.id, p.name]).flatMap((sp) => {
91 return tcpNodes.flatMap((i) => (i.data.exposed || []).filter((t) => t.serviceId === sp[0] && t.portId === sp[1]).map(() => ({
92 network: networkMap.get(i.data.network!)!,
93 subdomain: i.data.subdomain!,
94 port: { name: sp[2] },
95 })));
96 });
97 };
98 return {
99 service: nodes.filter((n) => n.type === "app").map((n): Service => {
100 return {
101 type: n.data.type,
102 name: n.data.label,
103 source: {
104 repository: nodes.filter((i) => i.type === "github").find((i) => i.id === n.data.repository.id)!.data.address,
105 branch: n.data.repository.branch,
106 rootDir: n.data.repository.rootDir,
107 },
108 ports: (n.data.ports || []).map((p) => ({
109 name: p.name,
110 value: p.value,
111 protocol: "TCP", // TODO(gio)
112 })),
113 env: (n.data.envVars || []).filter((e) => "name" in e).map((e) => ({
114 name: e.name,
115 alias: "alias" in e ? e.alias : undefined,
116 })),
gio218e8132025-04-22 17:11:58 +0000117 ingress: ingressNodes.filter((i) => i.data.https!.serviceId === n.id).map((i: GatewayHttpsNode): Ingress => ({
118 network: networkMap.get(i.data.network!)!,
119 subdomain: i.data.subdomain!,
120 port: {
121 name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
122 },
123 auth: { enabled: false },
124 })),
gio5f2f1002025-03-20 18:38:48 +0400125 expose: findExpose(n),
126 };
127 }),
128 volume: nodes.filter((n) => n.type === "volume").map((n): Volume => ({
129 name: n.data.label,
130 accessMode: n.data.type,
131 size: n.data.size,
132 })),
133 postgresql: nodes.filter((n) => n.type === "postgresql").map((n): PostgreSQL => ({
134 name: n.data.label,
135 size: "1Gi", // TODO(gio)
136 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
137 })),
138 mongodb: nodes.filter((n) => n.type === "mongodb").map((n): MongoDB => ({
139 name: n.data.label,
140 size: "1Gi", // TODO(gio)
141 expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
142 })),
143 };
144 } catch (e) {
145 console.log(e);
146 return null;
147 }
148}
149
150export interface Validator {
151 (nodes: AppNode[]): Message[];
152}
153
154function CombineValidators(...v: Validator[]): Validator {
155 return (n) => v.flatMap((v) => v(n));
156}
157
158function MessageTypeToNumber(t: MessageType) {
159 switch (t) {
160 case "FATAL": return 0;
161 case "WARNING": return 1;
162 case "INFO": return 2;
163 }
164}
165
166function NodeTypeToNumber(t?: NodeType) {
167 switch (t) {
168 case "github": return 0;
169 case "app": return 1;
170 case "volume": return 2;
171 case "postgresql": return 3;
172 case "mongodb": return 4;
173 case "gateway-https": return 5;
174 case undefined: return 100;
175 }
176}
177
178function SortingValidator(v: Validator): Validator {
179 return (n) => {
180 const nt = new Map(n.map((n) => [n.id, NodeTypeToNumber(n.type)]))
181 return v(n).sort((a, b) => {
182 const at = MessageTypeToNumber(a.type);
183 const bt = MessageTypeToNumber(b.type);
184 if (a.nodeId === undefined && b.nodeId === undefined) {
185 if (at !== bt) {
186 return at - bt;
187 }
188 return a.id.localeCompare(b.id);
189 }
190 if (a.nodeId === undefined) {
191 return -1;
192 }
193 if (b.nodeId === undefined) {
194 return 1;
195 }
196 if (a.nodeId === b.nodeId) {
197 if (at !== bt) {
198 return at - bt;
199 }
200 return a.id.localeCompare(b.id);
201 }
202 const ant = nt.get(a.id)!;
203 const bnt = nt.get(b.id)!;
204 if (ant !== bnt) {
205 return ant - bnt;
206 }
207 return a.id.localeCompare(b.id);
208 });
209 };
210}
211
212export function CreateValidators(): Validator {
213 return SortingValidator(
214 CombineValidators(
215 EmptyValidator,
216 GitRepositoryValidator,
217 ServiceValidator,
218 GatewayHTTPSValidator,
219 GatewayTCPValidator,
220 )
221 );
222}
223
224function EmptyValidator(nodes: AppNode[]): Message[] {
giof8acc612025-04-26 08:20:55 +0400225 nodes = nodes.filter((n) => n.type !== "network");
gio5f2f1002025-03-20 18:38:48 +0400226 if (nodes.length > 0) {
227 return [];
228 }
229 return [{
230 id: "no-nodes",
231 type: "FATAL",
232 message: "Start by importing application source code",
233 onHighlight: (store) => store.setHighlightCategory("repository", true),
234 onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
235 }];
236}
237
238function GitRepositoryValidator(nodes: AppNode[]): Message[] {
239 const git = nodes.filter((n) => n.type === "github");
240 const noAddress: Message[] = git.filter((n) => n.data == null || n.data.address == null || n.data.address === "").map((n) => ({
241 id: `${n.id}-no-address`,
242 type: "FATAL",
243 nodeId: n.id,
244 message: "Configure repository address",
245 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
246 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
247 } satisfies Message));
248 const noApp = git.filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.id === n.id)).map((n) => ({
249 id: `${n.id}-no-app`,
250 type: "WARNING",
251 nodeId: n.id,
252 message: "Connect to service",
253 onHighlight: (store) => store.setHighlightCategory("Services", true),
254 onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
255} satisfies Message));
256 return noAddress.concat(noApp);
257}
258
259function ServiceValidator(nodes: AppNode[]): Message[] {
260 const apps = nodes.filter((n) => n.type === "app");
261 const noName = apps.filter((n) => n.data == null || n.data.label == null || n.data.label === "").map((n): Message => ({
262 id: `${n.id}-no-name`,
263 type: "FATAL",
264 nodeId: n.id,
265 message: "Name the service",
266 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
267 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
268 onClick: (store) => {
269 store.updateNode(n.id, { selected: true });
270 store.updateNodeData<"app">(n.id, {
271 activeField: "name" ,
272 });
273 },
274 }));
275 const noSource = apps.filter((n) => n.data == null || n.data.repository == null || n.data.repository.id === "").map((n): Message => ({
276 id: `${n.id}-no-repo`,
277 type: "FATAL",
278 nodeId: n.id,
279 message: "Connect to source repository",
280 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
281 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
282 }));
283 const noRuntime = apps.filter((n) => n.data == null || n.data.type == null).map((n): Message => ({
284 id: `${n.id}-no-runtime`,
285 type: "FATAL",
286 nodeId: n.id,
287 message: "Choose runtime",
288 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
289 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
290 onClick: (store) => {
291 store.updateNode(n.id, { selected: true });
292 store.updateNodeData<"app">(n.id, {
293 activeField: "type" ,
294 });
295 },
296 }));
297 const noPorts = apps.filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0).map((n): Message => ({
298 id: `${n.id}-no-ports`,
299 type: "INFO",
300 nodeId: n.id,
301 message: "Expose ports",
302 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
303 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
304 }));
305 const noIngress = apps.flatMap((n): Message[] => {
306 if (n.data == null) {
307 return [];
308 }
309 return (n.data.ports || []).filter((p) => !nodes.filter((i) => i.type === "gateway-https").some((i) => {
310 if (i.data && i.data.https && i.data.https.serviceId === n.id && i.data.https.portId === p.id) {
311 return true;
312 }
313 return false;
314 })).map((p): Message => ({
315 id: `${n.id}-${p.id}-no-ingress`,
316 type: "WARNING",
317 nodeId: n.id,
318 message: `Connect to ingress: ${p.name} - ${p.value}`,
319 onHighlight: (store) => {
320 store.updateNode(n.id, { selected: true });
321 store.setHighlightCategory("gateways", true);
322 },
323 onLooseHighlight: (store) => {
324 store.updateNode(n.id, { selected: false });
325 store.setHighlightCategory("gateways", false);
326 },
327 }));
328 });
329 const multipleIngress = apps.filter((n) => n.data != null && n.data.ports != null).flatMap((n) => n.data.ports.map((p): Message | undefined => {
330 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);
331 if (ing.length < 2) {
332 return undefined;
333 }
334 return {
335 id: `${n.id}-${p.id}-multiple-ingress`,
336 type: "FATAL",
337 nodeId: n.id,
338 message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
339 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
340 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
341 };
342 })).filter((m) => m !== undefined);
343 return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
344}
345
346function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
347 const ing = nodes.filter((n) => n.type === "gateway-https");
348 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 => ({
349 id: `${n.id}-no-network`,
350 type: "FATAL",
351 nodeId: n.id,
352 message: "Network and subdomain must be defined",
353 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
354 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
355 }));
356 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) => ({
357 id: `${n.id}-not-connected`,
358 type: "FATAL",
359 nodeId: n.id,
360 message: "Connect to a service port",
361 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
362 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
363 }));
364 return noNetwork.concat(notConnected);
365}
366
367function GatewayTCPValidator(nodes: AppNode[]): Message[] {
368 const ing = nodes.filter((n) => n.type === "gateway-tcp");
369 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 => ({
370 id: `${n.id}-no-network`,
371 type: "FATAL",
372 nodeId: n.id,
373 message: "Network and subdomain must be defined",
374 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
375 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
376 }));
377 const notConnected: Message[] = ing.filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0).map((n) => ({
378 id: `${n.id}-not-connected`,
379 type: "FATAL",
380 nodeId: n.id,
381 message: "Connect to a service port",
382 onHighlight: (store) => store.updateNode(n.id, { selected: true }),
383 onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
384 }));
385 return noNetwork.concat(notConnected);
giof8acc612025-04-26 08:20:55 +0400386}