diff --git a/additional-containers/mariadb-backup/backup.sh b/additional-containers/mariadb-backup/backup.sh index 3cb8d9e..39430bc 100644 --- a/additional-containers/mariadb-backup/backup.sh +++ b/additional-containers/mariadb-backup/backup.sh @@ -21,6 +21,10 @@ if [ -z "$S3_BUCKET_NAME" ]; then echo "Error: S3_BUCKET_NAME is not set"; exit if [ -z "$S3_KEY" ]; then echo "Error: S3_KEY is not set"; exit 1; fi if [ -z "$S3_REGION" ]; then echo "Error: S3_REGION is not set"; exit 1; fi +# Insert a sleep timeout so that the network policy is fully applied before attempting to connect to the database +echo "Waiting for network policies to take effect..." +sleep 4 + echo "Starting backup process..." # Create a temporary directory for the dump diff --git a/additional-containers/mongodb-backup/backup.sh b/additional-containers/mongodb-backup/backup.sh index e4d406d..510e5d9 100644 --- a/additional-containers/mongodb-backup/backup.sh +++ b/additional-containers/mongodb-backup/backup.sh @@ -17,6 +17,10 @@ if [ -z "$S3_BUCKET_NAME" ]; then echo "Error: S3_BUCKET_NAME is not set"; exit if [ -z "$S3_KEY" ]; then echo "Error: S3_KEY is not set"; exit 1; fi if [ -z "$S3_REGION" ]; then echo "Error: S3_REGION is not set"; exit 1; fi +# Insert a sleep timeout so that the network policy is fully applied before attempting to connect to the database +echo "Waiting for network policies to take effect..." +sleep 4 + echo "Starting backup process..." # Create a temporary directory for the dump diff --git a/additional-containers/postgres-backup/backup.sh b/additional-containers/postgres-backup/backup.sh index 480ff0d..f439a40 100644 --- a/additional-containers/postgres-backup/backup.sh +++ b/additional-containers/postgres-backup/backup.sh @@ -21,6 +21,10 @@ if [ -z "$S3_BUCKET_NAME" ]; then echo "Error: S3_BUCKET_NAME is not set"; exit if [ -z "$S3_KEY" ]; then echo "Error: S3_KEY is not set"; exit 1; fi if [ -z "$S3_REGION" ]; then echo "Error: S3_REGION is not set"; exit 1; fi +# Insert a sleep timeout so that the network policy is fully applied before attempting to connect to the database +echo "Waiting for network policies to take effect..." +sleep 4 + echo "Starting backup process..." # Create a temporary directory for the dump diff --git a/package.json b/package.json index f860d18..64273ab 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@radix-ui/react-tooltip": "^1.1.4", "@tanstack/react-table": "^8.20.5", "@xterm/xterm": "^5.5.0", + "@xyflow/react": "^12.10.0", "bcrypt": "^5.1.1", "bufferutil": "^4.0.9", "class-variance-authority": "^0.7.1", diff --git a/prisma/migrations/20251216102326_migration/migration.sql b/prisma/migrations/20251216102326_migration/migration.sql new file mode 100644 index 0000000..b3c5ba9 --- /dev/null +++ b/prisma/migrations/20251216102326_migration/migration.sql @@ -0,0 +1,35 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_App" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "appType" TEXT NOT NULL DEFAULT 'APP', + "projectId" TEXT NOT NULL, + "sourceType" TEXT NOT NULL DEFAULT 'GIT', + "containerImageSource" TEXT, + "containerRegistryUsername" TEXT, + "containerRegistryPassword" TEXT, + "gitUrl" TEXT, + "gitBranch" TEXT, + "gitUsername" TEXT, + "gitToken" TEXT, + "dockerfilePath" TEXT NOT NULL DEFAULT './Dockerfile', + "replicas" INTEGER NOT NULL DEFAULT 1, + "envVars" TEXT NOT NULL DEFAULT '', + "memoryReservation" INTEGER, + "memoryLimit" INTEGER, + "cpuReservation" INTEGER, + "cpuLimit" INTEGER, + "webhookId" TEXT, + "ingressNetworkPolicy" TEXT NOT NULL DEFAULT 'ALLOW_ALL', + "egressNetworkPolicy" TEXT NOT NULL DEFAULT 'ALLOW_ALL', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "App_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_App" ("appType", "containerImageSource", "containerRegistryPassword", "containerRegistryUsername", "cpuLimit", "cpuReservation", "createdAt", "dockerfilePath", "envVars", "gitBranch", "gitToken", "gitUrl", "gitUsername", "id", "memoryLimit", "memoryReservation", "name", "projectId", "replicas", "sourceType", "updatedAt", "webhookId") SELECT "appType", "containerImageSource", "containerRegistryPassword", "containerRegistryUsername", "cpuLimit", "cpuReservation", "createdAt", "dockerfilePath", "envVars", "gitBranch", "gitToken", "gitUrl", "gitUsername", "id", "memoryLimit", "memoryReservation", "name", "projectId", "replicas", "sourceType", "updatedAt", "webhookId" FROM "App"; +DROP TABLE "App"; +ALTER TABLE "new_App" RENAME TO "App"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20251222131551_migration/migration.sql b/prisma/migrations/20251222131551_migration/migration.sql new file mode 100644 index 0000000..d421176 --- /dev/null +++ b/prisma/migrations/20251222131551_migration/migration.sql @@ -0,0 +1,36 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_App" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "appType" TEXT NOT NULL DEFAULT 'APP', + "projectId" TEXT NOT NULL, + "sourceType" TEXT NOT NULL DEFAULT 'GIT', + "containerImageSource" TEXT, + "containerRegistryUsername" TEXT, + "containerRegistryPassword" TEXT, + "gitUrl" TEXT, + "gitBranch" TEXT, + "gitUsername" TEXT, + "gitToken" TEXT, + "dockerfilePath" TEXT NOT NULL DEFAULT './Dockerfile', + "replicas" INTEGER NOT NULL DEFAULT 1, + "envVars" TEXT NOT NULL DEFAULT '', + "memoryReservation" INTEGER, + "memoryLimit" INTEGER, + "cpuReservation" INTEGER, + "cpuLimit" INTEGER, + "webhookId" TEXT, + "ingressNetworkPolicy" TEXT NOT NULL DEFAULT 'ALLOW_ALL', + "egressNetworkPolicy" TEXT NOT NULL DEFAULT 'ALLOW_ALL', + "useNetworkPolicy" BOOLEAN NOT NULL DEFAULT true, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "App_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_App" ("appType", "containerImageSource", "containerRegistryPassword", "containerRegistryUsername", "cpuLimit", "cpuReservation", "createdAt", "dockerfilePath", "egressNetworkPolicy", "envVars", "gitBranch", "gitToken", "gitUrl", "gitUsername", "id", "ingressNetworkPolicy", "memoryLimit", "memoryReservation", "name", "projectId", "replicas", "sourceType", "updatedAt", "webhookId") SELECT "appType", "containerImageSource", "containerRegistryPassword", "containerRegistryUsername", "cpuLimit", "cpuReservation", "createdAt", "dockerfilePath", "egressNetworkPolicy", "envVars", "gitBranch", "gitToken", "gitUrl", "gitUsername", "id", "ingressNetworkPolicy", "memoryLimit", "memoryReservation", "name", "projectId", "replicas", "sourceType", "updatedAt", "webhookId" FROM "App"; +DROP TABLE "App"; +ALTER TABLE "new_App" RENAME TO "App"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml index e5e5c47..2a5a444 100644 --- a/prisma/migrations/migration_lock.toml +++ b/prisma/migrations/migration_lock.toml @@ -1,3 +1,3 @@ # Please do not edit this file manually -# It should be added in your version-control system (i.e. Git) -provider = "sqlite" \ No newline at end of file +# It should be added in your version-control system (e.g., Git) +provider = "sqlite" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ae17663..f440dea 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -200,6 +200,10 @@ model App { webhookId String? + ingressNetworkPolicy String @default("ALLOW_ALL") // ALLOW_ALL, NAMESPACE_ONLY, DENY_ALL, INTERNET_ONLY + egressNetworkPolicy String @default("ALLOW_ALL") // ALLOW_ALL, NAMESPACE_ONLY, DENY_ALL, INTERNET_ONLY + useNetworkPolicy Boolean @default(true) + appDomains AppDomain[] appPorts AppPort[] appVolumes AppVolume[] diff --git a/src/app/project/[projectId]/page.tsx b/src/app/project/[projectId]/page.tsx index c083c9b..2851d3a 100644 --- a/src/app/project/[projectId]/page.tsx +++ b/src/app/project/[projectId]/page.tsx @@ -3,7 +3,7 @@ import { getAuthUserSession } from "@/server/utils/action-wrapper.utils"; import projectService from "@/server/services/project.service"; -import AppTable from "./apps-table"; +import ProjectOverview from "./project-overview"; import appService from "@/server/services/app.service"; import PageTitle from "@/components/custom/page-title"; import ProjectBreadcrumbs from "./project-breadcrumbs"; @@ -36,7 +36,7 @@ export default async function AppsPage({ {UserGroupUtils.sessionCanCreateNewAppsForProject(session, params.projectId) && } - + ) diff --git a/src/app/project/[projectId]/project-network-graph.tsx b/src/app/project/[projectId]/project-network-graph.tsx new file mode 100644 index 0000000..97943de --- /dev/null +++ b/src/app/project/[projectId]/project-network-graph.tsx @@ -0,0 +1,256 @@ +'use client'; + +import React, { useMemo } from 'react'; +import { ReactFlow, Background, Controls, Node, Edge, MarkerType, Handle, Position } from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; +import { App, AppDomain, AppPort } from '@prisma/client'; +import { Globe, Network, Lock, Cloud, Shield, ArrowDown } from 'lucide-react'; +import PodStatusIndicator from '@/components/custom/pod-status-indicator'; +import { useRouter } from 'next/navigation'; + +interface AppWithRelations extends App { + appPorts: AppPort[]; + appDomains: AppDomain[]; +} + +interface ProjectNetworkGraphProps { + apps: AppWithRelations[]; +} + +const PolicyIcon = ({ policy, type, ports, useNetworkPolicy }: { policy: string, type: 'ingress' | 'egress', ports: string, useNetworkPolicy: boolean }) => { + let Icon = Globe; + let color = type === 'egress' ? 'text-blue-500' : 'text-green-500'; + let title = policy; + + switch (policy) { + case 'ALLOW_ALL': + Icon = Globe; + color = 'text-green-500'; + break; + case 'NAMESPACE_ONLY': + Icon = Network; + color = 'text-blue-500'; + break; + case 'DENY_ALL': + Icon = Lock; + color = 'text-red-500'; + break; + case 'INTERNET_ONLY': + Icon = Cloud; + color = 'text-orange-500'; + break; + default: + Icon = Shield; + color = 'text-gray-500'; + } + + return ( +
+ {useNetworkPolicy &&
+
+ +
+
} + {ports && type === 'ingress' &&
+ {ports} +
} +
+ ); +}; + +const AppNode = ({ data }: { data: any }) => { + return ( +
+ + +
+ +
+ +
+

{data.label}

+
+ +
+ +
+ + +
+ ); +}; + +const nodeTypes = { + appNode: AppNode, +}; + +const Legend = () => { + return ( +
+
+
+

Node Layout

+
+
+
+
+ Top Icon: Ingress Policy (Incoming traffic) +
+
+
+
+
+ Bottom Icon: Egress Policy (Outgoing traffic) +
+
+
+

Network Policy Types

+
+
+ + Allow All +
+
+ + Project Only +
+
+ + Internet Only +
+
+ + Deny All +
+
+
+
+
+ ); +}; + +export default function ProjectNetworkGraph({ apps }: ProjectNetworkGraphProps) { + const router = useRouter(); + const { nodes, edges } = useMemo(() => { + const nodes: Node[] = []; + const edges: Edge[] = []; + + // Separate apps with domains and without domains + const appsWithDomains = apps.filter(app => app.appDomains.length > 0); + const appsWithoutDomains = apps.filter(app => app.appDomains.length === 0); + + const nodeSpacing = 250; // Horizontal spacing between nodes + const rowSpacing = 150; // Vertical spacing between rows + const internetY = 100; + const firstRowY = internetY + 200; + const secondRowY = firstRowY + rowSpacing; + + // Check if we need an Internet node + const hasInternetAccess = appsWithDomains.length > 0; + const internetX = (Math.max(appsWithDomains.length, appsWithoutDomains.length) * nodeSpacing) / 2; + + if (hasInternetAccess) { + nodes.push({ + id: 'INTERNET', + position: { x: internetX, y: internetY }, + data: { label: 'Internet' }, + style: { background: '#e0e0e0', border: '1px solid #777', padding: 10, borderRadius: '50%', width: 100, height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 'bold' }, + type: 'input', // It's a source only + }); + } + + // First row: Apps with domains (internet accessible) + appsWithDomains.forEach((app, index) => { + const totalWidth = (appsWithDomains.length - 1) * nodeSpacing; + const startX = internetX - (totalWidth / 2); + const x = startX + (index * nodeSpacing); + const y = firstRowY; + + const ports = Array.from(new Set([ + ...app.appDomains, + ...app.appPorts + ].map(d => d.port))).join(', '); + + nodes.push({ + id: app.id, + position: { x, y }, + data: { + label: app.name, + ingressPolicy: app.ingressNetworkPolicy, + egressPolicy: app.egressNetworkPolicy, + appId: app.id, + app, + ports + }, + type: 'appNode', + }); + + // Edge from Internet to App + const hostnames = app.appDomains.map(d => d.hostname).join(', '); + edges.push({ + id: `INTERNET-${app.id}`, + source: 'INTERNET', + target: app.id, + label: `${hostnames}`, + markerEnd: { + type: MarkerType.ArrowClosed, + }, + animated: true, + style: { stroke: '#000' }, + }); + }); + + // Second row: Apps without domains (not internet accessible) + appsWithoutDomains.forEach((app, index) => { + const totalWidth = (appsWithoutDomains.length - 1) * nodeSpacing; + const startX = internetX - (totalWidth / 2); + const x = startX + (index * nodeSpacing); + const y = secondRowY; + + const ports = Array.from(new Set([ + ...app.appDomains, + ...app.appPorts + ].map(d => d.port))).join(', '); + + nodes.push({ + id: app.id, + position: { x, y }, + data: { + label: app.name, + ingressPolicy: app.ingressNetworkPolicy, + egressPolicy: app.egressNetworkPolicy, + appId: app.id, + app, + ports + }, + type: 'appNode', + }); + }); + + return { nodes, edges }; + }, [apps]); + return ( +
+
+ { + if (node.id !== 'INTERNET') { + router.push(`/project/app/${node.id}`); + } + }} + > + {/* + */} + +
+ +
+ ); +} diff --git a/src/app/project/[projectId]/project-overview.tsx b/src/app/project/[projectId]/project-overview.tsx new file mode 100644 index 0000000..d9e4d9f --- /dev/null +++ b/src/app/project/[projectId]/project-overview.tsx @@ -0,0 +1,39 @@ +'use client'; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import AppTable from "./apps-table"; +import ProjectNetworkGraph from "./project-network-graph"; +import { App } from "@prisma/client"; +import { UserSession } from "@/shared/model/sim-session.model"; +import { useRouter, useSearchParams } from "next/navigation"; + +interface ProjectOverviewProps { + apps: any[]; // Using any to avoid complex type imports, as we know the data structure is correct + session: UserSession; + projectId: string; +} + +export default function ProjectOverview({ apps, session, projectId }: ProjectOverviewProps) { + const router = useRouter(); + const searchParams = useSearchParams(); + const currentTab = searchParams.get('tab') || 'table'; + + const handleTabChange = (value: string) => { + router.push(`?tab=${value}`, { scroll: false }); + }; + + return ( + + + Table View + Network Graph + + + + + + + + + ); +} diff --git a/src/app/project/app/[appId]/advanced/actions.ts b/src/app/project/app/[appId]/advanced/actions.ts index 3faa242..645d5eb 100644 --- a/src/app/project/app/[appId]/advanced/actions.ts +++ b/src/app/project/app/[appId]/advanced/actions.ts @@ -4,6 +4,7 @@ import { SuccessActionResult } from "@/shared/model/server-action-error-return.m import appService from "@/server/services/app.service"; import { isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; import { BasicAuthEditModel, basicAuthEditZodModel } from "@/shared/model/basic-auth-edit.model"; +import { appNetworkPolicy } from "@/shared/model/network-policy.model"; export const saveBasicAuth = async (prevState: any, inputData: BasicAuthEditModel) => @@ -24,3 +25,21 @@ export const deleteBasicAuth = async (basicAuthId: string) => await appService.deleteBasicAuthById(basicAuthId); return new SuccessActionResult(undefined, 'Successfully deleted item'); }); + +export const saveNetworkPolicy = async (appId: string, ingressPolicy: string, egressPolicy: string, useNetworkPolicy: boolean) => + simpleAction(async () => { + await isAuthorizedWriteForApp(appId); + + // validate policies + appNetworkPolicy.parse(ingressPolicy); + appNetworkPolicy.parse(egressPolicy); + + const app = await appService.getById(appId); + await appService.save({ + ...app, + ingressNetworkPolicy: ingressPolicy, + egressNetworkPolicy: egressPolicy, + useNetworkPolicy: useNetworkPolicy + }); + return new SuccessActionResult(undefined, 'Network policy saved'); + }); diff --git a/src/app/project/app/[appId]/advanced/basic-auth.tsx b/src/app/project/app/[appId]/advanced/basic-auth.tsx index ad909b1..a2a094c 100644 --- a/src/app/project/app/[appId]/advanced/basic-auth.tsx +++ b/src/app/project/app/[appId]/advanced/basic-auth.tsx @@ -39,7 +39,7 @@ export default function BasicAuth({ app, readonly }: { - {app.appFileMounts.length} Auth Credentials + {app.appBasicAuths.length} Auth Credentials Username diff --git a/src/app/project/app/[appId]/advanced/network-policy.tsx b/src/app/project/app/[appId]/advanced/network-policy.tsx new file mode 100644 index 0000000..3107106 --- /dev/null +++ b/src/app/project/app/[appId]/advanced/network-policy.tsx @@ -0,0 +1,167 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Label } from "@/components/ui/label"; +import { useState } from "react"; +import { Toast } from "@/frontend/utils/toast.utils"; +import { saveNetworkPolicy } from "./actions"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; +import { HelpCircle, AlertTriangle } from "lucide-react"; +import { Switch } from "@/components/ui/switch"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; + +export default function NetworkPolicy({ app, readonly }: { + app: AppExtendedModel; + readonly: boolean; +}) { + const [ingressPolicy, setIngressPolicy] = useState(app.ingressNetworkPolicy); + const [egressPolicy, setEgressPolicy] = useState(app.egressNetworkPolicy); + const [useNetworkPolicy, setUseNetworkPolicy] = useState(app.useNetworkPolicy); + const [showHelp, setShowHelp] = useState(false); + + const handleSave = async () => { + await Toast.fromAction(() => saveNetworkPolicy(app.id, ingressPolicy, egressPolicy, useNetworkPolicy)); + }; + + return ( + + + Network Policy + + Configure network traffic rules for your application. + Changes will take effect after the next deployment. + The Default setting for an App is "Allow All" wich allows traffic to/from all apps within the same project and the internet. + + + +
+
+ +

+ Control whether network policies are applied to this application +

+
+ +
+ + {!useNetworkPolicy && ( + + + Warning + + Disabling network policies removes all network traffic restrictions for this application. + This may expose your application to unauthorized access and security risks. + Only disable this if you fully understand the security implications. + + + )} + +
+
+ + +

+ Controls who can connect to your pods. +

+
+
+ + +

+ Controls where your pods can connect to. +

+
+
+
+ {!readonly && ( + + + + + + + + + Network Policy Types + + Understand how each policy type controls traffic to and from your application. + + +
+
+

Allow All (Internet + Project Apps)

+

+ Allows traffic from/to all apps within the same project and the internet. + External internet traffic reaches your app through the Traefik ingress controller. + Blocks traffic from/to other projects/namespaces. +

+
+
+

Internet Only

+

+ Allows traffic only from/to the internet (via Traefik ingress controller). + Blocks all direct pod-to-pod communication within the cluster, including same-project apps. + Useful for public-facing applications that should not communicate with internal services. +

+
+
+

Project Apps Only

+

+ Allows traffic only from/to apps within the same project. + Blocks all internet traffic and traffic from other projects. + Ideal for internal microservices that should only communicate within your project. +

+
+
+

Deny All

+

+ Blocks all incoming or outgoing traffic. + Use this for maximum isolation when your application should not communicate with any other service. +

+
+
+
+
+
+ )} +
+ ); +} diff --git a/src/app/project/app/[appId]/app-tabs.tsx b/src/app/project/app/[appId]/app-tabs.tsx index f95bdc7..7bab846 100644 --- a/src/app/project/app/[appId]/app-tabs.tsx +++ b/src/app/project/app/[appId]/app-tabs.tsx @@ -19,6 +19,7 @@ import DbCredentials from "./credentials/db-crendentials"; import VolumeBackupList from "./volumes/volume-backup"; import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended.model"; import BasicAuth from "./advanced/basic-auth"; +import NetworkPolicy from "./advanced/network-policy"; import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area"; import DbToolsCard from "./credentials/db-tools"; import { RolePermissionEnum } from "@/shared/model/role-extended.model.ts"; @@ -88,6 +89,7 @@ export default function AppTabs({ + ) diff --git a/src/app/project/app/[appId]/layout.tsx b/src/app/project/app/[appId]/layout.tsx index 95c4ef5..1c9b675 100644 --- a/src/app/project/app/[appId]/layout.tsx +++ b/src/app/project/app/[appId]/layout.tsx @@ -1,15 +1,9 @@ -import { Inter } from "next/font/google"; import { isAuthorizedReadForApp } from "@/server/utils/action-wrapper.utils"; import appService from "@/server/services/app.service"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb" import PageTitle from "@/components/custom/page-title"; import AppActionButtons from "./app-action-buttons"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { AlertTriangle } from "lucide-react"; export default async function RootLayout({ children, @@ -26,12 +20,24 @@ export default async function RootLayout({ const session = await isAuthorizedReadForApp(appId); const app = await appService.getExtendedById(appId); + const showIngressWarning = app.appDomains.length > 0 && app.ingressNetworkPolicy !== 'ALLOW_ALL' && app.ingressNetworkPolicy !== 'INTERNET_ONLY'; + return (
+ {showIngressWarning && ( + + + Warning + + You have configured domains for this app, but the Ingress Network Policy is not set to "Allow All" or "Internet Only". + External traffic via the domain might be blocked. + + + )} {children}
diff --git a/src/app/settings/maintenance/qs-maintenance-settings.tsx b/src/app/settings/maintenance/qs-maintenance-settings.tsx index c8045c5..477d8ed 100644 --- a/src/app/settings/maintenance/qs-maintenance-settings.tsx +++ b/src/app/settings/maintenance/qs-maintenance-settings.tsx @@ -1,13 +1,13 @@ 'use client'; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { cleanupOldBuildJobs, cleanupOldTmpFiles, deleteAllFailedAndSuccededPods, deleteOldAppLogs, purgeRegistryImages, updateRegistry } from "../server/actions"; +import { cleanupOldBuildJobs, cleanupOldTmpFiles, deleteAllFailedAndSuccededPods, deleteAllNetworkPolicies, deleteOldAppLogs, purgeRegistryImages, updateRegistry } from "../server/actions"; import { Button } from "@/components/ui/button"; import { Toast } from "@/frontend/utils/toast.utils"; import { useConfirmDialog } from "@/frontend/states/zustand.states"; import { LogsDialog } from "@/components/custom/logs-overlay"; import { Constants } from "@/shared/utils/constants"; -import { RotateCcw, SquareTerminal, Trash } from "lucide-react"; +import { RotateCcw, SquareTerminal, Trash, ShieldOff } from "lucide-react"; export default function QuickStackMaintenanceSettings({ qsPodName @@ -97,5 +97,23 @@ export default function QuickStackMaintenanceSettings({ + + + Network Policies + + + + + + + ; } \ No newline at end of file diff --git a/src/app/settings/server/actions.ts b/src/app/settings/server/actions.ts index f18ed56..d48128f 100644 --- a/src/app/settings/server/actions.ts +++ b/src/app/settings/server/actions.ts @@ -19,6 +19,7 @@ import maintenanceService from "@/server/services/standalone-services/maintenanc import appLogsService from "@/server/services/standalone-services/app-logs.service"; import systemBackupService from "@/server/services/standalone-services/system-backup.service"; import backupService from "@/server/services/standalone-services/backup.service"; +import networkPolicyService from "@/server/services/network-policy.service"; export const updateIngressSettings = async (prevState: any, inputData: QsIngressSettingsModel) => saveFormAction(inputData, qsIngressSettingsZodModel, async (validatedData) => { @@ -194,4 +195,13 @@ export const runSystemBackupNow = async () => await backupService.runSystemBackup(); return new SuccessActionResult(undefined, 'System backup started successfully'); + }); + +export const deleteAllNetworkPolicies = async () => + simpleAction(async () => { + await getAdminUserSession(); + + const deletedCount = await networkPolicyService.deleteAllNetworkPolicies(); + + return new SuccessActionResult(undefined, `Successfully deleted all (${deletedCount}) network policies.`); }); \ No newline at end of file diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts index 8e35ea4..c74bb3d 100644 --- a/src/server/services/app.service.ts +++ b/src/server/services/app.service.ts @@ -12,6 +12,7 @@ import pvcService from "./pvc.service"; import svcService from "./svc.service"; import deploymentLogService, { dlog } from "./deployment-logs.service"; import crypto from "crypto"; +import networkPolicyService from "./network-policy.service"; class AppService { @@ -53,6 +54,7 @@ class AppService { await ingressService.deleteAllIngressForApp(existingApp.projectId, existingApp.id); await pvcService.deleteAllPvcOfApp(existingApp.projectId, existingApp.id); await buildService.deleteAllBuildsOfApp(existingApp.id); + await networkPolicyService.deleteNetworkPolicy(existingApp.id, existingApp.projectId); await dataAccess.client.app.delete({ where: { id @@ -72,6 +74,10 @@ class AppService { where: { projectId }, + include: { + appPorts: true, + appDomains: true + }, orderBy: { name: 'asc' } diff --git a/src/server/services/db-tool-services/base-db-tool.service.ts b/src/server/services/db-tool-services/base-db-tool.service.ts index 60eb615..19fb868 100644 --- a/src/server/services/db-tool-services/base-db-tool.service.ts +++ b/src/server/services/db-tool-services/base-db-tool.service.ts @@ -11,6 +11,7 @@ import svcService from "../svc.service"; import podService from "../pod.service"; import appService from "../app.service"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import networkPolicyService from "../network-policy.service"; export class BaseDbToolService { @@ -78,7 +79,7 @@ export class BaseDbToolService { const hostnameDnsProviderHostname = await hostnameDnsProviderService.getDomainForApp(toolAppName); console.log(`Creating DB Tool ${toolAppName} deployment for app ${appId}`); - await this.createOrUpdateDbGateDeployment(app, deplyomentBuilder); + await this.createOrUpdateDbToolDeplyoment(app, deplyomentBuilder); console.log(`Creating service for DB Tool ${toolAppName} for app ${appId}`); await svcService.createOrUpdateService(namespace, toolAppName, [{ @@ -90,6 +91,9 @@ export class BaseDbToolService { console.log(`Creating ingress for DB Tool ${toolAppName} for app ${appId}`); await this.createOrUpdateIngress(toolAppName, namespace, hostnameDnsProviderHostname); + console.log(`Creating network policy for DB Tool ${toolAppName} for app ${appId}`); + await networkPolicyService.reconcileDbToolNetworkPolicy(toolAppName, appId, namespace); + const fileBrowserPods = await podService.getPodsForApp(namespace, toolAppName); for (const pod of fileBrowserPods) { await podService.waitUntilPodIsRunningFailedOrSucceded(namespace, pod.podName); @@ -97,7 +101,7 @@ export class BaseDbToolService { } - private async createOrUpdateDbGateDeployment(app: AppExtendedModel, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment | Promise) { + private async createOrUpdateDbToolDeplyoment(app: AppExtendedModel, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment | Promise) { const body = await deplyomentBuilder(app); const toolAppName = this.appIdToToolNameConverter(app.id); await deploymentService.applyDeployment(app.projectId, toolAppName, body); @@ -128,6 +132,8 @@ export class BaseDbToolService { // do not delete ingress to reduce cert-manager issues --> todo; add cleanup function in maintenance section //await k3s.network.deleteNamespacedIngress(KubeObjectNameUtils.getIngressName(toolAppName), projectId); } + + await networkPolicyService.deleteDbToolNetworkPolicy(toolAppName, projectId); } private async createOrUpdateIngress(dbGateAppName: string, namespace: string, hostname: string) { diff --git a/src/server/services/db-tool-services/dbgate.service.ts b/src/server/services/db-tool-services/dbgate.service.ts index 28291ee..211fd6c 100644 --- a/src/server/services/db-tool-services/dbgate.service.ts +++ b/src/server/services/db-tool-services/dbgate.service.ts @@ -108,7 +108,8 @@ class DbGateService extends BaseDbToolService { template: { metadata: { labels: { - app: dbGateAppName + app: dbGateAppName, + [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL }, annotations: { [Constants.QS_ANNOTATION_APP_ID]: app.id, diff --git a/src/server/services/db-tool-services/pgadmin.service.ts b/src/server/services/db-tool-services/pgadmin.service.ts index 5d14d7b..ff81ac5 100644 --- a/src/server/services/db-tool-services/pgadmin.service.ts +++ b/src/server/services/db-tool-services/pgadmin.service.ts @@ -62,7 +62,8 @@ class PgAdminService extends BaseDbToolService { template: { metadata: { labels: { - app: appName + app: appName, + [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL }, annotations: { [Constants.QS_ANNOTATION_APP_ID]: app.id, diff --git a/src/server/services/db-tool-services/phpmyadmin.service.ts b/src/server/services/db-tool-services/phpmyadmin.service.ts index 8054d36..2175e8c 100644 --- a/src/server/services/db-tool-services/phpmyadmin.service.ts +++ b/src/server/services/db-tool-services/phpmyadmin.service.ts @@ -45,7 +45,8 @@ class PhpMyAdminService extends BaseDbToolService { template: { metadata: { labels: { - app: appName + app: appName, + [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL }, annotations: { [Constants.QS_ANNOTATION_APP_ID]: app.id, diff --git a/src/server/services/deployment.service.ts b/src/server/services/deployment.service.ts index 2388f6f..484a4b8 100644 --- a/src/server/services/deployment.service.ts +++ b/src/server/services/deployment.service.ts @@ -18,6 +18,7 @@ import configMapService from "./config-map.service"; import secretService from "./secret.service"; import fileBrowserService from "./file-browser-service"; import podService from "./pod.service"; +import networkPolicyService from "./network-policy.service"; class DeploymentService { @@ -92,6 +93,9 @@ class DeploymentService { const envVars = EnvVarUtils.parseEnvVariables(app); dlog(deploymentId, `Configured ${envVars.length} Env Variables.`); + await networkPolicyService.reconcileNetworkPolicy(app); + dlog(deploymentId, `Configured Network Policy.`); + const existingDeployment = await this.getDeployment(app.projectId, app.id); const body: V1Deployment = { metadata: { diff --git a/src/server/services/file-browser-service.ts b/src/server/services/file-browser-service.ts index cf20794..889f1b2 100644 --- a/src/server/services/file-browser-service.ts +++ b/src/server/services/file-browser-service.ts @@ -11,6 +11,7 @@ import podService from "./pod.service"; import bcrypt from "bcrypt"; import hostnameDnsProviderService from "./hostname-dns-provider.service"; import pvcService from "./pvc.service"; +import networkPolicyService from "./network-policy.service"; class FileBrowserService { @@ -55,6 +56,9 @@ class FileBrowserService { console.log(`Creating ingress for filebrowser for volume ${volumeId}`); await this.createOrUpdateIngress(kubeAppName, namespace, appId, projectId, traefikHostname); + console.log(`Creating network policy for filebrowser for volume ${volumeId}`); + await networkPolicyService.reconcileFileBrowserNetworkPolicy(kubeAppName, projectId); + const fileBrowserPods = await podService.getPodsForApp(projectId, kubeAppName); for (const pod of fileBrowserPods) { await podService.waitUntilPodIsRunningFailedOrSucceded(projectId, pod.podName); @@ -92,6 +96,8 @@ class FileBrowserService { if (existingIngress) { await k3s.network.deleteNamespacedIngress(KubeObjectNameUtils.getIngressName(kubeAppName), projectId); } + + await networkPolicyService.deleteFileBrowserNetworkPolicy(kubeAppName, projectId); } private async createOrUpdateIngress(kubeAppName: string, namespace: string, appId: string, projectId: string, traefikHostname: string) { diff --git a/src/server/services/network-policy.service.ts b/src/server/services/network-policy.service.ts index 8a45654..556292e 100644 --- a/src/server/services/network-policy.service.ts +++ b/src/server/services/network-policy.service.ts @@ -1,47 +1,450 @@ +import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import k3s from "../adapter/kubernetes-api.adapter"; +import { V1NetworkPolicy, V1NetworkPolicyEgressRule, V1NetworkPolicyIngressRule, V1NetworkPolicyPeer } from "@kubernetes/client-node"; +import { KubeObjectNameUtils } from "../utils/kube-object-name.utils"; +import { Constants } from "../../shared/utils/constants"; +import { appNetworkPolicy, AppNetworkPolicyType } from "@/shared/model/network-policy.model"; + +class NetworkPolicyService { + + async reconcileNetworkPolicy(app: AppExtendedModel) { + const policyName = KubeObjectNameUtils.toNetworkPolicyName(app.id); + const namespace = app.projectId; + + // If network policies are disabled, delete existing policy if any and return + if (!app.useNetworkPolicy) { + await this.deleteNetworkPolicy(app.id, app.projectId); + return; + } + + const ingressPolicy = this.normalizePolicy(app.ingressNetworkPolicy); + const egressPolicy = this.normalizePolicy(app.egressNetworkPolicy); + + const policy: V1NetworkPolicy = { + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + metadata: { + name: policyName, + namespace: namespace, + labels: { + app: app.id + }, + annotations: { + [Constants.QS_ANNOTATION_APP_ID]: app.id, + [Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId, + } + }, + spec: { + podSelector: { + matchLabels: { + app: app.id + } + }, + policyTypes: ["Ingress", "Egress"], + ingress: this.getIngressRules(ingressPolicy), + egress: this.getEgressRules(egressPolicy) + } + }; + await this.applyNetworkPolicy(namespace, policyName, policy); + } + + private normalizePolicy(raw: string): AppNetworkPolicyType { + const parsed = appNetworkPolicy.safeParse(raw); + return parsed.success ? parsed.data : 'ALLOW_ALL'; + } + + private getIngressRules(policyType: AppNetworkPolicyType): V1NetworkPolicyIngressRule[] { + const rules: V1NetworkPolicyIngressRule[] = []; + + const traefikFrom: V1NetworkPolicyPeer[] = [ + { + namespaceSelector: { + matchLabels: { + 'kubernetes.io/metadata.name': 'kube-system' + } + }, + podSelector: { + matchLabels: { + 'app.kubernetes.io/name': 'traefik' + } + } + }, + /* // Fallback label used in some clusters/charts + { + namespaceSelector: { + matchLabels: { + 'kubernetes.io/metadata.name': 'kube-system' + } + }, + podSelector: { + matchLabels: { + app: 'traefik' + } + } + }*/ + ]; + + const backupPodFrom: V1NetworkPolicyPeer[] = [{ + podSelector: { + matchLabels: { + [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB + } + } + }]; + + const dbToolPod: V1NetworkPolicyPeer[] = [{ + podSelector: { + matchLabels: { + [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL + } + } + }]; + + if (policyType === 'ALLOW_ALL') { + // Allow from same namespace and from Traefik (internet traffic comes through Traefik) + rules.push({ + from: [ + ...traefikFrom, + { + podSelector: {} // Selects all pods in the same namespace + } + ] + }); + } else if (policyType === 'INTERNET_ONLY') { + // Allow from Traefik (internet traffic comes through Traefik) and from DB-backup jobs. + // Block other internal pod traffic. + rules.push({ + from: [ + ...traefikFrom, + ...backupPodFrom, + ...dbToolPod + ] + }); + } else if (policyType === 'NAMESPACE_ONLY') { + // Allow only from same namespace + rules.push({ + from: [{ + podSelector: {} // Selects all pods in the same namespace + }] + }); + } else if (policyType === 'DENY_ALL') { + // No rules means deny all --> except the separate container for database backups + rules.push({ + from: [ + ...backupPodFrom, + ...dbToolPod + ] + }); + } + + return rules; + } + + private getEgressRules(policyType: AppNetworkPolicyType): V1NetworkPolicyEgressRule[] { + const rules: V1NetworkPolicyEgressRule[] = []; + + // allow DNS (kube-dns/coredns) on UDP/TCP 53 + const dnsRuleAllow: V1NetworkPolicyEgressRule = { + to: [ + { + namespaceSelector: { + matchLabels: { + "kubernetes.io/metadata.name": "kube-system" + } + }, + podSelector: { + matchLabels: { + "k8s-app": "kube-dns" + } + } + }, + { + namespaceSelector: { + matchLabels: { + "kubernetes.io/metadata.name": "kube-system" + } + }, + podSelector: { + matchLabels: { + "k8s-app": "coredns" + } + } + } + ], + ports: [ + { protocol: 'UDP', port: 53 as any }, + { protocol: 'TCP', port: 53 as any } + ] + }; + + if (policyType === 'ALLOW_ALL') { + // Allow Internet + Local Namespace, Block other namespaces (Private IPs) + rules.push(dnsRuleAllow); + rules.push({ + to: [ + { + ipBlock: { + cidr: '0.0.0.0/0', + except: [ + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16' + ] + } + }, + { + podSelector: {} // Allow all in same namespace + } + ] + }); + } else if (policyType === 'INTERNET_ONLY') { + // Allow only to internet, block internal cluster traffic + rules.push(dnsRuleAllow); + rules.push({ + to: [{ + ipBlock: { + cidr: '0.0.0.0/0', + except: [ + '10.0.0.0/8', + '172.16.0.0/12', + '192.168.0.0/16' + ] + } + }] + }); + } else if (policyType === 'NAMESPACE_ONLY') { + // Allow only to same namespace + rules.push(dnsRuleAllow); + rules.push({ + to: [{ + podSelector: {} + }] + }); + } else if (policyType === 'DENY_ALL') { + // Allow completely nothing + } + + return rules; + } + + async deleteNetworkPolicy(appId: string, projectId: string) { + const policyName = KubeObjectNameUtils.toNetworkPolicyName(appId); + const existingNetworkPolicy = await this.getExistingNetworkPolicy(projectId, policyName); + if (!existingNetworkPolicy) { + return; + } + await k3s.network.deleteNamespacedNetworkPolicy(policyName, projectId); + } + + private async applyNetworkPolicy(namespace: string, policyName: string, body: V1NetworkPolicy) { + const existing = await this.getExistingNetworkPolicy(namespace, policyName); + if (existing) { + await k3s.network.replaceNamespacedNetworkPolicy(policyName, namespace, body); + } else { + await k3s.network.createNamespacedNetworkPolicy(namespace, body); + } + } + + private async getExistingNetworkPolicy(namespace: string, policyName: string) { + const allPolicies = await k3s.network.listNamespacedNetworkPolicy(namespace); + return allPolicies.body.items.find(np => np.metadata?.name === policyName); + } + + async reconcileDbToolNetworkPolicy(dbToolAppName: string, dbAppId: string, projectId: string) { + const policyName = KubeObjectNameUtils.toNetworkPolicyName(dbToolAppName); + const namespace = projectId; + + const policy: V1NetworkPolicy = { + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + metadata: { + name: policyName, + namespace: namespace, + labels: { + app: dbToolAppName, + 'db-tool': 'true' + }, + annotations: { + [Constants.QS_ANNOTATION_APP_ID]: dbAppId, + [Constants.QS_ANNOTATION_PROJECT_ID]: projectId, + } + }, + spec: { + podSelector: { + matchLabels: { + app: dbToolAppName + } + }, + policyTypes: ["Ingress", "Egress"], + ingress: [ + { + // Allow from Traefik (internet traffic) + from: [ + { + namespaceSelector: { + matchLabels: { + 'kubernetes.io/metadata.name': 'kube-system' + } + }, + podSelector: { + matchLabels: { + 'app.kubernetes.io/name': 'traefik' + } + } + } + ] + } + ], + egress: [ + { + // Allow DNS + to: [ + { + namespaceSelector: { + matchLabels: { + "kubernetes.io/metadata.name": "kube-system" + } + }, + podSelector: { + matchLabels: { + "k8s-app": "kube-dns" + } + } + }, + { + namespaceSelector: { + matchLabels: { + "kubernetes.io/metadata.name": "kube-system" + } + }, + podSelector: { + matchLabels: { + "k8s-app": "coredns" + } + } + } + ], + ports: [ + { protocol: 'UDP', port: 53 as any }, + { protocol: 'TCP', port: 53 as any } + ] + }, + { + // Allow only to database pod in same namespace + to: [ + { + podSelector: { + matchLabels: { + app: dbAppId + } + } + } + ] + } + ] + } + }; + console.log('Creating DB Tool Network Policy:', JSON.stringify(policy, null, 2)); + await this.applyNetworkPolicy(namespace, policyName, policy); + } + + async deleteDbToolNetworkPolicy(dbToolAppName: string, projectId: string) { + const policyName = KubeObjectNameUtils.toNetworkPolicyName(dbToolAppName); + const existingNetworkPolicy = await this.getExistingNetworkPolicy(projectId, policyName); + if (!existingNetworkPolicy) { + return; + } + await k3s.network.deleteNamespacedNetworkPolicy(policyName, projectId); + } + + async reconcileFileBrowserNetworkPolicy(fileBrowserAppName: string, projectId: string) { + const policyName = KubeObjectNameUtils.toNetworkPolicyName(fileBrowserAppName); + const namespace = projectId; + + const policy: V1NetworkPolicy = { + apiVersion: "networking.k8s.io/v1", + kind: "NetworkPolicy", + metadata: { + name: policyName, + namespace: namespace, + labels: { + app: fileBrowserAppName, + 'file-browser': 'true' + }, + annotations: { + [Constants.QS_ANNOTATION_PROJECT_ID]: projectId, + } + }, + spec: { + podSelector: { + matchLabels: { + app: fileBrowserAppName + } + }, + policyTypes: ["Ingress", "Egress"], + ingress: [ + { + // Allow from Traefik (internet traffic) + from: [ + { + namespaceSelector: { + matchLabels: { + 'kubernetes.io/metadata.name': 'kube-system' + } + }, + podSelector: { + matchLabels: { + 'app.kubernetes.io/name': 'traefik' + } + } + } + ] + } + ], + egress: [] // Deny all outgoing traffic + } + }; + console.log('Creating FileBrowser Network Policy:', JSON.stringify(policy, null, 2)); + await this.applyNetworkPolicy(namespace, policyName, policy); + } + + async deleteFileBrowserNetworkPolicy(fileBrowserAppName: string, projectId: string) { + const policyName = KubeObjectNameUtils.toNetworkPolicyName(fileBrowserAppName); + const existingNetworkPolicy = await this.getExistingNetworkPolicy(projectId, policyName); + if (!existingNetworkPolicy) { + return; + } + await k3s.network.deleteNamespacedNetworkPolicy(policyName, projectId); + } + + async deleteAllNetworkPolicies() { + const namespaces = await k3s.core.listNamespace(); + let deletedCount = 0; + + for (const ns of namespaces.body.items) { + const namespace = ns.metadata?.name; + if (!namespace) continue; + + try { + const policies = await k3s.network.listNamespacedNetworkPolicy(namespace); + for (const policy of policies.body.items) { + const policyName = policy.metadata?.name; + if (policyName) { + await k3s.network.deleteNamespacedNetworkPolicy(policyName, namespace); + deletedCount++; + } + } + } catch (error) { + console.error(`Error deleting network policies in namespace ${namespace}:`, error); + } + } + + return deletedCount; + } +} + +const networkPolicyService = new NetworkPolicyService(); +export default networkPolicyService; - -/* - -apiVersion: networking.k8s.io/v1 -kind: NetworkPolicy -metadata: - name: allow-same-namespace-and-external-traffic - namespace: proj-databases-ce53614e -spec: - podSelector: {} - policyTypes: - - Ingress - - Egress - ingress: - - from: - - podSelector: {} - - from: - - podSelector: - matchLabels: - app.kubernetes.io/name: traefik - - from: - - namespaceSelector: - matchLabels: - name: kube-system - - from: - - ipBlock: - cidr: 0.0.0.0/0 - egress: - - to: - - ipBlock: - cidr: 0.0.0.0/0 - - to: - - namespaceSelector: {} - podSelector: - matchLabels: - k8s-app: kube-dns - ports: - - port: 53 - protocol: UDP - - to: - - podSelector: {} - - -*/ \ No newline at end of file diff --git a/src/server/services/standalone-services/database-backup-services/mariadb-backup.service.ts b/src/server/services/standalone-services/database-backup-services/mariadb-backup.service.ts index d840585..592dbc7 100644 --- a/src/server/services/standalone-services/database-backup-services/mariadb-backup.service.ts +++ b/src/server/services/standalone-services/database-backup-services/mariadb-backup.service.ts @@ -16,7 +16,7 @@ class MariaDbBackupService { await namespaceService.createNamespaceIfNotExists(backupNamespace); - const jobName = KubeObjectNameUtils.addRandomSuffix(`backup-mysql-${app.id}`); + const jobName = KubeObjectNameUtils.addRandomSuffix(`backup-mariadb-${app.id}`); console.log(`Creating MariaDB/MySQL backup job with name: ${jobName}`); const dbCredentials = AppTemplateUtils.getDatabaseModelFromApp(app); @@ -31,7 +31,7 @@ class MariaDbBackupService { const endpoint = backupVolume.target.endpoint.includes('http') ? backupVolume.target.endpoint : `https://${backupVolume.target.endpoint}`; console.log(`S3 Endpoint: ${endpoint}`); - const imageTag = process.env.QS_VERSION?.includes('canary') ? 'canary' : 'latest'; + const imageTag = process.env.QS_VERSION?.includes('canary') || process.env.NODE_ENV !== 'production' ? 'canary' : 'latest'; const jobDefinition: V1Job = { apiVersion: "batch/v1", @@ -42,11 +42,17 @@ class MariaDbBackupService { annotations: { [Constants.QS_ANNOTATION_APP_ID]: app.id, [Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId, + [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB } }, spec: { ttlSecondsAfterFinished: 86400, // 1 day template: { + metadata: { + labels: { + [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB, + } + }, spec: { containers: [ { diff --git a/src/server/services/standalone-services/database-backup-services/mongodb-backup.service.ts b/src/server/services/standalone-services/database-backup-services/mongodb-backup.service.ts index b9f5588..dc63a83 100644 --- a/src/server/services/standalone-services/database-backup-services/mongodb-backup.service.ts +++ b/src/server/services/standalone-services/database-backup-services/mongodb-backup.service.ts @@ -7,7 +7,6 @@ import namespaceService from "../../namespace.service"; import sharedBackupService from "./shared-backup.service"; import { VolumeBackupExtendedModel } from "@/shared/model/volume-backup-extended.model"; import { AppExtendedModel } from "@/shared/model/app-extended.model"; - class MongoDbBackupService { async backupMongoDb(backupVolume: VolumeBackupExtendedModel, app: AppExtendedModel) { @@ -32,7 +31,7 @@ class MongoDbBackupService { const endpoint = backupVolume.target.endpoint.includes('http') ? backupVolume.target.endpoint : `https://${backupVolume.target.endpoint}`; console.log(`S3 Endpoint: ${endpoint}`); - const imageTag = process.env.QS_VERSION?.includes('canary') ? 'canary' : 'latest'; + const imageTag = process.env.QS_VERSION?.includes('canary') || process.env.NODE_ENV !== 'production' ? 'canary' : 'latest'; const jobDefinition: V1Job = { apiVersion: "batch/v1", @@ -43,11 +42,17 @@ class MongoDbBackupService { annotations: { [Constants.QS_ANNOTATION_APP_ID]: app.id, [Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId, + [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB } }, spec: { ttlSecondsAfterFinished: 86400, // 1 day template: { + metadata: { + labels: { + [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB, + } + }, spec: { containers: [ { diff --git a/src/server/services/standalone-services/database-backup-services/postgres-backup.service.ts b/src/server/services/standalone-services/database-backup-services/postgres-backup.service.ts index 99de043..c2b28b9 100644 --- a/src/server/services/standalone-services/database-backup-services/postgres-backup.service.ts +++ b/src/server/services/standalone-services/database-backup-services/postgres-backup.service.ts @@ -31,7 +31,7 @@ class PostgresBackupService { const endpoint = backupVolume.target.endpoint.includes('http') ? backupVolume.target.endpoint : `https://${backupVolume.target.endpoint}`; console.log(`S3 Endpoint: ${endpoint}`); - const imageTag = process.env.QS_VERSION?.includes('canary') ? 'canary' : 'latest'; + const imageTag = process.env.QS_VERSION?.includes('canary') || process.env.NODE_ENV !== 'production' ? 'canary' : 'latest'; const jobDefinition: V1Job = { apiVersion: "batch/v1", @@ -42,11 +42,18 @@ class PostgresBackupService { annotations: { [Constants.QS_ANNOTATION_APP_ID]: app.id, [Constants.QS_ANNOTATION_PROJECT_ID]: app.projectId, + [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB } }, spec: { ttlSecondsAfterFinished: 86400, // 1 day template: { + metadata: { + labels: { + [Constants.QS_ANNOTATION_CONTAINER_TYPE]: Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB, + 'qs-backup-job': jobName + } + }, spec: { containers: [ { diff --git a/src/server/utils/kube-object-name.utils.ts b/src/server/utils/kube-object-name.utils.ts index d218d88..b47aba6 100644 --- a/src/server/utils/kube-object-name.utils.ts +++ b/src/server/utils/kube-object-name.utils.ts @@ -80,4 +80,8 @@ export class KubeObjectNameUtils { static toPgAdminId(appId: string): `pga-${string}` { return `pga-${appId}`; } + + static toNetworkPolicyName(appId: string): string { + return `np-${appId}`.substring(0, 63); // not more than 63 characters + } } \ No newline at end of file diff --git a/src/shared/model/generated-zod/app.ts b/src/shared/model/generated-zod/app.ts index 92ac6d0..b5cac27 100644 --- a/src/shared/model/generated-zod/app.ts +++ b/src/shared/model/generated-zod/app.ts @@ -23,6 +23,9 @@ export const AppModel = z.object({ cpuReservation: z.number().int().nullish(), cpuLimit: z.number().int().nullish(), webhookId: z.string().nullish(), + ingressNetworkPolicy: z.string(), + egressNetworkPolicy: z.string(), + useNetworkPolicy: z.boolean(), createdAt: z.date(), updatedAt: z.date(), }) diff --git a/src/shared/model/network-policy.model.ts b/src/shared/model/network-policy.model.ts new file mode 100644 index 0000000..be73d54 --- /dev/null +++ b/src/shared/model/network-policy.model.ts @@ -0,0 +1,4 @@ +import { z } from "zod"; + +export const appNetworkPolicy = z.enum(["ALLOW_ALL", "INTERNET_ONLY", "NAMESPACE_ONLY", "DENY_ALL"]); +export type AppNetworkPolicyType = z.infer; \ No newline at end of file diff --git a/src/shared/templates/apps/wordpress.template.ts b/src/shared/templates/apps/wordpress.template.ts index 38f6079..7ceb01d 100644 --- a/src/shared/templates/apps/wordpress.template.ts +++ b/src/shared/templates/apps/wordpress.template.ts @@ -1,3 +1,4 @@ +import { Constants } from "@/shared/utils/constants"; import { AppTemplateModel } from "../../model/app-template.model"; import { mariadbAppTemplate } from "../databases/mariadb.template"; @@ -35,9 +36,12 @@ export const wordpressAppTemplate: AppTemplateModel = { sourceType: 'CONTAINER', containerImageSource: "", replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_APPS, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_APPS, envVars: `MYSQL_DATABASE=wordpress MYSQL_USER=wordpress `, + useNetworkPolicy: true, }, appDomains: [], appVolumes: [{ @@ -67,12 +71,15 @@ MYSQL_USER=wordpress sourceType: 'CONTAINER', containerImageSource: "", replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES, envVars: `WORDPRESS_DB_HOST={hostname}:{port} WORDPRESS_DB_NAME={databaseName} WORDPRESS_DB_USER={username} WORDPRESS_DB_PASSWORD={password} WORDPRESS_TABLE_PREFIX=wp_ `, + useNetworkPolicy: true, }, appDomains: [], appVolumes: [{ diff --git a/src/shared/templates/databases/mariadb.template.ts b/src/shared/templates/databases/mariadb.template.ts index abb9c20..9aa2d56 100644 --- a/src/shared/templates/databases/mariadb.template.ts +++ b/src/shared/templates/databases/mariadb.template.ts @@ -1,3 +1,4 @@ +import { Constants } from "@/shared/utils/constants"; import { AppTemplateModel } from "../../model/app-template.model"; export const mariadbAppTemplate: AppTemplateModel = { @@ -46,8 +47,11 @@ export const mariadbAppTemplate: AppTemplateModel = { appType: 'MARIADB', sourceType: 'CONTAINER', containerImageSource: "", + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES, replicas: 1, envVars: ``, + useNetworkPolicy: true, }, appDomains: [], appVolumes: [{ diff --git a/src/shared/templates/databases/mongodb.template.ts b/src/shared/templates/databases/mongodb.template.ts index 3725324..0a49d24 100644 --- a/src/shared/templates/databases/mongodb.template.ts +++ b/src/shared/templates/databases/mongodb.template.ts @@ -1,3 +1,4 @@ +import { Constants } from "@/shared/utils/constants"; import { AppTemplateModel } from "../../model/app-template.model"; export const mongodbAppTemplate: AppTemplateModel = { @@ -39,8 +40,11 @@ export const mongodbAppTemplate: AppTemplateModel = { appType: 'MONGODB', sourceType: 'CONTAINER', containerImageSource: "", + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES, replicas: 1, envVars: ``, + useNetworkPolicy: true, }, appDomains: [], appVolumes: [{ diff --git a/src/shared/templates/databases/mysql.template.ts b/src/shared/templates/databases/mysql.template.ts index 4c3756c..aa9c22d 100644 --- a/src/shared/templates/databases/mysql.template.ts +++ b/src/shared/templates/databases/mysql.template.ts @@ -1,3 +1,4 @@ +import { Constants } from "@/shared/utils/constants"; import { AppTemplateModel } from "../../model/app-template.model"; export const mysqlAppTemplate: AppTemplateModel = { @@ -48,6 +49,9 @@ export const mysqlAppTemplate: AppTemplateModel = { containerImageSource: "", replicas: 1, envVars: ``, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES, + useNetworkPolicy: true, }, appDomains: [], appVolumes: [{ diff --git a/src/shared/templates/databases/postgres.template.ts b/src/shared/templates/databases/postgres.template.ts index 2e05c4f..353836d 100644 --- a/src/shared/templates/databases/postgres.template.ts +++ b/src/shared/templates/databases/postgres.template.ts @@ -1,3 +1,4 @@ +import { Constants } from "@/shared/utils/constants"; import { AppTemplateModel } from "../../model/app-template.model"; export const postgreAppTemplate: AppTemplateModel = { @@ -40,8 +41,11 @@ export const postgreAppTemplate: AppTemplateModel = { sourceType: 'CONTAINER', containerImageSource: "", replicas: 1, + ingressNetworkPolicy: Constants.DEFAULT_INGRESS_NETWORK_POLICY_DATABASES, + egressNetworkPolicy: Constants.DEFAULT_EGRESS_NETWORK_POLICY_DATABASES, envVars: `PGDATA=/var/lib/qs-postgres/data `, + useNetworkPolicy: true, }, appDomains: [], appVolumes: [{ diff --git a/src/shared/utils/constants.ts b/src/shared/utils/constants.ts index 57ad686..54a9d63 100644 --- a/src/shared/utils/constants.ts +++ b/src/shared/utils/constants.ts @@ -1,6 +1,9 @@ export class Constants { static readonly QS_ANNOTATION_APP_ID = 'qs-app-id'; static readonly QS_ANNOTATION_PROJECT_ID = 'qs-project-id'; + static readonly QS_ANNOTATION_CONTAINER_TYPE = 'qs-containter-type'; + static readonly QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB = 'qs-job-database-backup'; + static readonly QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL = 'qs-container-db-tool'; static readonly QS_ANNOTATION_DEPLOYMENT_ID = 'qs-deplyoment-id'; static readonly QS_ANNOTATION_GIT_COMMIT = 'qs-git-commit'; static readonly K3S_JOIN_TOKEN = 'k3sJoinToken'; @@ -10,4 +13,8 @@ export class Constants { static readonly TRAEFIK_ME_SECRET_NAME = 'traefik-me-tls'; static readonly QS_SYSTEM_BACKUP_DEACTIVATED = 'deactivated'; static readonly QS_SYSTEM_BACKUP_LOCATION_PARAM_KEY = 'qsSystemBackupLocation'; + static readonly DEFAULT_INGRESS_NETWORK_POLICY_APPS = 'ALLOW_ALL'; + static readonly DEFAULT_EGRESS_NETWORK_POLICY_APPS = 'ALLOW_ALL'; + static readonly DEFAULT_INGRESS_NETWORK_POLICY_DATABASES = 'NAMESPACE_ONLY'; + static readonly DEFAULT_EGRESS_NETWORK_POLICY_DATABASES = 'DENY_ALL'; } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 582c9a2..88bc0c8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2956,12 +2956,19 @@ resolved "https://registry.yarnpkg.com/@types/d3-color/-/d3-color-3.1.3.tgz#368c961a18de721da8200e80bf3943fb53136af2" integrity sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A== +"@types/d3-drag@^3.0.7": + version "3.0.7" + resolved "https://registry.yarnpkg.com/@types/d3-drag/-/d3-drag-3.0.7.tgz#b13aba8b2442b4068c9a9e6d1d82f8bcea77fc02" + integrity sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ== + dependencies: + "@types/d3-selection" "*" + "@types/d3-ease@^3.0.0": version "3.0.2" resolved "https://registry.yarnpkg.com/@types/d3-ease/-/d3-ease-3.0.2.tgz#e28db1bfbfa617076f7770dd1d9a48eaa3b6c51b" integrity sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA== -"@types/d3-interpolate@^3.0.1": +"@types/d3-interpolate@*", "@types/d3-interpolate@^3.0.1", "@types/d3-interpolate@^3.0.4": version "3.0.4" resolved "https://registry.yarnpkg.com/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz#412b90e84870285f2ff8a846c6eb60344f12a41c" integrity sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA== @@ -2980,6 +2987,11 @@ dependencies: "@types/d3-time" "*" +"@types/d3-selection@*", "@types/d3-selection@^3.0.10": + version "3.0.11" + resolved "https://registry.yarnpkg.com/@types/d3-selection/-/d3-selection-3.0.11.tgz#bd7a45fc0a8c3167a631675e61bc2ca2b058d4a3" + integrity sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w== + "@types/d3-shape@^3.1.0": version "3.1.6" resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.6.tgz#65d40d5a548f0a023821773e39012805e6e31a72" @@ -2997,6 +3009,21 @@ resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== +"@types/d3-transition@^3.0.8": + version "3.0.9" + resolved "https://registry.yarnpkg.com/@types/d3-transition/-/d3-transition-3.0.9.tgz#1136bc57e9ddb3c390dccc9b5ff3b7d2b8d94706" + integrity sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg== + dependencies: + "@types/d3-selection" "*" + +"@types/d3-zoom@^3.0.8": + version "3.0.8" + resolved "https://registry.yarnpkg.com/@types/d3-zoom/-/d3-zoom-3.0.8.tgz#dccb32d1c56b1e1c6e0f1180d994896f038bc40b" + integrity sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw== + dependencies: + "@types/d3-interpolate" "*" + "@types/d3-selection" "*" + "@types/debug@4.1.7": version "4.1.7" resolved "https://registry.yarnpkg.com/@types/debug/-/debug-4.1.7.tgz#7cc0ea761509124709b8b2d1090d8f6c17aadb82" @@ -3242,6 +3269,30 @@ resolved "https://registry.yarnpkg.com/@xterm/xterm/-/xterm-5.5.0.tgz#275fb8f6e14afa6e8a0c05d4ebc94523ff775396" integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A== +"@xyflow/react@^12.10.0": + version "12.10.0" + resolved "https://registry.yarnpkg.com/@xyflow/react/-/react-12.10.0.tgz#d2924cb38074e8e6141643dd8bd7a0666aaac868" + integrity sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw== + dependencies: + "@xyflow/system" "0.0.74" + classcat "^5.0.3" + zustand "^4.4.0" + +"@xyflow/system@0.0.74": + version "0.0.74" + resolved "https://registry.yarnpkg.com/@xyflow/system/-/system-0.0.74.tgz#4bc01af020504387c88a3b13ac6d426b47cba845" + integrity sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q== + dependencies: + "@types/d3-drag" "^3.0.7" + "@types/d3-interpolate" "^3.0.4" + "@types/d3-selection" "^3.0.10" + "@types/d3-transition" "^3.0.8" + "@types/d3-zoom" "^3.0.8" + d3-drag "^3.0.0" + d3-interpolate "^3.0.1" + d3-selection "^3.0.0" + d3-zoom "^3.0.0" + abab@^2.0.6: version "2.0.6" resolved "https://registry.yarnpkg.com/abab/-/abab-2.0.6.tgz#41b80f2c871d19686216b82309231cfd3cb3d291" @@ -3907,6 +3958,11 @@ class-variance-authority@^0.7.1: dependencies: clsx "^2.1.1" +classcat@^5.0.3: + version "5.0.5" + resolved "https://registry.yarnpkg.com/classcat/-/classcat-5.0.5.tgz#8c209f359a93ac302404a10161b501eba9c09c77" + integrity sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w== + client-only@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" @@ -4129,7 +4185,20 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/d3-color/-/d3-color-3.1.0.tgz#395b2833dfac71507f12ac2f7af23bf819de24e2" integrity sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA== -d3-ease@^3.0.1: +"d3-dispatch@1 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-dispatch/-/d3-dispatch-3.0.1.tgz#5fc75284e9c2375c36c839411a0cf550cbfc4d5e" + integrity sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg== + +"d3-drag@2 - 3", d3-drag@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-drag/-/d3-drag-3.0.0.tgz#994aae9cd23c719f53b5e10e3a0a6108c69607ba" + integrity sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg== + dependencies: + d3-dispatch "1 - 3" + d3-selection "3" + +"d3-ease@1 - 3", d3-ease@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-ease/-/d3-ease-3.0.1.tgz#9658ac38a2140d59d346160f1f6c30fda0bd12f4" integrity sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w== @@ -4139,7 +4208,7 @@ d3-ease@^3.0.1: resolved "https://registry.yarnpkg.com/d3-format/-/d3-format-3.1.0.tgz#9260e23a28ea5cb109e93b21a06e24e2ebd55641" integrity sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA== -"d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: +"d3-interpolate@1 - 3", "d3-interpolate@1.2.0 - 3", d3-interpolate@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-interpolate/-/d3-interpolate-3.0.1.tgz#3c47aa5b32c5b3dfb56ef3fd4342078a632b400d" integrity sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g== @@ -4162,6 +4231,11 @@ d3-scale@^4.0.2: d3-time "2.1.1 - 3" d3-time-format "2 - 4" +"d3-selection@2 - 3", d3-selection@3, d3-selection@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-selection/-/d3-selection-3.0.0.tgz#c25338207efa72cc5b9bd1458a1a41901f1e1b31" + integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== + d3-shape@^3.1.0: version "3.2.0" resolved "https://registry.yarnpkg.com/d3-shape/-/d3-shape-3.2.0.tgz#a1a839cbd9ba45f28674c69d7f855bcf91dfc6a5" @@ -4183,11 +4257,33 @@ d3-shape@^3.1.0: dependencies: d3-array "2 - 3" -d3-timer@^3.0.1: +"d3-timer@1 - 3", d3-timer@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/d3-timer/-/d3-timer-3.0.1.tgz#6284d2a2708285b1abb7e201eda4380af35e63b0" integrity sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA== +"d3-transition@2 - 3": + version "3.0.1" + resolved "https://registry.yarnpkg.com/d3-transition/-/d3-transition-3.0.1.tgz#6869fdde1448868077fdd5989200cb61b2a1645f" + integrity sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w== + dependencies: + d3-color "1 - 3" + d3-dispatch "1 - 3" + d3-ease "1 - 3" + d3-interpolate "1 - 3" + d3-timer "1 - 3" + +d3-zoom@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/d3-zoom/-/d3-zoom-3.0.0.tgz#d13f4165c73217ffeaa54295cd6969b3e7aee8f3" + integrity sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw== + dependencies: + d3-dispatch "1 - 3" + d3-drag "2 - 3" + d3-interpolate "1 - 3" + d3-selection "2 - 3" + d3-transition "2 - 3" + damerau-levenshtein@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" @@ -8649,6 +8745,11 @@ use-sidecar@^1.1.2: detect-node-es "^1.1.0" tslib "^2.0.0" +use-sync-external-store@^1.2.2: + version "1.6.0" + resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz#b174bfa65cb2b526732d9f2ac0a408027876f32d" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== + util-deprecate@^1.0.1, util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" @@ -9034,6 +9135,13 @@ zod@^3.23.8: resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== +zustand@^4.4.0: + version "4.5.7" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-4.5.7.tgz#7d6bb2026a142415dd8be8891d7870e6dbe65f55" + integrity sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw== + dependencies: + use-sync-external-store "^1.2.2" + zustand@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.1.tgz#2bdca5e4be172539558ce3974fe783174a48fdcf"