blob: 17369163e0d39eb4b6982720705253232dc16b9b [file] [log] [blame]
import { AppNode, Env, NodeType, ServiceNode } from "config";
import { Message, MessageType } from "./state";
export interface Validator {
(nodes: AppNode[], env: Env): Message[];
}
function CombineValidators(...v: Validator[]): Validator {
return (n, env) => v.flatMap((v) => v(n, env));
}
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 "gateway-tcp":
return 7;
case "network":
return 8;
case undefined: // For NANode
return 100;
}
}
function SortingValidator(v: Validator): Validator {
return (nodes, env) => {
const nt = new Map(nodes.map((n) => [n.id, NodeTypeToNumber(n.type)]));
return v(nodes, env).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,
AgentApiKeyValidator,
),
);
}
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?.repoNodeId === 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.type !== "sketch:latest")
.filter((n) => n.data == null || n.data.repository == null || n.data.repository.repoNodeId === "")
.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
.filter((n) => n.data.type !== "sketch:latest")
.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);
}
function AgentApiKeyValidator(nodes: AppNode[], env: Env): Message[] {
return nodes
.filter((n): n is ServiceNode => n.type === "app" && n.data.type === "sketch:latest")
.flatMap((n) => {
const messages: Message[] = [];
const model = n.data.model;
if (!model || !model.name) {
messages.push({
id: `${n.id}-no-agent-model`,
type: "FATAL",
nodeId: n.id,
message: "Select an AI model for the agent (Gemini or Claude).",
});
return messages;
}
if (model.name === "gemini" && !model.apiKey && !env.integrations.gemini) {
messages.push({
id: `${n.id}-no-gemini-api-key`,
type: "FATAL",
nodeId: n.id,
message: "Configure Gemini API key either on the service or in the project integrations.",
});
}
if (model.name === "claude" && !model.apiKey && !env.integrations.anthropic) {
messages.push({
id: `${n.id}-no-anthropic-api-key`,
type: "FATAL",
nodeId: n.id,
message: "Configure Anthropic API key either on the service or in the project integrations.",
});
}
return messages;
});
}