feat: add image fetch with proxy support and registry proxy config
Backend: - Add image fetch handler using go-containerregistry - Support HTTP/HTTPS and SOCKS5 proxy protocols - Pull images from remote registries (Docker Hub, etc.) - Store fetched images as blobs and manifests - Add timeout handling (60s for descriptor fetch) - Add detailed logging for pull progress - Add /api/v1/registry/image/fetch endpoint Frontend: - Add registry proxy configuration field - Add "获取镜像" button and dialog - Add proxy checkbox and input in fetch dialog - Add LinearProgress feedback during fetch - Add multi-stage Snackbar notifications - Auto-refresh image list after successful fetch - Fix download filename regex (remove trailing quote) - Adjust button colors for better UI consistency Dependencies: - Add github.com/google/go-containerregistry for OCI operations - Add golang.org/x/net/proxy for SOCKS5 support 🤖 Generated with [Qoder](https://qoder.com)
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -33,6 +33,7 @@ cluster
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
.qoder/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
@@ -23,12 +23,15 @@ import {
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
LinearProgress,
|
||||
Checkbox,
|
||||
FormControlLabel,
|
||||
} from '@mui/material'
|
||||
import MuiAlert, { AlertProps } from '@mui/material/Alert'
|
||||
import DownloadIcon from '@mui/icons-material/Download'
|
||||
import UploadIcon from '@mui/icons-material/Upload'
|
||||
import SettingsIcon from '@mui/icons-material/Settings'
|
||||
import CloseIcon from '@mui/icons-material/Close'
|
||||
import CloudDownloadIcon from '@mui/icons-material/CloudDownload'
|
||||
import { useSettingsStore } from '../stores/settingsStore'
|
||||
|
||||
const SnackbarAlert = forwardRef<HTMLDivElement, AlertProps>(function SnackbarAlert(
|
||||
@@ -73,20 +76,28 @@ export default function RegistryImageList() {
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [uploadOpen, setUploadOpen] = useState(false)
|
||||
const [fetchImageOpen, setFetchImageOpen] = useState(false)
|
||||
const [selectedFile, setSelectedFile] = useState<File | null>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState(0)
|
||||
const [downloadingImage, setDownloadingImage] = useState<string | null>(null)
|
||||
const [fetchingImage, setFetchingImage] = useState(false)
|
||||
const [fetchImageName, setFetchImageName] = useState('')
|
||||
const [useProxy, setUseProxy] = useState(false)
|
||||
const [fetchProxyInput, setFetchProxyInput] = useState('')
|
||||
const {
|
||||
registryAddress,
|
||||
registryAddress,
|
||||
registryProxy,
|
||||
loading: configLoading,
|
||||
snackbar,
|
||||
fetchConfig,
|
||||
setRegistryAddress,
|
||||
setRegistryProxy,
|
||||
hideSnackbar,
|
||||
showSnackbar
|
||||
} = useSettingsStore()
|
||||
const [registryAddressInput, setRegistryAddressInput] = useState(registryAddress)
|
||||
const [registryProxyInput, setRegistryProxyInput] = useState(registryProxy)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -132,9 +143,9 @@ export default function RegistryImageList() {
|
||||
|
||||
// Get filename from Content-Disposition header or use default
|
||||
const contentDisposition = response.headers.get('Content-Disposition')
|
||||
let filename = `${imageName.replace(/\//g, '_')}-latest.tar`
|
||||
let filename = `${imageName.replace(/\//g, '_')}.tar`
|
||||
if (contentDisposition) {
|
||||
const filenameMatch = contentDisposition.match(/filename="?(.+)"?/i)
|
||||
const filenameMatch = contentDisposition.match(/filename="?([^"]+)"?/i)
|
||||
if (filenameMatch) {
|
||||
filename = filenameMatch[1]
|
||||
}
|
||||
@@ -164,6 +175,7 @@ export default function RegistryImageList() {
|
||||
try {
|
||||
setSaving(true)
|
||||
await setRegistryAddress(registryAddressInput)
|
||||
await setRegistryProxy(registryProxyInput)
|
||||
setSettingsOpen(false)
|
||||
} catch (error: any) {
|
||||
// Error already handled in store
|
||||
@@ -174,6 +186,7 @@ export default function RegistryImageList() {
|
||||
|
||||
const handleCloseSettings = () => {
|
||||
setRegistryAddressInput(registryAddress)
|
||||
setRegistryProxyInput(registryProxy)
|
||||
setSettingsOpen(false)
|
||||
}
|
||||
|
||||
@@ -181,10 +194,67 @@ export default function RegistryImageList() {
|
||||
setRegistryAddressInput(registryAddress)
|
||||
}, [registryAddress])
|
||||
|
||||
useEffect(() => {
|
||||
setRegistryProxyInput(registryProxy)
|
||||
}, [registryProxy])
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig()
|
||||
}, [fetchConfig])
|
||||
|
||||
useEffect(() => {
|
||||
setFetchProxyInput(registryProxy)
|
||||
}, [registryProxy])
|
||||
|
||||
const handleFetchImage = async () => {
|
||||
if (!fetchImageName.trim()) {
|
||||
showSnackbar('请输入镜像名称', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
setFetchingImage(true)
|
||||
showSnackbar('开始获取镜像...', 'info')
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/v1/registry/image/fetch', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
image: fetchImageName,
|
||||
proxy: useProxy ? fetchProxyInput : '',
|
||||
}),
|
||||
})
|
||||
|
||||
const result = await response.json()
|
||||
if (response.ok) {
|
||||
showSnackbar(result.data?.message || '镜像获取成功', 'success')
|
||||
setFetchImageOpen(false)
|
||||
setFetchImageName('')
|
||||
setUseProxy(false)
|
||||
setFetchProxyInput(registryProxy)
|
||||
// Refresh image list
|
||||
window.location.reload()
|
||||
} else {
|
||||
showSnackbar(result.err || result.msg || '获取失败', 'error')
|
||||
}
|
||||
} catch (error: any) {
|
||||
showSnackbar(`请求失败: ${error.message}`, 'error')
|
||||
} finally {
|
||||
setFetchingImage(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCloseFetchImage = () => {
|
||||
if (!fetchingImage) {
|
||||
setFetchImageOpen(false)
|
||||
setFetchImageName('')
|
||||
setUseProxy(false)
|
||||
setFetchProxyInput(registryProxy)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = event.target.files?.[0]
|
||||
if (file && file.name.endsWith('.tar')) {
|
||||
@@ -258,6 +328,13 @@ export default function RegistryImageList() {
|
||||
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
|
||||
<Typography variant="h5">镜像列表</Typography>
|
||||
<Stack direction="row" spacing={2}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
startIcon={<CloudDownloadIcon />}
|
||||
onClick={() => setFetchImageOpen(true)}
|
||||
>
|
||||
获取镜像
|
||||
</Button>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<UploadIcon />}
|
||||
@@ -371,6 +448,69 @@ export default function RegistryImageList() {
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Fetch Image Dialog */}
|
||||
<Dialog open={fetchImageOpen} onClose={handleCloseFetchImage} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>获取镜像</DialogTitle>
|
||||
<DialogContent>
|
||||
<Stack spacing={3} sx={{ mt: 2 }}>
|
||||
<Alert severity="info">
|
||||
请输入完整的镜像名称,例如: docker.io/library/nginx:latest
|
||||
</Alert>
|
||||
<TextField
|
||||
label="镜像名称"
|
||||
placeholder="例如: docker.io/library/nginx:latest"
|
||||
value={fetchImageName}
|
||||
onChange={(e) => setFetchImageName(e.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
disabled={fetchingImage}
|
||||
/>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Checkbox
|
||||
checked={useProxy}
|
||||
onChange={(e) => setUseProxy(e.target.checked)}
|
||||
disabled={fetchingImage}
|
||||
/>
|
||||
}
|
||||
label="使用代理"
|
||||
/>
|
||||
{useProxy && (
|
||||
<TextField
|
||||
label="代理地址"
|
||||
placeholder="例如: socks5://192.168.9.19:7890"
|
||||
value={fetchProxyInput}
|
||||
onChange={(e) => setFetchProxyInput(e.target.value)}
|
||||
fullWidth
|
||||
variant="outlined"
|
||||
disabled={fetchingImage}
|
||||
helperText="支持格式: http://, https://, socks5://"
|
||||
/>
|
||||
)}
|
||||
{fetchingImage && (
|
||||
<Box>
|
||||
<LinearProgress />
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
|
||||
正在获取镜像,请耐心等待...
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Stack>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleCloseFetchImage} disabled={fetchingImage}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleFetchImage}
|
||||
variant="contained"
|
||||
disabled={!fetchImageName.trim() || fetchingImage}
|
||||
>
|
||||
{fetchingImage ? '获取中...' : '获取'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Settings Drawer */}
|
||||
<Drawer
|
||||
anchor="right"
|
||||
@@ -400,6 +540,17 @@ export default function RegistryImageList() {
|
||||
disabled={configLoading || saving}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Registry Proxy"
|
||||
placeholder="例如: http://proxy.example.com:8080"
|
||||
value={registryProxyInput}
|
||||
onChange={(e) => setRegistryProxyInput(e.target.value)}
|
||||
fullWidth
|
||||
helperText="设置镜像代理地址,格式: <scheme>://<host>[:<port>]"
|
||||
variant="outlined"
|
||||
disabled={configLoading || saving}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 2, justifyContent: 'flex-end' }}>
|
||||
<Button variant="outlined" onClick={handleCloseSettings} disabled={saving}>
|
||||
取消
|
||||
|
||||
@@ -8,17 +8,20 @@ interface SnackbarMessage {
|
||||
|
||||
interface SettingsState {
|
||||
registryAddress: string
|
||||
registryProxy: string
|
||||
loading: boolean
|
||||
error: string | null
|
||||
snackbar: SnackbarMessage
|
||||
fetchConfig: () => Promise<void>
|
||||
setRegistryAddress: (address: string) => Promise<void>
|
||||
setRegistryProxy: (proxy: string) => Promise<void>
|
||||
showSnackbar: (message: string, severity: SnackbarMessage['severity']) => void
|
||||
hideSnackbar: () => void
|
||||
}
|
||||
|
||||
export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
registryAddress: '',
|
||||
registryProxy: '',
|
||||
loading: false,
|
||||
error: null,
|
||||
snackbar: {
|
||||
@@ -36,6 +39,7 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
const configs = result.data?.configs || {}
|
||||
set({
|
||||
registryAddress: configs.registry_address || '',
|
||||
registryProxy: configs.proxy || '',
|
||||
loading: false
|
||||
})
|
||||
} catch (error: any) {
|
||||
@@ -66,6 +70,29 @@ export const useSettingsStore = create<SettingsState>((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
setRegistryProxy: async (proxy: string) => {
|
||||
set({ loading: true, error: null })
|
||||
try {
|
||||
const res = await fetch('/api/v1/registry/config', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
key: 'proxy',
|
||||
value: proxy,
|
||||
}),
|
||||
})
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
set({ registryProxy: proxy, loading: false })
|
||||
get().showSnackbar('保存成功', 'success')
|
||||
} catch (error: any) {
|
||||
set({ error: error.message, loading: false })
|
||||
get().showSnackbar(`保存失败: ${error.message}`, 'error')
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
showSnackbar: (message: string, severity: SnackbarMessage['severity']) => {
|
||||
set({
|
||||
snackbar: {
|
||||
|
||||
17
go.mod
17
go.mod
@@ -6,14 +6,19 @@ require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/gofiber/fiber/v3 v3.0.0-beta.2
|
||||
github.com/gofrs/uuid v4.4.0+incompatible
|
||||
github.com/google/go-containerregistry v0.20.6
|
||||
github.com/jedib0t/go-pretty/v6 v6.7.1
|
||||
github.com/spf13/cobra v1.10.1
|
||||
golang.org/x/crypto v0.41.0
|
||||
golang.org/x/crypto v0.43.0
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect
|
||||
github.com/docker/cli v28.2.2+incompatible // indirect
|
||||
github.com/docker/distribution v2.8.3+incompatible // indirect
|
||||
github.com/docker/docker-credential-helpers v0.9.3 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/glebarez/go-sqlite v1.21.2 // indirect
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect
|
||||
@@ -25,13 +30,21 @@ require (
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/mitchellh/go-homedir v1.1.0 // indirect
|
||||
github.com/opencontainers/go-digest v1.0.0 // indirect
|
||||
github.com/opencontainers/image-spec v1.1.1 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
github.com/valyala/fasthttp v1.65.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
github.com/vbatts/tar-split v0.12.1 // indirect
|
||||
golang.org/x/net v0.46.0 // indirect
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
|
||||
39
go.sum
39
go.sum
@@ -1,8 +1,17 @@
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8=
|
||||
github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A=
|
||||
github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
|
||||
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
|
||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
|
||||
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
|
||||
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
|
||||
@@ -17,6 +26,10 @@ github.com/gofiber/utils/v2 v2.0.0-rc.1 h1:b77K5Rk9+Pjdxz4HlwEBnS7u5nikhx7armQB8
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
|
||||
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU=
|
||||
github.com/google/go-containerregistry v0.20.6/go.mod h1:T0x8MuoAoKX/873bkeSfLD2FAkwCDf9/HZgsFJ02E2Y=
|
||||
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7 h1:y3N7Bm7Y9/CtpiVkw/ZWj6lSlDF3F74SfKwfTCer72Q=
|
||||
github.com/google/pprof v0.0.0-20240227163752-401108e1b7e7/go.mod h1:czg5+yv1E0ZGTi6S6vVK1mke0fV+FaUhNGcd6VRS9Ik=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
@@ -37,6 +50,14 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
|
||||
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20200410134404-eec4a21b6bb0/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
@@ -48,32 +69,50 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/shamaton/msgpack/v2 v2.2.3 h1:uDOHmxQySlvlUYfQwdjxyybAOzjlQsD1Vjy+4jmO9NM=
|
||||
github.com/shamaton/msgpack/v2 v2.2.3/go.mod h1:6khjYnkx73f7VQU7wjcFS9DFjs+59naVWJv1TB7qdOI=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s=
|
||||
github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
|
||||
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
|
||||
github.com/valyala/fasthttp v1.65.0 h1:j/u3uzFEGFfRxw79iYzJN+TteTJwbYkru9uDp3d0Yf8=
|
||||
github.com/valyala/fasthttp v1.65.0/go.mod h1:P/93/YkKPMsKSnATEeELUCkG8a7Y+k99uxNHVbKINr4=
|
||||
github.com/vbatts/tar-split v0.12.1 h1:CqKoORW7BUWBe7UL/iqTVvkTBOF8UvOMKOIZykxnnbo=
|
||||
github.com/vbatts/tar-split v0.12.1/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA=
|
||||
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
|
||||
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
|
||||
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
|
||||
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
|
||||
golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4=
|
||||
golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc=
|
||||
golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
|
||||
golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
|
||||
golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4=
|
||||
golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
|
||||
golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
|
||||
golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg=
|
||||
gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||
gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0=
|
||||
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
|
||||
modernc.org/libc v1.22.5 h1:91BNch/e5B0uPbJFgqbxXuOnxBQjlS//icfQEGmvyjE=
|
||||
modernc.org/libc v1.22.5/go.mod h1:jj+Z7dTNX8fBScMVNRAYZ/jF91K8fdT2hYMThc3YjBY=
|
||||
modernc.org/mathutil v1.5.0 h1:rV0Ko/6SfM+8G+yKiyI830l3Wuz1zRutdslNoQ0kfiQ=
|
||||
|
||||
@@ -47,6 +47,7 @@ func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) (
|
||||
registryAPI.Get("/image/list", registry.RegistryImageList(ctx, db, store))
|
||||
registryAPI.Get("/image/download/*", registry.RegistryImageDownload(ctx, db, store))
|
||||
registryAPI.Post("/image/upload", registry.RegistryImageUpload(ctx, db, store))
|
||||
registryAPI.Post("/image/fetch", registry.RegistryImageFetch(ctx, db, store))
|
||||
// registry config apis
|
||||
registryAPI.Get("/config", registry.RegistryConfigGet(ctx, db, store))
|
||||
registryAPI.Post("/config", registry.RegistryConfigSet(ctx, db, store))
|
||||
|
||||
300
internal/module/registry/handler.fetch.go
Normal file
300
internal/module/registry/handler.fetch.go
Normal file
@@ -0,0 +1,300 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
v1 "github.com/google/go-containerregistry/pkg/v1"
|
||||
"github.com/google/go-containerregistry/pkg/v1/remote"
|
||||
"github.com/google/go-containerregistry/pkg/name"
|
||||
goproxy "golang.org/x/net/proxy"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type FetchImageRequest struct {
|
||||
Image string `json:"image"`
|
||||
Proxy string `json:"proxy"`
|
||||
}
|
||||
|
||||
func RegistryImageFetch(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
var req FetchImageRequest
|
||||
if err := c.Bind().JSON(&req); err != nil {
|
||||
return resp.R400(c, "INVALID_REQUEST", nil, "invalid request body")
|
||||
}
|
||||
|
||||
if req.Image == "" {
|
||||
return resp.R400(c, "MISSING_IMAGE", nil, "image name is required")
|
||||
}
|
||||
|
||||
log.Printf("[FetchImage] Start fetching image: %s, proxy: %s", req.Image, req.Proxy)
|
||||
|
||||
// Parse image name to extract repo and tag
|
||||
parts := strings.SplitN(req.Image, ":", 2)
|
||||
repo := parts[0]
|
||||
tag := "latest"
|
||||
if len(parts) == 2 {
|
||||
tag = parts[1]
|
||||
}
|
||||
|
||||
// Pull image
|
||||
manifest, err := pullImage(c.Context(), db, store, repo, tag, req.Proxy)
|
||||
if err != nil {
|
||||
log.Printf("[FetchImage] Failed to pull image: %v", err)
|
||||
return resp.R500(c, "PULL_FAILED", nil, fmt.Sprintf("拉取镜像失败: %v", err))
|
||||
}
|
||||
|
||||
log.Printf("[FetchImage] Successfully pulled image: %s:%s", repo, tag)
|
||||
|
||||
return resp.R200(c, map[string]interface{}{
|
||||
"message": "镜像拉取成功",
|
||||
"image": req.Image,
|
||||
"repository": repo,
|
||||
"tag": tag,
|
||||
"digest": manifest.Config.Digest.String(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func pullImage(ctx context.Context, db *gorm.DB, store store.Store, repo string, tag string, proxy string) (*v1.Manifest, error) {
|
||||
if repo == "" || tag == "" {
|
||||
return nil, fmt.Errorf("invalid repo or tag")
|
||||
}
|
||||
|
||||
log.Printf("[PullImage] Pulling %s:%s with proxy: %s", repo, tag, proxy)
|
||||
|
||||
var (
|
||||
err error
|
||||
transport = &http.Transport{
|
||||
MaxIdleConns: 10,
|
||||
IdleConnTimeout: 30 * time.Second,
|
||||
TLSHandshakeTimeout: 30 * time.Second,
|
||||
ResponseHeaderTimeout: 30 * time.Second,
|
||||
}
|
||||
options []remote.Option
|
||||
tn name.Tag
|
||||
des *remote.Descriptor
|
||||
img v1.Image
|
||||
manifest *v1.Manifest
|
||||
target = fmt.Sprintf("%s:%s", repo, tag)
|
||||
)
|
||||
|
||||
// Setup proxy if provided
|
||||
if proxy != "" {
|
||||
var pu *url.URL
|
||||
if pu, err = url.Parse(proxy); err != nil {
|
||||
return nil, fmt.Errorf("invalid proxy URL: %w", err)
|
||||
}
|
||||
|
||||
// Handle socks5 proxy
|
||||
if pu.Scheme == "socks5" {
|
||||
log.Printf("[PullImage] Using SOCKS5 proxy: %s", proxy)
|
||||
|
||||
// Create SOCKS5 dialer
|
||||
dialer, err := goproxy.SOCKS5("tcp", pu.Host, nil, goproxy.Direct)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SOCKS5 dialer: %w", err)
|
||||
}
|
||||
|
||||
// Set custom DialContext for SOCKS5
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return dialer.Dial(network, addr)
|
||||
}
|
||||
} else {
|
||||
// HTTP/HTTPS proxy
|
||||
log.Printf("[PullImage] Using HTTP(S) proxy: %s", proxy)
|
||||
transport.Proxy = http.ProxyURL(pu)
|
||||
}
|
||||
}
|
||||
|
||||
options = append(options, remote.WithTransport(transport))
|
||||
options = append(options, remote.WithContext(ctx))
|
||||
|
||||
// Parse image reference
|
||||
if tn, err = name.NewTag(target); err != nil {
|
||||
return nil, fmt.Errorf("invalid image tag: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[PullImage] Fetching image descriptor for %s", target)
|
||||
|
||||
// Get image descriptor with timeout
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
var e error
|
||||
des, e = remote.Get(tn, options...)
|
||||
done <- e
|
||||
}()
|
||||
|
||||
select {
|
||||
case err = <-done:
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get image (network timeout or connection error, try using a proxy): %w", err)
|
||||
}
|
||||
case <-time.After(60 * time.Second):
|
||||
return nil, fmt.Errorf("timeout fetching image descriptor (60s), the registry may be unreachable from your location, try using a proxy")
|
||||
}
|
||||
|
||||
// Get image
|
||||
if img, err = des.Image(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get image from descriptor: %w", err)
|
||||
}
|
||||
|
||||
// Get manifest
|
||||
if manifest, err = img.Manifest(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get manifest: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[PullImage] Got manifest with %d layers", len(manifest.Layers))
|
||||
|
||||
// Create repository
|
||||
if err := db.FirstOrCreate(&model.Repository{}, model.Repository{Name: repo}).Error; err != nil {
|
||||
return nil, fmt.Errorf("failed to create repository: %w", err)
|
||||
}
|
||||
|
||||
if err := store.CreatePartition(ctx, "registry"); err != nil {
|
||||
return nil, fmt.Errorf("failed to create partition: %w", err)
|
||||
}
|
||||
|
||||
// Pull config blob
|
||||
log.Printf("[PullImage] Pulling config blob: %s", manifest.Config.Digest)
|
||||
var (
|
||||
configLayer v1.Layer
|
||||
configDigest v1.Hash
|
||||
configReader io.ReadCloser
|
||||
)
|
||||
|
||||
if configLayer, err = img.LayerByDigest(manifest.Config.Digest); err != nil {
|
||||
return nil, fmt.Errorf("failed to get config layer: %w", err)
|
||||
}
|
||||
|
||||
if configDigest, err = configLayer.Digest(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get config digest: %w", err)
|
||||
}
|
||||
|
||||
if configReader, err = configLayer.Uncompressed(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get config reader: %w", err)
|
||||
}
|
||||
defer configReader.Close()
|
||||
|
||||
digest := fmt.Sprintf("%s:%s", configDigest.Algorithm, configDigest.Hex)
|
||||
if err = store.WriteBlob(ctx, digest, configReader); err != nil {
|
||||
return nil, fmt.Errorf("failed to write config blob: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Blob{
|
||||
Digest: digest,
|
||||
Size: manifest.Config.Size,
|
||||
MediaType: "application/vnd.docker.container.image.v1+json",
|
||||
Repository: repo,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return nil, fmt.Errorf("failed to save config blob metadata: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[PullImage] Config blob saved: %s", digest)
|
||||
|
||||
// Pull layer blobs
|
||||
for idx, layerDesc := range manifest.Layers {
|
||||
log.Printf("[PullImage] Pulling layer %d/%d: %s", idx+1, len(manifest.Layers), layerDesc.Digest)
|
||||
|
||||
var (
|
||||
layer v1.Layer
|
||||
layerReader io.ReadCloser
|
||||
)
|
||||
|
||||
if layer, err = img.LayerByDigest(layerDesc.Digest); err != nil {
|
||||
return nil, fmt.Errorf("failed to get layer %d: %w", idx, err)
|
||||
}
|
||||
|
||||
if layerReader, err = layer.Compressed(); err != nil {
|
||||
return nil, fmt.Errorf("failed to get layer reader %d: %w", idx, err)
|
||||
}
|
||||
defer layerReader.Close()
|
||||
|
||||
layerDigest := fmt.Sprintf("%s:%s", layerDesc.Digest.Algorithm, layerDesc.Digest.Hex)
|
||||
if err = store.WriteBlob(ctx, layerDigest, layerReader); err != nil {
|
||||
return nil, fmt.Errorf("failed to write layer blob %d: %w", idx, err)
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Blob{
|
||||
Digest: layerDigest,
|
||||
Size: layerDesc.Size,
|
||||
MediaType: string(layerDesc.MediaType),
|
||||
Repository: repo,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return nil, fmt.Errorf("failed to save layer blob metadata %d: %w", idx, err)
|
||||
}
|
||||
|
||||
log.Printf("[PullImage] Layer %d saved: %s (%d bytes)", idx+1, layerDigest, layerDesc.Size)
|
||||
}
|
||||
|
||||
// Convert manifest to Docker v2 format and save
|
||||
manifestData := map[string]interface{}{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": map[string]interface{}{
|
||||
"mediaType": "application/vnd.docker.container.image.v1+json",
|
||||
"size": manifest.Config.Size,
|
||||
"digest": manifest.Config.Digest.String(),
|
||||
},
|
||||
"layers": []map[string]interface{}{},
|
||||
}
|
||||
|
||||
layers := []map[string]interface{}{}
|
||||
for _, layer := range manifest.Layers {
|
||||
layers = append(layers, map[string]interface{}{
|
||||
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
"size": layer.Size,
|
||||
"digest": layer.Digest.String(),
|
||||
})
|
||||
}
|
||||
manifestData["layers"] = layers
|
||||
|
||||
manifestJSON, err := json.Marshal(manifestData)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to marshal manifest: %w", err)
|
||||
}
|
||||
|
||||
manifestHash := sha256.Sum256(manifestJSON)
|
||||
manifestDigest := "sha256:" + hex.EncodeToString(manifestHash[:])
|
||||
|
||||
if err := store.WriteManifest(ctx, manifestDigest, manifestJSON); err != nil {
|
||||
return nil, fmt.Errorf("failed to write manifest: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Manifest{
|
||||
Repository: repo,
|
||||
Tag: tag,
|
||||
Digest: manifestDigest,
|
||||
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||
Size: int64(len(manifestJSON)),
|
||||
Content: manifestJSON,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return nil, fmt.Errorf("failed to save manifest: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Tag{
|
||||
Repository: repo,
|
||||
Tag: tag,
|
||||
Digest: manifestDigest,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return nil, fmt.Errorf("failed to save tag: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("[PullImage] Manifest saved: %s", manifestDigest)
|
||||
|
||||
return manifest, nil
|
||||
}
|
||||
Reference in New Issue
Block a user