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:
loveuer
2025-11-10 22:23:23 +08:00
parent 9780a2b028
commit 01cfb2ede1
7 changed files with 537 additions and 5 deletions

1
.gitignore vendored
View File

@@ -33,6 +33,7 @@ cluster
# IDE
.idea/
.vscode/
.qoder/
*.swp
*.swo
*~

View File

@@ -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,
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}>

View File

@@ -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
View File

@@ -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
View File

@@ -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=

View File

@@ -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))

View 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
}