feat(applaunchpad): improve public domain and image port flows (#6998)

* feat(launchpad): support image ports and custom public domains

* fix(launchpad): surface public domain admission conflicts

* fix(launchpad): show image port detection status

* chore(applaunchpad): add local runner and project docs

* fix(applaunchpad): disable Node navigator for runner

* chore(applaunchpad): stop tracking codex runner

* docs(applaunchpad): document 209 dev user override

* docs(applaunchpad): remove stale dev user example

* fix(applaunchpad): reduce edit page side gutters

* feat(applaunchpad): configure public domain reserved prefixes

* fix(applaunchpad): improve public domain prefix editing

* feat(applaunchpad): precheck public domain availability

* fix(applaunchpad): keep custom domain action near address

* fix(applaunchpad): shrink public address field to content

* fix(launchpad): precheck public domain edits

* docs(applaunchpad): document repeatable runner startup

* fix(launchpad): detect admission conflict error bodies

* feat(applaunchpad): attribute public domain conflicts

* fix(applaunchpad): reject duplicate public domain hosts

* test(applaunchpad): isolate public domain reserved prefixes

* chore: ignore local impeccable config

* fix(applaunchpad): expose reserved prefixes in chart values

* feat(applaunchpad): gate branch features by config

* fix(applaunchpad): harden image port detection

* docs(applaunchpad): remove generated project docs

* fix(applaunchpad): harden image port registry fetch
This commit is contained in:
Alex Lee
2026-06-24 17:08:19 +08:00
committed by GitHub
parent 759b6ade80
commit 9bac817e30
42 changed files with 3650 additions and 271 deletions
+1
View File
@@ -24,6 +24,7 @@ deploy/cloud/tars
.vscode/
/lifecycle/tools/
.workflow/
**/.impeccable/
**.env*
!scripts/cloud/sealos.env
@@ -15,7 +15,8 @@ const createConfig = (): AppConfigType => ({
common: {
guideEnabled: false,
apiEnabled: false,
gpuEnabled: false
gpuEnabled: false,
networkStorageEnabled: false
},
launchpad: {
infrastructure: {
@@ -33,6 +34,13 @@ const createConfig = (): AppConfigType => ({
gtmId: null,
currencySymbol: Coin.shellCoin,
pvcStorageMax: 20,
imagePorts: {
enabled: false
},
publicDomain: {
customPrefixEnabled: false,
reservedPrefixes: []
},
eventAnalyze: {
enabled: false
},
@@ -45,12 +53,6 @@ const createConfig = (): AppConfigType => ({
fileManger: {
uploadLimit: 50,
downloadLimit: 100
},
checkIcpReg: {
enabled: false,
endpoint: '',
accessKeyID: '',
accessKeySecret: ''
}
}
});
@@ -63,4 +65,26 @@ describe('getServerEnv', () => {
expect(env.HTTP_PORT).toBe(':80');
expect(env.DISABLE_HTTPS).toBe(true);
});
it('defaults new branch feature gates to disabled', () => {
const env = getServerEnv(createConfig());
expect(env.IMAGE_PORTS_ENABLED).toBe(false);
expect(env.CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED).toBe(false);
});
it('returns enabled branch feature gates from config', () => {
const config = createConfig();
config.launchpad.imagePorts = { enabled: true };
config.launchpad.publicDomain = {
customPrefixEnabled: true,
reservedPrefixes: ['admin']
};
const env = getServerEnv(config);
expect(env.IMAGE_PORTS_ENABLED).toBe(true);
expect(env.CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED).toBe(true);
expect(env.PUBLIC_DOMAIN_RESERVED_PREFIXES).toEqual(['admin']);
});
});
@@ -0,0 +1,130 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
UpdateAppResourcesSchema as V1UpdateAppResourcesSchema,
transformFromLegacySchema as transformFromLegacySchemaV1
} from '@/types/request_schema';
import {
UpdateAppResourcesSchema as V2UpdateAppResourcesSchema,
transformFromLegacySchema as transformFromLegacySchemaV2
} from '@/types/v2alpha/request_schema';
function setCustomPublicDomainPrefixEnabled(enabled: boolean) {
(globalThis as any).AppConfig = {
cloud: {
domain: 'cloud.example.com'
},
launchpad: {
publicDomain: {
customPrefixEnabled: enabled,
reservedPrefixes: []
}
}
};
}
function createLegacyApp() {
return {
appName: 'demo',
imageName: 'nginx:latest',
secret: { use: false },
runCMD: '',
cmdParam: '',
hpa: { use: false },
replicas: 1,
cpu: 200,
memory: 512,
networks: [
{
serviceName: 'service-demo',
networkName: 'network-demo',
portName: 'port-demo',
port: 80,
protocol: 'TCP',
appProtocol: 'HTTP',
openPublicDomain: true,
publicDomain: 'demo-prefix',
customDomain: '',
domain: 'cloud.example.com',
openNodePort: false
}
],
envs: [],
configMapList: [],
storeList: [],
kind: 'deployment',
id: 'demo-id',
createTime: '2026-06-16 00:00',
status: { value: 'Running' },
openapi: {
status: {
observedGeneration: 1,
replicas: 1,
availableReplicas: 1,
updatedReplicas: 1,
isPause: false
}
}
} as any;
}
describe('request schema publicDomain feature gate', () => {
afterEach(() => {
delete (globalThis as any).AppConfig;
});
it('omits v1 publicDomain prefixes from GET responses when custom prefixes are disabled', () => {
setCustomPublicDomainPrefixEnabled(false);
const response = transformFromLegacySchemaV1(createLegacyApp());
expect(response.ports?.[0]).not.toHaveProperty('publicDomain');
expect(
V1UpdateAppResourcesSchema.safeParse({
resource: { cpu: 0.2 },
ports: response.ports
}).success
).toBe(true);
});
it('omits v2alpha publicDomain prefixes from GET responses when custom prefixes are disabled', () => {
setCustomPublicDomainPrefixEnabled(false);
const response = transformFromLegacySchemaV2(createLegacyApp(), 'demo', 'ns-demo');
expect(response.ports?.[0]).not.toHaveProperty('publicDomain');
expect(
V2UpdateAppResourcesSchema.safeParse({
quota: { cpu: 0.2 },
ports: response.ports
}).success
).toBe(true);
});
it('still rejects explicit custom prefixes while the feature gate is disabled', () => {
setCustomPublicDomainPrefixEnabled(false);
expect(
V1UpdateAppResourcesSchema.safeParse({
ports: [{ portName: 'port-demo', publicDomain: 'demo-prefix' }]
}).success
).toBe(false);
expect(
V2UpdateAppResourcesSchema.safeParse({
ports: [{ portName: 'port-demo', publicDomain: 'demo-prefix' }]
}).success
).toBe(false);
});
it('keeps publicDomain prefixes in responses when custom prefixes are enabled', () => {
setCustomPublicDomainPrefixEnabled(true);
expect(transformFromLegacySchemaV1(createLegacyApp()).ports?.[0]).toHaveProperty(
'publicDomain',
'demo-prefix'
);
expect(transformFromLegacySchemaV2(createLegacyApp(), 'demo', 'ns-demo').ports?.[0]).toHaveProperty(
'publicDomain',
'demo-prefix'
);
});
});
@@ -0,0 +1,262 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { lookup } from 'dns/promises';
import { request as httpRequest } from 'http';
import { request as httpsRequest } from 'https';
import { EventEmitter } from 'events';
import { Readable } from 'stream';
import type { IncomingMessage, RequestOptions } from 'http';
import { getImageExposedPorts, parseExposedPorts, parseImageRef } from '@/utils/image-exposed-ports';
vi.mock('dns/promises', () => ({
lookup: vi.fn()
}));
vi.mock('http', () => ({
request: vi.fn()
}));
vi.mock('https', () => ({
request: vi.fn()
}));
const lookupMock = vi.mocked(lookup);
const httpRequestMock = vi.mocked(httpRequest);
const httpsRequestMock = vi.mocked(httpsRequest);
type MockRegistryResponse = {
body?: unknown;
headers?: Record<string, string>;
status?: number;
};
type MockRequestCall = {
url: URL;
options: RequestOptions;
};
const requestCalls: MockRequestCall[] = [];
function createResponse(response: MockRegistryResponse) {
const body =
typeof response.body === 'string'
? response.body
: JSON.stringify(response.body ?? {});
const stream = Readable.from([body]) as IncomingMessage;
stream.statusCode = response.status ?? 200;
stream.headers = {
'content-type': 'application/json',
...(response.headers || {})
};
return stream;
}
function createRequestMock(responses: MockRegistryResponse[]) {
return vi.fn((url: string | URL, options: RequestOptions, callback: (res: IncomingMessage) => void) => {
const request = new EventEmitter() as EventEmitter & { end: () => void };
const parsedUrl = typeof url === 'string' ? new URL(url) : url;
requestCalls.push({ url: parsedUrl, options });
request.end = () => {
const lookupFn = options.lookup;
const finish = () => {
const response = responses.shift();
if (!response) {
request.emit('error', new Error(`Unexpected request to ${parsedUrl.toString()}`));
return;
}
callback(createResponse(response));
};
if (!lookupFn) {
finish();
return;
}
lookupFn(parsedUrl.hostname, {}, (error) => {
if (error) {
request.emit('error', error);
return;
}
finish();
});
};
return request;
});
}
function mockRegistryRequests(...responses: MockRegistryResponse[]) {
const requestMock = createRequestMock([...responses]);
httpRequestMock.mockImplementation(requestMock);
httpsRequestMock.mockImplementation(requestMock);
return requestMock;
}
afterEach(() => {
vi.clearAllMocks();
lookupMock.mockReset();
requestCalls.length = 0;
});
describe('parseExposedPorts', () => {
it('parses, normalizes, deduplicates, and sorts exposed ports', () => {
expect(
parseExposedPorts({
'8080/tcp': {},
'80/TCP': {},
'53/udp': {},
'80/tcp': {},
'70000/tcp': {},
bad: {}
})
).toEqual([
{ port: 53, protocol: 'UDP' },
{ port: 80, protocol: 'TCP' },
{ port: 8080, protocol: 'TCP' }
]);
});
});
describe('parseImageRef', () => {
it('keeps digest references intact and strips optional tags from the repository', () => {
expect(parseImageRef('nginx:1.27@sha256:abc123')).toEqual({
registry: 'registry-1.docker.io',
repository: 'library/nginx',
reference: 'sha256:abc123'
});
});
it('respects private registry overrides without rewriting repository paths', () => {
expect(parseImageRef('team/api:1.0.0', 'registry.example.com')).toEqual({
registry: 'registry.example.com',
repository: 'team/api',
reference: '1.0.0'
});
});
});
describe('getImageExposedPorts registry safety', () => {
it('rejects loopback registry hosts before fetch', async () => {
const requestMock = mockRegistryRequests();
await expect(getImageExposedPorts('localhost:5000/team/api:latest')).rejects.toThrow(
'Registry host is not allowed'
);
expect(requestMock).not.toHaveBeenCalled();
});
it('rejects registry hosts that resolve to private addresses', async () => {
const requestMock = mockRegistryRequests();
lookupMock.mockResolvedValue([{ address: '169.254.169.254', family: 4 }]);
await expect(getImageExposedPorts('registry.example.com/team/api:latest')).rejects.toThrow(
'Registry host resolves to a private address'
);
expect(requestMock).not.toHaveBeenCalled();
});
it('rejects registry redirects to private addresses', async () => {
lookupMock
.mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }])
.mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }])
.mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }])
.mockResolvedValueOnce([{ address: '169.254.169.254', family: 4 }]);
const requestMock = mockRegistryRequests({
status: 302,
headers: {
location: 'https://metadata.example/latest/meta-data'
}
});
await expect(getImageExposedPorts('registry.example.com/team/api:latest')).rejects.toThrow(
'Registry host resolves to a private address'
);
expect(requestMock).toHaveBeenCalledTimes(1);
expect(requestCalls[0].url.toString()).toBe(
'https://registry.example.com/v2/team/api/manifests/latest'
);
});
it('rejects DNS rebinding during the connection lookup', async () => {
lookupMock
.mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }])
.mockResolvedValueOnce([{ address: '8.8.8.8', family: 4 }])
.mockResolvedValueOnce([{ address: '10.0.0.8', family: 4 }]);
const requestMock = mockRegistryRequests({ body: { config: { digest: 'sha256:config' } } });
await expect(getImageExposedPorts('registry.example.com/team/api:latest')).rejects.toThrow(
'Registry host resolves to a private address'
);
expect(requestMock).toHaveBeenCalledTimes(1);
});
it('does not forward registry credentials to an untrusted challenge realm', async () => {
lookupMock.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]);
const requestMock = mockRegistryRequests({
status: 401,
body: '',
headers: {
'www-authenticate':
'Bearer realm="https://attacker.example/token",service="registry.example.com"'
}
});
await expect(
getImageExposedPorts('registry.example.com/team/api:latest', {
username: 'user',
password: 'password'
})
).rejects.toThrow('Registry auth realm is not trusted');
expect(requestMock).toHaveBeenCalledTimes(1);
});
it('does not fall back to Docker Hub auth for private registry challenges', async () => {
lookupMock.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]);
const requestMock = mockRegistryRequests({
status: 401,
body: '',
headers: {
'www-authenticate': 'Bearer'
}
});
await expect(
getImageExposedPorts('registry.example.com/team/api:latest', {
username: 'user',
password: 'password'
})
).rejects.toThrow('Registry auth challenge is invalid');
expect(requestMock).toHaveBeenCalledTimes(1);
});
it('aborts oversized registry JSON responses', async () => {
lookupMock.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]);
const largeManifest = { manifests: [{ digest: 'sha256:' + 'a'.repeat(1024 * 1024) }] };
mockRegistryRequests({ body: largeManifest });
await expect(getImageExposedPorts('registry.example.com/team/api:latest')).rejects.toThrow(
'Image manifest is too large'
);
});
it('parses exposed ports after trusted registry manifest and config fetches', async () => {
lookupMock.mockResolvedValue([{ address: '8.8.8.8', family: 4 }]);
mockRegistryRequests(
{ body: { config: { digest: 'sha256:config' } } },
{
body: {
config: {
ExposedPorts: {
'8080/tcp': {},
'8443/tcp': {}
}
}
}
}
);
await expect(getImageExposedPorts('registry.example.com/team/api:latest')).resolves.toEqual([
{ port: 8080, protocol: 'TCP' },
{ port: 8443, protocol: 'TCP' }
]);
});
});
@@ -0,0 +1,107 @@
import { afterEach, describe, expect, it } from 'vitest';
import {
isCustomPublicDomainPrefixEnabled,
isImagePortsEnabled
} from '@/utils/feature-gates';
import { setPublicDomainReservedPrefixes, validatePublicDomainPrefix } from '@/utils/public-domain';
import {
getPublicDomainConflictResponse,
isIngressPublicDomainConflictError
} from '@/services/backend/publicDomain';
describe('validatePublicDomainPrefix', () => {
afterEach(() => {
setPublicDomainReservedPrefixes([]);
});
it('normalizes and accepts dns-safe prefixes', () => {
expect(validatePublicDomainPrefix(' My-App1 ')).toEqual({
valid: true,
value: 'my-app1'
});
});
it('rejects reserved prefixes', () => {
setPublicDomainReservedPrefixes(['admin']);
expect(validatePublicDomainPrefix('admin')).toEqual({
valid: false,
value: 'admin',
reason: 'reserved'
});
});
it('rejects invalid dns label shapes', () => {
expect(validatePublicDomainPrefix('-bad')).toMatchObject({ valid: false, reason: 'format' });
expect(validatePublicDomainPrefix('bad-')).toMatchObject({ valid: false, reason: 'format' });
expect(validatePublicDomainPrefix('ab')).toMatchObject({ valid: false, reason: 'format' });
});
});
describe('feature gates', () => {
it('defaults branch feature gates to disabled', () => {
expect(isImagePortsEnabled()).toBe(false);
expect(isCustomPublicDomainPrefixEnabled()).toBe(false);
});
it('reads branch feature gates from config', () => {
const config = {
launchpad: {
imagePorts: {
enabled: true
},
publicDomain: {
customPrefixEnabled: true
}
}
};
expect(isImagePortsEnabled(config)).toBe(true);
expect(isCustomPublicDomainPrefixEnabled(config)).toBe(true);
});
});
describe('isIngressPublicDomainConflictError', () => {
it('detects ingress admission owner conflicts', () => {
const error = {
body: {
message:
'admission webhook "vingress.sealos.io" denied the request: 40301: ingress host demo.cloud.sealos.io is owned by other user, you can not create ingress with same host.'
}
};
expect(isIngressPublicDomainConflictError(error)).toBe(true);
expect(getPublicDomainConflictResponse(error)).toMatchObject({
code: 'PUBLIC_DOMAIN_CONFLICT',
message: 'Public domain is already in use by another workspace.'
});
});
it('detects ingress admission owner conflicts from Kubernetes Error body', () => {
const error = new Error('Forbidden');
Object.assign(error, {
body: {
message:
'admission webhook "vingress.sealos.io" denied the request: 40301: ingress host devbox.192.168.13.209.nip.io is owned by other user, you can not create ingress with same host.'
}
});
expect(isIngressPublicDomainConflictError(error)).toBe(true);
expect(getPublicDomainConflictResponse(error)).toMatchObject({
code: 'PUBLIC_DOMAIN_CONFLICT',
details:
'admission webhook "vingress.sealos.io" denied the request: 40301: ingress host devbox.192.168.13.209.nip.io is owned by other user, you can not create ingress with same host.'
});
});
it('does not treat other admission failures as public domain conflicts', () => {
expect(
isIngressPublicDomainConflictError({
body: {
message:
'admission webhook "vingress.sealos.io" denied the request: 40300: can not verify ingress host'
}
})
).toBe(false);
});
});
@@ -25,6 +25,11 @@ launchpad:
scripts: []
gtmId: null
pvcStorageMax: 100
imagePorts:
enabled: false
publicDomain:
customPrefixEnabled: false
reservedPrefixes: []
eventAnalyze:
enabled: false
fastGPTKey: ''
@@ -10,3 +10,8 @@ resources:
requests:
cpu: 10m
memory: 128Mi
applaunchpadConfig:
imagePortsEnabled: false
customPublicDomainPrefixEnabled: false
publicDomainReservedPrefixes: []
@@ -31,6 +31,11 @@ data:
eventAnalyze:
enabled: false
fastGPTKey: ""
imagePorts:
enabled: {{ .Values.applaunchpadConfig.imagePortsEnabled }}
publicDomain:
customPrefixEnabled: {{ .Values.applaunchpadConfig.customPublicDomainPrefixEnabled }}
reservedPrefixes:{{ toYaml .Values.applaunchpadConfig.publicDomainReservedPrefixes | nindent 10 }}
components:
monitor:
url: {{ .Values.applaunchpadConfig.monitorUrl }}
@@ -49,6 +49,9 @@ applaunchpadConfig:
billingUrl: "http://account-service.account-system.svc:2333"
logUrl: ""
tlsRejectUnauthorized: "1"
imagePortsEnabled: false
customPublicDomainPrefixEnabled: false
publicDomainReservedPrefixes: []
affinity:
podAntiAffinity:
@@ -98,6 +98,10 @@
"Image Name": "Image Name",
"Image Name (Private)": "Image Name (Private)",
"Image name cannot be empty": "Image name is required.",
"image_ports_detection_failed": "Port detection failed",
"no_image_ports_detected": "No exposed ports detected",
"recognized_image_ports": "{{count}} ports detected",
"recognizing_image_ports": "Detecting ports",
"Input your custom domain": "Enter your custom domain",
"Intelligent Analysis": "Smart Insights",
"Inventory": "Stock",
@@ -251,6 +255,7 @@
"contains": "contains",
"cpu": "CPU",
"custom_domain_input_title": "Your Domain",
"bind_custom_domain": "Bind custom domain",
"custom_domain_cname_required": "Point {{customDomain}} CNAME to {{publicDomain}} before submitting.",
"day": "days",
"delete_app_tip": "Are you sure you want to delete this application? All project data will be permanently lost if you proceed.",
@@ -286,6 +291,14 @@
"domain_verification_refresh": "Refresh",
"domain_verification_success": "Your domain has been successfully bound.",
"domain_verified": "Verified",
"public_domain_prefix_conflict_error": "This public address prefix is already in use. Please choose another one.",
"public_domain_prefix_conflict_owner_error": "This public address prefix is already used by {{type}} \"{{name}}\" in this workspace. Please choose another one.",
"public_domain_prefix_duplicate_error": "This public address prefix is duplicated in this app. Please choose another one.",
"public_domain_prefix_edit_tooltip": "Edit the public address prefix",
"public_domain_prefix_format_error": "Use {{min}}-{{max}} lowercase letters, numbers, or hyphens. It cannot start or end with a hyphen.",
"public_domain_prefix_input_label": "Public address prefix",
"public_domain_prefix_placeholder": "Edit prefix",
"public_domain_prefix_reserved_error": "This public address prefix is reserved. Please choose another one.",
"download": "Download",
"driver": {
"access_application": "Access Application",
@@ -98,6 +98,10 @@
"Image Name": "镜像名",
"Image Name (Private)": "镜像名称(私有)",
"Image name cannot be empty": "镜像名称不能为空。",
"image_ports_detection_failed": "端口识别失败",
"no_image_ports_detected": "未识别到暴露端口",
"recognized_image_ports": "已识别 {{count}} 个端口",
"recognizing_image_ports": "正在识别端口",
"Input your custom domain": "输入您的自定义域名",
"Intelligent Analysis": "智能分析",
"Inventory": "库存",
@@ -251,6 +255,7 @@
"contains": "包含",
"cpu": "CPU",
"custom_domain_input_title": "您的域名",
"bind_custom_domain": "绑定自定义域名",
"custom_domain_cname_required": "请先将 {{customDomain}} 的 CNAME 解析到 {{publicDomain}},生效后再提交。",
"day": "天",
"delete_app_tip": "确定要删除此应用吗?如果继续,该项目的所有数据将被删除。",
@@ -286,6 +291,14 @@
"domain_verification_refresh": "刷新",
"domain_verification_success": "您的域名已成功绑定。",
"domain_verified": "验证通过",
"public_domain_prefix_conflict_error": "该公网地址前缀已被占用,请换一个",
"public_domain_prefix_conflict_owner_error": "该公网地址前缀已被当前工作区的 {{type}}「{{name}}」占用,请换一个",
"public_domain_prefix_duplicate_error": "当前应用内已有端口使用该公网地址前缀,请换一个",
"public_domain_prefix_edit_tooltip": "可修改公网地址前缀",
"public_domain_prefix_format_error": "公网地址前缀需为 {{min}}-{{max}} 位小写字母、数字或连字符,且不能以连字符开头或结尾",
"public_domain_prefix_input_label": "公网地址前缀",
"public_domain_prefix_placeholder": "修改前缀",
"public_domain_prefix_reserved_error": "该公网地址前缀为系统保留,请换一个",
"download": "下载",
"driver": {
"access_application": "访问应用",
@@ -3,6 +3,7 @@ import type { UserQuotaItemType, UserTask, userPriceType } from '@/types/user';
import { getUserSession } from '@/utils/user';
import { AuthCnamePrams, AuthDomainChallengeParams } from './params';
import type { EnvResponse } from '@/types';
import type { PublicDomainConflictOwner } from '@/utils/public-domain';
export const getResourcePrice = () => GET<userPriceType>('/api/platform/resourcePrice');
@@ -27,6 +28,29 @@ export const postAuthDomainChallenge = (data: AuthDomainChallengeParams) =>
};
}>('/api/platform/authDomainChallenge', data);
export const getImagePorts = (data: {
imageName: string;
imageRegistry?: {
username?: string;
password?: string;
serverAddress?: string;
};
}) =>
POST<{
ports: {
port: number;
protocol: 'TCP' | 'UDP' | 'SCTP';
}[];
}>('/api/platform/getImagePorts', data);
export const checkPublicDomain = (data: { prefix: string; domain: string; appName?: string }) =>
POST<{
available: boolean;
prefix?: string;
host?: string;
conflictOwner?: PublicDomainConflictOwner;
}>('/api/platform/checkPublicDomain', data);
export const getUserTasks = () =>
GET<{ needGuide: boolean; task: UserTask }>('/api/guide/getTasks', undefined, {
headers: {
@@ -6,6 +6,8 @@ export async function register() {
const yaml = (await import('js-yaml')).default;
const fs = (await import('node:fs')).default;
const getGpuNode = (await import('./services/backend/gpu')).getGpuNode;
const setPublicDomainReservedPrefixes = (await import('./utils/public-domain'))
.setPublicDomainReservedPrefixes;
async function loadConfig() {
const defaultAppConfig: AppConfigType = {
@@ -44,6 +46,13 @@ export async function register() {
gtmId: null,
currencySymbol: Coin.shellCoin,
pvcStorageMax: 20,
imagePorts: {
enabled: false
},
publicDomain: {
customPrefixEnabled: false,
reservedPrefixes: []
},
eventAnalyze: {
enabled: false,
fastGPTKey: ''
@@ -84,6 +93,7 @@ export async function register() {
...res
};
global.AppConfig = config;
setPublicDomainReservedPrefixes(global.AppConfig.launchpad.publicDomain?.reservedPrefixes);
const gpuNodes = await getGpuNode();
console.log(gpuNodes, 'gpuNodes');
global.AppConfig.common.gpuEnabled = gpuNodes.length > 0;
@@ -2,9 +2,70 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { ApiResp } from '@/services/kubernet';
import { authSession } from '@/services/backend/auth';
import { getK8s } from '@/services/backend/kubernetes';
import { handleK8sError, jsonRes } from '@/services/backend/response';
import { getPublicDomainErrorResponse, handleK8sError, jsonRes } from '@/services/backend/response';
import yaml from 'js-yaml';
import { generateOwnerReference, shouldHaveOwnerReference } from '@/utils/deployYaml2Json';
import { appDeployKey } from '@/constants/app';
import {
ensurePublicDomainTargetsAvailable,
PublicDomainError,
PublicDomainTarget
} from '@/services/backend/publicDomain';
type K8sResource = {
kind?: string;
metadata?: {
name?: string;
labels?: Record<string, string>;
ownerReferences?: any[];
};
spec?: {
rules?: {
host?: string;
}[];
};
};
type WorkloadResource = K8sResource & {
kind: 'Deployment' | 'StatefulSet';
};
function isWorkloadResource(resource: K8sResource): resource is WorkloadResource {
return resource.kind === 'Deployment' || resource.kind === 'StatefulSet';
}
function getManagedDomains() {
return [
global.AppConfig?.cloud?.domain,
...(global.AppConfig?.cloud?.userDomains || []).map((domain: { name: string }) => domain.name)
].filter((domain): domain is string => Boolean(domain));
}
function getPublicDomainTargets(resources: K8sResource[]): PublicDomainTarget[] {
const workload = resources.find(isWorkloadResource);
const fallbackAppName = workload?.metadata?.name;
const managedDomains = getManagedDomains();
return resources
.filter((resource) => resource.kind === 'Ingress')
.flatMap((resource) =>
(resource.spec?.rules || [])
.map((rule) => rule.host)
.filter((host): host is string => typeof host === 'string' && !host.startsWith('*.'))
.flatMap((host) => {
const domain = managedDomains.find((item) => host.endsWith(`.${item}`));
if (!domain) return [];
return [
{
prefix: host.slice(0, -domain.length - 1),
domain,
appName: resource.metadata?.labels?.[appDeployKey] || fallbackAppName,
networkName: resource.metadata?.name
}
];
})
);
}
export default async function handler(req: NextApiRequest, res: NextApiResponse<ApiResp>) {
const { yamlList, mode = 'create' }: { yamlList: string[]; mode?: 'create' | 'replace' } =
@@ -17,19 +78,22 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
return;
}
try {
const { k8sApp, applyYamlList, namespace } = await getK8s({
const { k8sApp, k8sNetworkingApp, applyYamlList, namespace } = await getK8s({
kubeconfig: await authSession(req.headers)
});
const allResources: any[] = [];
const allResources: K8sResource[] = [];
yamlList.forEach((yamlStr) => {
const resources = yaml.loadAll(yamlStr).filter((item) => item);
const resources = yaml.loadAll(yamlStr).filter((item): item is K8sResource => Boolean(item));
allResources.push(...resources);
});
const mainWorkloadIndex = allResources.findIndex(
(resource) => resource.kind === 'Deployment' || resource.kind === 'StatefulSet'
);
await ensurePublicDomainTargetsAvailable(getPublicDomainTargets(allResources), {
k8sNetworkingApp,
namespace
});
const mainWorkloadIndex = allResources.findIndex(isWorkloadResource);
if (mainWorkloadIndex === -1) {
const applyRes = await applyYamlList(yamlList, mode);
@@ -37,7 +101,11 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
return;
}
const mainWorkload = allResources[mainWorkloadIndex];
const mainWorkload = allResources[mainWorkloadIndex] as WorkloadResource;
const mainWorkloadName = mainWorkload.metadata?.name;
if (!mainWorkloadName) {
throw new Error('Workload metadata.name is required');
}
const dependentResources = allResources.filter((_, index) => index !== mainWorkloadIndex);
const mainWorkloadYaml = yaml.dump(mainWorkload);
@@ -47,16 +115,10 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
let workloadUid: string;
try {
if (mainWorkload.kind === 'Deployment') {
const deployment = await k8sApp.readNamespacedDeployment(
mainWorkload.metadata.name,
namespace
);
const deployment = await k8sApp.readNamespacedDeployment(mainWorkloadName, namespace);
workloadUid = deployment.body.metadata?.uid || '';
} else {
const statefulSet = await k8sApp.readNamespacedStatefulSet(
mainWorkload.metadata.name,
namespace
);
const statefulSet = await k8sApp.readNamespacedStatefulSet(mainWorkloadName, namespace);
workloadUid = statefulSet.body.metadata?.uid || '';
}
} catch (err) {
@@ -69,13 +131,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
}
const ownerReferences = generateOwnerReference(
mainWorkload.metadata.name,
mainWorkloadName,
mainWorkload.kind,
workloadUid
);
dependentResources.forEach((resource) => {
if (shouldHaveOwnerReference(resource.kind)) {
if (resource.kind && shouldHaveOwnerReference(resource.kind)) {
if (!resource.metadata) {
resource.metadata = {};
}
@@ -92,6 +154,13 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
jsonRes(res, { data: allKinds });
} catch (err: any) {
console.log(err);
if (err instanceof PublicDomainError) {
return jsonRes(res, {
code: err.status,
message: err.message,
error: getPublicDomainErrorResponse(err)
});
}
jsonRes(res, handleK8sError(err));
}
}
@@ -0,0 +1,100 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { getPublicDomainErrorResponse, jsonRes } from '@/services/backend/response';
import { createK8sContext } from '@/services/backend';
import {
dryRunPublicDomainIngress,
ensurePublicDomainTargetsAvailable,
getPublicDomainConflictMessage,
getPublicDomainConflictResponse,
isIngressPublicDomainConflictError,
PublicDomainError
} from '@/services/backend/publicDomain';
import { isCustomPublicDomainPrefixEnabled } from '@/utils/feature-gates';
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST']);
return jsonRes(res, {
code: 405,
error: `Method ${req.method} Not Allowed`
});
}
if (!isCustomPublicDomainPrefixEnabled()) {
return jsonRes(res, {
code: 404,
error: 'Custom public domain prefixes are disabled'
});
}
try {
const { prefix, domain, appName } = req.body as {
prefix?: string;
domain?: string;
appName?: string;
};
if (!prefix || !domain) {
return jsonRes(res, {
code: 400,
error: 'prefix and domain are required'
});
}
const k8s = await createK8sContext(req);
const target = {
prefix,
domain,
appName
};
await ensurePublicDomainTargetsAvailable([target], k8s);
const result = await dryRunPublicDomainIngress(target, k8s);
return jsonRes(res, {
data: {
available: true,
...result
}
});
} catch (err: any) {
if (err === 'unAuthorization') {
return jsonRes(res, {
code: 401,
error: 'Unauthorized'
});
}
if (err instanceof PublicDomainError) {
const publicDomainError = getPublicDomainErrorResponse(err);
return jsonRes(res, {
code: err.status,
message:
err.code === 'PUBLIC_DOMAIN_CONFLICT' && !err.conflictOwner
? getPublicDomainConflictMessage()
: err.message,
data: {
available: false
},
error: publicDomainError
});
}
if (isIngressPublicDomainConflictError(err)) {
const conflict = getPublicDomainConflictResponse(err);
return jsonRes(res, {
code: 409,
message: conflict.message,
data: {
available: false
},
error: conflict
});
}
return jsonRes(res, {
code: 500,
error: err?.body || err?.message || err
});
}
}
@@ -0,0 +1,130 @@
import { getImageExposedPorts } from '@/utils/image-exposed-ports';
import { authSession } from '@/services/backend/auth';
import { jsonRes } from '@/services/backend/response';
import { isImagePortsEnabled } from '@/utils/feature-gates';
import { createHash } from 'crypto';
import type { NextApiRequest, NextApiResponse } from 'next';
const RATE_LIMIT_WINDOW_MS = 60 * 1000;
const MAX_REQUESTS_PER_WINDOW = 20;
const MAX_CONCURRENT_REQUESTS = 2;
const requestCounters = new Map<string, { windowStart: number; count: number; active: number }>();
function getRateLimitKey(kubeconfig: string) {
return createHash('sha256').update(kubeconfig).digest('hex');
}
function acquireRequestSlot(key: string) {
const now = Date.now();
for (const [entryKey, entry] of requestCounters) {
if (entry.active === 0 && now - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
requestCounters.delete(entryKey);
}
}
const current = requestCounters.get(key);
const entry =
current && now - current.windowStart < RATE_LIMIT_WINDOW_MS
? current
: { windowStart: now, count: 0, active: 0 };
if (entry.active >= MAX_CONCURRENT_REQUESTS) {
requestCounters.set(key, entry);
return { ok: false, code: 429, error: 'Too many concurrent image port requests' };
}
if (entry.count >= MAX_REQUESTS_PER_WINDOW) {
requestCounters.set(key, entry);
return { ok: false, code: 429, error: 'Too many image port requests' };
}
entry.count += 1;
entry.active += 1;
requestCounters.set(key, entry);
return { ok: true };
}
function releaseRequestSlot(key: string) {
const entry = requestCounters.get(key);
if (!entry) return;
entry.active = Math.max(0, entry.active - 1);
if (entry.active === 0 && Date.now() - entry.windowStart >= RATE_LIMIT_WINDOW_MS) {
requestCounters.delete(key);
}
}
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== 'POST') {
res.setHeader('Allow', ['POST']);
return jsonRes(res, {
code: 405,
error: `Method ${req.method} Not Allowed`
});
}
if (!isImagePortsEnabled()) {
return jsonRes(res, {
code: 404,
error: 'Image port detection is disabled'
});
}
try {
const kubeconfig = await authSession(req.headers);
const rateLimitKey = getRateLimitKey(kubeconfig);
const slot = acquireRequestSlot(rateLimitKey);
if (!slot.ok) {
return jsonRes(res, {
code: slot.code,
error: slot.error
});
}
try {
const { imageName, imageRegistry } = req.body as {
imageName?: string;
imageRegistry?: {
username?: string;
password?: string;
serverAddress?: string;
};
};
const normalizedImageName = imageName?.trim();
if (!normalizedImageName) {
return jsonRes(res, {
code: 400,
error: 'imageName is required'
});
}
if (normalizedImageName.length > 512) {
return jsonRes(res, {
code: 400,
error: 'imageName is too long'
});
}
const ports = await getImageExposedPorts(normalizedImageName, imageRegistry);
return jsonRes(res, { data: { ports } });
} finally {
releaseRequestSlot(rateLimitKey);
}
} catch (error: any) {
if (error === 'unAuthorization') {
return jsonRes(res, {
code: 401,
error: 'Unauthorized'
});
}
return jsonRes(res, {
code: 400,
error: error?.message || error
});
}
}
@@ -1,6 +1,8 @@
import { Coin } from '@/constants/app';
import { jsonRes } from '@/services/backend/response';
import type { AppConfigType, EnvResponse } from '@/types';
import { isCustomPublicDomainPrefixEnabled, isImagePortsEnabled } from '@/utils/feature-gates';
import { normalizePublicDomainReservedPrefixes } from '@/utils/public-domain';
import type { NextApiRequest, NextApiResponse } from 'next';
process.on('unhandledRejection', (reason, promise) => {
@@ -50,6 +52,11 @@ export const getServerEnv = (AppConfig: AppConfigType): EnvResponse => {
PVC_STORAGE_MAX: AppConfig.launchpad.pvcStorageMax || 20,
GPU_ENABLED: AppConfig.common.gpuEnabled,
LOG_ENABLED: !!AppConfig?.launchpad?.components?.log?.url,
NETWORK_STORAGE_ENABLED: AppConfig.common.networkStorageEnabled
NETWORK_STORAGE_ENABLED: AppConfig.common.networkStorageEnabled,
IMAGE_PORTS_ENABLED: isImagePortsEnabled(AppConfig),
CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED: isCustomPublicDomainPrefixEnabled(AppConfig),
PUBLIC_DOMAIN_RESERVED_PREFIXES: normalizePublicDomainReservedPrefixes(
AppConfig.launchpad.publicDomain?.reservedPrefixes
)
};
};
@@ -1,6 +1,6 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { ApiResp } from '@/services/kubernet';
import { jsonRes } from '@/services/backend/response';
import { handleK8sError, jsonRes } from '@/services/backend/response';
import { YamlKindEnum } from '@/utils/adapt';
import yaml from 'js-yaml';
import type { CustomObjectsApi, V1StatefulSet } from '@kubernetes/client-node';
@@ -489,9 +489,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
return jsonRes(res);
} catch (err: any) {
return jsonRes(res, {
code: 500,
error: err?.body
});
return jsonRes(res, handleK8sError(err?.body || err));
}
}
@@ -4,7 +4,7 @@ import {
UpdateAppResourcesSchema,
nanoid
} from '@/types/request_schema';
import { jsonRes } from '@/services/backend/response';
import { getPublicDomainErrorResponse, jsonRes } from '@/services/backend/response';
import { ApiResp } from '@/services/kubernet';
import type { NextApiRequest, NextApiResponse } from 'next';
import {
@@ -24,9 +24,19 @@ import { mountPathToConfigMapKey } from '@/utils/tools';
import { json2DeployCr, json2Service, json2Ingress } from '@/utils/deployYaml2Json';
import type { AppEditType } from '@/types/app';
import { appDeployKey } from '@/constants/app';
import {
ensurePublicDomainPrefixesAvailable,
PublicDomainError
} from '@/services/backend/publicDomain';
import { isCustomPublicDomainPrefixEnabled } from '@/utils/feature-gates';
import { validatePublicDomainPrefix } from '@/utils/public-domain';
class PortError extends Error {
constructor(message: string, public code: number = 500, public details?: any) {
constructor(
message: string,
public code: number = 500,
public details?: any
) {
super(message);
this.name = 'PortError';
}
@@ -53,6 +63,29 @@ class PortValidationError extends PortError {
}
}
function normalizePublicDomainPrefixOrThrow(value: string) {
if (!isCustomPublicDomainPrefixEnabled()) {
throw new PortValidationError('Custom public domain prefixes are disabled', {
operation: 'UPDATE_PUBLIC_DOMAIN_PREFIX'
});
}
const result = validatePublicDomainPrefix(value);
if (!result.valid) {
throw new PortValidationError(
result.reason === 'reserved'
? `Public domain prefix "${result.value}" is reserved`
: `Public domain prefix "${result.value}" is invalid`,
{
publicDomain: result.value,
reason: result.reason,
operation: 'VALIDATE_PUBLIC_DOMAIN_PREFIX'
}
);
}
return result.value;
}
async function validateAppExists(name: string, k8s: any, res: NextApiResponse<ApiResp>) {
try {
await k8s.getDeployApp(name);
@@ -225,6 +258,8 @@ async function updateConfigMap(
}
async function updateServiceAndIngress(appEditData: AppEditType, applyYamlList: any, k8s: any) {
await ensurePublicDomainPrefixesAvailable(appEditData, k8s);
const yamlList: string[] = [];
try {
@@ -746,6 +781,11 @@ async function manageAppPorts(
updatedNetwork.domain = '';
} else if (isApplicationProtocol && portConfig.exposesPublicDomain) {
updatedNetwork.publicDomain = existingNetwork.publicDomain || nanoid();
if (portConfig.publicDomain) {
updatedNetwork.publicDomain = normalizePublicDomainPrefixOrThrow(
portConfig.publicDomain
);
}
updatedNetwork.domain =
existingNetwork.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io';
}
@@ -760,6 +800,11 @@ async function manageAppPorts(
if (portConfig.exposesPublicDomain) {
updatedNetwork.publicDomain = updatedNetwork.publicDomain || nanoid();
if (portConfig.publicDomain) {
updatedNetwork.publicDomain = normalizePublicDomainPrefixOrThrow(
portConfig.publicDomain
);
}
updatedNetwork.domain =
updatedNetwork.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io';
@@ -790,6 +835,24 @@ async function manageAppPorts(
}
}
if (portConfig.publicDomain !== undefined) {
const finalAppProtocol = updatedNetwork.appProtocol;
const isApplicationProtocol = ['HTTP', 'GRPC', 'WS'].includes(finalAppProtocol || '');
if (!isApplicationProtocol || !updatedNetwork.openPublicDomain) {
throw new PortValidationError(
'Cannot set publicDomain for a port without public domain access',
{
currentAppProtocol: finalAppProtocol,
currentProtocol: updatedNetwork.protocol,
operation: 'UPDATE_PUBLIC_DOMAIN_PREFIX'
}
);
}
updatedNetwork.publicDomain = normalizePublicDomainPrefixOrThrow(portConfig.publicDomain);
updatedNetwork.domain =
updatedNetwork.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io';
}
newNetworks.push(updatedNetwork);
} else if (!portConfig.portName) {
if (!portConfig.number) {
@@ -819,6 +882,13 @@ async function manageAppPorts(
}
const isApplicationProtocol = ['HTTP', 'GRPC', 'WS'].includes(portConfig.protocol);
const openPublicDomain =
isApplicationProtocol &&
(portConfig.exposesPublicDomain !== undefined ? portConfig.exposesPublicDomain : false);
const publicDomain =
openPublicDomain && portConfig.publicDomain
? normalizePublicDomainPrefixOrThrow(portConfig.publicDomain)
: nanoid();
const newNetwork = {
serviceName: `service-${nanoid()}`,
networkName: `network-${nanoid()}`,
@@ -826,10 +896,8 @@ async function manageAppPorts(
port: portConfig.number,
protocol: isApplicationProtocol ? 'TCP' : portConfig.protocol || 'TCP',
appProtocol: isApplicationProtocol ? portConfig.protocol || 'HTTP' : undefined,
openPublicDomain:
isApplicationProtocol &&
(portConfig.exposesPublicDomain !== undefined ? portConfig.exposesPublicDomain : false),
publicDomain: isApplicationProtocol ? nanoid() : '',
openPublicDomain,
publicDomain: openPublicDomain ? publicDomain : '',
customDomain: '',
domain: isApplicationProtocol ? global.AppConfig?.cloud?.domain || 'cloud.sealos.io' : '',
nodePort: undefined,
@@ -851,13 +919,14 @@ async function manageAppPorts(
protocol: network.protocol
}));
await updateAppPorts(app, appName, k8sApp, namespace, targetContainerPorts);
const updatedAppData: AppEditType = {
...latestAppData,
networks: resultNetworks
};
await ensurePublicDomainPrefixesAvailable(updatedAppData, k8s);
await updateAppPorts(app, appName, k8sApp, namespace, targetContainerPorts);
await updateServiceAndIngress(updatedAppData, applyYamlList, k8s);
return updatedAppData;
@@ -974,6 +1043,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
try {
currentAppData = await manageAppPorts(name, updateData.ports, currentAppData!, k8s);
} catch (error: any) {
if (error instanceof PublicDomainError) {
return jsonRes(res, {
code: error.status,
error: {
type: 'PUBLIC_DOMAIN_ERROR',
...getPublicDomainErrorResponse(error)
}
});
}
if (error instanceof PortConflictError) {
return jsonRes(res, {
code: error.code,
@@ -1,4 +1,4 @@
import { jsonRes } from '@/services/backend/response';
import { getPublicDomainErrorResponse, jsonRes } from '@/services/backend/response';
import { ApiResp } from '@/services/kubernet';
import type { NextApiRequest, NextApiResponse } from 'next';
import {
@@ -11,6 +11,7 @@ import { adaptAppDetail } from '@/utils/adapt';
import { DeployKindsType, AppDetailType } from '@/types/app';
import { z } from 'zod';
import { LaunchpadApplicationSchema } from '@/types/schema';
import { PublicDomainError } from '@/services/backend/publicDomain';
async function processAppResponse(
response: PromiseSettledResult<any>[]
@@ -67,6 +68,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse<
});
}
} catch (err: any) {
if (err instanceof PublicDomainError) {
return jsonRes(res, {
code: err.status,
error: getPublicDomainErrorResponse(err)
});
}
jsonRes(res, {
code: 500,
error: err
@@ -25,6 +25,7 @@ import {
import { mountPathToConfigMapKey } from '@/utils/tools';
import { json2DeployCr, json2Service, json2Ingress } from '@/utils/deployYaml2Json';
import { appDeployKey } from '@/constants/app';
import { getPublicDomainErrorResponse } from '@/services/backend/response';
import {
sendError,
@@ -38,6 +39,12 @@ import {
sendK8sOperationError,
sendInternalError
} from '@/pages/api/v2alpha/k8sContext';
import {
ensurePublicDomainPrefixesAvailable,
PublicDomainError
} from '@/services/backend/publicDomain';
import { isCustomPublicDomainPrefixEnabled } from '@/utils/feature-gates';
import { validatePublicDomainPrefix } from '@/utils/public-domain';
// Constants
const DELAY_SHORT = 2000;
@@ -51,7 +58,11 @@ const SIZE_UNITS = {
// Custom Error Classes
class PortError extends Error {
constructor(message: string, public code: number = 500, public details?: ApiErrorDetails) {
constructor(
message: string,
public code: number = 500,
public details?: ApiErrorDetails
) {
super(message);
this.name = 'PortError';
}
@@ -78,6 +89,29 @@ class PortValidationError extends PortError {
}
}
function normalizePublicDomainPrefixOrThrow(value: string) {
if (!isCustomPublicDomainPrefixEnabled()) {
throw new PortValidationError('Custom public domain prefixes are disabled', {
operation: 'UPDATE_PUBLIC_DOMAIN_PREFIX'
});
}
const result = validatePublicDomainPrefix(value);
if (!result.valid) {
throw new PortValidationError(
result.reason === 'reserved'
? `Public domain prefix "${result.value}" is reserved`
: `Public domain prefix "${result.value}" is invalid`,
{
publicDomain: result.value,
reason: result.reason,
operation: 'VALIDATE_PUBLIC_DOMAIN_PREFIX'
}
);
}
return result.value;
}
// Utility Functions
function isApplicationProtocol(protocol?: string): boolean {
if (!protocol) return false;
@@ -372,6 +406,7 @@ async function updateServiceAndIngress(
applyYamlList: any,
k8s: any
): Promise<void> {
await ensurePublicDomainPrefixesAvailable(appEditData, k8s);
await deleteServiceAndIngress(k8s, appEditData.appName);
const yamlList: string[] = [];
@@ -656,6 +691,12 @@ async function updateAppPorts(
function createNetworkConfig(appName: string, portConfig: any): any {
const protocol = (portConfig.protocol || 'http').toUpperCase();
const isAppProtocol = isApplicationProtocol(protocol);
const openPublicDomain =
isAppProtocol && (portConfig.isPublic !== undefined ? portConfig.isPublic : false);
const publicDomain =
isAppProtocol && openPublicDomain && portConfig.publicDomain
? normalizePublicDomainPrefixOrThrow(portConfig.publicDomain)
: nanoid();
return {
serviceName: `${appName}-${portConfig.number}-${nanoid()}-service`,
@@ -664,9 +705,8 @@ function createNetworkConfig(appName: string, portConfig: any): any {
port: portConfig.number,
protocol: isAppProtocol ? 'TCP' : protocol,
appProtocol: isAppProtocol ? protocol : undefined,
openPublicDomain:
isAppProtocol && (portConfig.isPublic !== undefined ? portConfig.isPublic : false),
publicDomain: isAppProtocol ? nanoid() : '',
openPublicDomain,
publicDomain: openPublicDomain ? publicDomain : '',
customDomain: '',
domain: isAppProtocol ? global.AppConfig?.cloud?.domain || 'cloud.sealos.io' : '',
nodePort: undefined,
@@ -695,6 +735,9 @@ function updateNetworkConfig(existingNetwork: any, portConfig: any, appName: str
updatedNetwork.domain = '';
} else if (portConfig.isPublic) {
updatedNetwork.publicDomain = existingNetwork.publicDomain || nanoid();
if (portConfig.publicDomain) {
updatedNetwork.publicDomain = normalizePublicDomainPrefixOrThrow(portConfig.publicDomain);
}
updatedNetwork.domain =
existingNetwork.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io';
}
@@ -709,6 +752,9 @@ function updateNetworkConfig(existingNetwork: any, portConfig: any, appName: str
if (portConfig.isPublic) {
updatedNetwork.publicDomain = updatedNetwork.publicDomain || nanoid();
if (portConfig.publicDomain) {
updatedNetwork.publicDomain = normalizePublicDomainPrefixOrThrow(portConfig.publicDomain);
}
updatedNetwork.domain =
updatedNetwork.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io';
@@ -739,6 +785,27 @@ function updateNetworkConfig(existingNetwork: any, portConfig: any, appName: str
}
}
if (portConfig.publicDomain !== undefined) {
const finalAppProtocol = updatedNetwork.appProtocol;
const isAppProtocol = isApplicationProtocol(finalAppProtocol);
if (!isAppProtocol || !updatedNetwork.openPublicDomain) {
throw new PortValidationError(
'Cannot set publicDomain for a port without public domain access',
{
currentAppProtocol: finalAppProtocol,
currentProtocol: updatedNetwork.protocol,
supportedProtocols: APPLICATION_PROTOCOLS,
operation: 'UPDATE_PUBLIC_DOMAIN_PREFIX'
}
);
}
updatedNetwork.publicDomain = normalizePublicDomainPrefixOrThrow(portConfig.publicDomain);
updatedNetwork.domain =
updatedNetwork.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io';
}
return updatedNetwork;
}
@@ -849,13 +916,14 @@ async function manageAppPorts(appName: string, requestPorts: any[], k8s: any): P
protocol: network.protocol
}));
await updateAppPorts(app, appName, k8sApp, namespace, targetContainerPorts);
const updatedAppData: AppEditType = {
...latestAppData,
networks: resultNetworks
};
await ensurePublicDomainPrefixesAvailable(updatedAppData, k8s);
await updateAppPorts(app, appName, k8sApp, namespace, targetContainerPorts);
await updateServiceAndIngress(updatedAppData, applyYamlList, k8s);
}
@@ -1002,12 +1070,12 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
updateData.image.imageRegistry === null
? null
: updateData.image.imageRegistry
? {
username: updateData.image.imageRegistry.username,
password: updateData.image.imageRegistry.password,
serverAddress: updateData.image.imageRegistry.apiUrl
}
: undefined
? {
username: updateData.image.imageRegistry.username,
password: updateData.image.imageRegistry.password,
serverAddress: updateData.image.imageRegistry.apiUrl
}
: undefined
})
};
@@ -1028,6 +1096,16 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
try {
await manageAppPorts(name, updateData.ports, k8s);
} catch (error: any) {
if (error instanceof PublicDomainError) {
return sendError(res, {
status: error.status,
type: ErrorType.VALIDATION_ERROR,
code: ErrorCode.INVALID_VALUE,
message: error.message,
details: getPublicDomainErrorResponse(error)
});
}
if (error instanceof PortError) {
handlePortError(error, res);
return;
@@ -1,4 +1,4 @@
import { jsonRes } from '@/services/backend/response';
import { getPublicDomainErrorResponse, jsonRes } from '@/services/backend/response';
import { ApiResp } from '@/services/kubernet';
import type { NextApiRequest, NextApiResponse } from 'next';
import {
@@ -18,6 +18,7 @@ import {
sendK8sOperationError,
sendInternalError
} from '@/pages/api/v2alpha/k8sContext';
import { PublicDomainError } from '@/services/backend/publicDomain';
async function processAppResponse(
response: PromiseSettledResult<any>[],
@@ -77,6 +78,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const appData = await processAppResponse(response, k8s.namespace);
return res.status(201).json(appData);
} catch (err) {
if (err instanceof PublicDomainError) {
return sendError(res, {
status: err.status,
type: ErrorType.VALIDATION_ERROR,
code: ErrorCode.INVALID_VALUE,
message: err.message,
details: getPublicDomainErrorResponse(err)
});
}
console.error('Kubernetes create application error:', err);
return sendK8sOperationError(
res,
@@ -2,6 +2,10 @@ import type { NextApiRequest, NextApiResponse } from 'next';
import { createK8sContext } from '@/services/backend';
import type { K8sContext } from '@/services/backend';
import { sendError, ErrorType, ErrorCode } from '@/types/v2alpha/error';
import {
getPublicDomainConflictResponse,
isIngressPublicDomainConflictError
} from '@/services/backend/publicDomain';
/**
* Auth/kubeconfig error identifiers from authSession and getK8s.
@@ -134,6 +138,22 @@ export function sendK8sOperationError(
const errStr = errMessage.toLowerCase();
const statusCode = getK8sStatusCode(err);
if (isIngressPublicDomainConflictError(err)) {
const conflict = getPublicDomainConflictResponse(err);
sendError(res, {
status: 409,
type: ErrorType.RESOURCE_ERROR,
code: ErrorCode.CONFLICT,
message: conflict.message,
details: {
code: conflict.code,
message: conflict.message,
rawMessage: conflict.details
}
});
return;
}
// 403 Forbidden - K8s permission denied
if (statusCode === 403 || errStr.includes('forbidden') || errStr.includes('permission denied')) {
sendError(res, {
@@ -1,12 +1,18 @@
import { obj2Query } from '@/api/tools';
import { getSharePVCs } from '@/api/app';
import { getImagePorts } from '@/api/platform';
import MyIcon from '@/components/Icon';
import { MyRangeSlider, MySelect, MySlider, MyTooltip, RangeInput, Tabs, Tip } from '@sealos/ui';
import { defaultSliderKey, defaultGpuSliderKey } from '@/constants/app';
import { GpuAmountMarkList } from '@/constants/editApp';
import { useToast } from '@/hooks/useToast';
import { useGlobalStore } from '@/store/global';
import { PVC_STORAGE_MAX, NETWORK_STORAGE_ENABLED } from '@/store/static';
import {
PVC_STORAGE_MAX,
NETWORK_STORAGE_ENABLED,
SEALOS_DOMAIN,
IMAGE_PORTS_ENABLED
} from '@/store/static';
import { useUserStore } from '@/store/user';
import type { QueryType } from '@/types';
import { type AppEditType } from '@/types/app';
@@ -28,6 +34,7 @@ import {
IconButton,
Image,
Input,
Spinner,
Switch,
useDisclosure,
useTheme
@@ -36,8 +43,9 @@ import { throttle } from 'lodash';
import { useTranslation } from 'next-i18next';
import dynamic from 'next/dynamic';
import { useRouter } from 'next/router';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useFieldArray, UseFormReturn } from 'react-hook-form';
import { customAlphabet } from 'nanoid';
import type { ConfigMapType } from './ConfigmapModal';
import PriceBox from './PriceBox';
import QuotaBox from './QuotaBox';
@@ -53,6 +61,40 @@ const NetworkStoreModal = dynamic(() => import('./NetworkStoreModal'));
const EditEnvs = dynamic(() => import('./EditEnvs'));
const labelWidth = 120;
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12);
type ImagePortDetectionState = {
status: 'idle' | 'loading' | 'success' | 'empty' | 'error';
count?: number;
};
function getNetworkSignature(networks: AppEditType['networks']) {
return networks
.map((network) =>
[
network.port,
network.protocol,
network.appProtocol || '',
network.openPublicDomain ? 'public' : 'private',
network.openNodePort ? 'nodeport' : 'cluster'
].join(':')
)
.join('|');
}
function canAutoReplaceNetworks(networks: AppEditType['networks'], lastAutoSignature: string) {
const networkSignature = getNetworkSignature(networks);
return (
networks.length === 0 ||
networkSignature === lastAutoSignature ||
(networks.length === 1 &&
Number(networks[0].port) === 80 &&
networks[0].protocol === 'TCP' &&
networks[0].appProtocol === 'HTTP' &&
!networks[0].openPublicDomain &&
!networks[0].openNodePort)
);
}
const Form = ({
formHook,
@@ -163,6 +205,22 @@ const Form = ({
const [storeEdit, setStoreEdit] = useState<StoreType>();
const [networkStoreEdit, setNetworkStoreEdit] = useState(false);
const { isOpen: isEditEnvs, onOpen: onOpenEditEnvs, onClose: onCloseEditEnvs } = useDisclosure();
const imagePortRequestId = useRef(0);
const secretPasswordVersion = useRef(0);
const lastAutoPortKey = useRef('');
const lastAutoNetworkSignature = useRef('');
const [imagePortDetection, setImagePortDetection] = useState<ImagePortDetectionState>({
status: 'idle'
});
const watchedImageName = watch('imageName');
const watchedSecretUse = watch('secret.use');
const watchedSecretUsername = watch('secret.username');
const watchedSecretPassword = watch('secret.password');
const watchedSecretServerAddress = watch('secret.serverAddress');
useEffect(() => {
secretPasswordVersion.current += 1;
}, [watchedSecretPassword]);
// For quota calculation in fields
const { userQuota, loadUserQuota } = useUserStore();
@@ -230,6 +288,129 @@ const Form = ({
return () => subscription.unsubscribe();
}, [watch, setValue]);
useEffect(() => {
if (!IMAGE_PORTS_ENABLED || isEdit || !already) {
setImagePortDetection({ status: 'idle' });
return;
}
const imageName = getValues('imageName');
if (!imageName) {
setImagePortDetection({ status: 'idle' });
return;
}
const secret = getValues('secret');
const autoPortKey = [
imageName,
secret.use ? secret.username : '',
secret.use ? secret.serverAddress : '',
secret.use && secret.password ? `password-v${secretPasswordVersion.current}` : ''
].join('|');
if (lastAutoPortKey.current === autoPortKey) return;
if (!canAutoReplaceNetworks(getValues('networks'), lastAutoNetworkSignature.current)) {
setImagePortDetection({ status: 'idle' });
return;
}
const requestId = ++imagePortRequestId.current;
setImagePortDetection({ status: 'loading' });
const timer = setTimeout(async () => {
try {
const res = await getImagePorts({
imageName,
imageRegistry: secret.use
? {
username: secret.username,
password: secret.password,
serverAddress: secret.serverAddress
}
: undefined
});
if (requestId !== imagePortRequestId.current) {
return;
}
if (!res.ports.length) {
setImagePortDetection({ status: 'empty' });
return;
}
if (!canAutoReplaceNetworks(getValues('networks'), lastAutoNetworkSignature.current)) {
setImagePortDetection({ status: 'idle' });
return;
}
lastAutoPortKey.current = autoPortKey;
const nextNetworks: AppEditType['networks'] = res.ports.slice(0, 15).map((item, index) => ({
networkName: '',
portName: nanoid(),
port: item.port,
protocol: item.protocol,
appProtocol: item.protocol === 'TCP' && index === 0 ? 'HTTP' : undefined,
openPublicDomain: false,
publicDomain: '',
customDomain: '',
domain: SEALOS_DOMAIN,
openNodePort: false,
nodePort: undefined
}));
lastAutoNetworkSignature.current = getNetworkSignature(nextNetworks);
setValue('networks', nextNetworks);
setImagePortDetection({ status: 'success', count: nextNetworks.length });
} catch (error) {
if (requestId === imagePortRequestId.current) {
setImagePortDetection({ status: 'error' });
}
}
}, 700);
return () => clearTimeout(timer);
}, [
already,
getValues,
isEdit,
refresh,
setValue,
watchedImageName,
watchedSecretPassword,
watchedSecretServerAddress,
watchedSecretUse,
watchedSecretUsername
]);
const imagePortDetectionView = useMemo(() => {
switch (imagePortDetection.status) {
case 'loading':
return {
color: 'brightBlue.600',
text: t('recognizing_image_ports'),
showSpinner: true
};
case 'success':
return {
color: 'green.600',
text: t('recognized_image_ports', { count: imagePortDetection.count || 0 }),
showSpinner: false
};
case 'empty':
return {
color: 'grayModern.600',
text: t('no_image_ports_detected'),
showSpinner: false
};
case 'error':
return {
color: 'grayModern.600',
text: t('image_ports_detection_failed'),
showSpinner: false
};
default:
return null;
}
}, [imagePortDetection, t]);
// common form label
const Label = ({
children,
@@ -583,22 +764,42 @@ const Form = ({
/>
</Flex>
<Box mt={4} pl={`${labelWidth}px`}>
<FormControl isInvalid={!!errors.imageName} w={'420px'}>
<FormControl isInvalid={!!errors.imageName} w={'620px'} maxW={'100%'}>
<Box mb={1} fontSize={'sm'}>
{t('Image Name')}
</Box>
<Input
width={'350px'}
value={getValues('imageName')}
backgroundColor={getValues('imageName') ? 'myWhite.500' : 'grayModern.100'}
placeholder={`${t('Image Name')}`}
{...register('imageName', {
required: 'Image name cannot be empty',
setValueAs(e) {
return e.replace(/\s*/g, '');
}
})}
/>
<Flex alignItems={'center'} gap={3}>
<Input
width={'350px'}
flexShrink={0}
value={getValues('imageName')}
backgroundColor={getValues('imageName') ? 'myWhite.500' : 'grayModern.100'}
placeholder={`${t('Image Name')}`}
{...register('imageName', {
required: 'Image name cannot be empty',
setValueAs(e) {
return e.replace(/\s*/g, '');
}
})}
/>
<Flex
alignItems={'center'}
gap={2}
minW={'160px'}
maxW={'220px'}
h={'22px'}
fontSize={'12px'}
color={imagePortDetectionView?.color}
visibility={imagePortDetectionView ? 'visible' : 'hidden'}
>
{imagePortDetectionView?.showSpinner ? (
<Spinner size={'xs'} thickness={'2px'} speed={'0.8s'} />
) : null}
<Box as={'span'} whiteSpace={'nowrap'} className="textEllipsis">
{imagePortDetectionView?.text}
</Box>
</Flex>
</Flex>
</FormControl>
{getValues('secret.use') ? (
<>
@@ -1,11 +1,29 @@
import MyIcon from '@/components/Icon';
import { checkPublicDomain } from '@/api/platform';
import { MySelect } from '@sealos/ui';
import { APPLICATION_PROTOCOLS, ProtocolList } from '@/constants/app';
import { DISABLE_HTTPS, DOMAIN_PORT, HTTP_PORT, SEALOS_DOMAIN } from '@/store/static';
import {
CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED,
DISABLE_HTTPS,
DOMAIN_PORT,
HTTP_PORT,
SEALOS_DOMAIN
} from '@/store/static';
import { useTranslation } from 'next-i18next';
import { customAlphabet } from 'nanoid';
import { UseFormReturn, useFieldArray, useWatch } from 'react-hook-form';
import { Box, Button, Flex, IconButton, Input, Switch, Tooltip, useTheme } from '@chakra-ui/react';
import {
Box,
Button,
Flex,
FormControl,
FormErrorMessage,
IconButton,
Input,
Switch,
Tooltip,
useTheme
} from '@chakra-ui/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { AppEditType } from '@/types/app';
import RouteRulesModal from './RouteRulesModal';
@@ -14,11 +32,199 @@ import { buildExternalUrl, getExternalProtocol } from '@/utils/network-url';
import { syncDefaultRouteServicePort } from '@/utils/network-routes';
import type { CustomAccessModalParams } from './CustomAccessModal';
import dynamic from 'next/dynamic';
import {
PUBLIC_DOMAIN_PREFIX_MAX_LENGTH,
PUBLIC_DOMAIN_PREFIX_MIN_LENGTH,
PublicDomainConflictOwner,
getDuplicateManagedPublicDomainHosts,
normalizePublicDomainPrefix,
validatePublicDomainPrefix
} from '@/utils/public-domain';
const CustomAccessModal = dynamic(() => import('./CustomAccessModal'));
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12);
const getPublicDomainPrefixErrorMessage = (
t: ReturnType<typeof useTranslation>['t'],
reason: 'format' | 'reserved' | 'conflict' | 'duplicate',
conflictOwner?: PublicDomainConflictOwner
) => {
if (reason === 'duplicate') {
return (
t('public_domain_prefix_duplicate_error') ||
'This public address prefix is duplicated in this app. Please choose another one.'
);
}
if (reason === 'conflict') {
if (conflictOwner) {
return (
t('public_domain_prefix_conflict_owner_error', {
type: conflictOwner.displayType,
name: conflictOwner.displayName
}) ||
`This public address prefix is already used by ${conflictOwner.displayType} "${conflictOwner.displayName}" in this workspace.`
);
}
return (
t('public_domain_prefix_conflict_error') ||
'This public address prefix is already in use. Please choose another one.'
);
}
if (reason === 'reserved') {
return (
t('public_domain_prefix_reserved_error') ||
'This public address prefix is reserved. Please choose another one.'
);
}
return (
t('public_domain_prefix_format_error', {
min: PUBLIC_DOMAIN_PREFIX_MIN_LENGTH,
max: PUBLIC_DOMAIN_PREFIX_MAX_LENGTH
}) ||
`Use ${PUBLIC_DOMAIN_PREFIX_MIN_LENGTH}-${PUBLIC_DOMAIN_PREFIX_MAX_LENGTH} lowercase letters, numbers, or hyphens. It cannot start or end with a hyphen.`
);
};
const getConflictOwnerFromError = (error: any): PublicDomainConflictOwner | undefined => {
return error?.error?.conflictOwner;
};
function PublicDomainPrefixInput({
value,
errorMessage,
onDraftChange,
onStartEdit,
onCommit
}: {
value: string;
errorMessage?: string;
onDraftChange: (value: string) => void;
onStartEdit: () => void;
onCommit: (value: string) => Promise<string | null>;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState(value);
const [isFocused, setIsFocused] = useState(false);
const lastCommittedValueRef = useRef(value);
const skipNextBlurCommitRef = useRef(false);
useEffect(() => {
if (!isFocused) {
setDraft(value);
lastCommittedValueRef.current = value;
}
}, [isFocused, value]);
const commitDraft = useCallback(async () => {
const committedValue = await onCommit(draft);
if (committedValue) {
lastCommittedValueRef.current = committedValue;
}
return committedValue;
}, [draft, onCommit]);
return (
<Tooltip label={t('public_domain_prefix_edit_tooltip')}>
<Box
position={'relative'}
h={'30px'}
w={'164px'}
flexShrink={0}
border={0}
borderRight={'1px solid'}
borderColor={'grayModern.200'}
bg={'white'}
_hover={{
'& .public-domain-prefix-edit-icon': {
opacity: 1
}
}}
>
<Input
aria-label={t('public_domain_prefix_input_label') || 'Public address prefix'}
autoCapitalize="none"
autoComplete="off"
spellCheck={false}
h={'30px'}
w={'100%'}
border={0}
borderRadius={0}
bg={'transparent'}
pl={3}
pr={'30px'}
fontSize={'15px'}
fontWeight={500}
color={'grayModern.900'}
cursor={'text'}
userSelect={'text'}
value={draft}
maxLength={PUBLIC_DOMAIN_PREFIX_MAX_LENGTH}
placeholder={t('public_domain_prefix_placeholder') || 'Edit prefix'}
isInvalid={!!errorMessage && !isFocused}
_placeholder={{
color: 'grayModern.500'
}}
_focusVisible={{
boxShadow: 'inset 0 0 0 1px #219BF4'
}}
_invalid={{
boxShadow: 'inset 0 0 0 1px #E53E3E'
}}
onFocus={() => {
setIsFocused(true);
onStartEdit();
}}
onBlur={() => {
setIsFocused(false);
if (skipNextBlurCommitRef.current) {
skipNextBlurCommitRef.current = false;
return;
}
void commitDraft();
}}
onChange={(e) => {
const nextValue = e.target.value;
setDraft(nextValue);
onDraftChange(nextValue);
}}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
skipNextBlurCommitRef.current = true;
void commitDraft();
e.currentTarget.blur();
}
if (e.key === 'Escape') {
e.preventDefault();
setDraft(lastCommittedValueRef.current);
onDraftChange(lastCommittedValueRef.current);
skipNextBlurCommitRef.current = true;
e.currentTarget.blur();
}
}}
/>
<MyIcon
className="public-domain-prefix-edit-icon"
name={'edit'}
position={'absolute'}
right={'10px'}
top={'50%'}
transform={'translateY(-50%)'}
w={'13px'}
color={isFocused ? 'brightBlue.600' : 'grayModern.500'}
opacity={isFocused ? 1 : 0.72}
pointerEvents={'none'}
/>
</Box>
</Tooltip>
);
}
type NetworkAction =
| { type: 'ADD_PORT'; payload: AppEditType['networks'][0] }
| { type: 'REMOVE_PORT'; payload: { index: number } }
@@ -112,10 +318,26 @@ export function NetworkSection({
const { copyData } = useCopyData();
const [routeRulesIndex, setRouteRulesIndex] = useState<number>();
const [customAccessModalData, setCustomAccessModalData] = useState<CustomAccessModalParams>();
const publicDomainCheckSeqRef = useRef<Record<number, number>>({});
const publicDomainDraftCheckTimerRef = useRef<Record<number, ReturnType<typeof setTimeout>>>({});
const { register, control, getValues, setValue } = formHook;
const {
register,
control,
getValues,
setValue,
setError,
clearErrors,
formState: { errors }
} = formHook;
const watchedNetworks = useWatch({ control, name: 'networks' });
const previousNetworkPortsRef = useRef<Record<string, number>>({});
const clearPublicDomainErrorByIndex = useCallback(
(index: number) => {
clearErrors(`networks.${index}.publicDomain`);
},
[clearErrors]
);
const {
fields: networks,
@@ -180,6 +402,7 @@ export function NetworkSection({
case 'REMOVE_PORT': {
const { index } = action.payload;
if (currentNetworks.length > 1 && index >= 0 && index < currentNetworks.length) {
clearPublicDomainErrorByIndex(index);
removeNetworks(index);
}
break;
@@ -209,6 +432,7 @@ export function NetworkSection({
case 'DISABLE_EXTERNAL_ACCESS': {
const { index } = action.payload;
clearPublicDomainErrorByIndex(index);
updateNetworks(index, {
...currentNetworks[index],
serviceName: '',
@@ -241,6 +465,7 @@ export function NetworkSection({
})
);
} else {
clearPublicDomainErrorByIndex(index);
updateNetworks(
index,
withDefaultRoutes({
@@ -262,6 +487,9 @@ export function NetworkSection({
case 'UPDATE_CUSTOM_DOMAIN': {
const { index, customDomain } = action.payload;
if (customDomain) {
clearPublicDomainErrorByIndex(index);
}
updateNetworks(index, {
...currentNetworks[index],
customDomain
@@ -282,7 +510,267 @@ export function NetworkSection({
break;
}
},
[getValues, appendNetworks, removeNetworks, updateNetworks]
[getValues, appendNetworks, removeNetworks, updateNetworks, clearPublicDomainErrorByIndex]
);
const getPublicDomainFieldName = useCallback(
(index: number) => `networks.${index}.publicDomain` as const,
[]
);
const setPublicDomainValidationError = useCallback(
(
index: number,
reason: 'format' | 'reserved' | 'conflict' | 'duplicate',
conflictOwner?: PublicDomainConflictOwner
) => {
setError(getPublicDomainFieldName(index), {
type: reason,
message: getPublicDomainPrefixErrorMessage(t, reason, conflictOwner)
});
},
[getPublicDomainFieldName, setError, t]
);
const clearPublicDomainValidationError = useCallback(
(index: number) => {
clearErrors(getPublicDomainFieldName(index));
},
[clearErrors, getPublicDomainFieldName]
);
const getPublicDomainValidationError = useCallback(
(index: number) => {
const message = (errors.networks as any)?.[index]?.publicDomain?.message;
return typeof message === 'string' ? message : undefined;
},
[errors.networks]
);
const getPublicDomainValidationErrorType = useCallback(
(index: number) => {
const type = (errors.networks as any)?.[index]?.publicDomain?.type;
return typeof type === 'string' ? type : undefined;
},
[errors.networks]
);
const hasManagedPublicDomainHostDuplicate = useCallback(
(index: number, publicDomain: string, domain: string) => {
const networks = getValues('networks').map((network, networkIndex) =>
networkIndex === index
? {
...network,
publicDomain,
domain
}
: network
);
if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return false;
return getDuplicateManagedPublicDomainHosts(networks, SEALOS_DOMAIN).some(({ indexes }) =>
indexes.includes(index)
);
},
[getValues]
);
const syncManagedPublicDomainHostDuplicateErrors = useCallback(
(nextNetworks: AppEditType['networks']) => {
if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return new Set<number>();
const duplicateIndexes = new Set(
getDuplicateManagedPublicDomainHosts(nextNetworks, SEALOS_DOMAIN).flatMap(
({ indexes }) => indexes
)
);
const message = getPublicDomainPrefixErrorMessage(t, 'duplicate');
nextNetworks.forEach((_, index) => {
const currentErrorType = getPublicDomainValidationErrorType(index);
if (duplicateIndexes.has(index)) {
if (currentErrorType !== 'duplicate') {
setError(getPublicDomainFieldName(index), {
type: 'duplicate',
message
});
}
return;
}
if (currentErrorType === 'duplicate') {
clearErrors(getPublicDomainFieldName(index));
}
});
return duplicateIndexes;
},
[clearErrors, getPublicDomainFieldName, getPublicDomainValidationErrorType, setError, t]
);
useEffect(() => {
if (watchedNetworks) {
syncManagedPublicDomainHostDuplicateErrors(watchedNetworks);
}
}, [syncManagedPublicDomainHostDuplicateErrors, watchedNetworks]);
const clearPublicDomainDraftCheckTimer = useCallback((index?: number) => {
if (typeof index === 'number') {
if (publicDomainDraftCheckTimerRef.current[index]) {
clearTimeout(publicDomainDraftCheckTimerRef.current[index]);
delete publicDomainDraftCheckTimerRef.current[index];
}
return;
}
Object.values(publicDomainDraftCheckTimerRef.current).forEach(clearTimeout);
publicDomainDraftCheckTimerRef.current = {};
}, []);
useEffect(() => clearPublicDomainDraftCheckTimer, [clearPublicDomainDraftCheckTimer]);
const isPublicDomainCheckCurrent = useCallback(
(index: number, prefix: string, domain: string, checkSeq: number) => {
const network = getValues(`networks.${index}`);
return (
checkSeq === publicDomainCheckSeqRef.current[index] &&
!!network?.openPublicDomain &&
!network.openNodePort &&
!network.customDomain &&
normalizePublicDomainPrefix(network.publicDomain) === prefix &&
(network.domain || SEALOS_DOMAIN) === domain
);
},
[getValues]
);
const commitPublicDomainDraft = useCallback(
async (index: number, value: string, options: { commitValue?: boolean } = {}) => {
const { commitValue = true } = options;
clearPublicDomainDraftCheckTimer(index);
if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) {
const network = getValues(`networks.${index}`);
const publicDomain = network?.publicDomain || nanoid();
if (!network?.publicDomain) {
setValue(getPublicDomainFieldName(index), publicDomain, {
shouldDirty: true,
shouldValidate: false
});
}
clearPublicDomainValidationError(index);
return publicDomain;
}
const result = validatePublicDomainPrefix(value);
if (!result.valid) {
setValue(getPublicDomainFieldName(index), value, {
shouldDirty: true,
shouldValidate: false
});
setPublicDomainValidationError(index, result.reason);
return null;
}
const network = getValues(`networks.${index}`);
const domain = network.domain || SEALOS_DOMAIN;
if (hasManagedPublicDomainHostDuplicate(index, result.value, domain)) {
setValue(getPublicDomainFieldName(index), commitValue ? result.value : value, {
shouldDirty: true,
shouldValidate: false
});
setPublicDomainValidationError(index, 'duplicate');
return null;
}
const checkSeq = (publicDomainCheckSeqRef.current[index] || 0) + 1;
publicDomainCheckSeqRef.current[index] = checkSeq;
try {
await checkPublicDomain({
prefix: result.value,
domain,
appName: getValues('appName')
});
} catch (error: any) {
if (!isPublicDomainCheckCurrent(index, result.value, domain, checkSeq)) return null;
if (error?.error?.code === 'PUBLIC_DOMAIN_CONFLICT') {
setPublicDomainValidationError(index, 'conflict', getConflictOwnerFromError(error));
return null;
}
return result.value;
}
if (!isPublicDomainCheckCurrent(index, result.value, domain, checkSeq)) return null;
clearPublicDomainValidationError(index);
if (commitValue) {
setValue(getPublicDomainFieldName(index), result.value, {
shouldDirty: true,
shouldValidate: false
});
}
return result.value;
},
[
clearPublicDomainDraftCheckTimer,
clearPublicDomainValidationError,
getPublicDomainFieldName,
getValues,
hasManagedPublicDomainHostDuplicate,
isPublicDomainCheckCurrent,
setPublicDomainValidationError,
setValue
]
);
const schedulePublicDomainDraftCheck = useCallback(
(index: number, value: string) => {
if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return;
clearPublicDomainDraftCheckTimer(index);
publicDomainCheckSeqRef.current[index] = (publicDomainCheckSeqRef.current[index] || 0) + 1;
publicDomainDraftCheckTimerRef.current[index] = setTimeout(() => {
void commitPublicDomainDraft(index, value, { commitValue: false });
}, 600);
},
[clearPublicDomainDraftCheckTimer, commitPublicDomainDraft]
);
const updatePublicDomainDraft = useCallback(
(index: number, value: string) => {
if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return;
setValue(getPublicDomainFieldName(index), value, {
shouldDirty: true,
shouldValidate: false
});
clearPublicDomainValidationError(index);
const nextNetworks = getValues('networks').map((network, networkIndex) =>
networkIndex === index
? {
...network,
publicDomain: value
}
: network
);
syncManagedPublicDomainHostDuplicateErrors(nextNetworks);
schedulePublicDomainDraftCheck(index, value);
},
[
clearPublicDomainValidationError,
getPublicDomainFieldName,
getValues,
schedulePublicDomainDraftCheck,
syncManagedPublicDomainHostDuplicateErrors,
setValue
]
);
const getServiceOptions = useCallback(() => {
@@ -362,13 +850,22 @@ export function NetworkSection({
{t('Network Configuration')}
</Box>
<Box px={'42px'} py={'24px'} userSelect={'none'}>
{networks.map((network, i) => {
{networks.map((field, i) => {
const network = watchedNetworks?.[i] || field;
const isExternalAccess = !!network.openPublicDomain || !!network.openNodePort;
const canConfigureRouteRules = !!network.openPublicDomain && !network.openNodePort;
const isPublicDomainPrefixVisible =
CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED &&
network.openPublicDomain &&
!network.openNodePort &&
!network.customDomain;
const publicDomainErrorMessage = isPublicDomainPrefixVisible
? getPublicDomainValidationError(i)
: undefined;
return (
<Box
key={network.id}
key={field.id}
w={'697px'}
maxW={'100%'}
_notLast={{ pb: 6, mb: 6, borderBottom: theme.borders.base }}
@@ -413,170 +910,213 @@ export function NetworkSection({
<Box ml={'32px'} flex={isExternalAccess ? '1 1 auto' : '0 0 93px'} minW={0}>
<Box {...fieldLabelStyles}>{t('Public Access')}</Box>
<Flex alignItems={'center'} h={'32px'} minW={0}>
<Switch
className="driver-deploy-network-switch"
size={'lg'}
isChecked={isExternalAccess}
mr={isExternalAccess ? '24px' : 0}
sx={{
lineHeight: 0,
'.chakra-switch__track': {
bg: 'grayModern.200',
transitionProperty:
'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform',
transitionDuration: '0.15s',
transitionTimingFunction: 'ease'
},
'.chakra-switch__thumb': {
bg: 'white',
boxShadow: '0px 1px 2px rgba(17, 24, 36, 0.16)',
transitionProperty: 'transform',
transitionDuration: '0.2s',
transitionTimingFunction: 'ease'
},
'.chakra-switch__input:checked + .chakra-switch__track': {
bg: 'grayModern.900'
}
}}
onChange={(e) => {
if (e.target.checked) {
dispatch({
type: 'ENABLE_EXTERNAL_ACCESS',
payload: { index: i }
});
} else {
dispatch({
type: 'DISABLE_EXTERNAL_ACCESS',
payload: { index: i }
});
}
}}
/>
<FormControl isInvalid={!!publicDomainErrorMessage}>
<Flex alignItems={'center'} h={'32px'} minW={0}>
<Switch
className="driver-deploy-network-switch"
size={'lg'}
isChecked={isExternalAccess}
mr={isExternalAccess ? '24px' : 0}
sx={{
lineHeight: 0,
'.chakra-switch__track': {
bg: 'grayModern.200',
transitionProperty:
'background-color, border-color, color, fill, stroke, opacity, box-shadow, transform',
transitionDuration: '0.15s',
transitionTimingFunction: 'ease'
},
'.chakra-switch__thumb': {
bg: 'white',
boxShadow: '0px 1px 2px rgba(17, 24, 36, 0.16)',
transitionProperty: 'transform',
transitionDuration: '0.2s',
transitionTimingFunction: 'ease'
},
'.chakra-switch__input:checked + .chakra-switch__track': {
bg: 'grayModern.900'
}
}}
onChange={(e) => {
if (e.target.checked) {
dispatch({
type: 'ENABLE_EXTERNAL_ACCESS',
payload: { index: i }
});
} else {
dispatch({
type: 'DISABLE_EXTERNAL_ACCESS',
payload: { index: i }
});
}
}}
/>
{isExternalAccess && (
<>
<Flex alignItems={'center'} w={'349px'} mr={'8px'} h={'32px'}>
<MySelect
width={'90px'}
height={'32px'}
borderTopRightRadius={0}
borderBottomRightRadius={0}
fontSize={'12px'}
fontWeight={400}
lineHeight={'16px'}
letterSpacing={'0.048px'}
value={
network.openPublicDomain
? network.appProtocol
: network.openNodePort
? network.protocol
: 'HTTP'
}
list={ProtocolList}
onchange={(val: any) => {
dispatch({
type: 'UPDATE_PROTOCOL',
payload: {
index: i,
protocol: val
}
});
}}
/>
<Flex
alignItems={'center'}
h={'32px'}
w={'260px'}
bg={'grayModern.50'}
border={theme.borders.base}
borderLeft={0}
borderTopRightRadius={'md'}
borderBottomRightRadius={'md'}
overflow={'hidden'}
>
<Tooltip label={t('click_to_copy_tooltip')}>
<Box
h={'30px'}
display={'flex'}
alignItems={'center'}
{isExternalAccess && (
<>
<Flex alignItems={'center'} w={'389px'} mr={'8px'} h={'32px'} minW={0}>
<MySelect
width={'90px'}
height={'32px'}
borderTopRightRadius={0}
borderBottomRightRadius={0}
fontSize={'12px'}
fontWeight={400}
lineHeight={'16px'}
letterSpacing={'0.048px'}
value={
network.openPublicDomain
? network.appProtocol
: network.openNodePort
? network.protocol
: 'HTTP'
}
list={ProtocolList}
onchange={(val: any) => {
dispatch({
type: 'UPDATE_PROTOCOL',
payload: {
index: i,
protocol: val
}
});
}}
/>
<Flex
alignItems={'center'}
h={'32px'}
w={'300px'}
bg={'grayModern.50'}
border={theme.borders.base}
borderLeft={0}
borderTopRightRadius={'md'}
borderBottomRightRadius={'md'}
overflow={'hidden'}
>
{isPublicDomainPrefixVisible ? (
<>
<PublicDomainPrefixInput
value={network.publicDomain}
errorMessage={publicDomainErrorMessage}
onDraftChange={(value) => updatePublicDomainDraft(i, value)}
onStartEdit={() => clearPublicDomainValidationError(i)}
onCommit={(value) => commitPublicDomainDraft(i, value)}
/>
<Tooltip label={t('click_to_copy_tooltip')}>
<Box
h={'30px'}
display={'flex'}
alignItems={'center'}
flex={'1 1 auto'}
minW={0}
px={'8px'}
userSelect={'all'}
className="textEllipsis"
cursor={'pointer'}
{...fieldInputStyles}
onClick={() => {
copyData(getDomainDisplay(network));
}}
>
.{network.domain}
</Box>
</Tooltip>
</>
) : (
<Tooltip label={t('click_to_copy_tooltip')}>
<Box
h={'30px'}
display={'flex'}
alignItems={'center'}
flex={'1 1 auto'}
minW={0}
px={'12px'}
userSelect={'all'}
className="textEllipsis"
cursor={'pointer'}
{...fieldInputStyles}
onClick={() => {
copyData(getDomainDisplay(network));
}}
>
{getDomainDisplay(network)}
</Box>
</Tooltip>
)}
{network.openPublicDomain && !network.openNodePort && (
<Box
flex={'0 0 auto'}
px={'8px'}
py={'4px'}
fontSize={'11px'}
lineHeight={'16px'}
fontWeight={500}
letterSpacing={'0.5px'}
color={'brightBlue.600'}
cursor={'pointer'}
onClick={async () => {
const publicDomain = network.customDomain
? network.publicDomain
: await commitPublicDomainDraft(i, network.publicDomain);
if (!publicDomain) return;
setCustomAccessModalData({
publicDomain,
currentCustomDomain: network.customDomain,
domain: network.domain
});
}}
>
{t('bind_custom_domain')}
</Box>
)}
{/* keep a hidden field registered so customDomain remains part of form state */}
<Input
display={'none'}
flex={'1 1 auto'}
minW={0}
px={'12px'}
userSelect={'all'}
className="textEllipsis"
cursor={'pointer'}
{...fieldInputStyles}
onClick={() => {
copyData(getDomainDisplay(network));
}}
>
{getDomainDisplay(network)}
</Box>
</Tooltip>
{network.openPublicDomain && !network.openNodePort && (
<Box
flex={'0 0 auto'}
px={'8px'}
py={'4px'}
fontSize={'11px'}
lineHeight={'16px'}
fontWeight={500}
letterSpacing={'0.5px'}
color={'brightBlue.600'}
cursor={'pointer'}
onClick={() =>
setCustomAccessModalData({
publicDomain: network.publicDomain,
currentCustomDomain: network.customDomain,
domain: network.domain
})
}
>
{t('Custom Domain')}
</Box>
)}
{/* keep a hidden field registered so customDomain remains part of form state */}
<Input
display={'none'}
flex={'1 1 auto'}
minW={0}
{...register(`networks.${i}.customDomain`)}
/>
{...register(`networks.${i}.customDomain`)}
/>
</Flex>
</Flex>
</Flex>
{canConfigureRouteRules && (
<Button
type={'button'}
w={'113px'}
minW={'113px'}
variant={'outline'}
{...actionButtonStyles}
onClick={() => setRouteRulesIndex(i)}
>
{t('Configure Route Rules')}
</Button>
)}
{networks.length > 1 && (
<IconButton
ml={2}
height={'32px'}
width={'32px'}
minW={'32px'}
aria-label={t('Delete')}
variant={'outline'}
bg={'#FFF'}
_hover={{
color: 'red.600',
bg: 'rgba(17, 24, 36, 0.05)'
}}
icon={<MyIcon name={'delete'} w={'16px'} fill={'#485264'} />}
onClick={() => dispatch({ type: 'REMOVE_PORT', payload: { index: i } })}
/>
)}
</>
)}
</Flex>
{canConfigureRouteRules && (
<Button
type={'button'}
w={'113px'}
minW={'113px'}
variant={'outline'}
{...actionButtonStyles}
onClick={() => setRouteRulesIndex(i)}
>
{t('Configure Route Rules')}
</Button>
)}
{networks.length > 1 && (
<IconButton
ml={2}
height={'32px'}
width={'32px'}
minW={'32px'}
aria-label={t('Delete')}
variant={'outline'}
bg={'#FFF'}
_hover={{
color: 'red.600',
bg: 'rgba(17, 24, 36, 0.05)'
}}
icon={<MyIcon name={'delete'} w={'16px'} fill={'#485264'} />}
onClick={() =>
dispatch({ type: 'REMOVE_PORT', payload: { index: i } })
}
/>
)}
</>
)}
</Flex>
<FormErrorMessage mt={1} fontSize={'12px'}>
{publicDomainErrorMessage}
</FormErrorMessage>
</FormControl>
</Box>
</Flex>
</Box>
@@ -639,7 +1179,7 @@ export function NetworkSection({
{...customAccessModalData}
onClose={() => setCustomAccessModalData(undefined)}
onSuccess={(customDomain) => {
const index = networks.findIndex(
const index = getValues('networks').findIndex(
(network) => network.publicDomain === customAccessModalData.publicDomain
);
if (index === -1) return;
@@ -1,11 +1,17 @@
import { postDeployApp, putApp } from '@/api/app';
import { checkPermission, postAuthCname, postAuthDomainChallenge } from '@/api/platform';
import {
checkPermission,
checkPublicDomain,
postAuthCname,
postAuthDomainChallenge
} from '@/api/platform';
import { defaultSliderKey } from '@/constants/app';
import { defaultEditVal, editModeMap } from '@/constants/editApp';
import { useConfirm } from '@/hooks/useConfirm';
import { useLoading } from '@/hooks/useLoading';
import { useAppStore } from '@/store/app';
import { useGlobalStore } from '@/store/global';
import { CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED, SEALOS_DOMAIN } from '@/store/static';
import { useUserStore } from '@/store/user';
import type { YamlItemType } from '@/types';
import type { AppEditSyncedFields, AppEditType, DeployKindsType } from '@/types/app';
@@ -37,13 +43,154 @@ import { customAlphabet } from 'nanoid';
import { ResponseCode } from '@/types/response';
import { useGuideStore } from '@/store/guide';
import { track } from '@sealos/gtm';
import { SEALOS_DOMAIN } from '@/store/static';
import {
PUBLIC_DOMAIN_PREFIX_MAX_LENGTH,
PUBLIC_DOMAIN_PREFIX_MIN_LENGTH,
PublicDomainConflictOwner,
getDuplicateManagedPublicDomainHosts,
validatePublicDomainPrefix
} from '@/utils/public-domain';
import { getCustomDomainBindings } from '@/utils/custom-domain';
const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12);
const ErrorModal = dynamic(() => import('./components/ErrorModal'));
const EDIT_PAGE_MIN_PADDING = 20;
const EDIT_PAGE_NAV_WIDTH = 220;
const EDIT_PAGE_COLUMN_GAP = 20;
const EDIT_PAGE_CONTENT_TARGET_WIDTH = 1100;
const EDIT_PAGE_TARGET_WIDTH =
EDIT_PAGE_NAV_WIDTH + EDIT_PAGE_COLUMN_GAP + EDIT_PAGE_CONTENT_TARGET_WIDTH;
const getPublicDomainPrefixErrorMessage = (
t: ReturnType<typeof useTranslation>['t'],
reason: 'format' | 'reserved' | 'conflict' | 'duplicate',
conflictOwner?: PublicDomainConflictOwner
) => {
if (reason === 'duplicate') {
return (
t('public_domain_prefix_duplicate_error') ||
'This public address prefix is duplicated in this app. Please choose another one.'
);
}
if (reason === 'conflict') {
if (conflictOwner) {
return (
t('public_domain_prefix_conflict_owner_error', {
type: conflictOwner.displayType,
name: conflictOwner.displayName
}) ||
`This public address prefix is already used by ${conflictOwner.displayType} "${conflictOwner.displayName}" in this workspace.`
);
}
return (
t('public_domain_prefix_conflict_error') ||
'This public address prefix is already in use. Please choose another one.'
);
}
if (reason === 'reserved') {
return (
t('public_domain_prefix_reserved_error') ||
'This public address prefix is reserved. Please choose another one.'
);
}
return (
t('public_domain_prefix_format_error', {
min: PUBLIC_DOMAIN_PREFIX_MIN_LENGTH,
max: PUBLIC_DOMAIN_PREFIX_MAX_LENGTH
}) ||
`Use ${PUBLIC_DOMAIN_PREFIX_MIN_LENGTH}-${PUBLIC_DOMAIN_PREFIX_MAX_LENGTH} lowercase letters, numbers, or hyphens. It cannot start or end with a hyphen.`
);
};
const getConflictOwnerFromError = (error: any): PublicDomainConflictOwner | undefined => {
return error?.error?.conflictOwner;
};
function validatePublicDomainPrefixBeforeSubmit(
data: AppEditType,
t: ReturnType<typeof useTranslation>['t'],
setFieldError: (index: number, message: string) => void
) {
if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return '';
for (const [index, network] of data.networks.entries()) {
if (!network.openPublicDomain || network.openNodePort || network.customDomain) {
continue;
}
const result = validatePublicDomainPrefix(network.publicDomain);
if (result.valid) {
network.publicDomain = result.value;
continue;
}
const message = getPublicDomainPrefixErrorMessage(t, result.reason);
setFieldError(index, message);
return message;
}
return '';
}
function validateManagedPublicDomainHostDuplicatesBeforeSubmit(
data: AppEditType,
t: ReturnType<typeof useTranslation>['t'],
setFieldError: (index: number, message: string) => void
) {
if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return '';
const duplicatedHosts = getDuplicateManagedPublicDomainHosts(data.networks, SEALOS_DOMAIN);
if (duplicatedHosts.length === 0) return '';
const message = getPublicDomainPrefixErrorMessage(t, 'duplicate');
duplicatedHosts.forEach(({ indexes }) => {
indexes.forEach((index) => setFieldError(index, message));
});
return message;
}
async function validatePublicDomainAvailabilityBeforeSubmit(
data: AppEditType,
t: ReturnType<typeof useTranslation>['t'],
setFieldError: (index: number, message: string) => void
) {
if (!CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED) return '';
for (const [index, network] of data.networks.entries()) {
if (!network.openPublicDomain || network.openNodePort || network.customDomain) {
continue;
}
try {
await checkPublicDomain({
prefix: network.publicDomain,
domain: network.domain,
appName: data.appName
});
} catch (error: any) {
if (error?.error?.code !== 'PUBLIC_DOMAIN_CONFLICT') {
throw error;
}
const message = getPublicDomainPrefixErrorMessage(
t,
'conflict',
getConflictOwnerFromError(error)
);
setFieldError(index, message);
return message;
}
}
return '';
}
export const formData2Yamls = (
data: AppEditType
// handleType: 'edit' | 'create' = 'create',
@@ -125,11 +272,7 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) =>
content: applyMessage
});
const pxVal = useMemo(() => {
const val = Math.floor((screenWidth - 1050) / 2);
if (val < 20) {
return 20;
}
return val;
return Math.max(EDIT_PAGE_MIN_PADDING, Math.floor((screenWidth - EDIT_PAGE_TARGET_WIDTH) / 2));
}, [screenWidth]);
const { createCompleted } = useGuideStore();
@@ -539,6 +682,54 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) =>
formHook.handleSubmit(async (data) => {
console.log('data', data);
const publicDomainErrorMessage = validatePublicDomainPrefixBeforeSubmit(
data,
t,
(index, message) => {
formHook.setError(`networks.${index}.publicDomain`, {
type: 'validate',
message
});
}
);
if (publicDomainErrorMessage) {
return toast({
status: 'warning',
title: publicDomainErrorMessage
});
}
const publicDomainDuplicateErrorMessage =
validateManagedPublicDomainHostDuplicatesBeforeSubmit(data, t, (index, message) => {
formHook.setError(`networks.${index}.publicDomain`, {
type: 'duplicate',
message
});
});
if (publicDomainDuplicateErrorMessage) {
return toast({
status: 'warning',
title: publicDomainDuplicateErrorMessage
});
}
const publicDomainAvailabilityErrorMessage =
await validatePublicDomainAvailabilityBeforeSubmit(data, t, (index, message) => {
formHook.setError(`networks.${index}.publicDomain`, {
type: 'validate',
message
});
});
if (publicDomainAvailabilityErrorMessage) {
return toast({
status: 'warning',
title: publicDomainAvailabilityErrorMessage
});
}
const parseYamls = formData2Yamls(data);
setYamlList(parseYamls);
@@ -623,8 +814,8 @@ const EditApp = ({ appName, tabType }: { appName?: string; tabType: string }) =>
data.hpa.target === 'cpu'
? 'CPU'
: data.hpa.target === 'gpu'
? 'GPU'
: 'RAM',
? 'GPU'
: 'RAM',
value: data.hpa.value
}
: undefined
@@ -28,6 +28,7 @@ import {
KubernetesObject,
User
} from '@kubernetes/client-node';
import { ensurePublicDomainPrefixesAvailable } from './publicDomain';
export interface K8sContext {
kc: KubeConfig;
@@ -117,6 +118,7 @@ export async function createApp(appForm: AppEditType, k8s: K8sContext) {
...network,
domain: global.AppConfig.cloud.domain
}));
await ensurePublicDomainPrefixesAvailable(appForm, k8s);
const parseYamls = formData2Yamls(appForm);
const yamls = parseYamls.map((item) => item.value);
@@ -0,0 +1,360 @@
import { appDeployKey, publicDomainKey } from '@/constants/app';
import type { KubernetesObjectApi, NetworkingV1Api, V1Ingress } from '@kubernetes/client-node';
import type { K8sContext } from '@/services/backend/appService';
import type { AppEditType } from '@/types/app';
import { isCustomPublicDomainPrefixEnabled } from '@/utils/feature-gates';
import { PublicDomainConflictOwner, validatePublicDomainPrefix } from '@/utils/public-domain';
const INGRESS_OWNER_CONFLICT_CODE = '40301';
const INGRESS_OWNER_CONFLICT_MESSAGE = 'owned by other user';
const APP_LABEL_KEYS = [
appDeployKey,
publicDomainKey,
'cloud.sealos.io/app-deploy-manager-port',
'app.kubernetes.io/name',
'app.kubernetes.io/instance',
'app.kubernetes.io/component',
'app.kubernetes.io/part-of',
'app.kubernetes.io/managed-by',
'helm.sh/chart'
];
export class PublicDomainError extends Error {
constructor(
message: string,
public code: 'INVALID_PUBLIC_DOMAIN' | 'RESERVED_PUBLIC_DOMAIN' | 'PUBLIC_DOMAIN_CONFLICT',
public status = 400,
public conflictOwner?: PublicDomainConflictOwner
) {
super(message);
this.name = 'PublicDomainError';
}
}
export type PublicDomainTarget = {
prefix: string;
domain: string;
appName?: string;
networkName?: string;
};
type PublicDomainDryRunContext = {
apiClient: KubernetesObjectApi;
namespace: string;
};
function getDryRunIngressName(prefix: string) {
const suffix = Date.now().toString(36);
const maxPrefixLength = 63 - 'public-domain-check--'.length - suffix.length;
return `public-domain-check-${prefix.slice(0, maxPrefixLength)}-${suffix}`.replace(/-+$/, '');
}
export function getPublicDomainErrorMessage(error: unknown) {
if (typeof error === 'string') return error;
const anyError = error as {
body?: {
message?: unknown;
};
message?: unknown;
};
if (typeof anyError?.body?.message === 'string') return anyError.body.message;
if (error instanceof Error) return error.message;
if (typeof anyError?.message === 'string') return anyError.message;
return String(error);
}
export function isIngressPublicDomainConflictError(error: unknown) {
const message = getPublicDomainErrorMessage(error).toLowerCase();
return (
message.includes(`admission webhook "vingress.sealos.io" denied the request`) &&
message.includes(`${INGRESS_OWNER_CONFLICT_CODE}:`) &&
message.includes(INGRESS_OWNER_CONFLICT_MESSAGE)
);
}
export function getPublicDomainConflictResponse(error: unknown) {
const details = getPublicDomainErrorMessage(error);
return {
code: 'PUBLIC_DOMAIN_CONFLICT' as const,
message: getPublicDomainConflictMessage(),
details
};
}
export function getPublicDomainConflictMessage() {
return 'Public domain is already in use by another workspace.';
}
export function getSameWorkspacePublicDomainConflictMessage(owner: PublicDomainConflictOwner) {
return `Public domain "${owner.host}" is already used by ${owner.displayType} "${owner.displayName}" in this workspace.`;
}
function pickSafeLabels(labels?: Record<string, string>) {
if (!labels) return undefined;
const safeLabels = APP_LABEL_KEYS.reduce<Record<string, string>>((result, key) => {
const value = labels[key];
if (typeof value === 'string' && value) {
result[key] = value;
}
return result;
}, {});
return Object.keys(safeLabels).length > 0 ? safeLabels : undefined;
}
function getRecommendedDisplayName(labels: Record<string, string> | undefined, fallback: string) {
return (
labels?.['app.kubernetes.io/instance'] ||
labels?.['app.kubernetes.io/name'] ||
labels?.['app.kubernetes.io/component'] ||
fallback
);
}
function getPublicDomainConflictOwner(ingress: V1Ingress, host: string): PublicDomainConflictOwner {
const labels = ingress.metadata?.labels || {};
const ingressName = ingress.metadata?.name || 'unknown-ingress';
const namespace = ingress.metadata?.namespace || '';
const launchpadAppName = labels[appDeployKey];
const publicDomainPrefix = labels[publicDomainKey];
if (launchpadAppName) {
return {
scope: 'same_workspace',
resourceKind: 'Ingress',
component: 'app_launchpad',
displayType: 'App Launchpad app',
displayName: launchpadAppName,
host,
namespace,
ingressName,
publicDomainPrefix,
labels: pickSafeLabels(labels),
matchedBy: 'cloud.sealos.io/app-deploy-manager',
confidence: 'high'
};
}
if (
labels['app.kubernetes.io/part-of'] === 'devbox' ||
labels['app.kubernetes.io/name'] === 'devbox'
) {
return {
scope: 'same_workspace',
resourceKind: 'Ingress',
component: 'devbox',
displayType: 'Devbox',
displayName: getRecommendedDisplayName(labels, ingressName),
host,
namespace,
ingressName,
publicDomainPrefix,
labels: pickSafeLabels(labels),
matchedBy: 'app.kubernetes.io/part-of=devbox',
confidence: labels['app.kubernetes.io/part-of'] === 'devbox' ? 'high' : 'medium'
};
}
if (
labels['app.kubernetes.io/name'] ||
labels['app.kubernetes.io/instance'] ||
labels['app.kubernetes.io/component'] ||
labels['app.kubernetes.io/managed-by']
) {
return {
scope: 'same_workspace',
resourceKind: 'Ingress',
component: 'workspace_component',
displayType: 'workspace component',
displayName: getRecommendedDisplayName(labels, ingressName),
host,
namespace,
ingressName,
publicDomainPrefix,
labels: pickSafeLabels(labels),
matchedBy: 'kubernetes-recommended-labels',
confidence: 'medium'
};
}
return {
scope: 'same_workspace',
resourceKind: 'Ingress',
component: 'ingress',
displayType: 'Ingress',
displayName: ingressName,
host,
namespace,
ingressName,
publicDomainPrefix,
labels: pickSafeLabels(labels),
matchedBy: 'unlabeled-ingress',
confidence: 'low'
};
}
type PublicDomainK8sContext = {
k8sNetworkingApp: NetworkingV1Api;
namespace: string;
};
export function normalizeAndValidatePublicDomainPrefix(value: string) {
if (!isCustomPublicDomainPrefixEnabled()) {
throw new PublicDomainError(
'Custom public domain prefixes are disabled',
'INVALID_PUBLIC_DOMAIN'
);
}
const result = validatePublicDomainPrefix(value);
if (!result.valid) {
throw new PublicDomainError(
result.reason === 'reserved'
? `Public domain prefix "${result.value}" is reserved`
: `Public domain prefix "${result.value}" is invalid`,
result.reason === 'reserved' ? 'RESERVED_PUBLIC_DOMAIN' : 'INVALID_PUBLIC_DOMAIN'
);
}
return result.value;
}
export async function dryRunPublicDomainIngress(
target: PublicDomainTarget,
k8s: PublicDomainDryRunContext
) {
const prefix = normalizeAndValidatePublicDomainPrefix(target.prefix);
const host = `${prefix}.${target.domain}`;
const appName = target.appName || 'public-domain-check';
const networkName = target.networkName || getDryRunIngressName(prefix);
await k8s.apiClient.create(
{
apiVersion: 'networking.k8s.io/v1',
kind: 'Ingress',
metadata: {
name: networkName,
namespace: k8s.namespace,
labels: {
[appDeployKey]: appName,
[publicDomainKey]: prefix
},
annotations: {
'kubernetes.io/ingress.class': 'nginx'
}
},
spec: {
rules: [
{
host,
http: {
paths: [
{
path: '/',
pathType: 'Prefix',
backend: {
service: {
name: appName,
port: {
number: 80
}
}
}
}
]
}
}
]
}
},
undefined,
'All',
'applaunchpad-public-domain-check'
);
return {
prefix,
host
};
}
export async function ensurePublicDomainTargetsAvailable(
targets: PublicDomainTarget[],
k8s: PublicDomainK8sContext
) {
if (!isCustomPublicDomainPrefixEnabled()) return;
if (targets.length === 0) return;
const seenHosts = new Set<string>();
const appsByHost = new Map<string, Set<string>>();
for (const target of targets) {
const prefix = normalizeAndValidatePublicDomainPrefix(target.prefix);
const host = `${prefix}.${target.domain}`;
if (seenHosts.has(host)) {
throw new PublicDomainError(
`Public domain "${host}" is duplicated in this application`,
'PUBLIC_DOMAIN_CONFLICT',
409
);
}
target.prefix = prefix;
seenHosts.add(host);
if (target.appName) {
const apps = appsByHost.get(host) || new Set<string>();
apps.add(target.appName);
appsByHost.set(host, apps);
}
}
const { body } = await k8s.k8sNetworkingApp.listNamespacedIngress(k8s.namespace);
for (const item of body.items || []) {
const ownerAppName = item.metadata?.labels?.[appDeployKey];
const hosts =
item.spec?.rules
?.map((rule) => rule.host)
.filter((host): host is string => typeof host === 'string') || [];
const conflictingHost = hosts.find((host) => {
if (!seenHosts.has(host)) return false;
return !ownerAppName || !appsByHost.get(host)?.has(ownerAppName);
});
if (conflictingHost) {
const conflictOwner = getPublicDomainConflictOwner(item, conflictingHost);
throw new PublicDomainError(
getSameWorkspacePublicDomainConflictMessage(conflictOwner),
'PUBLIC_DOMAIN_CONFLICT',
409,
conflictOwner
);
}
}
}
export function validateAppPublicDomainPrefixes(app: AppEditType) {
return app.networks
.filter((network) => network.openPublicDomain && !network.openNodePort && !network.customDomain)
.map((network) => {
const prefix = normalizeAndValidatePublicDomainPrefix(network.publicDomain);
network.publicDomain = prefix;
return {
prefix,
domain: network.domain || global.AppConfig?.cloud?.domain || 'cloud.sealos.io',
appName: app.appName,
networkName: network.networkName
};
});
}
export async function ensurePublicDomainPrefixesAvailable(app: AppEditType, k8s: K8sContext) {
if (!isCustomPublicDomainPrefixEnabled()) return;
const targets = validateAppPublicDomainPrefixes(app);
await ensurePublicDomainTargetsAvailable(targets, k8s);
}
@@ -1,6 +1,11 @@
import { NextApiResponse } from 'next';
import { ApiResponse, ResponseCode, ResponseMessages } from '@/types/response';
import { V1Status } from '@kubernetes/client-node';
import {
getPublicDomainConflictResponse,
isIngressPublicDomainConflictError,
PublicDomainError
} from './publicDomain';
export const jsonRes = <T = any>(res: NextApiResponse, options: Partial<ApiResponse<T>> = {}) => {
const { code = ResponseCode.SUCCESS, message, data, error } = options;
@@ -16,7 +21,24 @@ export const jsonRes = <T = any>(res: NextApiResponse, options: Partial<ApiRespo
return res.json(response);
};
export function getPublicDomainErrorResponse(err: PublicDomainError) {
return {
code: err.code,
message: err.message,
...(err.conflictOwner ? { conflictOwner: err.conflictOwner } : {})
};
}
export const handleK8sError = (err: any): Partial<ApiResponse> => {
if (isIngressPublicDomainConflictError(err)) {
const conflict = getPublicDomainConflictResponse(err);
return {
code: 409,
message: conflict.message,
error: conflict
};
}
if (err?.kind === 'Status' && err?.apiVersion === 'v1' && err?.status) {
const k8sApiErr = err as V1Status;
if (k8sApiErr.code === 403) {
@@ -1,5 +1,6 @@
import { getInitData } from '@/api/platform';
import { Coin } from '@/constants/app';
import { setPublicDomainReservedPrefixes } from '@/utils/public-domain';
export let SEALOS_DOMAIN = 'cloud.sealos.io';
export let SEALOS_USER_DOMAINS = [{ name: 'cloud.sealos.io', secretName: 'wildcard-cert' }];
@@ -19,6 +20,9 @@ export let PVC_STORAGE_MAX = 20;
export let GPU_ENABLED = false;
export let LOG_ENABLED = false;
export let NETWORK_STORAGE_ENABLED = false;
export let IMAGE_PORTS_ENABLED = false;
export let CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED = false;
export let PUBLIC_DOMAIN_RESERVED_PREFIXES: string[] = [];
export const loadInitData = async () => {
try {
@@ -42,6 +46,10 @@ export const loadInitData = async () => {
GPU_ENABLED = res.GPU_ENABLED;
LOG_ENABLED = res.LOG_ENABLED;
NETWORK_STORAGE_ENABLED = res.NETWORK_STORAGE_ENABLED;
IMAGE_PORTS_ENABLED = res.IMAGE_PORTS_ENABLED;
CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED = res.CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED;
PUBLIC_DOMAIN_RESERVED_PREFIXES = res.PUBLIC_DOMAIN_RESERVED_PREFIXES || [];
setPublicDomainReservedPrefixes(PUBLIC_DOMAIN_RESERVED_PREFIXES);
return {
SEALOS_DOMAIN,
@@ -51,7 +59,10 @@ export const loadInitData = async () => {
CURRENCY,
FORM_SLIDER_LIST_CONFIG: res.FORM_SLIDER_LIST_CONFIG,
DESKTOP_DOMAIN: res.DESKTOP_DOMAIN,
GPU_ENABLED
GPU_ENABLED,
IMAGE_PORTS_ENABLED,
CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED,
PUBLIC_DOMAIN_RESERVED_PREFIXES
};
} catch (error) {}
@@ -69,5 +80,11 @@ export const serverLoadInitData = () => {
DISABLE_HTTPS = !!global.AppConfig.cloud.disableHttps;
SHOW_EVENT_ANALYZE = global.AppConfig.launchpad.eventAnalyze.enabled;
SEALOS_USER_DOMAINS = global.AppConfig.cloud.userDomains;
IMAGE_PORTS_ENABLED = !!global.AppConfig.launchpad.imagePorts?.enabled;
CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED =
!!global.AppConfig.launchpad.publicDomain?.customPrefixEnabled;
PUBLIC_DOMAIN_RESERVED_PREFIXES =
global.AppConfig.launchpad.publicDomain?.reservedPrefixes || [];
setPublicDomainReservedPrefixes(PUBLIC_DOMAIN_RESERVED_PREFIXES);
} catch (error) {}
};
+10
View File
@@ -62,6 +62,13 @@ export type AppConfigType = {
gtmId: string | null;
currencySymbol: Coin;
pvcStorageMax: number;
imagePorts?: {
enabled?: boolean;
};
publicDomain?: {
customPrefixEnabled?: boolean;
reservedPrefixes?: string[];
};
eventAnalyze: {
enabled: boolean;
fastGPTKey?: string;
@@ -112,4 +119,7 @@ export type EnvResponse = {
GPU_ENABLED: boolean;
LOG_ENABLED: boolean;
NETWORK_STORAGE_ENABLED: boolean;
IMAGE_PORTS_ENABLED: boolean;
CUSTOM_PUBLIC_DOMAIN_PREFIX_ENABLED: boolean;
PUBLIC_DOMAIN_RESERVED_PREFIXES: string[];
};
@@ -41,15 +41,80 @@ import {
imageRegistrySchema,
resourceConverters
} from './schema';
import { isCustomPublicDomainPrefixEnabled } from '@/utils/feature-gates';
import { validatePublicDomainPrefix } from '@/utils/public-domain';
export const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12);
function includePublicDomainPrefix() {
return isCustomPublicDomainPrefixEnabled();
}
const PublicDomainPrefixSchema = z.string().superRefine((value, ctx) => {
if (!isCustomPublicDomainPrefixEnabled()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Custom public domain prefixes are disabled'
});
return;
}
const result = validatePublicDomainPrefix(value);
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
result.reason === 'reserved'
? `Public domain prefix "${result.value}" is reserved`
: `Public domain prefix "${result.value}" is invalid`
});
}
});
function getPublicDomainPrefixOrRandom(value?: string) {
if (!value) return nanoid();
if (!isCustomPublicDomainPrefixEnabled()) {
throw new Error('Custom public domain prefixes are disabled');
}
const result = validatePublicDomainPrefix(value);
if (!result.valid) {
throw new Error(
result.reason === 'reserved'
? `Public domain prefix "${result.value}" is reserved`
: `Public domain prefix "${result.value}" is invalid`
);
}
return result.value;
}
export const GetAppByAppNameQuerySchema = z.object({
name: z.string().min(1, { message: 'name cannot be empty' })
});
export const GetAppByAppNameResponseSchema = z.array(z.any()).nullable();
const CreatePortConfigSchema = PortConfigSchema.pick({
number: true,
protocol: true,
exposesPublicDomain: true,
publicDomain: true
})
.extend({
publicDomain: PublicDomainPrefixSchema.optional()
})
.superRefine((data, ctx) => {
if (data.publicDomain === undefined) return;
const isApplicationProtocol = ['HTTP', 'GRPC', 'WS'].includes(data.protocol);
if (!isApplicationProtocol || data.exposesPublicDomain === false) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['publicDomain'],
message: 'publicDomain can only be set for HTTP/GRPC/WS ports with public domain access'
});
}
});
export const DeleteAppByNameQuerySchema = z.object({
name: z.string().min(1, { message: 'name cannot be empty' })
});
@@ -84,6 +149,9 @@ export const PortUpdateSchema = z
description:
'Whether to expose this port via public domain (only effective for HTTP/GRPC/WS protocols)'
}),
publicDomain: PublicDomainPrefixSchema.optional().openapi({
description: 'Custom public subdomain prefix. Omit to keep or auto-generate.'
}),
portName: z.string().optional().openapi({
description: 'Port name (include this to update existing port, omit to create new port)'
}),
@@ -97,6 +165,18 @@ export const PortUpdateSchema = z
.openapi({
description:
'Port configuration. Include portName to update existing port. Omit portName to create new port. Ports not included in the array will be deleted.'
})
.superRefine((data, ctx) => {
if (data.publicDomain === undefined) return;
const isKnownNonApplicationProtocol =
data.protocol !== undefined && !['HTTP', 'GRPC', 'WS'].includes(data.protocol);
if (isKnownNonApplicationProtocol || data.exposesPublicDomain === false) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['publicDomain'],
message: 'publicDomain can only be set for HTTP/GRPC/WS ports with public domain access'
});
}
});
export const UpdateImageSchema = z
@@ -316,13 +396,7 @@ export const CreateLaunchpadRequestSchema = z
resource: ResourceSchema,
ports: z
.array(
PortConfigSchema.pick({
number: true,
protocol: true,
exposesPublicDomain: true
})
)
.array(CreatePortConfigSchema)
.default([
{
number: 80,
@@ -363,6 +437,11 @@ export function transformToLegacySchema(
const networks = standardRequest.ports?.map((port) => {
const isApplicationProtocol = ['HTTP', 'GRPC', 'WS'].includes(port.protocol);
const publicDomain =
isApplicationProtocol && port.exposesPublicDomain
? getPublicDomainPrefixOrRandom(port.publicDomain)
: '';
return {
serviceName: `service-${nanoid()}`,
networkName: `network-${nanoid()}`,
@@ -371,7 +450,7 @@ export function transformToLegacySchema(
protocol: (isApplicationProtocol ? 'TCP' : port.protocol) as TransportProtocolType,
appProtocol: (isApplicationProtocol ? port.protocol : 'HTTP') as ApplicationProtocolType,
openPublicDomain: isApplicationProtocol ? port.exposesPublicDomain : false,
publicDomain: isApplicationProtocol ? nanoid() : '',
publicDomain,
customDomain: '',
domain: '',
nodePort: undefined,
@@ -542,7 +621,10 @@ export function transformFromLegacySchema(
exposesPublicDomain: exposesPublicDomain,
networkName: network.networkName,
portName: network.portName,
publicDomain: network.publicDomain,
...(includePublicDomainPrefix() &&
network.publicDomain && {
publicDomain: network.publicDomain
}),
domain: network.domain,
customDomain: network.customDomain,
nodePort: network.nodePort
@@ -266,7 +266,7 @@ export const PortConfigSchema = z.object({
}),
networkName: z.string().default(() => `network-${nanoid()}`),
portName: z.string().default(() => nanoid()),
publicDomain: z.string().default(() => nanoid()),
publicDomain: z.string().optional(),
domain: z.string().default(''),
customDomain: z.string().optional().openapi({
description: 'Custom domain'
@@ -19,9 +19,53 @@ import {
resourceConverters
} from './schema';
import { buildExternalUrl } from '@/utils/network-url';
import { isCustomPublicDomainPrefixEnabled } from '@/utils/feature-gates';
import { validatePublicDomainPrefix } from '@/utils/public-domain';
export const nanoid = customAlphabet('abcdefghijklmnopqrstuvwxyz', 12);
function includePublicDomainPrefix() {
return isCustomPublicDomainPrefixEnabled();
}
const PublicDomainPrefixSchema = z.string().superRefine((value, ctx) => {
if (!isCustomPublicDomainPrefixEnabled()) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Custom public domain prefixes are disabled'
});
return;
}
const result = validatePublicDomainPrefix(value);
if (!result.valid) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message:
result.reason === 'reserved'
? `Public domain prefix "${result.value}" is reserved`
: `Public domain prefix "${result.value}" is invalid`
});
}
});
function getPublicDomainPrefixOrRandom(value?: string) {
if (!value) return nanoid();
if (!isCustomPublicDomainPrefixEnabled()) {
throw new Error('Custom public domain prefixes are disabled');
}
const result = validatePublicDomainPrefix(value);
if (!result.valid) {
throw new Error(
result.reason === 'reserved'
? `Public domain prefix "${result.value}" is reserved`
: `Public domain prefix "${result.value}" is invalid`
);
}
return result.value;
}
function parseCreateTimeToDate(createTime: string): Date | null {
// Common formats in this repo: 'YYYY/MM/DD HH:mm' or 'YYYY-MM-DD HH:mm' (sometimes with seconds).
const m = createTime.match(/^(\d{4})[/-](\d{2})[/-](\d{2})[ T](\d{2}):(\d{2})(?::(\d{2}))?$/);
@@ -100,8 +144,22 @@ export const CreatePortConfigSchema = z
isPublic: z.boolean().default(true).openapi({
description:
'Whether to expose this port via public domain (only effective for http/grpc/ws protocols)'
}),
publicDomain: PublicDomainPrefixSchema.optional().openapi({
description: 'Custom public subdomain prefix. Omit to auto-generate.'
})
})
.superRefine((data, ctx) => {
if (data.publicDomain === undefined) return;
const isApplicationProtocol = ['http', 'grpc', 'ws'].includes(data.protocol);
if (!isApplicationProtocol || data.isPublic === false) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['publicDomain'],
message: 'publicDomain can only be set for http/grpc/ws ports with public domain access'
});
}
})
.openapi({
description: 'Port configuration for creating applications'
});
@@ -119,6 +177,9 @@ export const PortUpdateSchema = z
description:
'Whether to expose this port via public domain (only effective for http/grpc/ws protocols)'
}),
publicDomain: PublicDomainPrefixSchema.optional().openapi({
description: 'Custom public subdomain prefix. Omit to keep or auto-generate.'
}),
portName: z.string().optional().openapi({
description: 'Port name (include this to update existing port, omit to create new port)'
})
@@ -126,6 +187,18 @@ export const PortUpdateSchema = z
.openapi({
description:
'Port configuration. Include portName to update existing port. Omit portName to create new port. Ports not included in the array will be deleted.'
})
.superRefine((data, ctx) => {
if (data.publicDomain === undefined) return;
const isKnownNonApplicationProtocol =
data.protocol !== undefined && !['http', 'grpc', 'ws'].includes(data.protocol);
if (isKnownNonApplicationProtocol || data.isPublic === false) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['publicDomain'],
message: 'publicDomain can only be set for http/grpc/ws ports with public domain access'
});
}
});
export const UpdateImageSchema = z
@@ -369,6 +442,11 @@ export function transformToLegacySchema(
const protocolLower = (port.protocol || 'http').toLowerCase();
const isApplicationProtocol = ['http', 'grpc', 'ws'].includes(protocolLower);
const protocolUpper = protocolLower.toUpperCase();
const openPublicDomain = isApplicationProtocol ? (port.isPublic ?? true) : false;
const publicDomain =
isApplicationProtocol && openPublicDomain
? getPublicDomainPrefixOrRandom(port.publicDomain)
: '';
return {
serviceName: `${standardRequest.name}-${port.number}-${nanoid()}-service`,
networkName: `${standardRequest.name}-${port.number}-${nanoid()}-network`,
@@ -378,8 +456,8 @@ export function transformToLegacySchema(
appProtocol: (isApplicationProtocol
? protocolUpper
: 'HTTP') as ApplicationProtocolType,
openPublicDomain: isApplicationProtocol ? port.isPublic ?? true : false,
publicDomain: isApplicationProtocol ? nanoid() : '',
openPublicDomain,
publicDomain,
customDomain: '',
domain: '',
nodePort: undefined,
@@ -587,6 +665,8 @@ export function transformFromLegacySchema(
number: network.port,
portName: network.portName,
protocol: protocolLower,
...(includePublicDomainPrefix() &&
network.publicDomain && { publicDomain: network.publicDomain }),
...(privateAddress && { privateAddress }),
...(publicAddress && { publicAddress }),
...(network.customDomain && { customDomain: network.customDomain })
@@ -233,6 +233,10 @@ export const PortConfigSchema = z
description: 'Public access address',
example: 'https://xyz789.cloud.sealos.io'
}),
publicDomain: z.string().optional().openapi({
description: 'Public subdomain prefix',
example: 'xyz789'
}),
customDomain: z.string().optional().openapi({
description: 'Custom domain (if configured)',
example: 'api.example.com'
@@ -578,8 +578,8 @@ export const adaptAppDetail = async (
domain: isCustomDomain
? SEALOS_DOMAIN
: item?.nodePort
? domain
: domain.split('.').slice(1).join('.') || SEALOS_DOMAIN,
? domain
: domain.split('.').slice(1).join('.') || SEALOS_DOMAIN,
routes: ingressPaths.length
? ingressPaths.map((path) => ({
path: path.path || '/',
@@ -714,8 +714,8 @@ export const sliderNumber2MarkList = ({
? `${item / 1024} G`
: `${item} M`
: type === 'ephemeralStorage'
? `${item}`
: `${item / 1000}`,
? `${item}`
: `${item / 1000}`,
value: item
}));
};
@@ -104,10 +104,13 @@ export const json2DeployCr = (data: AppEditType, type: 'deployment' | 'statefuls
? {
[`${data.gpu.manufacturers}.com/use-gputype`]: data.gpu.type
}
: supportedGpuManufacturers.reduce((acc, manufacturer) => {
acc[`${manufacturer}.com/use-gputype`] = null;
return acc;
}, {} as Record<string, null>);
: supportedGpuManufacturers.reduce(
(acc, manufacturer) => {
acc[`${manufacturer}.com/use-gputype`] = null;
return acc;
},
{} as Record<string, null>
);
const metadata = {
name: data.appName,
@@ -0,0 +1,11 @@
function getAppConfig(config?: unknown) {
return (config || (globalThis as any).AppConfig) as any;
}
export function isCustomPublicDomainPrefixEnabled(config?: unknown) {
return !!getAppConfig(config)?.launchpad?.publicDomain?.customPrefixEnabled;
}
export function isImagePortsEnabled(config?: unknown) {
return !!getAppConfig(config)?.launchpad?.imagePorts?.enabled;
}
@@ -0,0 +1,643 @@
import { lookup } from 'dns/promises';
import { request as httpRequest } from 'http';
import type { IncomingHttpHeaders, IncomingMessage, RequestOptions } from 'http';
import { request as httpsRequest } from 'https';
import { isIP } from 'net';
import type { LookupFunction } from 'net';
export type ImageRegistryAuth = {
username?: string;
password?: string;
serverAddress?: string;
};
export type ImageExposedPort = {
port: number;
protocol: 'TCP' | 'UDP' | 'SCTP';
};
type ImageRef = {
registry: string;
repository: string;
reference: string;
};
type RegistryManifest = {
mediaType?: string;
config?: {
digest?: string;
};
manifests?: {
digest?: string;
platform?: {
architecture?: string;
os?: string;
variant?: string;
};
}[];
};
type ChallengeParams = {
realm?: string;
service?: string;
scope?: string;
};
type RegistryResponse = {
ok: boolean;
status: number;
headers: {
get: (name: string) => string | null;
};
json: () => Promise<unknown>;
};
const DEFAULT_REGISTRY = 'registry-1.docker.io';
const DOCKER_HUB_AUTH_SERVICE = 'registry.docker.io';
const DOCKER_HUB_AUTH_HOST = 'auth.docker.io';
const DOCKER_HUB_REGISTRY_ALIASES = new Set([
'docker.io',
'index.docker.io',
'registry.hub.docker.com',
DEFAULT_REGISTRY
]);
const ACCEPT_HEADER = [
'application/vnd.oci.image.index.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.docker.distribution.manifest.v1+json'
].join(', ');
const REGISTRY_REQUEST_TIMEOUT = 10000;
const MAX_REGISTRY_RESPONSE_BYTES = 1024 * 1024;
const MAX_REGISTRY_REDIRECTS = 3;
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
const JSON_CONTENT_TYPES = new Set([
'application/json',
'application/vnd.oci.image.index.v1+json',
'application/vnd.docker.distribution.manifest.list.v2+json',
'application/vnd.oci.image.manifest.v1+json',
'application/vnd.docker.distribution.manifest.v2+json',
'application/vnd.docker.distribution.manifest.v1+json',
'application/vnd.oci.image.config.v1+json',
'application/vnd.docker.container.image.v1+json'
]);
const OCTET_STREAM_CONTENT_TYPE = 'application/octet-stream';
function isBlockedIPv4(address: string) {
const parts = address.split('.').map((part) => Number(part));
if (
parts.length !== 4 ||
parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)
) {
return true;
}
const [a, b] = parts;
return (
a === 0 ||
a === 10 ||
a === 127 ||
(a === 169 && b === 254) ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 198 && (b === 18 || b === 19)) ||
(a === 100 && b >= 64 && b <= 127) ||
(a === 192 && b === 0 && parts[2] === 0) ||
(a === 192 && b === 0 && parts[2] === 2) ||
(a === 198 && b === 51 && parts[2] === 100) ||
(a === 203 && b === 0 && parts[2] === 113) ||
a >= 224
);
}
function isBlockedIPv6(address: string) {
const normalized = address.toLowerCase();
const ipv4Mapped = normalized.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/);
if (ipv4Mapped) {
return isBlockedIPv4(ipv4Mapped[1]);
}
return (
normalized === '::' ||
normalized === '::1' ||
normalized.startsWith('2001:db8:') ||
normalized.startsWith('fc') ||
normalized.startsWith('fd') ||
/^fe[89ab][0-9a-f]:/.test(normalized) ||
normalized.startsWith('ff')
);
}
function isBlockedAddress(address: string) {
const ipVersion = isIP(address);
if (ipVersion === 4) return isBlockedIPv4(address);
if (ipVersion === 6) return isBlockedIPv6(address);
return true;
}
function getUrlHost(value: string) {
const url = new URL(
value.startsWith('http://') || value.startsWith('https://') ? value : `https://${value}`
);
return url.hostname.replace(/^\[|\]$/g, '');
}
async function assertPublicRegistryHost(registry: string) {
const host = getUrlHost(registry);
await assertPublicHost(host, 'Registry host');
}
async function assertPublicHost(host: string, context: string) {
const hostIsIp = isIP(host) !== 0;
if (host === 'localhost' || host.endsWith('.localhost') || (hostIsIp && isBlockedAddress(host))) {
throw new Error(`${context} is not allowed`);
}
if (hostIsIp) {
return;
}
const addresses = await lookup(host, { all: true });
if (!addresses.length || addresses.some((item) => isBlockedAddress(item.address))) {
throw new Error(`${context} resolves to a private address`);
}
}
function assertRegistryUrl(url: URL) {
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('Registry request protocol is not allowed');
}
}
const publicLookup: LookupFunction = (hostname, options, callback) => {
const lookupOptions = {
family: options.family,
hints: options.hints,
verbatim: options.verbatim
};
const hostIsIp = isIP(hostname) !== 0;
if (hostIsIp) {
if (isBlockedAddress(hostname)) {
callback(
Object.assign(new Error('Registry host is not allowed'), { code: 'EHOSTDENIED' }),
'',
0
);
return;
}
callback(null, hostname, isIP(hostname));
return;
}
lookup(hostname, { ...lookupOptions, all: true })
.then((addresses) => {
if (!addresses.length || addresses.some((item) => isBlockedAddress(item.address))) {
callback(
Object.assign(new Error('Registry host resolves to a private address'), {
code: 'EHOSTDENIED'
}),
'',
0
);
return;
}
const preferredFamily =
lookupOptions.family === 4 || lookupOptions.family === 6 ? lookupOptions.family : undefined;
const selected = preferredFamily
? addresses.find((item) => item.family === preferredFamily)
: addresses[0];
if (!selected) {
callback(
Object.assign(new Error('Registry host address family is unavailable'), {
code: 'ENOTFOUND'
}),
'',
0
);
return;
}
callback(null, selected.address, selected.family);
})
.catch((error) => {
callback(error, '', 0);
});
};
function getHeader(headers: IncomingHttpHeaders, name: string) {
const value = headers[name.toLowerCase()];
if (Array.isArray(value)) return value.join(', ');
return value ?? null;
}
function createRegistryResponse(message: IncomingMessage, body: Buffer): RegistryResponse {
const status = message.statusCode || 0;
return {
ok: status >= 200 && status < 300,
status,
headers: {
get: (name) => getHeader(message.headers, name)
},
json: async () => JSON.parse(body.toString('utf8'))
};
}
async function readRegistryResponseBody(message: IncomingMessage, context: string) {
return await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
let settled = false;
const fail = (error: Error) => {
if (settled) return;
settled = true;
reject(error);
};
message.on('data', (chunk: Buffer | string) => {
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
total += buffer.byteLength;
if (total > MAX_REGISTRY_RESPONSE_BYTES) {
message.destroy();
fail(new Error(`${context} is too large`));
return;
}
chunks.push(buffer);
});
message.on('error', fail);
message.on('end', () => {
if (settled) return;
settled = true;
resolve(Buffer.concat(chunks));
});
});
}
async function requestRegistryUrl(
url: URL,
options: { headers?: Record<string, string>; signal?: AbortSignal; context: string }
) {
assertRegistryUrl(url);
await assertPublicHost(url.hostname, 'Registry host');
const request = url.protocol === 'https:' ? httpsRequest : httpRequest;
const requestOptions: RequestOptions = {
method: 'GET',
headers: options.headers,
lookup: publicLookup,
signal: options.signal
};
return await new Promise<RegistryResponse>((resolve, reject) => {
const req = request(url, requestOptions, async (message) => {
try {
const body = await readRegistryResponseBody(message, options.context);
resolve(createRegistryResponse(message, body));
} catch (error) {
reject(error);
}
});
req.on('error', reject);
req.end();
});
}
function stripRedirectHeaders(headers: Record<string, string> | undefined, from: URL, to: URL) {
if (from.origin === to.origin) return headers;
if (!headers) return headers;
const nextHeaders = { ...headers };
delete nextHeaders.Authorization;
delete nextHeaders.authorization;
return nextHeaders;
}
async function registryHttpRequest(
url: string,
options: { headers?: Record<string, string>; signal?: AbortSignal; context: string },
redirectCount = 0
): Promise<RegistryResponse> {
const currentUrl = new URL(url);
const response = await requestRegistryUrl(currentUrl, options);
if (!REDIRECT_STATUSES.has(response.status)) {
return response;
}
if (redirectCount >= MAX_REGISTRY_REDIRECTS) {
throw new Error('Registry request redirected too many times');
}
const location = response.headers.get('location');
if (!location) {
throw new Error('Registry request redirect is invalid');
}
const redirectUrl = new URL(location, currentUrl);
assertRegistryUrl(redirectUrl);
if (currentUrl.protocol === 'https:' && redirectUrl.protocol !== 'https:') {
throw new Error('Registry request redirect is not trusted');
}
await assertPublicHost(redirectUrl.hostname, 'Registry host');
return await registryHttpRequest(
redirectUrl.toString(),
{
...options,
headers: stripRedirectHeaders(options.headers, currentUrl, redirectUrl)
},
redirectCount + 1
);
}
function assertTrustedRealm(challenge: ChallengeParams, image: ImageRef) {
if (!challenge.realm) return;
let realmUrl: URL;
try {
realmUrl = new URL(challenge.realm);
} catch {
throw new Error('Registry auth realm is invalid');
}
if (realmUrl.protocol !== 'https:') {
throw new Error('Registry auth realm is not trusted');
}
const registryHost = getUrlHost(image.registry);
const allowedHosts =
image.registry === DEFAULT_REGISTRY ? new Set([DOCKER_HUB_AUTH_HOST]) : new Set([registryHost]);
if (!allowedHosts.has(realmUrl.hostname)) {
throw new Error('Registry auth realm is not trusted');
}
}
async function readJsonResponse<T>(
response: RegistryResponse,
context: string,
options?: { allowOctetStream?: boolean }
): Promise<T> {
const contentType = response.headers.get('content-type')?.split(';')[0]?.trim().toLowerCase();
const contentTypeAllowed =
contentType &&
(JSON_CONTENT_TYPES.has(contentType) ||
(options?.allowOctetStream && contentType === OCTET_STREAM_CONTENT_TYPE));
if (contentType && !contentTypeAllowed) {
throw new Error(`${context} returned unsupported content type`);
}
return (await response.json()) as T;
}
function normalizeRegistry(registry?: string) {
const normalized = registry?.replace(/^https?:\/\//, '').replace(/\/$/, '');
if (!normalized) return DEFAULT_REGISTRY;
return DOCKER_HUB_REGISTRY_ALIASES.has(normalized) ? DEFAULT_REGISTRY : normalized;
}
export function parseImageRef(imageName: string, registryOverride?: string): ImageRef {
const trimmed = imageName.trim();
const [namePart, digest] = trimmed.split('@');
const tagIndex = namePart.lastIndexOf(':');
const slashIndex = namePart.indexOf('/');
const hasExplicitTag = tagIndex > slashIndex;
const reference = digest || (hasExplicitTag ? namePart.slice(tagIndex + 1) : 'latest');
const withoutTag = hasExplicitTag ? namePart.slice(0, tagIndex) : namePart;
const segments = withoutTag.split('/').filter(Boolean);
const first = segments[0] || '';
const hasRegistry = first.includes('.') || first.includes(':') || first === 'localhost';
const registry = registryOverride
? normalizeRegistry(registryOverride)
: hasRegistry
? normalizeRegistry(first)
: DEFAULT_REGISTRY;
let repositorySegments = hasRegistry ? segments.slice(1) : segments;
if (registryOverride && !hasRegistry && segments.length > 1) {
repositorySegments = segments;
}
const repository =
registry === DEFAULT_REGISTRY && repositorySegments.length === 1
? `library/${repositorySegments[0]}`
: repositorySegments.join('/');
if (!repository) {
throw new Error('Invalid image name');
}
return { registry, repository, reference };
}
function toRegistryUrl(registry: string, path: string) {
const base =
registry.startsWith('http://') || registry.startsWith('https://')
? registry
: `https://${registry}`;
return `${base.replace(/\/$/, '')}${path}`;
}
function parseAuthenticateHeader(header: string) {
const params: Record<string, string> = {};
const challenge = header.replace(/^Bearer\s+/i, '');
challenge.replace(/(\w+)="([^"]*)"/g, (_, key, value) => {
params[key] = value;
return '';
});
return params;
}
async function getBearerToken(image: ImageRef, auth?: ImageRegistryAuth) {
if (image.registry !== DEFAULT_REGISTRY) {
throw new Error('Registry auth challenge is invalid');
}
const service = image.registry === DEFAULT_REGISTRY ? DOCKER_HUB_AUTH_SERVICE : image.registry;
const realm = 'https://auth.docker.io/token';
const url = `${realm}?service=${encodeURIComponent(service)}&scope=${encodeURIComponent(
`repository:${image.repository}:pull`
)}`;
const headers: Record<string, string> = {};
if (auth?.username && auth.password) {
headers.Authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString(
'base64'
)}`;
}
const response = await registryHttpRequest(url, {
headers,
signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT),
context: 'Registry auth response'
});
if (!response.ok) {
throw new Error(`Registry auth failed: ${response.status}`);
}
const data = await readJsonResponse<{ token?: string; access_token?: string }>(
response,
'Registry auth response'
);
return data.token || data.access_token;
}
async function registryFetch(
url: string,
image: ImageRef,
auth?: ImageRegistryAuth,
context = 'Registry response'
) {
const headers: Record<string, string> = {
Accept: ACCEPT_HEADER
};
if (auth?.username && auth.password && image.registry !== DEFAULT_REGISTRY) {
headers.Authorization = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString(
'base64'
)}`;
}
let response = await registryHttpRequest(url, {
headers,
signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT),
context
});
if (response.status === 401) {
const authenticate = response.headers.get('www-authenticate') || '';
const challenge = parseAuthenticateHeader(authenticate);
assertTrustedRealm(challenge, image);
const token =
challenge.realm && challenge.service
? await registryHttpRequest(
`${challenge.realm}?service=${encodeURIComponent(
challenge.service
)}&scope=${encodeURIComponent(
challenge.scope || `repository:${image.repository}:pull`
)}`,
{
headers:
auth?.username && auth.password
? {
Authorization: `Basic ${Buffer.from(
`${auth.username}:${auth.password}`
).toString('base64')}`
}
: undefined,
signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT),
context: 'Registry auth response'
}
)
.then((res) => {
if (!res.ok) throw new Error(`Registry auth failed: ${res.status}`);
return readJsonResponse<{ token?: string; access_token?: string }>(
res,
'Registry auth response'
);
})
.then((data) => data.token || data.access_token)
: await getBearerToken(image, auth);
response = await registryHttpRequest(url, {
headers: {
...headers,
Authorization: `Bearer ${token}`
},
signal: AbortSignal.timeout(REGISTRY_REQUEST_TIMEOUT),
context
});
}
return response;
}
async function fetchManifest(image: ImageRef, reference: string, auth?: ImageRegistryAuth) {
const manifestUrl = toRegistryUrl(
image.registry,
`/v2/${image.repository}/manifests/${reference}`
);
const manifestResponse = await registryFetch(manifestUrl, image, auth, 'Image manifest');
if (!manifestResponse.ok) {
throw new Error(`Failed to fetch image manifest: ${manifestResponse.status}`);
}
return await readJsonResponse<RegistryManifest>(manifestResponse, 'Image manifest');
}
function selectManifestDigest(manifest: RegistryManifest) {
const manifests = manifest.manifests || [];
const linuxAmd64 = manifests.find(
(item) => item.platform?.os === 'linux' && item.platform?.architecture === 'amd64'
);
const linux = manifests.find((item) => item.platform?.os === 'linux');
return linuxAmd64?.digest || linux?.digest || manifests[0]?.digest;
}
export function parseExposedPorts(exposedPorts?: Record<string, unknown>): ImageExposedPort[] {
if (!exposedPorts) return [];
const ports = Object.keys(exposedPorts)
.map((key) => {
const [port, protocol = 'tcp'] = key.split('/');
const parsedPort = Number(port);
const parsedProtocol = protocol.toUpperCase();
if (
!Number.isInteger(parsedPort) ||
parsedPort < 1 ||
parsedPort > 65535 ||
!['TCP', 'UDP', 'SCTP'].includes(parsedProtocol)
) {
return null;
}
return {
port: parsedPort,
protocol: parsedProtocol as ImageExposedPort['protocol']
};
})
.filter((item): item is ImageExposedPort => item !== null);
return [...new Map(ports.map((item) => [`${item.port}/${item.protocol}`, item])).values()].sort(
(a, b) => a.port - b.port || a.protocol.localeCompare(b.protocol)
);
}
export async function getImageExposedPorts(
imageName: string,
auth?: ImageRegistryAuth
): Promise<ImageExposedPort[]> {
const image = parseImageRef(imageName, auth?.serverAddress);
await assertPublicRegistryHost(image.registry);
let manifest = await fetchManifest(image, image.reference, auth);
const platformManifestDigest = selectManifestDigest(manifest);
if (!manifest.config?.digest && platformManifestDigest) {
manifest = await fetchManifest(image, platformManifestDigest, auth);
}
const configDigest = manifest.config?.digest;
if (!configDigest) {
return [];
}
const configUrl = toRegistryUrl(image.registry, `/v2/${image.repository}/blobs/${configDigest}`);
const configResponse = await registryFetch(configUrl, image, auth, 'Image config');
if (!configResponse.ok) {
throw new Error(`Failed to fetch image config: ${configResponse.status}`);
}
const config = await readJsonResponse<{ config?: { ExposedPorts?: Record<string, unknown> } }>(
configResponse,
'Image config',
{ allowOctetStream: true }
);
return parseExposedPorts(config?.config?.ExposedPorts);
}
@@ -20,12 +20,7 @@ export const syncDefaultRouteServicePort = ({
const newPort = Number(nextPort);
const defaultPort = Number(defaultServicePort);
if (
!routes?.length ||
!oldPort ||
!newPort ||
(oldPort === newPort && defaultPort === oldPort)
) {
if (!routes?.length || !oldPort || !newPort || (oldPort === newPort && defaultPort === oldPort)) {
return routes;
}
@@ -0,0 +1,107 @@
export const PUBLIC_DOMAIN_PREFIX_MAX_LENGTH = 32;
export const PUBLIC_DOMAIN_PREFIX_MIN_LENGTH = 3;
export type PublicDomainConflictOwnerComponent =
| 'app_launchpad'
| 'devbox'
| 'workspace_component'
| 'ingress';
export type PublicDomainConflictOwner = {
scope: 'same_workspace';
resourceKind: 'Ingress';
component: PublicDomainConflictOwnerComponent;
displayType: string;
displayName: string;
host: string;
namespace: string;
ingressName: string;
publicDomainPrefix?: string;
labels?: Record<string, string>;
matchedBy:
| 'cloud.sealos.io/app-deploy-manager'
| 'app.kubernetes.io/part-of=devbox'
| 'kubernetes-recommended-labels'
| 'unlabeled-ingress';
confidence: 'high' | 'medium' | 'low';
};
let reservedPublicDomainPrefixes = new Set<string>();
export type PublicDomainPrefixValidationResult =
| { valid: true; value: string }
| { valid: false; value: string; reason: 'format' | 'reserved' };
export type ManagedPublicDomainNetwork = {
openPublicDomain?: boolean;
openNodePort?: boolean;
customDomain?: string;
publicDomain?: string;
domain?: string;
};
export type DuplicateManagedPublicDomainHost = {
host: string;
indexes: number[];
};
export function normalizePublicDomainPrefix(value: string) {
return value.trim().toLowerCase();
}
export function normalizePublicDomainReservedPrefixes(prefixes?: unknown) {
if (!Array.isArray(prefixes)) return [];
return prefixes
.filter((prefix): prefix is string => typeof prefix === 'string')
.map(normalizePublicDomainPrefix);
}
export function setPublicDomainReservedPrefixes(prefixes?: unknown) {
reservedPublicDomainPrefixes = new Set(normalizePublicDomainReservedPrefixes(prefixes));
}
export function getPublicDomainReservedPrefixes() {
return Array.from(reservedPublicDomainPrefixes);
}
export function validatePublicDomainPrefix(value: string): PublicDomainPrefixValidationResult {
const normalized = normalizePublicDomainPrefix(value);
const pattern = new RegExp(
`^[a-z0-9](?:[a-z0-9-]{${PUBLIC_DOMAIN_PREFIX_MIN_LENGTH - 2},${
PUBLIC_DOMAIN_PREFIX_MAX_LENGTH - 2
}}[a-z0-9])$`
);
if (!pattern.test(normalized)) {
return { valid: false, value: normalized, reason: 'format' };
}
if (reservedPublicDomainPrefixes.has(normalized)) {
return { valid: false, value: normalized, reason: 'reserved' };
}
return { valid: true, value: normalized };
}
export function getDuplicateManagedPublicDomainHosts(
networks: ManagedPublicDomainNetwork[],
defaultDomain: string
) {
const indexesByHost = new Map<string, number[]>();
networks.forEach((network, index) => {
if (!network.openPublicDomain || network.openNodePort || network.customDomain) return;
const prefixResult = validatePublicDomainPrefix(network.publicDomain || '');
const domain = network.domain || defaultDomain;
if (!prefixResult.valid || !domain) return;
const host = `${prefixResult.value}.${domain}`;
indexesByHost.set(host, [...(indexesByHost.get(host) || []), index]);
});
return Array.from(indexesByHost.entries())
.filter(([, indexes]) => indexes.length > 1)
.map(([host, indexes]): DuplicateManagedPublicDomainHost => ({ host, indexes }));
}
@@ -0,0 +1,12 @@
import { resolve } from 'path';
export default {
resolve: {
alias: {
'@': resolve(__dirname, 'src')
}
},
test: {
globals: false
}
};