diff --git a/CONTEXT.md b/CONTEXT.md index 67c66b82..d9af41b4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -26,13 +26,18 @@ _Avoid_: SSH key when specifically referring to the provider-side access grant The named Git ref selected as the source revision stream for an **App**. _Avoid_: branch name when referring to the selected source branch +**App Node Port**: +A direct node-level exposure that maps a cluster node port to one container port and protocol on an + ## Relationships - An **App** has exactly one **Source**. +- An **App** can have zero or more **App Node Ports**. - A **Git HTTPS Source** belongs to exactly one **App**. - A **Git SSH Source** belongs to exactly one **App**. - A **Git Source** has exactly one selected **Git Branch** before it can be saved. - A **Git SSH Source** requires a **Deploy Key** before QuickStack offers **Git Branch** selection. +- An **App Node Port** belongs to exactly one **App** and exposes exactly one container port/protocol. ## Example Dialogue @@ -42,7 +47,11 @@ _Avoid_: branch name when referring to the selected source branch > **Dev:** "Can QuickStack list branches for a **Git SSH Source** before the provider knows its **Deploy Key**?" > **Domain expert:** "No, the user must generate the key and register it as a **Deploy Key** with the Git provider before branch selection is shown." +> **Dev:** "If an **App** uses an **App Node Port**, should a restrictive ingress policy still block that node-level traffic?" +> **Domain expert:** "No — creating the **App Node Port** is the explicit decision to expose that one container port/protocol through the cluster node." + ## Flagged Ambiguities - "connect to a git https" means configuring a **Git HTTPS Source** for an **App**. - "branch" means the selected **Git Branch**, not a build branch or deployment branch. +- "node portforwarding" means an **App Node Port**, not an ad hoc developer port-forward session. diff --git a/prisma/migrations/20260406153931_migration/migration.sql b/prisma/migrations/20260406153931_migration/migration.sql new file mode 100644 index 00000000..a11a40b6 --- /dev/null +++ b/prisma/migrations/20260406153931_migration/migration.sql @@ -0,0 +1,14 @@ +-- CreateTable +CREATE TABLE "AppNodePort" ( + "id" TEXT NOT NULL PRIMARY KEY, + "appId" TEXT NOT NULL, + "port" INTEGER NOT NULL, + "nodePort" INTEGER NOT NULL, + "protocol" TEXT NOT NULL DEFAULT 'TCP', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AppNodePort_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "AppNodePort_nodePort_key" ON "AppNodePort"("nodePort"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f9daba2e..64ccfb4f 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -224,6 +224,7 @@ model App { appDomains AppDomain[] appPorts AppPort[] + appNodePorts AppNodePort[] appVolumes AppVolume[] appFileMounts AppFileMount[] appBasicAuths AppBasicAuth[] @@ -257,6 +258,20 @@ model AppPort { @@unique([appId, port]) } +model AppNodePort { + id String @id @default(uuid()) + appId String + app App @relation(fields: [appId], references: [id], onDelete: Cascade) + port Int + nodePort Int + protocol String @default("TCP") + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([nodePort]) +} + model AppDomain { id String @id @default(uuid()) hostname String @unique diff --git a/src/__tests__/git-test-repositories.utils.ts b/src/__tests__/git-test-repositories.utils.ts index f4b30e16..9ba76ea3 100644 --- a/src/__tests__/git-test-repositories.utils.ts +++ b/src/__tests__/git-test-repositories.utils.ts @@ -44,8 +44,9 @@ export function createGitApp(input: Pick ({ default: {} })); + +import * as k8s from '@kubernetes/client-node'; +import type { StartedK3sContainer } from '@testcontainers/k3s'; +import { createK3sTestContext } from '@/__tests__/k3s-test.utils'; +import networkPolicyService from '@/server/services/network-policy.service'; +import svcService from '@/server/services/svc.service'; +import { KubeObjectNameUtils } from '@/server/utils/kube-object-name.utils'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; +import { AppNetworkPolicyType } from '@/shared/model/network-policy.model'; +import { Constants } from '@/shared/utils/constants'; + +const networkPolicyTypes: AppNetworkPolicyType[] = ['ALLOW_ALL', 'INTERNET_ONLY', 'NAMESPACE_ONLY', 'DENY_ALL']; +const networkPolicyCombinations = networkPolicyTypes.flatMap(ingressPolicy => + networkPolicyTypes.map(egressPolicy => [ingressPolicy, egressPolicy] as const)); + +describe('network-policy.service integration', () => { + const ctx = createK3sTestContext(); + + it('creates a NetworkPolicy that allows external ingress to App Node Ports', async () => { + const namespace = 'node-port-policy-test'; + const { core, network } = ctx.getClients(); + await core.createNamespace({ + metadata: { + name: namespace, + }, + }); + + await networkPolicyService.reconcileNetworkPolicy({ + id: 'demo-app', + projectId: namespace, + useNetworkPolicy: true, + ingressNetworkPolicy: 'DENY_ALL', + egressNetworkPolicy: 'DENY_ALL', + appNodePorts: [ + { + id: 'node-port-1', + appId: 'demo-app', + port: 300, + nodePort: 30080, + protocol: 'TCP', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + } as AppExtendedModel); + + const policy = await network.readNamespacedNetworkPolicy(KubeObjectNameUtils.toNetworkPolicyName('demo-app'), namespace); + + expect(policy.body.spec?.ingress).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: [{ ipBlock: { cidr: '0.0.0.0/0' } }], + ports: [{ protocol: 'TCP', port: 300 }], + }), + ]) + ); + }); + + it('creates a standard NetworkPolicy for a normal App with network policies enabled', async () => { + const namespace = 'normal-app-policy-test'; + const appId = 'normal-app'; + const { core, network } = ctx.getClients(); + await core.createNamespace({ + metadata: { + name: namespace, + }, + }); + + await networkPolicyService.reconcileNetworkPolicy(createNetworkPolicyApp({ + id: appId, + projectId: namespace, + useNetworkPolicy: true, + ingressNetworkPolicy: 'ALLOW_ALL', + egressNetworkPolicy: 'ALLOW_ALL', + appNodePorts: [], + })); + + const policy = await network.readNamespacedNetworkPolicy(KubeObjectNameUtils.toNetworkPolicyName(appId), namespace); + + expect(policy.body.spec?.podSelector).toEqual({ + matchLabels: { + app: appId, + }, + }); + expect(policy.body.spec?.policyTypes).toEqual(['Ingress', 'Egress']); + expect(policy.body.spec?.ingress).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: expect.arrayContaining([ + { podSelector: {} }, + ]), + }), + ]) + ); + expect(policy.body.spec?.ingress).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: [{ ipBlock: { cidr: '0.0.0.0/0' } }], + }), + ]) + ); + }); + + it('removes the NetworkPolicy when network policies are turned off for an App', async () => { + const namespace = 'disabled-app-policy-test'; + const appId = 'disabled-policy-app'; + const { core, network } = ctx.getClients(); + await core.createNamespace({ + metadata: { + name: namespace, + }, + }); + + const enabledApp = createNetworkPolicyApp({ + id: appId, + projectId: namespace, + useNetworkPolicy: true, + ingressNetworkPolicy: 'ALLOW_ALL', + egressNetworkPolicy: 'ALLOW_ALL', + appNodePorts: [], + }); + await networkPolicyService.reconcileNetworkPolicy(enabledApp); + + await expect(network.readNamespacedNetworkPolicy(KubeObjectNameUtils.toNetworkPolicyName(appId), namespace)) + .resolves + .toBeDefined(); + + await networkPolicyService.reconcileNetworkPolicy({ + ...enabledApp, + useNetworkPolicy: false, + }); + + const policies = await network.listNamespacedNetworkPolicy(namespace); + expect(policies.body.items.map(policy => policy.metadata?.name)) + .not + .toContain(KubeObjectNameUtils.toNetworkPolicyName(appId)); + }); + + it.each(networkPolicyCombinations)( + 'creates expected rules for ingress %s and egress %s', + async (ingressPolicy, egressPolicy) => { + const namespace = toKubeName(`policy-matrix-${ingressPolicy}-${egressPolicy}`); + const appId = 'matrix-app'; + const { core, network } = ctx.getClients(); + await core.createNamespace({ + metadata: { + name: namespace, + }, + }); + + await networkPolicyService.reconcileNetworkPolicy(createNetworkPolicyApp({ + id: appId, + projectId: namespace, + useNetworkPolicy: true, + ingressNetworkPolicy: ingressPolicy, + egressNetworkPolicy: egressPolicy, + appNodePorts: [], + })); + + const policy = await network.readNamespacedNetworkPolicy(KubeObjectNameUtils.toNetworkPolicyName(appId), namespace); + + expectIngressRules(policy.body.spec?.ingress ?? [], ingressPolicy); + expectEgressRules(policy.body.spec?.egress ?? [], egressPolicy); + } + ); + + it('allows an nginx Deployment to be reached through a node on NodePort 30081', async () => { + const app = createNginxApp(); + const { core, apps } = ctx.getClients(); + await core.createNamespace({ + metadata: { + name: app.projectId, + }, + }); + + await apps.createNamespacedDeployment(app.projectId, { + metadata: { + name: app.id, + }, + spec: { + replicas: 1, + selector: { + matchLabels: { + app: app.id, + }, + }, + template: { + metadata: { + labels: { + app: app.id, + }, + }, + spec: { + containers: [ + { + name: 'nginx', + image: 'nginx:1.27-alpine', + ports: [ + { + containerPort: 80, + protocol: 'TCP', + }, + ], + }, + ], + }, + }, + }, + }); + + await svcService.createOrUpdateServiceForApp('deployment-1', app); + await networkPolicyService.reconcileNetworkPolicy(app); + + const deployment = await waitForDeploymentAvailable(apps, app.projectId, app.id); + expect(deployment.status?.availableReplicas).toBe(1); + + const response = await fetchNodePortFromK3sNode(ctx.getContainer(), 30081); + expect(response.exitCode).toBe(0); + expect(response.stdout).toContain('Welcome to nginx!'); + }, 180_000); +}); + +function createNetworkPolicyApp(overrides: Pick): AppExtendedModel { + return { + ...createNginxApp(), + project: { + id: overrides.projectId, + name: overrides.projectId, + createdAt: new Date(), + updatedAt: new Date(), + }, + name: overrides.id, + sourceType: 'CONTAINER', + containerImageSource: 'nginx:1.27-alpine', + appDomains: [], + appPorts: [], + appVolumes: [], + appFileMounts: [], + appBasicAuths: [], + ...overrides, + }; +} + +function createNginxApp(): AppExtendedModel { + return { + id: 'nginx-node-port-app', + name: 'Nginx Node Port App', + appType: 'APP', + projectId: 'nginx-node-port-test', + project: { + id: 'nginx-node-port-test', + name: 'Nginx Node Port Test', + createdAt: new Date(), + updatedAt: new Date(), + }, + sourceType: 'CONTAINER', + buildMethod: 'RAILPACK', + containerImageSource: 'nginx:1.27-alpine', + containerRegistryUsername: null, + containerRegistryPassword: null, + containerCommand: null, + containerArgs: null, + securityContextRunAsUser: null, + securityContextRunAsGroup: null, + securityContextFsGroup: null, + securityContextPrivileged: false, + gitUrl: null, + gitBranch: null, + gitUsername: null, + gitToken: null, + dockerfilePath: './Dockerfile', + replicas: 1, + envVars: '', + memoryReservation: null, + memoryLimit: null, + cpuReservation: null, + cpuLimit: null, + webhookId: null, + ingressNetworkPolicy: 'DENY_ALL', + egressNetworkPolicy: 'DENY_ALL', + useNetworkPolicy: true, + healthChechHttpGetPath: null, + healthCheckHttpScheme: null, + healthCheckHttpHeadersJson: null, + healthCheckHttpPort: null, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: 3, + healthCheckTcpPort: null, + appDomains: [], + appPorts: [], + appNodePorts: [ + { + id: 'nginx-node-port', + appId: 'nginx-node-port-app', + port: 80, + nodePort: 30081, + protocol: 'TCP', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + appVolumes: [], + appFileMounts: [], + appBasicAuths: [], + createdAt: new Date(), + updatedAt: new Date(), + }; +} + +function expectIngressRules(rules: k8s.V1NetworkPolicyIngressRule[], policyType: AppNetworkPolicyType) { + const peers = rules.flatMap(rule => rule.from ?? []); + const expectedPeers: Record = { + ALLOW_ALL: { + traefik: true, + namespace: true, + backupJob: false, + dbTool: false, + }, + INTERNET_ONLY: { + traefik: true, + namespace: false, + backupJob: true, + dbTool: true, + }, + NAMESPACE_ONLY: { + traefik: false, + namespace: true, + backupJob: false, + dbTool: false, + }, + DENY_ALL: { + traefik: false, + namespace: false, + backupJob: true, + dbTool: true, + }, + }; + + expect(hasTraefikIngressPeer(peers)).toBe(expectedPeers[policyType].traefik); + expect(hasSameNamespacePeer(peers)).toBe(expectedPeers[policyType].namespace); + expect(hasContainerTypePeer(peers, Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_BACKUP_JOB)) + .toBe(expectedPeers[policyType].backupJob); + expect(hasContainerTypePeer(peers, Constants.QS_ANNOTATION_CONTAINER_TYPE_DB_TOOL)) + .toBe(expectedPeers[policyType].dbTool); +} + +function expectEgressRules(rules: k8s.V1NetworkPolicyEgressRule[], policyType: AppNetworkPolicyType) { + const expectedRules: Record = { + ALLOW_ALL: { + dns: true, + internet: true, + namespace: true, + }, + INTERNET_ONLY: { + dns: true, + internet: true, + namespace: false, + }, + NAMESPACE_ONLY: { + dns: true, + internet: false, + namespace: true, + }, + DENY_ALL: { + dns: false, + internet: false, + namespace: false, + }, + }; + + expect(hasDnsEgressRule(rules)).toBe(expectedRules[policyType].dns); + expect(hasInternetEgressPeer(rules)).toBe(expectedRules[policyType].internet); + expect(hasSameNamespaceEgressPeer(rules)).toBe(expectedRules[policyType].namespace); +} + +function hasTraefikIngressPeer(peers: k8s.V1NetworkPolicyPeer[]) { + return peers.some(peer => + peer.namespaceSelector?.matchLabels?.['kubernetes.io/metadata.name'] === 'kube-system' && + peer.podSelector?.matchLabels?.['app.kubernetes.io/name'] === 'traefik'); +} + +function hasSameNamespacePeer(peers: k8s.V1NetworkPolicyPeer[]) { + return peers.some(peer => isEmptySelector(peer.podSelector)); +} + +function hasContainerTypePeer(peers: k8s.V1NetworkPolicyPeer[], containerType: string) { + return peers.some(peer => + peer.podSelector?.matchLabels?.[Constants.QS_ANNOTATION_CONTAINER_TYPE] === containerType); +} + +function hasDnsEgressRule(rules: k8s.V1NetworkPolicyEgressRule[]) { + return rules.some(rule => { + const ports = rule.ports ?? []; + const destinations = rule.to ?? []; + return ports.some(port => port.protocol === 'UDP' && port.port === 53) && + ports.some(port => port.protocol === 'TCP' && port.port === 53) && + destinations.some(destination => + destination.namespaceSelector?.matchLabels?.['kubernetes.io/metadata.name'] === 'kube-system' && + destination.podSelector?.matchLabels?.['k8s-app'] === 'kube-dns') && + destinations.some(destination => + destination.namespaceSelector?.matchLabels?.['kubernetes.io/metadata.name'] === 'kube-system' && + destination.podSelector?.matchLabels?.['k8s-app'] === 'coredns'); + }); +} + +function hasInternetEgressPeer(rules: k8s.V1NetworkPolicyEgressRule[]) { + return rules.some(rule => + (rule.to ?? []).some(destination => + destination.ipBlock?.cidr === '0.0.0.0/0' && + destination.ipBlock?.except?.includes('10.0.0.0/8') && + destination.ipBlock?.except?.includes('172.16.0.0/12') && + destination.ipBlock?.except?.includes('192.168.0.0/16'))); +} + +function hasSameNamespaceEgressPeer(rules: k8s.V1NetworkPolicyEgressRule[]) { + return rules.some(rule => + (rule.to ?? []).some(destination => + isEmptySelector(destination.podSelector))); +} + +function isEmptySelector(selector: k8s.V1LabelSelector | undefined) { + return !!selector && + Object.keys(selector.matchLabels ?? {}).length === 0 && + (selector.matchExpressions ?? []).length === 0; +} + +function toKubeName(value: string) { + return value.toLowerCase().replace(/_/g, '-'); +} + +async function fetchNodePortFromK3sNode(container: StartedK3sContainer, nodePort: number) { + let lastResponse: Awaited> | undefined; + for (let attempt = 0; attempt < 30; attempt++) { + lastResponse = await container.exec([ + '/bin/sh', + '-c', + `wget -q -O - http://127.0.0.1:${nodePort}`, + ]); + if (lastResponse.exitCode === 0) { + return lastResponse; + } + await sleep(1_000); + } + return lastResponse!; +} + +async function waitForDeploymentAvailable( + apps: k8s.AppsV1Api, + namespace: string, + name: string +) { + return await waitFor(async () => { + const deployment = await apps.readNamespacedDeployment(name, namespace); + const status = deployment.body.status; + const available = status?.conditions?.some(condition => + condition.type === 'Available' && condition.status === 'True'); + if (available && status?.readyReplicas === 1 && status?.availableReplicas === 1) { + return deployment.body; + } + return undefined; + }, `Deployment ${name} was not deployed in namespace ${namespace}.`); +} + +async function waitFor(predicate: () => Promise, message: string): Promise { + for (let attempt = 0; attempt < 60; attempt++) { + const result = await predicate(); + if (result) { + return result; + } + await sleep(1_000); + } + throw new Error(message); +} + +function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/src/__tests__/k3s-test.utils.ts b/src/__tests__/k3s-test.utils.ts index bf2b79b9..3cd98886 100644 --- a/src/__tests__/k3s-test.utils.ts +++ b/src/__tests__/k3s-test.utils.ts @@ -145,5 +145,12 @@ export function createK3sTestContext(image = DEFAULT_IMAGE) { }; } - return { getKubeConfig, getClients }; + function getContainer(): StartedK3sContainer { + if (!container) { + throw new Error('K3s test context not initialised. Ensure createK3sTestContext() was called inside a describe block.'); + } + return container; + } + + return { getKubeConfig, getClients, getContainer }; } diff --git a/src/__tests__/prisma-test.utils.ts b/src/__tests__/prisma-test.utils.ts index ca677820..3312a7e9 100644 --- a/src/__tests__/prisma-test.utils.ts +++ b/src/__tests__/prisma-test.utils.ts @@ -55,6 +55,7 @@ export function createPrismaTestContext(label: string) { await dataAccess.client.appFileMount.deleteMany(); await dataAccess.client.appDomain.deleteMany(); await dataAccess.client.appPort.deleteMany(); + await dataAccess.client.appNodePort.deleteMany(); await dataAccess.client.roleAppPermission.deleteMany(); await dataAccess.client.roleProjectPermission.deleteMany(); // AppVolume has a self-referential relation; clear the FK first @@ -73,7 +74,7 @@ export function createPrismaTestContext(label: string) { }); afterAll(async () => { - await testClient.$disconnect(); + await testClient?.$disconnect(); await fs.rm(dbFile, { force: true }); }); diff --git a/src/app/project/app/[appId]/app-tabs.tsx b/src/app/project/app/[appId]/app-tabs.tsx index 79f56bde..c0fd40ab 100644 --- a/src/app/project/app/[appId]/app-tabs.tsx +++ b/src/app/project/app/[appId]/app-tabs.tsx @@ -14,6 +14,7 @@ import BuildsTab from "./overview/deployments"; import Logs from "./overview/logs"; import MonitoringTab from "./overview/monitoring-app"; import InternalHostnames from "./domains/ports-and-internal-hostnames"; +import NodePortsCard from "./domains/node-ports"; import FileMount from "./volumes/file-mount"; import WebhookDeploymentInfo from "./overview/webhook-deployment"; import DbCredentials from "./credentials/db-crendentials"; @@ -86,6 +87,7 @@ export default function AppTabs({ + diff --git a/src/app/project/app/[appId]/domains/actions.ts b/src/app/project/app/[appId]/domains/actions.ts index 6fb8b257..cedc592b 100644 --- a/src/app/project/app/[appId]/domains/actions.ts +++ b/src/app/project/app/[appId]/domains/actions.ts @@ -2,6 +2,7 @@ import { AppPortModel, appPortZodModel } from "@/shared/model/default-port.model"; import { appDomainEditZodModel } from "@/shared/model/domain-edit.model"; +import { nodePortEditZodModel } from "@/shared/model/node-port-edit.model"; import { SuccessActionResult } from "@/shared/model/server-action-error-return.model"; import appService from "@/server/services/app.service"; import { getAuthUserSession, isAuthorizedWriteForApp, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; @@ -67,4 +68,25 @@ export const getQuickstackDomainSuffix = async () => simpleAction(async () => { throw new ServiceException('Please set the main public IPv4 address in the QuickStack settings first.'); } return HostnameDnsProviderUtils.getHexHostnameForIpAddress(publicIpv4); -}); \ No newline at end of file +}); + +const actionNodePortEditZodModel = nodePortEditZodModel.extend({ + appId: z.string(), + id: z.string().nullish(), +}); + +export const saveNodePort = async (prevState: any, inputData: z.infer) => + saveFormAction(inputData, actionNodePortEditZodModel, async (validatedData) => { + await isAuthorizedWriteForApp(validatedData.appId); + await appService.saveNodePort({ + ...validatedData, + id: validatedData.id ?? undefined, + }); + }); + +export const deleteNodePort = async (nodePortId: string) => + simpleAction(async () => { + await isAuthorizedWriteForApp(await appService.getNodePortById(nodePortId).then(np => np.appId)); + await appService.deleteNodePortById(nodePortId); + return new SuccessActionResult(undefined, 'Successfully deleted node port'); + }); diff --git a/src/app/project/app/[appId]/domains/node-port-edit-dialog.tsx b/src/app/project/app/[appId]/domains/node-port-edit-dialog.tsx new file mode 100644 index 00000000..a39e4977 --- /dev/null +++ b/src/app/project/app/[appId]/domains/node-port-edit-dialog.tsx @@ -0,0 +1,140 @@ +'use client' + +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { useFormState } from 'react-dom' +import { useEffect, useState } from "react"; +import { FormUtils } from "@/frontend/utils/form.utilts"; +import { SubmitButton } from "@/components/custom/submit-button"; +import { AppNodePort } from "@prisma/client" +import { ServerActionResult } from "@/shared/model/server-action-error-return.model" +import { saveNodePort } from "./actions" +import { toast } from "sonner" +import { NodePortEditModel, nodePortEditZodModel } from "@/shared/model/node-port-edit.model" + +export default function NodePortEditDialog({ children, appNodePort, appId }: { children: React.ReactNode; appNodePort?: AppNodePort; appId: string; }) { + + const [isOpen, setIsOpen] = useState(false); + + const form = useForm({ + resolver: zodResolver(nodePortEditZodModel), + defaultValues: appNodePort ? { + port: appNodePort.port, + nodePort: appNodePort.nodePort, + protocol: appNodePort.protocol as 'TCP' | 'UDP', + } : { protocol: 'TCP' } + }); + + const [state, formAction] = useFormState( + (state: ServerActionResult, payload: NodePortEditModel) => + saveNodePort(state, { ...payload, appId, id: appNodePort?.id }), + FormUtils.getInitialFormState() + ); + + useEffect(() => { + if (state.status === 'success') { + form.reset(); + toast.success('Node port saved successfully.', { + description: 'Click "deploy" to apply the changes to your app.', + }); + setIsOpen(false); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [state]); + + useEffect(() => { + if (appNodePort) { + form.reset({ + port: appNodePort.port, + nodePort: appNodePort.nodePort, + protocol: appNodePort.protocol as 'TCP' | 'UDP', + }); + } + }, [appNodePort]); + + return ( + <> +
setIsOpen(true)}> + {children} +
+ setIsOpen(false)}> + + + {appNodePort ? 'Edit' : 'Add'} Node Port + + Expose this app directly on a host/node port. Changes take effect after redeployment. + + +
+ form.handleSubmit((data) => { + return formAction(data); + })()}> +
+ ( + + Container Port + + + + + + )} + /> + ( + + Node Port + + + + + + )} + /> + ( + + Protocol + + + + )} + /> +

{state.message}

+ Save +
+
+ +
+
+ + ); +} diff --git a/src/app/project/app/[appId]/domains/node-ports.tsx b/src/app/project/app/[appId]/domains/node-ports.tsx new file mode 100644 index 00000000..556b6477 --- /dev/null +++ b/src/app/project/app/[appId]/domains/node-ports.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { deleteNodePort } from "./actions"; +import { AppExtendedModel } from "@/shared/model/app-extended.model"; +import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import NodePortEditDialog from "./node-port-edit-dialog"; +import { Button } from "@/components/ui/button"; +import { EditIcon, Plus, TrashIcon } from "lucide-react"; +import { Toast } from "@/frontend/utils/toast.utils"; +import { useConfirmDialog } from "@/frontend/states/zustand.states"; + +export default function NodePortsCard({ app, readonly }: { + app: AppExtendedModel; + readonly: boolean; +}) { + const { openConfirmDialog: openDialog } = useConfirmDialog(); + + const asyncDeleteNodePort = async (nodePortId: string) => { + const confirm = await openDialog({ + title: 'Delete Node Port', + description: 'The node port will be removed and the changes will take effect after you redeploy the app. Are you sure you want to remove this node port?', + okButton: 'Delete Node Port', + }); + if (confirm) { + await Toast.fromAction(() => deleteNodePort(nodePortId)); + } + }; + + return ( + + + Node Ports + + Expose this app directly on a node/host port, bypassing Traefik. Useful for non-HTTP workloads such as SFTP, game servers, or other TCP/UDP services. + + + + + {app.appNodePorts.length} Node Port{app.appNodePorts.length !== 1 ? 's' : ''} + + + Container Port + Node Port + Protocol + {!readonly && Actions} + + + + {app.appNodePorts.map((np) => ( + + {np.port} + {np.nodePort} + {np.protocol} + {!readonly && ( + + + + + + + )} + + ))} + +
+
+ {!readonly && ( + + + + + + )} +
+ ); +} diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts index 67de3839..1d927e81 100644 --- a/src/server/services/app.service.ts +++ b/src/server/services/app.service.ts @@ -1,7 +1,7 @@ import { revalidateTag, unstable_cache } from "next/cache"; import dataAccess from "../adapter/db.client"; import { Tags } from "../utils/cache-tag-generator.utils"; -import { App, AppBasicAuth, AppDomain, AppFileMount, AppPort, AppVolume, Prisma } from "@prisma/client"; +import { App, AppBasicAuth, AppDomain, AppFileMount, AppNodePort, AppPort, AppVolume, Prisma } from "@prisma/client"; import { AppExtendedModel, AppWithProjectModel } from "@/shared/model/app-extended.model"; import { ServiceException } from "@/shared/model/service.exception.model"; import { KubeObjectNameUtils } from "../utils/kube-object-name.utils"; @@ -13,7 +13,7 @@ import svcService from "./svc.service"; import deploymentLogService, { dlog } from "./deployment-logs.service"; import crypto from "crypto"; import networkPolicyService from "./network-policy.service"; -import { AppBasicAuthModel, AppDomainModel, AppFileMountModel, AppModel, AppPortModel, AppVolumeModel } from "@/shared/model/generated-zod"; +import { AppBasicAuthModel, AppDomainModel, AppFileMountModel, AppModel, AppNodePortModel, AppPortModel, AppVolumeModel } from "@/shared/model/generated-zod"; import { z } from "zod"; class AppService { @@ -102,6 +102,7 @@ class AppService { appDomains: true, appVolumes: true, appPorts: true, + appNodePorts: true, appFileMounts: true, appBasicAuths: true }; @@ -229,6 +230,14 @@ class AppService { }, tx); } + const parsedNodePorts = AppNodePortModel.merge(optionalParam).array().parse(app.appNodePorts); + for (const nodePort of parsedNodePorts) { + await this.saveNodePort({ + ...nodePort, + appId: app.id + }, tx); + } + const parsedBasicAuths = AppBasicAuthModel.merge(optionalParam).array().parse(app.appBasicAuths); for (const basicAuth of parsedBasicAuths) { await this.saveBasicAuth({ @@ -632,6 +641,64 @@ class AppService { }); return apps; } + + async saveNodePort(nodePortToBeSaved: Prisma.AppNodePortUncheckedCreateInput | Prisma.AppNodePortUncheckedUpdateInput, tx?: Prisma.TransactionClient) { + const client = tx || dataAccess.client; + const existingApp = await this.getExtendedById(nodePortToBeSaved.appId as string, false, client); + + const nodePortValue = nodePortToBeSaved.nodePort as number; + const existingWithSameNodePort = await client.appNodePort.findFirst({ + where: { + nodePort: nodePortValue, + NOT: { id: nodePortToBeSaved.id as string | undefined }, + } + }); + if (existingWithSameNodePort) { + throw new ServiceException(`Node port ${nodePortValue} is already in use by another app.`); + } + + let savedItem: AppNodePort; + try { + if (nodePortToBeSaved.id) { + savedItem = await client.appNodePort.update({ + where: { id: nodePortToBeSaved.id as string }, + data: nodePortToBeSaved, + }); + } else { + savedItem = await client.appNodePort.create({ + data: nodePortToBeSaved as Prisma.AppNodePortUncheckedCreateInput, + }); + } + } finally { + revalidateTag(Tags.apps(existingApp.projectId as string)); + revalidateTag(Tags.app(existingApp.id as string)); + } + return savedItem; + } + + async getNodePortById(id: string) { + return await dataAccess.client.appNodePort.findFirstOrThrow({ + where: { id }, + }); + } + + async deleteNodePortById(id: string) { + const existing = await dataAccess.client.appNodePort.findFirst({ + where: { id }, + include: { app: true }, + }); + if (!existing) { + return; + } + try { + await dataAccess.client.appNodePort.delete({ + where: { id }, + }); + } finally { + revalidateTag(Tags.app(existing.appId)); + revalidateTag(Tags.apps(existing.app.projectId)); + } + } } const appService = new AppService(); diff --git a/src/server/services/app.service.unit.spec.ts b/src/server/services/app.service.unit.spec.ts new file mode 100644 index 00000000..d2add662 --- /dev/null +++ b/src/server/services/app.service.unit.spec.ts @@ -0,0 +1,120 @@ +vi.mock('next/cache', () => ({ + revalidateTag: vi.fn(), + unstable_cache: vi.fn().mockImplementation( + (fn: (...args: unknown[]) => Promise) => + (...args: unknown[]) => + fn(...args) + ), +})); + +vi.mock('@/server/adapter/db.client', () => ({ default: { client: {} } })); +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ default: {} })); +vi.mock('@/server/services/deployment.service', () => ({ default: {} })); +vi.mock('@/server/services/build.service', () => ({ default: {} })); +vi.mock('@/server/services/ingress.service', () => ({ default: {} })); +vi.mock('@/server/services/pvc.service', () => ({ default: {} })); +vi.mock('@/server/services/svc.service', () => ({ default: {} })); +vi.mock('@/server/services/deployment-logs.service', () => ({ default: {}, dlog: vi.fn() })); +vi.mock('@/server/services/network-policy.service', () => ({ default: {} })); + +import appService from './app.service'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; + +describe('app.service', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('persists App Node Ports when saving an extended App', async () => { + vi.spyOn(appService, 'save').mockResolvedValue({} as never); + vi.spyOn(appService, 'saveDomain').mockResolvedValue({} as never); + vi.spyOn(appService, 'saveVolume').mockResolvedValue({} as never); + vi.spyOn(appService, 'saveFileMount').mockResolvedValue({} as never); + vi.spyOn(appService, 'savePort').mockResolvedValue({} as never); + vi.spyOn(appService, 'saveBasicAuth').mockResolvedValue({} as never); + const saveNodePort = vi.spyOn(appService, 'saveNodePort').mockResolvedValue({} as never); + + await appService.saveAppExtendedModel(createApp({ + appNodePorts: [ + { + id: 'node-port-1', + appId: 'demo-app', + port: 300, + nodePort: 30080, + protocol: 'TCP', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + })); + + expect(saveNodePort).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'node-port-1', + appId: 'demo-app', + port: 300, + nodePort: 30080, + protocol: 'TCP', + }), + undefined + ); + }); +}); + +function createApp(overrides: Partial): AppExtendedModel { + return { + id: 'demo-app', + name: 'Demo App', + appType: 'APP', + projectId: 'demo-project', + project: { + id: 'demo-project', + name: 'Demo Project', + createdAt: new Date(), + updatedAt: new Date(), + }, + sourceType: 'CONTAINER', + buildMethod: 'RAILPACK', + containerImageSource: null, + containerRegistryUsername: null, + containerRegistryPassword: null, + containerCommand: null, + containerArgs: null, + securityContextRunAsUser: null, + securityContextRunAsGroup: null, + securityContextFsGroup: null, + securityContextPrivileged: false, + gitUrl: null, + gitBranch: null, + gitUsername: null, + gitToken: null, + dockerfilePath: './Dockerfile', + replicas: 1, + envVars: '', + memoryReservation: null, + memoryLimit: null, + cpuReservation: null, + cpuLimit: null, + webhookId: null, + ingressNetworkPolicy: 'ALLOW_ALL', + egressNetworkPolicy: 'ALLOW_ALL', + useNetworkPolicy: true, + healthChechHttpGetPath: null, + healthCheckHttpScheme: null, + healthCheckHttpHeadersJson: null, + healthCheckHttpPort: null, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: 3, + healthCheckTcpPort: null, + appDomains: [], + appPorts: [], + appNodePorts: [], + appVolumes: [], + appFileMounts: [], + appBasicAuths: [], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} diff --git a/src/server/services/network-policy.service.ts b/src/server/services/network-policy.service.ts index 556292ee..a5747d0b 100644 --- a/src/server/services/network-policy.service.ts +++ b/src/server/services/network-policy.service.ts @@ -41,7 +41,7 @@ class NetworkPolicyService { } }, policyTypes: ["Ingress", "Egress"], - ingress: this.getIngressRules(ingressPolicy), + ingress: this.getIngressRules(ingressPolicy, app.appNodePorts), egress: this.getEgressRules(egressPolicy) } }; @@ -53,7 +53,7 @@ class NetworkPolicyService { return parsed.success ? parsed.data : 'ALLOW_ALL'; } - private getIngressRules(policyType: AppNetworkPolicyType): V1NetworkPolicyIngressRule[] { + private getIngressRules(policyType: AppNetworkPolicyType, nodePorts: { port: number; protocol?: string }[] = []): V1NetworkPolicyIngressRule[] { const rules: V1NetworkPolicyIngressRule[] = []; const traefikFrom: V1NetworkPolicyPeer[] = [ @@ -137,6 +137,26 @@ class NetworkPolicyService { }); } + if (nodePorts.length > 0) { + const exposedPorts = nodePorts + .filter((nodePort, index, self) => + index === self.findIndex(item => + item.port === nodePort.port && (item.protocol || 'TCP') === (nodePort.protocol || 'TCP'))) + .map(nodePort => ({ + protocol: (nodePort.protocol || 'TCP') as any, + port: nodePort.port as any + })); + + rules.push({ + from: [{ + ipBlock: { + cidr: '0.0.0.0/0' + } + }], + ports: exposedPorts + }); + } + return rules; } @@ -447,4 +467,3 @@ const networkPolicyService = new NetworkPolicyService(); export default networkPolicyService; - diff --git a/src/server/services/network-policy.service.unit.spec.ts b/src/server/services/network-policy.service.unit.spec.ts new file mode 100644 index 00000000..aef66812 --- /dev/null +++ b/src/server/services/network-policy.service.unit.spec.ts @@ -0,0 +1,61 @@ +const k3sMocks = vi.hoisted(() => ({ + listNamespacedNetworkPolicy: vi.fn(), + createNamespacedNetworkPolicy: vi.fn(), + replaceNamespacedNetworkPolicy: vi.fn(), + deleteNamespacedNetworkPolicy: vi.fn(), +})); + +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ + default: { + network: { + listNamespacedNetworkPolicy: k3sMocks.listNamespacedNetworkPolicy, + createNamespacedNetworkPolicy: k3sMocks.createNamespacedNetworkPolicy, + replaceNamespacedNetworkPolicy: k3sMocks.replaceNamespacedNetworkPolicy, + deleteNamespacedNetworkPolicy: k3sMocks.deleteNamespacedNetworkPolicy, + }, + }, +})); + +import networkPolicyService from './network-policy.service'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; + +describe('network-policy.service', () => { + beforeEach(() => { + vi.clearAllMocks(); + k3sMocks.listNamespacedNetworkPolicy.mockResolvedValue({ body: { items: [] } }); + }); + + it('allows external ingress to configured App Node Ports', async () => { + const app = { + id: 'demo-app', + projectId: 'demo-project', + useNetworkPolicy: true, + ingressNetworkPolicy: 'DENY_ALL', + egressNetworkPolicy: 'DENY_ALL', + appNodePorts: [ + { + id: 'node-port-1', + appId: 'demo-app', + port: 300, + nodePort: 30080, + protocol: 'TCP', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + } as AppExtendedModel; + + await networkPolicyService.reconcileNetworkPolicy(app); + + expect(k3sMocks.createNamespacedNetworkPolicy).toHaveBeenCalledTimes(1); + const [, policy] = k3sMocks.createNamespacedNetworkPolicy.mock.calls[0]; + expect(policy.spec.ingress).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + from: [{ ipBlock: { cidr: '0.0.0.0/0' } }], + ports: [{ protocol: 'TCP', port: 300 }], + }), + ]) + ); + }); +}); diff --git a/src/server/services/standalone-services/database-backup.service.ts b/src/server/services/standalone-services/database-backup.service.ts index eca64e80..23dd8b7a 100644 --- a/src/server/services/standalone-services/database-backup.service.ts +++ b/src/server/services/standalone-services/database-backup.service.ts @@ -21,6 +21,7 @@ class DatabaseBackupService { project: true, appDomains: true, appPorts: true, + appNodePorts: true, appBasicAuths: true, appVolumes: true, appFileMounts: true diff --git a/src/server/services/svc.service.ts b/src/server/services/svc.service.ts index 18ebb6c4..b2616f2c 100644 --- a/src/server/services/svc.service.ts +++ b/src/server/services/svc.service.ts @@ -32,6 +32,8 @@ class SvcService { name: string; port: number; targetPort: number; + nodePort?: number; + protocol?: string; }[] = [ ...app.appDomains.map((domain) => ({ name: `domain-port-${domain.id}`, @@ -47,11 +49,29 @@ class SvcService { index === self.findIndex((t) => (t.port === port.port && t.targetPort === port.targetPort))); + for (const np of app.appNodePorts) { + const existing = ports.find(p => p.port === np.port); + if (existing) { + existing.nodePort = np.nodePort; + existing.protocol = np.protocol; + } else { + ports.push({ + name: `nodeport-${np.id}`, + port: np.port, + targetPort: np.port, + nodePort: np.nodePort, + protocol: np.protocol, + }); + } + } + + const serviceType = app.appNodePorts.length > 0 ? 'NodePort' : undefined; + if (ports.length === 0) { dlog(deplyomentId, `No domain or internal port settings found, service (HTTP) will not be created or updated. The application will run, but will not be accessible via the internal network or the internet.`); } - await this.createOrUpdateService(app.projectId, app.id, ports); + await this.createOrUpdateService(app.projectId, app.id, ports, serviceType); dlog(deplyomentId, `Updating service (HTTP) with ports ${ports.map(x => x.port).join(', ')}...`); @@ -61,7 +81,9 @@ class SvcService { name: string; port: number; targetPort: number; - }[]) { + nodePort?: number; + protocol?: string; + }[], serviceType?: string) { const existingService = await this.getService(namespace, kubeAppName); // port configuration with removed duplicates @@ -77,6 +99,7 @@ class SvcService { name: KubeObjectNameUtils.toServiceName(kubeAppName) }, spec: { + ...(serviceType ? { type: serviceType } : {}), selector: { app: kubeAppName }, diff --git a/src/server/services/svc.service.unit.spec.ts b/src/server/services/svc.service.unit.spec.ts new file mode 100644 index 00000000..4036c8e8 --- /dev/null +++ b/src/server/services/svc.service.unit.spec.ts @@ -0,0 +1,169 @@ +const k3sMocks = vi.hoisted(() => ({ + listNamespacedService: vi.fn(), + readNamespacedService: vi.fn(), + createNamespacedService: vi.fn(), + replaceNamespacedService: vi.fn(), + deleteNamespacedService: vi.fn(), +})); + +const logMocks = vi.hoisted(() => ({ + dlog: vi.fn(), +})); + +vi.mock('@/server/adapter/kubernetes-api.adapter', () => ({ + default: { + core: { + listNamespacedService: k3sMocks.listNamespacedService, + readNamespacedService: k3sMocks.readNamespacedService, + createNamespacedService: k3sMocks.createNamespacedService, + replaceNamespacedService: k3sMocks.replaceNamespacedService, + deleteNamespacedService: k3sMocks.deleteNamespacedService, + }, + }, +})); + +vi.mock('@/server/services/deployment-logs.service', () => ({ + dlog: logMocks.dlog, +})); + +import svcService from './svc.service'; +import { AppExtendedModel } from '@/shared/model/app-extended.model'; + +describe('svc.service', () => { + beforeEach(() => { + vi.clearAllMocks(); + k3sMocks.listNamespacedService.mockResolvedValue({ body: { items: [] } }); + }); + + it('creates a NodePort service for an App with only an App Node Port', async () => { + const app = createApp({ + appNodePorts: [ + { + id: 'node-port-1', + appId: 'demo-app', + port: 300, + nodePort: 30080, + protocol: 'TCP', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + await svcService.createOrUpdateServiceForApp('deployment-1', app); + + expect(k3sMocks.createNamespacedService).toHaveBeenCalledTimes(1); + const [, service] = k3sMocks.createNamespacedService.mock.calls[0]; + expect(service.spec).toMatchObject({ + type: 'NodePort', + ports: [ + { + name: 'nodeport-node-port-1', + port: 300, + targetPort: 300, + nodePort: 30080, + protocol: 'TCP', + }, + ], + }); + }); + + it('merges an App Node Port into an existing app port for the same container port', async () => { + const app = createApp({ + appPorts: [ + { + id: 'app-port-1', + appId: 'demo-app', + port: 300, + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + appNodePorts: [ + { + id: 'node-port-1', + appId: 'demo-app', + port: 300, + nodePort: 30080, + protocol: 'UDP', + createdAt: new Date(), + updatedAt: new Date(), + }, + ], + }); + + await svcService.createOrUpdateServiceForApp('deployment-1', app); + + const [, service] = k3sMocks.createNamespacedService.mock.calls[0]; + expect(service.spec).toMatchObject({ + type: 'NodePort', + ports: [ + { + name: 'default-port-app-port-1', + port: 300, + targetPort: 300, + nodePort: 30080, + protocol: 'UDP', + }, + ], + }); + }); +}); + +function createApp(overrides: Partial): AppExtendedModel { + return { + id: 'demo-app', + name: 'Demo App', + appType: 'APP', + projectId: 'demo-project', + project: { + id: 'demo-project', + name: 'Demo Project', + createdAt: new Date(), + updatedAt: new Date(), + }, + sourceType: 'CONTAINER', + buildMethod: 'RAILPACK', + containerImageSource: null, + containerRegistryUsername: null, + containerRegistryPassword: null, + containerCommand: null, + containerArgs: null, + securityContextRunAsUser: null, + securityContextRunAsGroup: null, + securityContextFsGroup: null, + securityContextPrivileged: false, + gitUrl: null, + gitBranch: null, + gitUsername: null, + gitToken: null, + dockerfilePath: './Dockerfile', + replicas: 1, + envVars: '', + memoryReservation: null, + memoryLimit: null, + cpuReservation: null, + cpuLimit: null, + webhookId: null, + ingressNetworkPolicy: 'ALLOW_ALL', + egressNetworkPolicy: 'ALLOW_ALL', + useNetworkPolicy: true, + healthChechHttpGetPath: null, + healthCheckHttpScheme: null, + healthCheckHttpHeadersJson: null, + healthCheckHttpPort: null, + healthCheckPeriodSeconds: 15, + healthCheckTimeoutSeconds: 5, + healthCheckFailureThreshold: 3, + healthCheckTcpPort: null, + appDomains: [], + appPorts: [], + appNodePorts: [], + appVolumes: [], + appFileMounts: [], + appBasicAuths: [], + createdAt: new Date(), + updatedAt: new Date(), + ...overrides, + }; +} diff --git a/src/server/utils/kube-object-name.utils.ts b/src/server/utils/kube-object-name.utils.ts index b47aba6e..55eb705d 100644 --- a/src/server/utils/kube-object-name.utils.ts +++ b/src/server/utils/kube-object-name.utils.ts @@ -49,6 +49,10 @@ export class KubeObjectNameUtils { return `svc-${appId}`; } + static toNodePortServiceName(appId: string): `np-${string}` { + return `np-${appId}`; + } + static toPvcName(volumeId: string): `pvc-${string}` { return `pvc-${volumeId}`; } diff --git a/src/shared/model/app-extended.model.ts b/src/shared/model/app-extended.model.ts index bf88f7d0..26bd7d47 100644 --- a/src/shared/model/app-extended.model.ts +++ b/src/shared/model/app-extended.model.ts @@ -1,11 +1,12 @@ import { z } from "zod"; -import { AppBasicAuthModel, AppDomainModel, AppFileMountModel, AppModel, AppPortModel, AppVolumeModel, ProjectModel, VolumeBackupModel } from "./generated-zod"; +import { AppBasicAuthModel, AppDomainModel, AppFileMountModel, AppModel, AppNodePortModel, AppPortModel, AppVolumeModel, ProjectModel, VolumeBackupModel } from "./generated-zod"; import { App, Project } from "@prisma/client"; export const AppExtendedZodModel= z.lazy(() => AppModel.extend({ project: ProjectModel, appDomains: AppDomainModel.array(), appPorts: AppPortModel.array(), + appNodePorts: AppNodePortModel.array(), appFileMounts: AppFileMountModel.array(), appVolumes: AppVolumeModel.array(), appBasicAuths: AppBasicAuthModel.array(), diff --git a/src/shared/model/generated-zod/app.ts b/src/shared/model/generated-zod/app.ts index 00da95e6..1f046313 100644 --- a/src/shared/model/generated-zod/app.ts +++ b/src/shared/model/generated-zod/app.ts @@ -1,6 +1,6 @@ import * as z from "zod" -import { CompleteProject, RelatedProjectModel, CompleteAppDomain, RelatedAppDomainModel, CompleteAppPort, RelatedAppPortModel, CompleteAppVolume, RelatedAppVolumeModel, CompleteAppFileMount, RelatedAppFileMountModel, CompleteAppBasicAuth, RelatedAppBasicAuthModel, CompleteAppGitSshKey, RelatedAppGitSshKeyModel, CompleteRoleAppPermission, RelatedRoleAppPermissionModel } from "./index" +import { CompleteProject, RelatedProjectModel, CompleteAppDomain, RelatedAppDomainModel, CompleteAppPort, RelatedAppPortModel, CompleteAppNodePort, RelatedAppNodePortModel, CompleteAppVolume, RelatedAppVolumeModel, CompleteAppFileMount, RelatedAppFileMountModel, CompleteAppBasicAuth, RelatedAppBasicAuthModel, CompleteAppGitSshKey, RelatedAppGitSshKeyModel, CompleteRoleAppPermission, RelatedRoleAppPermissionModel } from "./index" export const AppModel = z.object({ id: z.string(), @@ -49,6 +49,7 @@ export interface CompleteApp extends z.infer { project: CompleteProject appDomains: CompleteAppDomain[] appPorts: CompleteAppPort[] + appNodePorts: CompleteAppNodePort[] appVolumes: CompleteAppVolume[] appFileMounts: CompleteAppFileMount[] appBasicAuths: CompleteAppBasicAuth[] @@ -65,6 +66,7 @@ export const RelatedAppModel: z.ZodSchema = z.lazy(() => AppModel.e project: RelatedProjectModel, appDomains: RelatedAppDomainModel.array(), appPorts: RelatedAppPortModel.array(), + appNodePorts: RelatedAppNodePortModel.array(), appVolumes: RelatedAppVolumeModel.array(), appFileMounts: RelatedAppFileMountModel.array(), appBasicAuths: RelatedAppBasicAuthModel.array(), diff --git a/src/shared/model/generated-zod/appnodeport.ts b/src/shared/model/generated-zod/appnodeport.ts new file mode 100644 index 00000000..7dd52bd6 --- /dev/null +++ b/src/shared/model/generated-zod/appnodeport.ts @@ -0,0 +1,26 @@ +import * as z from "zod" + +import { CompleteApp, RelatedAppModel } from "./index" + +export const AppNodePortModel = z.object({ + id: z.string(), + appId: z.string(), + port: z.number().int(), + nodePort: z.number().int(), + protocol: z.string(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +export interface CompleteAppNodePort extends z.infer { + app: CompleteApp +} + +/** + * RelatedAppNodePortModel contains all relations on your model in addition to the scalars + * + * NOTE: Lazy required in case of potential circular dependencies within schema + */ +export const RelatedAppNodePortModel: z.ZodSchema = z.lazy(() => AppNodePortModel.extend({ + app: RelatedAppModel, +})) diff --git a/src/shared/model/generated-zod/index.ts b/src/shared/model/generated-zod/index.ts index 0a8d736d..546d9713 100644 --- a/src/shared/model/generated-zod/index.ts +++ b/src/shared/model/generated-zod/index.ts @@ -10,6 +10,7 @@ export * from "./project" export * from "./app" export * from "./appgitsshkey" export * from "./appport" +export * from "./appnodeport" export * from "./appdomain" export * from "./appvolume" export * from "./appfilemount" diff --git a/src/shared/model/node-port-edit.model.ts b/src/shared/model/node-port-edit.model.ts new file mode 100644 index 00000000..08e41c6d --- /dev/null +++ b/src/shared/model/node-port-edit.model.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; +import { stringToNumber } from "@/shared/utils/zod.utils"; + +export const nodePortEditZodModel = z.object({ + port: stringToNumber.refine((val) => val >= 1 && val <= 65535, { + message: 'Container port must be between 1 and 65535.', + }), + nodePort: stringToNumber.refine((val) => val >= 30001 && val <= 32767, { + message: 'Node port must be between 30001 and 32767.', // these are the valid node port ranges in Kubernetes by default: https://kubernetes.io/docs/concepts/services-networking/service/#type-nodeport + }), + protocol: z.enum(['TCP', 'UDP']).default('TCP'), +}); + +export type NodePortEditModel = z.infer;