diff --git a/.github/workflows/frontends.yml b/.github/workflows/frontends.yml index 43b174cde0b..d9d181b33fd 100644 --- a/.github/workflows/frontends.yml +++ b/.github/workflows/frontends.yml @@ -71,6 +71,9 @@ jobs: modules: ${{ steps.set-matrix.outputs.modules }} steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + # paths-filter may need the merge-base; avoid its shallow-history deepen race. + fetch-depth: 0 - name: Verify shared frontend runtime settings run: grep -qx 'ENV UV_USE_IO_URING=0' frontend/Dockerfile - uses: dorny/paths-filter@d1c1ffe0248fe513906c8e24db8ea791d46f8590 # v3 diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a2778923cae..6562d887289 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -20,15 +20,28 @@ WORKDIR /app FROM base AS deps # Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed. -RUN apk add --no-cache libc6-compat && corepack enable && corepack prepare pnpm@8.9.0 --activate +RUN apk add --no-cache libc6-compat python3 make g++ && \ + corepack enable && corepack prepare pnpm@8.9.0 --activate # Install dependencies based on the preferred package manager root workspace COPY pnpm-lock.yaml package.json pnpm-workspace.yaml ./ RUN --mount=type=cache,id=pnpm,target=/pnpm/store \ - [ -f pnpm-lock.yaml ] && pnpm fetch || \ - (echo "Lockfile not found." && exit 1) + if [ ! -f pnpm-lock.yaml ]; then \ + echo "Lockfile not found." >&2; \ + exit 1; \ + fi; \ + for attempt in 1 2 3; do \ + if pnpm fetch; then \ + exit 0; \ + fi; \ + if [ "${attempt}" -lt 3 ]; then \ + echo "pnpm fetch failed (attempt ${attempt}/3), retrying..." >&2; \ + sleep $((attempt * 10)); \ + fi; \ + done; \ + exit 1 COPY ./tsconfig.json ./tsconfig.json COPY ./tsconfig.deps.json ./tsconfig.deps.json COPY ./tsconfig.base.json ./tsconfig.base.json diff --git a/frontend/providers/applaunchpad/__tests__/unit/utils/deployYaml2Json.test.ts b/frontend/providers/applaunchpad/__tests__/unit/utils/deployYaml2Json.test.ts index 52887f63aa4..20092acbf6b 100644 --- a/frontend/providers/applaunchpad/__tests__/unit/utils/deployYaml2Json.test.ts +++ b/frontend/providers/applaunchpad/__tests__/unit/utils/deployYaml2Json.test.ts @@ -13,6 +13,7 @@ import { } from '@/utils/deployYaml2Json'; import type { AppEditType } from '@/types/app'; import { resolveAppImageName } from '@/utils/adapt'; +import { rebindMainServiceRoutes } from '@/utils/network-routes'; const createApp = (customDomain = ''): AppEditType => ({ @@ -208,6 +209,40 @@ describe('json2Ingress', () => { ]); }); + it('writes a regenerated main service name after stale route bindings are removed', () => { + const app = createApp(); + app.networks[0].serviceName = ''; + app.networks[0].port = 8080; + app.networks[0].routes = rebindMainServiceRoutes({ + routes: [ + { + path: '/', + pathType: 'Prefix', + serviceName: 'demo-old-service', + servicePort: 8080 + }, + { + path: '/api', + pathType: 'Prefix', + serviceName: 'demo-api', + servicePort: 8081 + } + ], + previousServiceName: 'demo-old-service' + }); + + const objects = yamlString2Objects( + json2Ingress(app, { + disableHttps: true + }) + ) as any[]; + const paths = objects[0].spec.rules[0].http.paths; + + expect(paths[0].backend.service.name).not.toBe('demo-old-service'); + expect(paths[0].backend.service.name).toMatch(/^demo-[a-z]{12}$/); + expect(paths[1].backend.service.name).toBe('demo-api'); + }); + it('syncs the default main service route port with the network port', () => { const app = createApp(); app.networks[0].port = 8080; diff --git a/frontend/providers/applaunchpad/__tests__/unit/utils/network-routes.test.ts b/frontend/providers/applaunchpad/__tests__/unit/utils/network-routes.test.ts index d802f12350d..25c6f6129ee 100644 --- a/frontend/providers/applaunchpad/__tests__/unit/utils/network-routes.test.ts +++ b/frontend/providers/applaunchpad/__tests__/unit/utils/network-routes.test.ts @@ -1,5 +1,67 @@ import { describe, expect, it } from 'vitest'; -import { syncDefaultRouteServicePort } from '@/utils/network-routes'; +import { rebindMainServiceRoutes, syncDefaultRouteServicePort } from '@/utils/network-routes'; + +describe('rebindMainServiceRoutes', () => { + it('removes a stale main service name while preserving explicit backends', () => { + expect( + rebindMainServiceRoutes({ + routes: [ + { + path: '/', + pathType: 'Prefix', + serviceName: 'demo-old-service', + servicePort: 80 + }, + { + path: '/healthz', + pathType: 'Exact', + serviceName: '', + servicePort: 80 + }, + { + path: '/api', + pathType: 'Prefix', + serviceName: 'demo-api', + servicePort: 8080 + } + ], + previousServiceName: 'demo-old-service' + }) + ).toEqual([ + { + path: '/', + pathType: 'Prefix', + serviceName: '', + servicePort: 80 + }, + { + path: '/healthz', + pathType: 'Exact', + serviceName: '', + servicePort: 80 + }, + { + path: '/api', + pathType: 'Prefix', + serviceName: 'demo-api', + servicePort: 8080 + } + ]); + }); + + it('does not alter routes when the previous main service is unknown', () => { + const routes = [ + { + path: '/api', + pathType: 'Prefix' as const, + serviceName: 'demo-api', + servicePort: 8080 + } + ]; + + expect(rebindMainServiceRoutes({ routes, previousServiceName: '' })).toBe(routes); + }); +}); describe('syncDefaultRouteServicePort', () => { it('updates the default main service route when the network port changes', () => { diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/components/NetworkSection.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/components/NetworkSection.tsx index 7ffb5e91b18..1306b97e23e 100644 --- a/frontend/providers/applaunchpad/src/pages/app/edit/components/NetworkSection.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/edit/components/NetworkSection.tsx @@ -31,7 +31,7 @@ import type { AppEditType, ApplicationProtocolType } from '@/types/app'; import RouteRulesModal from './RouteRulesModal'; import { useCopyData } from '@/utils/tools'; import { buildExternalUrl, getExternalProtocol } from '@/utils/network-url'; -import { syncDefaultRouteServicePort } from '@/utils/network-routes'; +import { rebindMainServiceRoutes, syncDefaultRouteServicePort } from '@/utils/network-routes'; import type { CustomAccessModalParams } from './CustomAccessModal'; import type { CertificateCustomAccessModalParams } from './CertificateCustomAccessModal'; import dynamic from 'next/dynamic'; @@ -267,6 +267,17 @@ const withDefaultRoutes = (network: AppEditType['networks'][0]): AppEditType['ne routes: network.routes?.length ? network.routes : [createDefaultRoute(network.port)] }); +const withoutMainServiceBinding = ( + network: AppEditType['networks'][0] +): AppEditType['networks'][0] => ({ + ...network, + serviceName: '', + routes: rebindMainServiceRoutes({ + routes: network.routes, + previousServiceName: network.serviceName + }) +}); + const getNextAvailablePort = (networks: AppEditType['networks']) => { const usedPorts = new Set(networks.map((network) => Number(network.port)).filter(Boolean)); @@ -430,8 +441,7 @@ export function NetworkSection({ updateNetworks( index, withDefaultRoutes({ - ...currentNetwork, - serviceName: '', + ...withoutMainServiceBinding(currentNetwork), networkName: currentNetwork.networkName || `network-${nanoid()}`, protocol: 'TCP', appProtocol: currentNetwork.appProtocol || 'HTTP', @@ -449,8 +459,7 @@ export function NetworkSection({ const { index } = action.payload; clearPublicDomainErrorByIndex(index); updateNetworks(index, { - ...currentNetworks[index], - serviceName: '', + ...withoutMainServiceBinding(currentNetworks[index]), openPublicDomain: false, openNodePort: false, customDomain: '', @@ -477,8 +486,7 @@ export function NetworkSection({ updateNetworks( index, withDefaultRoutes({ - ...currentNetwork, - serviceName: '', + ...withoutMainServiceBinding(currentNetwork), networkName: currentNetwork.networkName || `network-${nanoid()}`, protocol: currentNetwork.appProtocol ? 'TCP' : currentNetwork.protocol, appProtocol: @@ -506,8 +514,7 @@ export function NetworkSection({ updateNetworks( index, withDefaultRoutes({ - ...currentNetwork, - serviceName: '', + ...withoutMainServiceBinding(currentNetwork), networkName: currentNetwork.networkName || `network-${nanoid()}`, protocol: 'TCP', appProtocol, @@ -532,8 +539,7 @@ export function NetworkSection({ updateNetworks( index, withDefaultRoutes({ - ...currentNetwork, - serviceName: '', + ...withoutMainServiceBinding(currentNetwork), protocol: 'TCP', appProtocol: protocol as any, openNodePort: true, @@ -549,8 +555,7 @@ export function NetworkSection({ updateNetworks( index, withDefaultRoutes({ - ...currentNetwork, - serviceName: '', + ...withoutMainServiceBinding(currentNetwork), protocol: 'TCP', appProtocol: protocol as any, openNodePort: false, @@ -567,8 +572,7 @@ export function NetworkSection({ updateNetworks( index, withDefaultRoutes({ - ...currentNetwork, - serviceName: '', + ...withoutMainServiceBinding(currentNetwork), protocol: protocol as any, appProtocol: undefined, openNodePort: true, diff --git a/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx b/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx index e1bd95c1036..15f3309ff95 100644 --- a/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx +++ b/frontend/providers/applaunchpad/src/pages/app/edit/index.tsx @@ -57,6 +57,7 @@ import { validatePublicDomainPrefix } from '@/utils/public-domain'; import { getCustomDomainBindings } from '@/utils/custom-domain'; +import { rebindMainServiceRoutes } from '@/utils/network-routes'; import { APP_NAME_BASE_MAX_LENGTH, getInvalidNameMessageI18nKey @@ -656,18 +657,12 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => }); if (Array.isArray(parsedData.networks)) { - const completeNetworks = parsedData.networks.map((network) => ({ - networkName: network.networkName || `network-${nanoid()}`, - portName: network.portName || nanoid(), - port: network.port || 80, - protocol: network.protocol || 'TCP', - appProtocol: network.appProtocol || undefined, - openPublicDomain: network.openPublicDomain || false, - openNodePort: network.openNodePort || false, - publicDomain: network.publicDomain || nanoid(), - customDomain: network.customDomain || '', - domain: network.domain || SEALOS_DOMAIN, - routes: network.routes?.length + const currentNetworks = formHook.getValues('networks'); + const completeNetworks = parsedData.networks.map((network) => { + const previousServiceName = currentNetworks.find( + (currentNetwork) => currentNetwork.portName === network.portName + )?.serviceName; + const routes = network.routes?.length ? network.routes.map((route) => ({ path: route.path || '/', pathType: route.pathType || ('Prefix' as const), @@ -681,8 +676,22 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) => serviceName: '', servicePort: network.port || 80 } - ] - })); + ]; + + return { + networkName: network.networkName || `network-${nanoid()}`, + portName: network.portName || nanoid(), + port: network.port || 80, + protocol: network.protocol || 'TCP', + appProtocol: network.appProtocol || undefined, + openPublicDomain: network.openPublicDomain || false, + openNodePort: network.openNodePort || false, + publicDomain: network.publicDomain || nanoid(), + customDomain: network.customDomain || '', + domain: network.domain || SEALOS_DOMAIN, + routes: rebindMainServiceRoutes({ routes, previousServiceName }) + }; + }); formHook.setValue('networks', completeNetworks); } diff --git a/frontend/providers/applaunchpad/src/utils/network-routes.ts b/frontend/providers/applaunchpad/src/utils/network-routes.ts index f37ccbef70c..f570b50fb19 100644 --- a/frontend/providers/applaunchpad/src/utils/network-routes.ts +++ b/frontend/providers/applaunchpad/src/utils/network-routes.ts @@ -3,6 +3,33 @@ import type { AppNetworkRouteType } from '@/types/app'; const targetsMainService = (route: AppNetworkRouteType, networkServiceName?: string) => !route.serviceName || (!!networkServiceName && route.serviceName === networkServiceName); +export const rebindMainServiceRoutes = ({ + routes, + previousServiceName +}: { + routes?: AppNetworkRouteType[]; + previousServiceName?: string; +}) => { + if (!routes?.length || !previousServiceName) { + return routes; + } + + let changed = false; + const nextRoutes = routes.map((route) => { + if (route.serviceName !== previousServiceName) { + return route; + } + + changed = true; + return { + ...route, + serviceName: '' + }; + }); + + return changed ? nextRoutes : routes; +}; + export const syncDefaultRouteServicePort = ({ routes, previousPort,