mirror of
https://github.com/labring/sealos.git
synced 2026-08-30 17:58:09 +08:00
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:
@@ -24,6 +24,7 @@ deploy/cloud/tars
|
||||
.vscode/
|
||||
/lifecycle/tools/
|
||||
.workflow/
|
||||
**/.impeccable/
|
||||
|
||||
**.env*
|
||||
!scripts/cloud/sealos.env
|
||||
|
||||
+31
-7
@@ -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: ''
|
||||
|
||||
+5
@@ -10,3 +10,8 @@ resources:
|
||||
requests:
|
||||
cpu: 10m
|
||||
memory: 128Mi
|
||||
|
||||
applaunchpadConfig:
|
||||
imagePortsEnabled: false
|
||||
customPublicDomainPrefixEnabled: false
|
||||
publicDomainReservedPrefixes: []
|
||||
|
||||
+5
@@ -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') ? (
|
||||
<>
|
||||
|
||||
+707
-167
@@ -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) {}
|
||||
};
|
||||
|
||||
@@ -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
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user