From 4b582b403c999e842a86efb875b892df277e31e5 Mon Sep 17 00:00:00 2001 From: Alex Lee <3076438032@qq.com> Date: Wed, 20 May 2026 17:00:04 +0800 Subject: [PATCH 1/8] refactor(v1): inline registry retag flow --- v1/frontend/.env.template | 7 +- .../withTemplate/create/route.ts | 15 +- .../withTemplate/update/route.ts | 13 +- v1/frontend/deploy/Kubefile | 2 + v1/frontend/deploy/manifests/deploy.yaml.tmpl | 6 +- .../services/backend/registry-retag.ts | 488 ++++++++++++++++++ v1/frontend/services/retag.ts | 7 - 7 files changed, 503 insertions(+), 35 deletions(-) create mode 100644 v1/frontend/services/backend/registry-retag.ts delete mode 100644 v1/frontend/services/retag.ts diff --git a/v1/frontend/.env.template b/v1/frontend/.env.template index e027447..9b5ca4a 100644 --- a/v1/frontend/.env.template +++ b/v1/frontend/.env.template @@ -47,10 +47,9 @@ DATABASE_URL= # database provider for prisma runtime/migration routing # values: cockroachdb (default) | postgresql DATABASE_PROVIDER="cockroachdb" -# url for template retag -# in dev: http://127.0.0.1:8092 -# in prod: http://devbox-service.devbox-system.svc.cluster.local:8092 -RETAG_SVC_URL= +# registry credentials for template image retag +REGISTRY_USER= +REGISTRY_PASSWORD= # privacy document url(for user to create template) PRIVACY_URL_ZH="https://sealos.run/docs/msa/privacy-policy" PRIVACY_URL_EN="https://sealos.io/docs/msa/privacy-policy" diff --git a/v1/frontend/app/api/templateRepository/withTemplate/create/route.ts b/v1/frontend/app/api/templateRepository/withTemplate/create/route.ts index e7267de..3efdf26 100644 --- a/v1/frontend/app/api/templateRepository/withTemplate/create/route.ts +++ b/v1/frontend/app/api/templateRepository/withTemplate/create/route.ts @@ -1,10 +1,10 @@ import { TagType, TemplateRepositoryKind } from '@/prisma/generated/client'; import { authSessionWithJWT } from '@/services/backend/auth'; import { getK8s } from '@/services/backend/kubernetes'; +import { retagImage } from '@/services/backend/registry-retag'; import { jsonRes } from '@/services/backend/response'; import { devboxDB } from '@/services/db/init'; import { ERROR_ENUM } from '@/services/error'; -import { retagSvcClient } from '@/services/retag'; import { KBDevboxReleaseType, KBDevboxTypeV2 } from '@/types/k8s'; import { getRegionUid } from '@/utils/env'; import { mergeTemplateDefaults } from '@/utils/templateConfig'; @@ -24,7 +24,7 @@ export async function POST(req: NextRequest) { }); } const query = createTemplateRepositorySchema.parse(queryRaw); - const { kubeConfig, payload, token } = await authSessionWithJWT(headerList); + const { kubeConfig, payload } = await authSessionWithJWT(headerList); const { namespace, k8sCustomObjects } = await getK8s({ kubeconfig: kubeConfig }); @@ -97,16 +97,7 @@ export async function POST(req: NextRequest) { original: originalImage, target: targetImage }; - const retagResult = await retagSvcClient.post('/tag', retagbody, { - headers: { - Authorization: token - } - }); - if (retagResult.status !== 200) { - console.log('retagResult', retagResult); - throw Error('retag failed'); - } - // invoke retag service !todo + await retagImage(retagbody.original, retagbody.target); // suported deleted because devbox instance of deleted template const origionalTemplate = await devboxDB.template.findUnique({ where: { diff --git a/v1/frontend/app/api/templateRepository/withTemplate/update/route.ts b/v1/frontend/app/api/templateRepository/withTemplate/update/route.ts index 20eaab6..fae29fc 100644 --- a/v1/frontend/app/api/templateRepository/withTemplate/update/route.ts +++ b/v1/frontend/app/api/templateRepository/withTemplate/update/route.ts @@ -1,10 +1,10 @@ import { TagType } from '@/prisma/generated/client'; import { authSessionWithJWT } from '@/services/backend/auth'; import { getK8s } from '@/services/backend/kubernetes'; +import { retagImage } from '@/services/backend/registry-retag'; import { jsonRes } from '@/services/backend/response'; import { devboxDB } from '@/services/db/init'; import { ERROR_ENUM } from '@/services/error'; -import { retagSvcClient } from '@/services/retag'; import { KBDevboxReleaseType, KBDevboxTypeV2 } from '@/types/k8s'; import { getRegionUid } from '@/utils/env'; import { mergeTemplateDefaults } from '@/utils/templateConfig'; @@ -26,7 +26,7 @@ export async function POST(req: NextRequest) { }); } const query = updateTemplateSchema.parse(queryRaw); - const { kubeConfig, payload, token } = await authSessionWithJWT(headerList); + const { kubeConfig, payload } = await authSessionWithJWT(headerList); const { namespace, k8sCustomObjects } = await getK8s({ kubeconfig: kubeConfig }); @@ -107,14 +107,7 @@ export async function POST(req: NextRequest) { original: originalImage, target: tagretImage }; - const retagResult = await retagSvcClient.post('/tag', retagbody, { - headers: { - Authorization: token - } - }); - if (retagResult.status !== 200) { - throw Error('retag failed'); - } + await retagImage(retagbody.original, retagbody.target); const officialTagList = await devboxDB.tag.findMany({ where: { type: TagType.OFFICIAL_CONTENT diff --git a/v1/frontend/deploy/Kubefile b/v1/frontend/deploy/Kubefile index b572988..5dd8084 100644 --- a/v1/frontend/deploy/Kubefile +++ b/v1/frontend/deploy/Kubefile @@ -9,5 +9,7 @@ ENV cloudDomain="127.0.0.1.nip.io" ENV cloudPort="" ENV certSecretName="wildcard-cert" ENV registryAddr="sealos.hub:5000" +ENV registryUser="" +ENV registryPassword="" CMD ["kubectl apply -f manifests"] diff --git a/v1/frontend/deploy/manifests/deploy.yaml.tmpl b/v1/frontend/deploy/manifests/deploy.yaml.tmpl index 46850dc..95c6e0f 100644 --- a/v1/frontend/deploy/manifests/deploy.yaml.tmpl +++ b/v1/frontend/deploy/manifests/deploy.yaml.tmpl @@ -88,6 +88,10 @@ spec: value: wildcard-cert - name: REGISTRY_ADDR value: {{ .registryAddr }} + - name: REGISTRY_USER + value: {{ default "" .registryUser }} + - name: REGISTRY_PASSWORD + value: {{ default "" .registryPassword }} - name: DEVBOX_AFFINITY_ENABLE value: 'true' - name: MONITOR_URL @@ -106,8 +110,6 @@ spec: value: 'false' - name: PRIVACY_URL value: https://sealos.run/docs/msa/privacy-policy - - name: RETAG_SVC_URL - value: http://devbox-service.devbox-system.svc.cluster.local:8092 - name: JWT_SECRET value: {{ .jwtSecret }} # -nsealos cm desktop-frontend-config ->jwt->Internal - name: REGION_UID diff --git a/v1/frontend/services/backend/registry-retag.ts b/v1/frontend/services/backend/registry-retag.ts new file mode 100644 index 0000000..067e61d --- /dev/null +++ b/v1/frontend/services/backend/registry-retag.ts @@ -0,0 +1,488 @@ +const MANIFEST_ACCEPT = [ + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.docker.distribution.manifest.v2+json' +].join(', '); + +type ImageRef = { + registry: string; + repository: string; + reference: string; +}; + +type Descriptor = { + mediaType?: string; + digest?: string; + size?: number; + platform?: unknown; + annotations?: Record; +}; + +type Manifest = { + schemaVersion?: number; + mediaType?: string; + config?: Descriptor; + layers?: Descriptor[]; + manifests?: Descriptor[]; +}; + +type RegistryCredentials = { + username: string; + password: string; +}; + +type RegistryRequestOptions = { + method?: string; + body?: BodyInit; + headers?: Record; + accept?: string; + scope?: string | string[]; +}; + +class RegistryRetagError extends Error { + status?: number; + + constructor(message: string, status?: number) { + super(message); + this.name = 'RegistryRetagError'; + this.status = status; + } +} + +const normalizeRegistry = (registry: string) => registry.replace(/^https?:\/\//, ''); + +const parseImageRef = (image: string): ImageRef => { + const trimmed = image.trim().replace(/^https?:\/\//, ''); + const firstSlash = trimmed.indexOf('/'); + + if (firstSlash <= 0) { + throw new RegistryRetagError(`Invalid image reference: ${image}`); + } + + const registry = trimmed.slice(0, firstSlash); + const remainder = trimmed.slice(firstSlash + 1); + const digestIndex = remainder.indexOf('@'); + + if (digestIndex > 0) { + return { + registry: normalizeRegistry(registry), + repository: remainder.slice(0, digestIndex), + reference: remainder.slice(digestIndex + 1) + }; + } + + const lastSlash = remainder.lastIndexOf('/'); + const tagIndex = remainder.lastIndexOf(':'); + + if (tagIndex > lastSlash) { + return { + registry: normalizeRegistry(registry), + repository: remainder.slice(0, tagIndex), + reference: remainder.slice(tagIndex + 1) + }; + } + + return { + registry: normalizeRegistry(registry), + repository: remainder, + reference: 'latest' + }; +}; + +const getRegistryCredentials = (): RegistryCredentials => { + const username = + process.env.REGISTRY_USER && process.env.REGISTRY_PASSWORD + ? process.env.REGISTRY_USER + : process.env.USER && process.env.PASSWORD + ? process.env.USER + : ''; + const password = + process.env.REGISTRY_USER && process.env.REGISTRY_PASSWORD + ? process.env.REGISTRY_PASSWORD + : process.env.USER && process.env.PASSWORD + ? process.env.PASSWORD + : ''; + + if (!username || !password) { + throw new RegistryRetagError('Registry credentials are not configured'); + } + + return { username, password }; +}; + +const registryBaseUrl = (registry: string) => `http://${normalizeRegistry(registry)}`; + +const authHeader = ({ username, password }: RegistryCredentials) => + `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + +const parseAuthenticateHeader = (value: string) => { + const separatorIndex = value.indexOf(' '); + const scheme = separatorIndex >= 0 ? value.slice(0, separatorIndex) : value; + const paramsRaw = separatorIndex >= 0 ? value.slice(separatorIndex + 1) : ''; + const params: Record = {}; + const pattern = /(\w+)="([^"]*)"/g; + let match = pattern.exec(paramsRaw); + + while (match) { + params[match[1]] = match[2]; + match = pattern.exec(paramsRaw); + } + + return { + scheme: scheme?.toLowerCase(), + params + }; +}; + +const getBearerToken = async ( + challenge: string, + credentials: RegistryCredentials, + requestedScope?: string | string[] +) => { + const { scheme, params } = parseAuthenticateHeader(challenge); + + if (scheme !== 'bearer' || !params.realm) { + return null; + } + + const url = new URL(params.realm); + + if (params.service) { + url.searchParams.set('service', params.service); + } + + const scopes = Array.isArray(requestedScope) + ? requestedScope + : requestedScope + ? [requestedScope] + : params.scope + ? [params.scope] + : []; + + for (const scope of scopes) { + url.searchParams.append('scope', scope); + } + + const response = await fetch(url, { + headers: { + Authorization: authHeader(credentials) + } + }); + await assertOk(response, 'Get registry bearer token'); + + const body = (await response.json()) as { token?: string; access_token?: string }; + return body.token || body.access_token || null; +}; + +const fetchWithAuthRetry = async ( + url: string | URL, + credentials: RegistryCredentials, + init: RequestInit, + scope?: string | string[] +) => { + const requestInit: RequestInit = { + ...init, + cache: 'no-store', + headers: { + Authorization: authHeader(credentials), + ...(init.headers || {}) + } + }; + + const response = await fetch(url, requestInit); + + if (response.status !== 401) { + return response; + } + + const bearerToken = await getBearerToken( + response.headers.get('www-authenticate') || '', + credentials, + scope + ); + + if (!bearerToken) { + return response; + } + + return fetch(url, { + ...requestInit, + headers: { + ...requestInit.headers, + Authorization: `Bearer ${bearerToken}` + } + }); +}; + +const readErrorBody = async (response: Response) => { + try { + const text = await response.text(); + return text ? `: ${text.slice(0, 500)}` : ''; + } catch { + return ''; + } +}; + +const registryFetch = async ( + image: Pick, + path: string, + credentials: RegistryCredentials, + options: RegistryRequestOptions = {} +) => { + const url = `${registryBaseUrl(image.registry)}${path}`; + return fetchWithAuthRetry( + url, + credentials, + { + method: options.method || 'GET', + body: options.body, + headers: { + ...(options.accept ? { Accept: options.accept } : {}), + ...options.headers + } + }, + options.scope + ); +}; + +const assertOk = async (response: Response, action: string) => { + if (!response.ok) { + throw new RegistryRetagError( + `${action} failed with HTTP ${response.status}${await readErrorBody(response)}`, + response.status + ); + } +}; + +const getManifest = async ( + image: ImageRef, + credentials: RegistryCredentials, + reference = image.reference +) => { + const response = await registryFetch( + image, + `/v2/${image.repository}/manifests/${encodeURIComponent(reference)}`, + credentials, + { + accept: MANIFEST_ACCEPT, + scope: `repository:${image.repository}:pull` + } + ); + await assertOk(response, `Get manifest ${image.repository}:${reference}`); + + const contentType = response.headers.get('content-type')?.split(';')[0] || ''; + const body = await response.text(); + const manifest = JSON.parse(body) as Manifest; + + return { + body, + contentType: + contentType || manifest.mediaType || 'application/vnd.docker.distribution.manifest.v2+json', + manifest + }; +}; + +const layerBlobDigests = (manifest: Manifest): string[] => { + const digests = new Set(); + + if (manifest.config?.digest) { + digests.add(manifest.config.digest); + } + + for (const layer of manifest.layers || []) { + if (layer.digest) digests.add(layer.digest); + } + + return [...digests]; +}; + +const blobExists = async ( + image: Pick, + digest: string, + credentials: RegistryCredentials +) => { + const response = await registryFetch( + image, + `/v2/${image.repository}/blobs/${digest}`, + credentials, + { + method: 'HEAD', + scope: `repository:${image.repository}:pull` + } + ); + + if (response.status === 200) return true; + if (response.status === 404) return false; + await assertOk(response, `Check blob ${digest}`); + return true; +}; + +const mountBlob = async ( + source: ImageRef, + target: ImageRef, + digest: string, + credentials: RegistryCredentials +) => { + const query = new URLSearchParams({ + mount: digest, + from: source.repository + }); + const response = await registryFetch( + target, + `/v2/${target.repository}/blobs/uploads/?${query.toString()}`, + credentials, + { + method: 'POST', + scope: [`repository:${target.repository}:pull,push`, `repository:${source.repository}:pull`], + headers: { + 'Content-Length': '0' + } + } + ); + + if (response.status === 201) return true; + if (response.status === 202 || response.status === 404 || response.status === 405) return false; + await assertOk(response, `Mount blob ${digest}`); + return false; +}; + +const uploadBlob = async ( + source: ImageRef, + target: ImageRef, + digest: string, + credentials: RegistryCredentials +) => { + const blobResponse = await registryFetch( + source, + `/v2/${source.repository}/blobs/${digest}`, + credentials, + { + accept: 'application/octet-stream', + scope: `repository:${source.repository}:pull` + } + ); + await assertOk(blobResponse, `Download blob ${digest}`); + + const blob = await blobResponse.arrayBuffer(); + const startResponse = await registryFetch( + target, + `/v2/${target.repository}/blobs/uploads/`, + credentials, + { + method: 'POST', + scope: `repository:${target.repository}:pull,push`, + headers: { + 'Content-Length': '0' + } + } + ); + await assertOk(startResponse, `Start blob upload ${digest}`); + + const uploadLocation = startResponse.headers.get('location'); + if (!uploadLocation) { + throw new RegistryRetagError(`Start blob upload ${digest} did not return a location`); + } + + const uploadUrl = new URL(uploadLocation, registryBaseUrl(target.registry)); + uploadUrl.searchParams.set('digest', digest); + const uploadResponse = await fetchWithAuthRetry( + uploadUrl, + credentials, + { + method: 'PUT', + body: blob, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(blob.byteLength) + } + }, + `repository:${target.repository}:pull,push` + ); + + if (uploadResponse.status !== 201) { + await assertOk(uploadResponse, `Upload blob ${digest}`); + throw new RegistryRetagError(`Upload blob ${digest} returned HTTP ${uploadResponse.status}`); + } +}; + +const ensureBlob = async ( + source: ImageRef, + target: ImageRef, + digest: string, + credentials: RegistryCredentials +) => { + if (await blobExists(target, digest, credentials)) { + return; + } + + if ( + source.registry === target.registry && + (await mountBlob(source, target, digest, credentials)) + ) { + return; + } + + await uploadBlob(source, target, digest, credentials); +}; + +const putManifest = async ( + target: ImageRef, + manifestBody: string, + contentType: string, + credentials: RegistryCredentials, + reference = target.reference +) => { + const response = await registryFetch( + target, + `/v2/${target.repository}/manifests/${encodeURIComponent(reference)}`, + credentials, + { + method: 'PUT', + body: manifestBody, + scope: `repository:${target.repository}:pull,push`, + headers: { + 'Content-Type': contentType, + 'Content-Length': String(Buffer.byteLength(manifestBody)) + } + } + ); + + await assertOk(response, `Put manifest ${target.repository}:${reference}`); +}; + +const copyManifest = async ( + source: ImageRef, + target: ImageRef, + credentials: RegistryCredentials, + sourceReference = source.reference +) => { + const { body, contentType, manifest } = await getManifest(source, credentials, sourceReference); + + for (const childManifest of manifest.manifests || []) { + if (childManifest.digest) { + await copyManifest(source, target, credentials, childManifest.digest); + } + } + + for (const digest of layerBlobDigests(manifest)) { + await ensureBlob(source, target, digest, credentials); + } + + if (sourceReference !== source.reference) { + await putManifest(target, body, contentType, credentials, sourceReference); + } + + return { + body, + contentType + }; +}; + +export const retagImage = async (original: string, target: string) => { + const sourceRef = parseImageRef(original); + const targetRef = parseImageRef(target); + const credentials = getRegistryCredentials(); + const { body, contentType } = await copyManifest(sourceRef, targetRef, credentials); + await putManifest(targetRef, body, contentType, credentials); +}; diff --git a/v1/frontend/services/retag.ts b/v1/frontend/services/retag.ts deleted file mode 100644 index 8a0f538..0000000 --- a/v1/frontend/services/retag.ts +++ /dev/null @@ -1,7 +0,0 @@ -import axios from 'axios'; - -export const retagSvcClient = axios.create({ - baseURL: process.env.RETAG_SVC_URL, - withCredentials: true, - timeout: 60000 -}); From 88cb4301c86848b89a65a055795b9d33f2a6cbee Mon Sep 17 00:00:00 2001 From: Alex Lee <3076438032@qq.com> Date: Wed, 20 May 2026 17:00:21 +0800 Subject: [PATCH 2/8] refactor(v2): inline registry retag flow --- v2/frontend/.env.template | 7 +- .../withTemplate/create/route.ts | 15 +- .../withTemplate/update/route.ts | 13 +- v2/frontend/deploy/Kubefile | 2 + v2/frontend/deploy/manifests/deploy.yaml.tmpl | 6 +- .../services/backend/registry-retag.ts | 488 ++++++++++++++++++ v2/frontend/services/retag.ts | 7 - 7 files changed, 503 insertions(+), 35 deletions(-) create mode 100644 v2/frontend/services/backend/registry-retag.ts delete mode 100644 v2/frontend/services/retag.ts diff --git a/v2/frontend/.env.template b/v2/frontend/.env.template index 7bc0534..87645c8 100644 --- a/v2/frontend/.env.template +++ b/v2/frontend/.env.template @@ -51,10 +51,9 @@ ACCOUNT_URL= # in dev: postgresql://username:password@127.0.0.1:26257/devboxdb?connection_limit=50&pool_timeout=20 # in prod: postgresql://username:password@cockroachdb-global.cockroach-operator-system:26257/devboxdb?connection_limit=50&pool_timeout=20 DATABASE_URL= -# url for template retag -# in dev: http://127.0.0.1:8092 -# in prod: http://devbox-service.devbox-system.svc.cluster.local:8092 -RETAG_SVC_URL= +# registry credentials for template image retag +REGISTRY_USER= +REGISTRY_PASSWORD= # privacy document url(for user to create template) PRIVACY_URL_ZH="https://sealos.run/docs/msa/privacy-policy" PRIVACY_URL_EN="https://sealos.io/docs/msa/privacy-policy" diff --git a/v2/frontend/app/api/templateRepository/withTemplate/create/route.ts b/v2/frontend/app/api/templateRepository/withTemplate/create/route.ts index 9fca59b..1a8fbd8 100644 --- a/v2/frontend/app/api/templateRepository/withTemplate/create/route.ts +++ b/v2/frontend/app/api/templateRepository/withTemplate/create/route.ts @@ -1,10 +1,10 @@ import { TagType, TemplateRepositoryKind } from '@/prisma/generated/client'; import { authSessionWithJWT } from '@/services/backend/auth'; import { getK8s } from '@/services/backend/kubernetes'; +import { retagImage } from '@/services/backend/registry-retag'; import { jsonRes } from '@/services/backend/response'; import { devboxDB } from '@/services/db/init'; import { ERROR_ENUM } from '@/services/error'; -import { retagSvcClient } from '@/services/retag'; import { KBDevboxReleaseType, KBDevboxTypeV2 } from '@/types/k8s'; import { getRegionUid } from '@/utils/env'; import { createTemplateRepositorySchema } from '@/utils/validate'; @@ -23,7 +23,7 @@ export async function POST(req: NextRequest) { }); } const query = createTemplateRepositorySchema.parse(queryRaw); - const { kubeConfig, payload, token } = await authSessionWithJWT(headerList); + const { kubeConfig, payload } = await authSessionWithJWT(headerList); const { namespace, k8sCustomObjects } = await getK8s({ kubeconfig: kubeConfig }); @@ -96,16 +96,7 @@ export async function POST(req: NextRequest) { original: originalImage, target: targetImage }; - const retagResult = await retagSvcClient.post('/tag', retagbody, { - headers: { - Authorization: token - } - }); - if (retagResult.status !== 200) { - console.log('retagResult', retagResult); - throw Error('retag failed'); - } - // invoke retag service !todo + await retagImage(retagbody.original, retagbody.target); // suported deleted because devbox instance of deleted template const origionalTemplate = await devboxDB.template.findUnique({ where: { diff --git a/v2/frontend/app/api/templateRepository/withTemplate/update/route.ts b/v2/frontend/app/api/templateRepository/withTemplate/update/route.ts index b994547..c3f9fa4 100644 --- a/v2/frontend/app/api/templateRepository/withTemplate/update/route.ts +++ b/v2/frontend/app/api/templateRepository/withTemplate/update/route.ts @@ -1,10 +1,10 @@ import { TagType } from '@/prisma/generated/client'; import { authSessionWithJWT } from '@/services/backend/auth'; import { getK8s } from '@/services/backend/kubernetes'; +import { retagImage } from '@/services/backend/registry-retag'; import { jsonRes } from '@/services/backend/response'; import { devboxDB } from '@/services/db/init'; import { ERROR_ENUM } from '@/services/error'; -import { retagSvcClient } from '@/services/retag'; import { KBDevboxReleaseType, KBDevboxTypeV2 } from '@/types/k8s'; import { getRegionUid } from '@/utils/env'; import { updateTemplateSchema } from '@/utils/validate'; @@ -25,7 +25,7 @@ export async function POST(req: NextRequest) { }); } const query = updateTemplateSchema.parse(queryRaw); - const { kubeConfig, payload, token } = await authSessionWithJWT(headerList); + const { kubeConfig, payload } = await authSessionWithJWT(headerList); const { namespace, k8sCustomObjects } = await getK8s({ kubeconfig: kubeConfig }); @@ -106,14 +106,7 @@ export async function POST(req: NextRequest) { original: originalImage, target: tagretImage }; - const retagResult = await retagSvcClient.post('/tag', retagbody, { - headers: { - Authorization: token - } - }); - if (retagResult.status !== 200) { - throw Error('retag failed'); - } + await retagImage(retagbody.original, retagbody.target); const officialTagList = await devboxDB.tag.findMany({ where: { type: TagType.OFFICIAL_CONTENT diff --git a/v2/frontend/deploy/Kubefile b/v2/frontend/deploy/Kubefile index b572988..5dd8084 100644 --- a/v2/frontend/deploy/Kubefile +++ b/v2/frontend/deploy/Kubefile @@ -9,5 +9,7 @@ ENV cloudDomain="127.0.0.1.nip.io" ENV cloudPort="" ENV certSecretName="wildcard-cert" ENV registryAddr="sealos.hub:5000" +ENV registryUser="" +ENV registryPassword="" CMD ["kubectl apply -f manifests"] diff --git a/v2/frontend/deploy/manifests/deploy.yaml.tmpl b/v2/frontend/deploy/manifests/deploy.yaml.tmpl index a3c0cdf..2273d0a 100644 --- a/v2/frontend/deploy/manifests/deploy.yaml.tmpl +++ b/v2/frontend/deploy/manifests/deploy.yaml.tmpl @@ -79,6 +79,10 @@ spec: value: wildcard-cert - name: REGISTRY_ADDR value: {{ .registryAddr }} + - name: REGISTRY_USER + value: {{ default "" .registryUser }} + - name: REGISTRY_PASSWORD + value: {{ default "" .registryPassword }} - name: DEVBOX_AFFINITY_ENABLE value: 'true' - name: METRICS_URL @@ -95,8 +99,6 @@ spec: value: 'false' - name: STORAGE_LIMIT value: 10Gi # default is 10Gi - - name: RETAG_SVC_URL - value: http://devbox-service.devbox-system.svc.cluster.local:8092 - name: JWT_SECRET value: {{ .jwtSecret }} # -nsealos cm desktop-frontend-config ->jwt->Internal - name: REGION_UID diff --git a/v2/frontend/services/backend/registry-retag.ts b/v2/frontend/services/backend/registry-retag.ts new file mode 100644 index 0000000..067e61d --- /dev/null +++ b/v2/frontend/services/backend/registry-retag.ts @@ -0,0 +1,488 @@ +const MANIFEST_ACCEPT = [ + 'application/vnd.oci.image.index.v1+json', + 'application/vnd.oci.image.manifest.v1+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.docker.distribution.manifest.v2+json' +].join(', '); + +type ImageRef = { + registry: string; + repository: string; + reference: string; +}; + +type Descriptor = { + mediaType?: string; + digest?: string; + size?: number; + platform?: unknown; + annotations?: Record; +}; + +type Manifest = { + schemaVersion?: number; + mediaType?: string; + config?: Descriptor; + layers?: Descriptor[]; + manifests?: Descriptor[]; +}; + +type RegistryCredentials = { + username: string; + password: string; +}; + +type RegistryRequestOptions = { + method?: string; + body?: BodyInit; + headers?: Record; + accept?: string; + scope?: string | string[]; +}; + +class RegistryRetagError extends Error { + status?: number; + + constructor(message: string, status?: number) { + super(message); + this.name = 'RegistryRetagError'; + this.status = status; + } +} + +const normalizeRegistry = (registry: string) => registry.replace(/^https?:\/\//, ''); + +const parseImageRef = (image: string): ImageRef => { + const trimmed = image.trim().replace(/^https?:\/\//, ''); + const firstSlash = trimmed.indexOf('/'); + + if (firstSlash <= 0) { + throw new RegistryRetagError(`Invalid image reference: ${image}`); + } + + const registry = trimmed.slice(0, firstSlash); + const remainder = trimmed.slice(firstSlash + 1); + const digestIndex = remainder.indexOf('@'); + + if (digestIndex > 0) { + return { + registry: normalizeRegistry(registry), + repository: remainder.slice(0, digestIndex), + reference: remainder.slice(digestIndex + 1) + }; + } + + const lastSlash = remainder.lastIndexOf('/'); + const tagIndex = remainder.lastIndexOf(':'); + + if (tagIndex > lastSlash) { + return { + registry: normalizeRegistry(registry), + repository: remainder.slice(0, tagIndex), + reference: remainder.slice(tagIndex + 1) + }; + } + + return { + registry: normalizeRegistry(registry), + repository: remainder, + reference: 'latest' + }; +}; + +const getRegistryCredentials = (): RegistryCredentials => { + const username = + process.env.REGISTRY_USER && process.env.REGISTRY_PASSWORD + ? process.env.REGISTRY_USER + : process.env.USER && process.env.PASSWORD + ? process.env.USER + : ''; + const password = + process.env.REGISTRY_USER && process.env.REGISTRY_PASSWORD + ? process.env.REGISTRY_PASSWORD + : process.env.USER && process.env.PASSWORD + ? process.env.PASSWORD + : ''; + + if (!username || !password) { + throw new RegistryRetagError('Registry credentials are not configured'); + } + + return { username, password }; +}; + +const registryBaseUrl = (registry: string) => `http://${normalizeRegistry(registry)}`; + +const authHeader = ({ username, password }: RegistryCredentials) => + `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`; + +const parseAuthenticateHeader = (value: string) => { + const separatorIndex = value.indexOf(' '); + const scheme = separatorIndex >= 0 ? value.slice(0, separatorIndex) : value; + const paramsRaw = separatorIndex >= 0 ? value.slice(separatorIndex + 1) : ''; + const params: Record = {}; + const pattern = /(\w+)="([^"]*)"/g; + let match = pattern.exec(paramsRaw); + + while (match) { + params[match[1]] = match[2]; + match = pattern.exec(paramsRaw); + } + + return { + scheme: scheme?.toLowerCase(), + params + }; +}; + +const getBearerToken = async ( + challenge: string, + credentials: RegistryCredentials, + requestedScope?: string | string[] +) => { + const { scheme, params } = parseAuthenticateHeader(challenge); + + if (scheme !== 'bearer' || !params.realm) { + return null; + } + + const url = new URL(params.realm); + + if (params.service) { + url.searchParams.set('service', params.service); + } + + const scopes = Array.isArray(requestedScope) + ? requestedScope + : requestedScope + ? [requestedScope] + : params.scope + ? [params.scope] + : []; + + for (const scope of scopes) { + url.searchParams.append('scope', scope); + } + + const response = await fetch(url, { + headers: { + Authorization: authHeader(credentials) + } + }); + await assertOk(response, 'Get registry bearer token'); + + const body = (await response.json()) as { token?: string; access_token?: string }; + return body.token || body.access_token || null; +}; + +const fetchWithAuthRetry = async ( + url: string | URL, + credentials: RegistryCredentials, + init: RequestInit, + scope?: string | string[] +) => { + const requestInit: RequestInit = { + ...init, + cache: 'no-store', + headers: { + Authorization: authHeader(credentials), + ...(init.headers || {}) + } + }; + + const response = await fetch(url, requestInit); + + if (response.status !== 401) { + return response; + } + + const bearerToken = await getBearerToken( + response.headers.get('www-authenticate') || '', + credentials, + scope + ); + + if (!bearerToken) { + return response; + } + + return fetch(url, { + ...requestInit, + headers: { + ...requestInit.headers, + Authorization: `Bearer ${bearerToken}` + } + }); +}; + +const readErrorBody = async (response: Response) => { + try { + const text = await response.text(); + return text ? `: ${text.slice(0, 500)}` : ''; + } catch { + return ''; + } +}; + +const registryFetch = async ( + image: Pick, + path: string, + credentials: RegistryCredentials, + options: RegistryRequestOptions = {} +) => { + const url = `${registryBaseUrl(image.registry)}${path}`; + return fetchWithAuthRetry( + url, + credentials, + { + method: options.method || 'GET', + body: options.body, + headers: { + ...(options.accept ? { Accept: options.accept } : {}), + ...options.headers + } + }, + options.scope + ); +}; + +const assertOk = async (response: Response, action: string) => { + if (!response.ok) { + throw new RegistryRetagError( + `${action} failed with HTTP ${response.status}${await readErrorBody(response)}`, + response.status + ); + } +}; + +const getManifest = async ( + image: ImageRef, + credentials: RegistryCredentials, + reference = image.reference +) => { + const response = await registryFetch( + image, + `/v2/${image.repository}/manifests/${encodeURIComponent(reference)}`, + credentials, + { + accept: MANIFEST_ACCEPT, + scope: `repository:${image.repository}:pull` + } + ); + await assertOk(response, `Get manifest ${image.repository}:${reference}`); + + const contentType = response.headers.get('content-type')?.split(';')[0] || ''; + const body = await response.text(); + const manifest = JSON.parse(body) as Manifest; + + return { + body, + contentType: + contentType || manifest.mediaType || 'application/vnd.docker.distribution.manifest.v2+json', + manifest + }; +}; + +const layerBlobDigests = (manifest: Manifest): string[] => { + const digests = new Set(); + + if (manifest.config?.digest) { + digests.add(manifest.config.digest); + } + + for (const layer of manifest.layers || []) { + if (layer.digest) digests.add(layer.digest); + } + + return [...digests]; +}; + +const blobExists = async ( + image: Pick, + digest: string, + credentials: RegistryCredentials +) => { + const response = await registryFetch( + image, + `/v2/${image.repository}/blobs/${digest}`, + credentials, + { + method: 'HEAD', + scope: `repository:${image.repository}:pull` + } + ); + + if (response.status === 200) return true; + if (response.status === 404) return false; + await assertOk(response, `Check blob ${digest}`); + return true; +}; + +const mountBlob = async ( + source: ImageRef, + target: ImageRef, + digest: string, + credentials: RegistryCredentials +) => { + const query = new URLSearchParams({ + mount: digest, + from: source.repository + }); + const response = await registryFetch( + target, + `/v2/${target.repository}/blobs/uploads/?${query.toString()}`, + credentials, + { + method: 'POST', + scope: [`repository:${target.repository}:pull,push`, `repository:${source.repository}:pull`], + headers: { + 'Content-Length': '0' + } + } + ); + + if (response.status === 201) return true; + if (response.status === 202 || response.status === 404 || response.status === 405) return false; + await assertOk(response, `Mount blob ${digest}`); + return false; +}; + +const uploadBlob = async ( + source: ImageRef, + target: ImageRef, + digest: string, + credentials: RegistryCredentials +) => { + const blobResponse = await registryFetch( + source, + `/v2/${source.repository}/blobs/${digest}`, + credentials, + { + accept: 'application/octet-stream', + scope: `repository:${source.repository}:pull` + } + ); + await assertOk(blobResponse, `Download blob ${digest}`); + + const blob = await blobResponse.arrayBuffer(); + const startResponse = await registryFetch( + target, + `/v2/${target.repository}/blobs/uploads/`, + credentials, + { + method: 'POST', + scope: `repository:${target.repository}:pull,push`, + headers: { + 'Content-Length': '0' + } + } + ); + await assertOk(startResponse, `Start blob upload ${digest}`); + + const uploadLocation = startResponse.headers.get('location'); + if (!uploadLocation) { + throw new RegistryRetagError(`Start blob upload ${digest} did not return a location`); + } + + const uploadUrl = new URL(uploadLocation, registryBaseUrl(target.registry)); + uploadUrl.searchParams.set('digest', digest); + const uploadResponse = await fetchWithAuthRetry( + uploadUrl, + credentials, + { + method: 'PUT', + body: blob, + headers: { + 'Content-Type': 'application/octet-stream', + 'Content-Length': String(blob.byteLength) + } + }, + `repository:${target.repository}:pull,push` + ); + + if (uploadResponse.status !== 201) { + await assertOk(uploadResponse, `Upload blob ${digest}`); + throw new RegistryRetagError(`Upload blob ${digest} returned HTTP ${uploadResponse.status}`); + } +}; + +const ensureBlob = async ( + source: ImageRef, + target: ImageRef, + digest: string, + credentials: RegistryCredentials +) => { + if (await blobExists(target, digest, credentials)) { + return; + } + + if ( + source.registry === target.registry && + (await mountBlob(source, target, digest, credentials)) + ) { + return; + } + + await uploadBlob(source, target, digest, credentials); +}; + +const putManifest = async ( + target: ImageRef, + manifestBody: string, + contentType: string, + credentials: RegistryCredentials, + reference = target.reference +) => { + const response = await registryFetch( + target, + `/v2/${target.repository}/manifests/${encodeURIComponent(reference)}`, + credentials, + { + method: 'PUT', + body: manifestBody, + scope: `repository:${target.repository}:pull,push`, + headers: { + 'Content-Type': contentType, + 'Content-Length': String(Buffer.byteLength(manifestBody)) + } + } + ); + + await assertOk(response, `Put manifest ${target.repository}:${reference}`); +}; + +const copyManifest = async ( + source: ImageRef, + target: ImageRef, + credentials: RegistryCredentials, + sourceReference = source.reference +) => { + const { body, contentType, manifest } = await getManifest(source, credentials, sourceReference); + + for (const childManifest of manifest.manifests || []) { + if (childManifest.digest) { + await copyManifest(source, target, credentials, childManifest.digest); + } + } + + for (const digest of layerBlobDigests(manifest)) { + await ensureBlob(source, target, digest, credentials); + } + + if (sourceReference !== source.reference) { + await putManifest(target, body, contentType, credentials, sourceReference); + } + + return { + body, + contentType + }; +}; + +export const retagImage = async (original: string, target: string) => { + const sourceRef = parseImageRef(original); + const targetRef = parseImageRef(target); + const credentials = getRegistryCredentials(); + const { body, contentType } = await copyManifest(sourceRef, targetRef, credentials); + await putManifest(targetRef, body, contentType, credentials); +}; diff --git a/v2/frontend/services/retag.ts b/v2/frontend/services/retag.ts deleted file mode 100644 index 8a0f538..0000000 --- a/v2/frontend/services/retag.ts +++ /dev/null @@ -1,7 +0,0 @@ -import axios from 'axios'; - -export const retagSvcClient = axios.create({ - baseURL: process.env.RETAG_SVC_URL, - withCredentials: true, - timeout: 60000 -}); From f9305438a9a99116a80ccd0257af615db0b29743 Mon Sep 17 00:00:00 2001 From: Alex Lee <3076438032@qq.com> Date: Wed, 20 May 2026 17:00:32 +0800 Subject: [PATCH 3/8] docs: update devbox registry env guidance --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1e23048..76be2b3 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Frontend runs on `http://localhost:3000` by default. > [!IMPORTANT] > Before running features that require cluster access, configure `.env.local` with at least: -> `NEXT_PUBLIC_MOCK_USER`, `SEALOS_DOMAIN`, and related backend endpoints (`DATABASE_URL`, `METRICS_URL`, `ACCOUNT_URL`, `RETAG_SVC_URL`) based on your environment. +> `NEXT_PUBLIC_MOCK_USER`, `SEALOS_DOMAIN`, and related backend endpoints (`DATABASE_URL`, `METRICS_URL`, `ACCOUNT_URL`), plus registry retag settings (`REGISTRY_ADDR`, `REGISTRY_USER`, `REGISTRY_PASSWORD`) based on your environment. ### 2. Run controller From 43a44dce89fe15ed40bf395ae11097d07597c9ed Mon Sep 17 00:00:00 2001 From: Alex Lee <3076438032@qq.com> Date: Wed, 20 May 2026 17:22:00 +0800 Subject: [PATCH 4/8] fix(ui): fallback runtime icons and align list footer --- .../(platform)/(home)/components/List.tsx | 39 ++++++++------- v1/frontend/components/RuntimeIcon.tsx | 48 +++++++++++++++++++ .../(platform)/(home)/components/List.tsx | 4 +- v2/frontend/components/RuntimeIcon.tsx | 15 ++++-- 4 files changed, 81 insertions(+), 25 deletions(-) create mode 100644 v1/frontend/components/RuntimeIcon.tsx diff --git a/v1/frontend/app/[lang]/(platform)/(home)/components/List.tsx b/v1/frontend/app/[lang]/(platform)/(home)/components/List.tsx index a140961..067fc35 100644 --- a/v1/frontend/app/[lang]/(platform)/(home)/components/List.tsx +++ b/v1/frontend/app/[lang]/(platform)/(home)/components/List.tsx @@ -29,7 +29,6 @@ import { type HeaderContext, type CellContext } from '@tanstack/react-table'; -import Image from 'next/image'; import dynamic from 'next/dynamic'; import { useTranslations } from 'next-intl'; import { useCallback, useMemo, useState, useEffect, useRef } from 'react'; @@ -63,6 +62,7 @@ import DatePicker from '@/components/DatePicker'; import { Separator } from '@labring/sealos-ui/separator'; import SearchEmpty from './SearchEmpty'; import GPUItem from '@/components/GPUItem'; +import { RuntimeIcon } from '@/components/RuntimeIcon'; const DeleteDevboxDialog = dynamic(() => import('@/components/dialogs/DeleteDevboxDialog')); const EditRemarkDialog = dynamic(() => import('@/components/dialogs/EditRemarkDialog')); @@ -203,23 +203,13 @@ const DevboxList = ({
- {item.template.name} +
- {item.template.name} +

{iconId}

@@ -672,8 +662,8 @@ const DevboxList = ({ return ( <> {/* table */} -
-
+
+
{/* table header */}
{table.getFlatHeaders().map((header) => ( @@ -707,11 +697,20 @@ const DevboxList = ({
{/* pagination */} {table.getRowModel().rows.length > 0 && ( - table.setPageIndex(page - 1)} - /> +
+ {t('Total') + ': ' + table.getFilteredRowModel().rows.length} +
+ table.setPageIndex(page - 1)} + /> +
+ {table.getState().pagination.pageSize}/ + {t('Page')} +
+
+
)}
diff --git a/v1/frontend/components/RuntimeIcon.tsx b/v1/frontend/components/RuntimeIcon.tsx new file mode 100644 index 0000000..2436f69 --- /dev/null +++ b/v1/frontend/components/RuntimeIcon.tsx @@ -0,0 +1,48 @@ +'use client'; + +import Image from 'next/image'; +import { useEffect, useState } from 'react'; + +interface RuntimeIconProps { + iconId: string | null; + alt: string; + width?: number; + height?: number; + className?: string; + priority?: boolean; + onLoad?: () => void; +} + +const fallbackRuntimeIcon = '/images/runtime/custom.svg'; + +const getRuntimeIconSrc = (iconId: string | null) => + iconId ? `/images/runtime/${iconId}.svg` : fallbackRuntimeIcon; + +export const RuntimeIcon = ({ + iconId, + alt, + width = 21, + height = 21, + className, + priority, + onLoad +}: RuntimeIconProps) => { + const [imgSrc, setImgSrc] = useState(getRuntimeIconSrc(iconId)); + + useEffect(() => { + setImgSrc(getRuntimeIconSrc(iconId)); + }, [iconId]); + + return ( + {alt} setImgSrc(fallbackRuntimeIcon)} + onLoad={onLoad} + /> + ); +}; diff --git a/v2/frontend/app/[lang]/(platform)/(home)/components/List.tsx b/v2/frontend/app/[lang]/(platform)/(home)/components/List.tsx index bd58585..a44b5f1 100644 --- a/v2/frontend/app/[lang]/(platform)/(home)/components/List.tsx +++ b/v2/frontend/app/[lang]/(platform)/(home)/components/List.tsx @@ -293,8 +293,8 @@ const DevboxList = ({ return ( <> {/* table */} -
-
+
+
{/* table header */}
{table.getFlatHeaders().map((header) => ( diff --git a/v2/frontend/components/RuntimeIcon.tsx b/v2/frontend/components/RuntimeIcon.tsx index 6a861b5..2436f69 100644 --- a/v2/frontend/components/RuntimeIcon.tsx +++ b/v2/frontend/components/RuntimeIcon.tsx @@ -1,7 +1,7 @@ 'use client'; import Image from 'next/image'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; interface RuntimeIconProps { iconId: string | null; @@ -13,6 +13,11 @@ interface RuntimeIconProps { onLoad?: () => void; } +const fallbackRuntimeIcon = '/images/runtime/custom.svg'; + +const getRuntimeIconSrc = (iconId: string | null) => + iconId ? `/images/runtime/${iconId}.svg` : fallbackRuntimeIcon; + export const RuntimeIcon = ({ iconId, alt, @@ -22,7 +27,11 @@ export const RuntimeIcon = ({ priority, onLoad }: RuntimeIconProps) => { - const [imgSrc, setImgSrc] = useState(`/images/runtime/${iconId}.svg`); + const [imgSrc, setImgSrc] = useState(getRuntimeIconSrc(iconId)); + + useEffect(() => { + setImgSrc(getRuntimeIconSrc(iconId)); + }, [iconId]); return ( setImgSrc('/images/runtime/custom.svg')} + onError={() => setImgSrc(fallbackRuntimeIcon)} onLoad={onLoad} /> ); From 5a8ae0ed0f439ed9ae2bae4ba6e3f6aaaed00c01 Mon Sep 17 00:00:00 2001 From: Alex Lee <3076438032@qq.com> Date: Wed, 20 May 2026 17:27:00 +0800 Subject: [PATCH 5/8] fix(ui): keep list footer pinned to viewport --- v1/frontend/app/[lang]/(platform)/(home)/components/List.tsx | 2 +- v2/frontend/app/[lang]/(platform)/(home)/components/List.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/v1/frontend/app/[lang]/(platform)/(home)/components/List.tsx b/v1/frontend/app/[lang]/(platform)/(home)/components/List.tsx index 067fc35..be02c41 100644 --- a/v1/frontend/app/[lang]/(platform)/(home)/components/List.tsx +++ b/v1/frontend/app/[lang]/(platform)/(home)/components/List.tsx @@ -662,7 +662,7 @@ const DevboxList = ({ return ( <> {/* table */} -
+
{/* table header */}
diff --git a/v2/frontend/app/[lang]/(platform)/(home)/components/List.tsx b/v2/frontend/app/[lang]/(platform)/(home)/components/List.tsx index a44b5f1..d62c6ab 100644 --- a/v2/frontend/app/[lang]/(platform)/(home)/components/List.tsx +++ b/v2/frontend/app/[lang]/(platform)/(home)/components/List.tsx @@ -293,7 +293,7 @@ const DevboxList = ({ return ( <> {/* table */} -
+
{/* table header */}
From 831e206646424a31bd6665f160b0e85b5a16222b Mon Sep 17 00:00:00 2001 From: Alex Lee <3076438032@qq.com> Date: Wed, 20 May 2026 18:04:05 +0800 Subject: [PATCH 6/8] fix(v1): include prisma schemas for migration image --- v1/frontend/Dockerfile | 2 ++ .../migration.sql | 34 +++++++++---------- 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/v1/frontend/Dockerfile b/v1/frontend/Dockerfile index a6cb276..5c68bb7 100644 --- a/v1/frontend/Dockerfile +++ b/v1/frontend/Dockerfile @@ -60,7 +60,9 @@ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static # Next standalone includes prisma schemas/clients but not migrations. # Keep both providers' migrations for initContainer migrate deploy. +COPY --from=builder --chown=nextjs:nodejs /app/prisma/cockroach/schema.prisma ./providers/devbox/prisma/cockroach/schema.prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma/cockroach/migrations ./providers/devbox/prisma/cockroach/migrations +COPY --from=builder --chown=nextjs:nodejs /app/prisma/postgresql/schema.prisma ./providers/devbox/prisma/postgresql/schema.prisma COPY --from=builder --chown=nextjs:nodejs /app/prisma/postgresql/migrations ./providers/devbox/prisma/postgresql/migrations USER nextjs diff --git a/v1/frontend/prisma/postgresql/migrations/20250103095011_region_update/migration.sql b/v1/frontend/prisma/postgresql/migrations/20250103095011_region_update/migration.sql index 144ebe5..204a1e8 100644 --- a/v1/frontend/prisma/postgresql/migrations/20250103095011_region_update/migration.sql +++ b/v1/frontend/prisma/postgresql/migrations/20250103095011_region_update/migration.sql @@ -1,22 +1,22 @@ --- upgrade region --- DropIndex -DROP INDEX "TemplateRepository_isDeleted_name_key"; +-- upgrade region +-- DropIndex +DROP INDEX "TemplateRepository_isDeleted_name_key"; -- add regionUid column -ALTER TABLE "TemplateRepository" - ADD COLUMN "regionUid" TEXT NOT NULL default '00000000-0000-0000-0000-000000000000'; -ALTER TABLE "TemplateRepository" - ALTER COLUMN "regionUid" DROP DEFAULT; +ALTER TABLE "TemplateRepository" + ADD COLUMN "regionUid" TEXT NOT NULL default '00000000-0000-0000-0000-000000000000'; +ALTER TABLE "TemplateRepository" + ALTER COLUMN "regionUid" DROP DEFAULT; --- CreateIndex -CREATE INDEX "TemplateRepository_isDeleted_createdAt_idx" ON "TemplateRepository" ("isDeleted", "createdAt"); +-- CreateIndex +CREATE INDEX "TemplateRepository_isDeleted_createdAt_idx" ON "TemplateRepository" ("isDeleted", "createdAt"); --- CreateIndex -CREATE UNIQUE INDEX "TemplateRepository_isDeleted_regionUid_name_key" ON "TemplateRepository" ("isDeleted", "regionUid", "name"); +-- CreateIndex +CREATE UNIQUE INDEX "TemplateRepository_isDeleted_regionUid_name_key" ON "TemplateRepository" ("isDeleted", "regionUid", "name"); --- AlterTable -ALTER TABLE public."TemplateRepository" alter column "organizationUid" type uuid using "organizationUid"::uuid; --- AlterTable -DROP INDEX "Template_isDeleted_templateRepositoryUid_name_key"; -ALTER TABLE "Template" ALTER COLUMN "templateRepositoryUid" type uuid using "templateRepositoryUid"::uuid; -CREATE UNIQUE INDEX "Template_isDeleted_templateRepositoryUid_name_key" ON "Template" ("isDeleted", "templateRepositoryUid", "name"); +-- AlterTable +ALTER TABLE public."TemplateRepository" alter column "organizationUid" type uuid using "organizationUid"::uuid; +-- AlterTable +DROP INDEX "Template_isDeleted_templateRepositoryUid_name_key"; +ALTER TABLE "Template" ALTER COLUMN "templateRepositoryUid" type uuid using "templateRepositoryUid"::uuid; +CREATE UNIQUE INDEX "Template_isDeleted_templateRepositoryUid_name_key" ON "Template" ("isDeleted", "templateRepositoryUid", "name"); From 2f7121e989f0aeab4718ae03c82ca30d30dba7dc Mon Sep 17 00:00:00 2001 From: Alex Lee <3076438032@qq.com> Date: Wed, 20 May 2026 18:04:22 +0800 Subject: [PATCH 7/8] fix(registry): harden retag transport and credentials --- v1/frontend/.env.template | 2 + v1/frontend/deploy/Kubefile | 1 + v1/frontend/deploy/manifests/deploy.yaml.tmpl | 2 + v1/frontend/docs/runbook.md | 5 + .../services/backend/registry-retag.ts | 134 +++++++++++++----- v2/frontend/.env.template | 2 + v2/frontend/deploy/Kubefile | 1 + v2/frontend/deploy/manifests/deploy.yaml.tmpl | 2 + .../services/backend/registry-retag.ts | 134 +++++++++++++----- 9 files changed, 211 insertions(+), 72 deletions(-) diff --git a/v1/frontend/.env.template b/v1/frontend/.env.template index 9b5ca4a..e6f8e81 100644 --- a/v1/frontend/.env.template +++ b/v1/frontend/.env.template @@ -50,6 +50,8 @@ DATABASE_PROVIDER="cockroachdb" # registry credentials for template image retag REGISTRY_USER= REGISTRY_PASSWORD= +# set true only for internal HTTP registries +REGISTRY_INSECURE="false" # privacy document url(for user to create template) PRIVACY_URL_ZH="https://sealos.run/docs/msa/privacy-policy" PRIVACY_URL_EN="https://sealos.io/docs/msa/privacy-policy" diff --git a/v1/frontend/deploy/Kubefile b/v1/frontend/deploy/Kubefile index 5dd8084..59ed057 100644 --- a/v1/frontend/deploy/Kubefile +++ b/v1/frontend/deploy/Kubefile @@ -11,5 +11,6 @@ ENV certSecretName="wildcard-cert" ENV registryAddr="sealos.hub:5000" ENV registryUser="" ENV registryPassword="" +ENV registryInsecure="true" CMD ["kubectl apply -f manifests"] diff --git a/v1/frontend/deploy/manifests/deploy.yaml.tmpl b/v1/frontend/deploy/manifests/deploy.yaml.tmpl index 95c6e0f..fb11e31 100644 --- a/v1/frontend/deploy/manifests/deploy.yaml.tmpl +++ b/v1/frontend/deploy/manifests/deploy.yaml.tmpl @@ -92,6 +92,8 @@ spec: value: {{ default "" .registryUser }} - name: REGISTRY_PASSWORD value: {{ default "" .registryPassword }} + - name: REGISTRY_INSECURE + value: "{{ default "false" .registryInsecure }}" - name: DEVBOX_AFFINITY_ENABLE value: 'true' - name: MONITOR_URL diff --git a/v1/frontend/docs/runbook.md b/v1/frontend/docs/runbook.md index 90ea771..c65d899 100644 --- a/v1/frontend/docs/runbook.md +++ b/v1/frontend/docs/runbook.md @@ -17,6 +17,7 @@ NEXT_PUBLIC_MOCK_USER='' SEALOS_DOMAIN='192.168.10.70.nip.io' INGRESS_DOMAIN='192.168.10.70.nip.io' REGISTRY_ADDR='hub.192.168.10.70.nip.io' +REGISTRY_INSECURE='true' JWT_SECRET='' REGION_UID='' DATABASE_URL='