blob: 513f1f03c8595be68dd1f04ce5da96ee1f506006 [file] [log] [blame]
import { AppNode, Env, Message, MessageType, NodeType, ServiceType, VolumeType } from "./state";
export type AuthDisabled = {
enabled: false;
};
export type AuthEnabled = {
enabled: true;
groups: string[];
noAuthPathPatterns: string[];
};
export type Auth = AuthDisabled | AuthEnabled;
export type Ingress = {
network: string;
subdomain: string;
port: { name: string } | { value: string };
auth: Auth;
};
export type Domain = {
network: string;
subdomain: string;
};
export type PortValue =
| {
name: string;
}
| {
value: number;
};
export type PortDomain = Domain & {
port: PortValue;
};
export type Service = {
type: ServiceType;
name: string;
source: {
repository: string;
branch: string;
rootDir: string;
};
ports?: {
name: string;
value: number;
protocol: "TCP" | "UDP";
}[];
env?: {
name: string;
alias?: string;
}[];
ingress?: Ingress[];
expose?: PortDomain[];
volume?: string[];
preBuildCommands?: { bin: string }[];
dev?: {
enabled: boolean;
username?: string;
ssh?: Domain;
codeServer?: Domain;
};
};
export type Volume = {
name: string;
accessMode: VolumeType;
size: string;
};
export type PostgreSQL = {
name: string;
size: string;
expose?: Domain[];
};
export type MongoDB = {
name: string;
size: string;
expose?: Domain[];
};
export type Config = {
input: {
appId: string;
managerAddr: string;
};
service?: Service[];
volume?: Volume[];
postgresql?: PostgreSQL[];
mongodb?: MongoDB[];
};
export function generateDodoConfig(appId: string | undefined, nodes: AppNode[], env: Env): Config | null {
try {
if (appId == null || env.managerAddr == null) {
return null;
}
const networkMap = new Map(env.networks.map((n) => [n.domain, n.name]));
const ingressNodes = nodes
.filter((n) => n.type === "gateway-https")
.filter((n) => n.data.https !== undefined && !n.data.readonly);
const tcpNodes = nodes
.filter((n) => n.type === "gateway-tcp")
.filter((n) => n.data.exposed !== undefined && !n.data.readonly);
const findExpose = (n: AppNode): PortDomain[] => {
return n.data.ports
.map((p) => [n.id, p.id, p.name])
.flatMap((sp) => {
return tcpNodes.flatMap((i) =>
(i.data.exposed || [])
.filter((t) => t.serviceId === sp[0] && t.portId === sp[1])
.map(() => ({
network: networkMap.get(i.data.network!)!,
subdomain: i.data.subdomain!,
port: { name: sp[2] },
})),
);
});
};
return {
input: {
appId: appId,
managerAddr: env.managerAddr,
},
service: nodes
.filter((n) => n.type === "app")
.map((n): Service => {
return {
type: n.data.type,
name: n.data.label,
source: {
repository: nodes
.filter((i) => i.type === "github")
.find((i) => i.id === n.data.repository.id)!.data.repository!.sshURL,
branch: n.data.repository.branch,
rootDir: n.data.repository.rootDir,
},
ports: (n.data.ports || [])
.filter((p) => !n.data.dev?.enabled || (p.value != 22 && p.value != 9090))
.map((p) => ({
name: p.name,
value: p.value,
protocol: "TCP", // TODO(gio)
})),
env: (n.data.envVars || [])
.filter((e) => "name" in e)
.map((e) => ({
name: e.name,
alias: "alias" in e ? e.alias : undefined,
})),
ingress: ingressNodes
.filter((i) => i.data.https!.serviceId === n.id)
.map(
(i): Ingress => ({
network: networkMap.get(i.data.network!)!,
subdomain: i.data.subdomain!,
port: {
name: n.data.ports.find((p) => p.id === i.data.https!.portId)!.name,
},
auth:
i.data.auth?.enabled || false
? {
enabled: true,
groups: i.data.auth!.groups,
noAuthPathPatterns: i.data.auth!.noAuthPathPatterns,
}
: {
enabled: false,
},
}),
),
expose: findExpose(n),
preBuildCommands: n.data.preBuildCommands
? n.data.preBuildCommands.split("\n").map((cmd) => ({ bin: cmd }))
: [],
dev: {
enabled: n.data.dev ? n.data.dev.enabled : false,
username: n.data.dev && n.data.dev.enabled ? env.user.username : undefined,
codeServer:
n.data.dev?.enabled && n.data.dev.expose != null
? {
network: networkMap.get(n.data.dev.expose.network)!,
subdomain: n.data.dev.expose.subdomain,
}
: undefined,
ssh:
n.data.dev?.enabled && n.data.dev.expose != null
? {
network: networkMap.get(n.data.dev.expose.network)!,
subdomain: n.data.dev.expose.subdomain,
}
: undefined,
},
};
}),
volume: nodes
.filter((n) => n.type === "volume")
.map(
(n): Volume => ({
name: n.data.label,
accessMode: n.data.type,
size: n.data.size,
}),
),
postgresql: nodes
.filter((n) => n.type === "postgresql")
.map(
(n): PostgreSQL => ({
name: n.data.label,
size: "1Gi", // TODO(gio)
expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
}),
),
mongodb: nodes
.filter((n) => n.type === "mongodb")
.map(
(n): MongoDB => ({
name: n.data.label,
size: "1Gi", // TODO(gio)
expose: findExpose(n).map((e) => ({ network: e.network, subdomain: e.subdomain })),
}),
),
};
} catch (e) {
console.log(e);
return null;
}
}
export interface Validator {
(nodes: AppNode[]): Message[];
}
function CombineValidators(...v: Validator[]): Validator {
return (n) => v.flatMap((v) => v(n));
}
function MessageTypeToNumber(t: MessageType) {
switch (t) {
case "FATAL":
return 0;
case "WARNING":
return 1;
case "INFO":
return 2;
}
}
function NodeTypeToNumber(t?: NodeType) {
switch (t) {
case "github":
return 0;
case "app":
return 1;
case "volume":
return 2;
case "postgresql":
return 3;
case "mongodb":
return 4;
case "gateway-https":
return 5;
case undefined:
return 100;
}
}
function SortingValidator(v: Validator): Validator {
return (n) => {
const nt = new Map(n.map((n) => [n.id, NodeTypeToNumber(n.type)]));
return v(n).sort((a, b) => {
const at = MessageTypeToNumber(a.type);
const bt = MessageTypeToNumber(b.type);
if (a.nodeId === undefined && b.nodeId === undefined) {
if (at !== bt) {
return at - bt;
}
return a.id.localeCompare(b.id);
}
if (a.nodeId === undefined) {
return -1;
}
if (b.nodeId === undefined) {
return 1;
}
if (a.nodeId === b.nodeId) {
if (at !== bt) {
return at - bt;
}
return a.id.localeCompare(b.id);
}
const ant = nt.get(a.id)!;
const bnt = nt.get(b.id)!;
if (ant !== bnt) {
return ant - bnt;
}
return a.id.localeCompare(b.id);
});
};
}
export function CreateValidators(): Validator {
return SortingValidator(
CombineValidators(
EmptyValidator,
GitRepositoryValidator,
ServiceValidator,
ServiceAnalyzisValidator,
GatewayHTTPSValidator,
GatewayTCPValidator,
),
);
}
function EmptyValidator(nodes: AppNode[]): Message[] {
if (nodes.some((n) => n.type !== "network")) {
return [];
}
return [
{
id: "no-nodes",
type: "FATAL",
message: "Start by importing application source code",
onHighlight: (store) => store.setHighlightCategory("repository", true),
onLooseHighlight: (store) => store.setHighlightCategory("repository", false),
},
];
}
function GitRepositoryValidator(nodes: AppNode[]): Message[] {
const git = nodes.filter((n) => n.type === "github");
const noAddress: Message[] = git
.filter((n) => n.data == null || n.data.repository == null)
.map(
(n) =>
({
id: `${n.id}-no-address`,
type: "FATAL",
nodeId: n.id,
message: "Configure repository address",
onHighlight: (store) => store.updateNode(n.id, { selected: true }),
onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
}) satisfies Message,
);
const noApp = git
.filter((n) => !nodes.some((i) => i.type === "app" && i.data?.repository?.id === n.id))
.map(
(n) =>
({
id: `${n.id}-no-app`,
type: "WARNING",
nodeId: n.id,
message: "Connect to service",
onHighlight: (store) => store.setHighlightCategory("Services", true),
onLooseHighlight: (store) => store.setHighlightCategory("Services", false),
}) satisfies Message,
);
return noAddress.concat(noApp);
}
function ServiceValidator(nodes: AppNode[]): Message[] {
const apps = nodes.filter((n) => n.type === "app");
const noName = apps
.filter((n) => n.data == null || n.data.label == null || n.data.label === "")
.map(
(n): Message => ({
id: `${n.id}-no-name`,
type: "FATAL",
nodeId: n.id,
message: "Name the service",
onHighlight: (store) => store.updateNode(n.id, { selected: true }),
onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
onClick: (store) => {
store.updateNode(n.id, { selected: true });
store.updateNodeData<"app">(n.id, {
activeField: "name",
});
},
}),
);
const noSource = apps
.filter((n) => n.data == null || n.data.repository == null || n.data.repository.id === "")
.map(
(n): Message => ({
id: `${n.id}-no-repo`,
type: "FATAL",
nodeId: n.id,
message: "Connect to source repository",
onHighlight: (store) => store.updateNode(n.id, { selected: true }),
onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
}),
);
const noRuntime = apps
.filter((n) => n.data == null || n.data.type == null)
.map(
(n): Message => ({
id: `${n.id}-no-runtime`,
type: "FATAL",
nodeId: n.id,
message: "Choose runtime",
onHighlight: (store) => store.updateNode(n.id, { selected: true }),
onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
onClick: (store) => {
store.updateNode(n.id, { selected: true });
store.updateNodeData<"app">(n.id, {
activeField: "type",
});
},
}),
);
const noPorts = apps
.filter((n) => n.data == null || n.data.ports == null || n.data.ports.length === 0)
.map(
(n): Message => ({
id: `${n.id}-no-ports`,
type: "INFO",
nodeId: n.id,
message: "Expose ports",
onHighlight: (store) => store.updateNode(n.id, { selected: true }),
onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
}),
);
const noIngress = apps.flatMap((n): Message[] => {
if (n.data == null) {
return [];
}
return (n.data.ports || [])
.filter(
(p) =>
!nodes
.filter((i) => i.type === "gateway-https")
.some((i) => {
if (
i.data &&
i.data.https &&
i.data.https.serviceId === n.id &&
i.data.https.portId === p.id
) {
return true;
}
return false;
}),
)
.map(
(p): Message => ({
id: `${n.id}-${p.id}-no-ingress`,
type: "WARNING",
nodeId: n.id,
message: `Connect to gateway: ${p.name} - ${p.value}`,
onHighlight: (store) => {
store.updateNode(n.id, { selected: true });
store.setHighlightCategory("gateways", true);
},
onLooseHighlight: (store) => {
store.updateNode(n.id, { selected: false });
store.setHighlightCategory("gateways", false);
},
}),
);
});
const multipleIngress = apps
.filter((n) => n.data != null && n.data.ports != null)
.flatMap((n) =>
n.data.ports.map((p): Message | undefined => {
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,
);
if (ing.length < 2) {
return undefined;
}
return {
id: `${n.id}-${p.id}-multiple-ingress`,
type: "FATAL",
nodeId: n.id,
message: `Can not expose same port using multiple ingresses: ${p.name} - ${p.value}`,
onHighlight: (store) => store.updateNode(n.id, { selected: true }),
onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
};
}),
)
.filter((m) => m !== undefined);
return noName.concat(noSource).concat(noRuntime).concat(noPorts).concat(noIngress).concat(multipleIngress);
}
function ServiceAnalyzisValidator(nodes: AppNode[]): Message[] {
const apps = nodes.filter((n) => n.type === "app");
return apps
.filter((n) => n.data.info != null)
.flatMap((n) => {
return n.data
.info!.configVars.map((cv): Message | undefined => {
if (cv.semanticType === "PORT") {
if (
!(n.data.envVars || []).some(
(p) => ("name" in p && p.name === cv.name) || ("alias" in p && p.alias === cv.name),
)
) {
return {
id: `${n.id}-missing-port-${cv.name}`,
type: "WARNING",
nodeId: n.id,
message: `Service requires port env variable ${cv.name}`,
};
}
}
if (cv.category === "EnvironmentVariable") {
if (
!(n.data.envVars || []).some(
(p) => ("name" in p && p.name === cv.name) || ("alias" in p && p.alias === cv.name),
)
) {
if (cv.semanticType === "FILESYSTEM_PATH") {
return {
id: `${n.id}-missing-env-${cv.name}`,
type: "FATAL",
nodeId: n.id,
message: `Service requires env variable ${cv.name}, representing filesystem path`,
};
} else if (cv.semanticType === "POSTGRES_URL") {
return {
id: `${n.id}-missing-env-${cv.name}`,
type: "FATAL",
nodeId: n.id,
message: `Service requires env variable ${cv.name}, representing postgres connection URL`,
};
}
}
}
return undefined;
})
.filter((m) => m !== undefined);
});
}
function GatewayHTTPSValidator(nodes: AppNode[]): Message[] {
const ing = nodes.filter((n) => n.type === "gateway-https");
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 => ({
id: `${n.id}-no-network`,
type: "FATAL",
nodeId: n.id,
message: "Network and subdomain must be defined",
onHighlight: (store) => store.updateNode(n.id, { selected: true }),
onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
}),
);
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) => ({
id: `${n.id}-not-connected`,
type: "FATAL",
nodeId: n.id,
message: "Connect to a service port",
onHighlight: (store) => store.updateNode(n.id, { selected: true }),
onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
}));
return noNetwork.concat(notConnected);
}
function GatewayTCPValidator(nodes: AppNode[]): Message[] {
const ing = nodes.filter((n) => n.type === "gateway-tcp");
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 => ({
id: `${n.id}-no-network`,
type: "FATAL",
nodeId: n.id,
message: "Network and subdomain must be defined",
onHighlight: (store) => store.updateNode(n.id, { selected: true }),
onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
}),
);
const notConnected: Message[] = ing
.filter((n) => n.data == null || n.data.exposed == null || n.data.exposed.length === 0)
.map((n) => ({
id: `${n.id}-not-connected`,
type: "FATAL",
nodeId: n.id,
message: "Connect to a service port",
onHighlight: (store) => store.updateNode(n.id, { selected: true }),
onLooseHighlight: (store) => store.updateNode(n.id, { selected: false }),
}));
return noNetwork.concat(notConnected);
}