mirror of
https://github.com/biersoeckli/QuickStack.git
synced 2026-02-10 13:39:07 -06:00
Merge pull request #53 from biersoeckli/feat/implement-network-policies
Feat/implement network policies
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
35
prisma/migrations/20251216102326_migration/migration.sql
Normal file
35
prisma/migrations/20251216102326_migration/migration.sql
Normal file
@@ -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;
|
||||
36
prisma/migrations/20251222131551_migration/migration.sql
Normal file
36
prisma/migrations/20251222131551_migration/migration.sql
Normal file
@@ -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;
|
||||
@@ -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"
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "sqlite"
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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) &&
|
||||
<CreateProjectActions projectId={projectId} />}
|
||||
</PageTitle>
|
||||
<AppTable session={session} app={relevantApps} projectId={project.id} />
|
||||
<ProjectOverview session={session} apps={relevantApps} projectId={project.id} />
|
||||
<ProjectBreadcrumbs project={project} />
|
||||
</div>
|
||||
)
|
||||
|
||||
256
src/app/project/[projectId]/project-network-graph.tsx
Normal file
256
src/app/project/[projectId]/project-network-graph.tsx
Normal file
@@ -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 (
|
||||
<div className='flex items-center gap-2'>
|
||||
{useNetworkPolicy && <div className={`p-1 bg-white rounded-full border shadow-sm ${color}`} title={`${type}: ${title}`}>
|
||||
<div className=' flex gap-1 items-center'>
|
||||
<Icon size={16} />
|
||||
</div>
|
||||
</div>}
|
||||
{ports && type === 'ingress' && <div className={`p-1 px-2 bg-white rounded-full border shadow-sm text-xs text-gray-500`} title={`${type}: ${title}`}>
|
||||
{ports}
|
||||
</div>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const AppNode = ({ data }: { data: any }) => {
|
||||
return (
|
||||
<div className="relative bg-white border border-slate-300 rounded-md p-4 min-w-[150px] shadow-sm text-center cursor-pointer hover:border-slate-400 transition-colors">
|
||||
<Handle type="target" position={Position.Top} className="!bg-transparent !border-0" />
|
||||
|
||||
<div className="absolute -top-3 left-1/2 -translate-x-1/2 z-10">
|
||||
<PolicyIcon policy={data.ingressPolicy} ports={data.ports} useNetworkPolicy={data.app.useNetworkPolicy} type="ingress" />
|
||||
</div>
|
||||
|
||||
<div className="font-semibold text-sm mt-2 mb-2 flex gap-3 items-center justify-center">
|
||||
<PodStatusIndicator appId={data.appId} /> <p>{data.label}</p>
|
||||
</div>
|
||||
|
||||
<div className="absolute -bottom-3 left-1/2 -translate-x-1/2 z-10">
|
||||
<PolicyIcon policy={data.egressPolicy} ports={data.ports} useNetworkPolicy={data.app.useNetworkPolicy} type="egress" />
|
||||
</div>
|
||||
|
||||
<Handle type="source" position={Position.Bottom} className="!bg-transparent !border-0" />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const nodeTypes = {
|
||||
appNode: AppNode,
|
||||
};
|
||||
|
||||
const Legend = () => {
|
||||
return (
|
||||
<div className="mt-4 p-4 border rounded-md bg-slate-50 text-sm">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 className="font-medium mb-1 text-xs uppercase text-slate-500 pb-2">Node Layout</h4>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="w-8 h-8 border border-slate-300 rounded bg-white relative mx-1">
|
||||
<div className="absolute -top-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-slate-200 rounded-full border border-slate-300"></div>
|
||||
</div>
|
||||
<span>Top Icon: <strong>Ingress Policy</strong> (Incoming traffic)</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-8 h-8 border border-slate-300 rounded bg-white relative mx-1">
|
||||
<div className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 w-3 h-3 bg-slate-200 rounded-full border border-slate-300"></div>
|
||||
</div>
|
||||
<span>Bottom Icon: <strong>Egress Policy</strong> (Outgoing traffic)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-medium mb-1 text-xs uppercase text-slate-500 pb-2">Network Policy Types</h4>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe size={16} className="text-green-500" />
|
||||
<span>Allow All</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Network size={16} className="text-blue-500" />
|
||||
<span>Project Only</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Cloud size={16} className="text-orange-500" />
|
||||
<span>Internet Only</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock size={16} className="text-red-500" />
|
||||
<span>Deny All</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className="space-y-4">
|
||||
<div style={{ height: 600, border: '1px solid #eee', borderRadius: 8 }}>
|
||||
<ReactFlow
|
||||
defaultNodes={nodes}
|
||||
defaultEdges={edges}
|
||||
nodeTypes={nodeTypes}
|
||||
fitView
|
||||
nodesDraggable={false}
|
||||
nodesConnectable={false}
|
||||
elementsSelectable={false}
|
||||
onNodeClick={(event, node) => {
|
||||
if (node.id !== 'INTERNET') {
|
||||
router.push(`/project/app/${node.id}`);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* <Background />
|
||||
<Controls />*/}
|
||||
</ReactFlow>
|
||||
</div>
|
||||
<Legend />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/app/project/[projectId]/project-overview.tsx
Normal file
39
src/app/project/[projectId]/project-overview.tsx
Normal file
@@ -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 (
|
||||
<Tabs value={currentTab} onValueChange={handleTabChange} className="w-full">
|
||||
<TabsList>
|
||||
<TabsTrigger value="table">Table View</TabsTrigger>
|
||||
<TabsTrigger value="graph">Network Graph</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="table">
|
||||
<AppTable session={session} app={apps} projectId={projectId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="graph">
|
||||
<ProjectNetworkGraph apps={apps} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function BasicAuth({ app, readonly }: {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableCaption>{app.appFileMounts.length} Auth Credentials</TableCaption>
|
||||
<TableCaption>{app.appBasicAuths.length} Auth Credentials</TableCaption>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Username</TableHead>
|
||||
|
||||
167
src/app/project/app/[appId]/advanced/network-policy.tsx
Normal file
167
src/app/project/app/[appId]/advanced/network-policy.tsx
Normal file
@@ -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 (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Network Policy</CardTitle>
|
||||
<CardDescription>
|
||||
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.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between space-x-2 p-4 border rounded-lg">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="use-network-policy">Enable Network Policies</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Control whether network policies are applied to this application
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="use-network-policy"
|
||||
disabled={readonly}
|
||||
checked={useNetworkPolicy}
|
||||
onCheckedChange={setUseNetworkPolicy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!useNetworkPolicy && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertDescription>
|
||||
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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="ingress">Ingress Policy (Incoming Traffic)</Label>
|
||||
<Select
|
||||
disabled={readonly || !useNetworkPolicy}
|
||||
value={ingressPolicy}
|
||||
onValueChange={setIngressPolicy}
|
||||
>
|
||||
<SelectTrigger id="ingress">
|
||||
<SelectValue placeholder="Select Ingress Policy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALLOW_ALL">Allow All (Internet + Project Apps)</SelectItem>
|
||||
<SelectItem value="INTERNET_ONLY">Internet Only</SelectItem>
|
||||
<SelectItem value="NAMESPACE_ONLY">Project Apps Only</SelectItem>
|
||||
<SelectItem value="DENY_ALL">Deny All</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Controls who can connect to your pods.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="egress">Egress Policy (Outgoing Traffic)</Label>
|
||||
<Select
|
||||
disabled={readonly || !useNetworkPolicy}
|
||||
value={egressPolicy}
|
||||
onValueChange={setEgressPolicy}
|
||||
>
|
||||
<SelectTrigger id="egress">
|
||||
<SelectValue placeholder="Select Egress Policy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="ALLOW_ALL">Allow All (Internet + Project Apps)</SelectItem>
|
||||
<SelectItem value="INTERNET_ONLY">Internet Only</SelectItem>
|
||||
<SelectItem value="NAMESPACE_ONLY">Project Apps Only</SelectItem>
|
||||
<SelectItem value="DENY_ALL">Deny All</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Controls where your pods can connect to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
{!readonly && (
|
||||
<CardFooter className="gap-3">
|
||||
<Button onClick={handleSave}>Save</Button>
|
||||
<Dialog open={showHelp} onOpenChange={setShowHelp}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon">
|
||||
<HelpCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Network Policy Types</DialogTitle>
|
||||
<DialogDescription>
|
||||
Understand how each policy type controls traffic to and from your application.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-sm">Allow All (Internet + Project Apps)</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-sm">Internet Only</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-sm">Project Apps Only</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-semibold text-sm">Deny All</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Blocks all incoming or outgoing traffic.
|
||||
Use this for maximum isolation when your application should not communicate with any other service.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</CardFooter>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
</TabsContent>
|
||||
<TabsContent value="advanced" className="space-y-4">
|
||||
<BasicAuth readonly={readonly} app={app} />
|
||||
<NetworkPolicy readonly={readonly} app={app} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex-1 space-y-6 pt-6">
|
||||
<PageTitle
|
||||
title={app.name}
|
||||
subtitle={`App ID: ${app.id}`}>
|
||||
</PageTitle>
|
||||
{showIngressWarning && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<AlertTitle>Warning</AlertTitle>
|
||||
<AlertDescription>
|
||||
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.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
<AppActionButtons session={session} app={app} />
|
||||
{children}
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Network Policies</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex gap-4 flex-wrap">
|
||||
|
||||
<Button variant="destructive" onClick={async () => {
|
||||
if (await useConfirm.openConfirmDialog({
|
||||
title: '⚠️ Delete All Network Policies',
|
||||
description: 'WARNING: This is a bad idea! This action will delete ALL network policies across all namespaces. Your applications will lose all network security restrictions. Only use this for troubleshooting or emergency situations. Are you absolutely sure?',
|
||||
okButton: "Yes, Delete All Policies",
|
||||
})) {
|
||||
Toast.fromAction(() => deleteAllNetworkPolicies());
|
||||
}
|
||||
}}><ShieldOff /> Delete All Network Policies</Button>
|
||||
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>;
|
||||
}
|
||||
@@ -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.`);
|
||||
});
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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<V1Deployment>) {
|
||||
private async createOrUpdateDbToolDeplyoment(app: AppExtendedModel, deplyomentBuilder: (app: AppExtendedModel) => V1Deployment | Promise<V1Deployment>) {
|
||||
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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {}
|
||||
|
||||
|
||||
*/
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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: [
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
})
|
||||
|
||||
4
src/shared/model/network-policy.model.ts
Normal file
4
src/shared/model/network-policy.model.ts
Normal file
@@ -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<typeof appNetworkPolicy>;
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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: [{
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
116
yarn.lock
116
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"
|
||||
|
||||
Reference in New Issue
Block a user