feat: complete OCI registry implementation with docker push/pull support
A lightweight OCI (Open Container Initiative) registry implementation written in Go.
This commit is contained in:
51
.gitignore
vendored
Normal file
51
.gitignore
vendored
Normal file
@@ -0,0 +1,51 @@
|
||||
# Binaries
|
||||
*.exe
|
||||
*.exe~
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
bin/
|
||||
dist/
|
||||
|
||||
# Test binary
|
||||
*.test
|
||||
|
||||
# Output of the go coverage tool
|
||||
*.out
|
||||
|
||||
# Dependency directories
|
||||
vendor/
|
||||
|
||||
# Go workspace file
|
||||
go.work
|
||||
|
||||
# Storage directory
|
||||
x-storage/
|
||||
*.db
|
||||
main
|
||||
|
||||
# Build artifacts and binaries
|
||||
cluster
|
||||
*.bin
|
||||
/tmp/cluster
|
||||
/tmp/cluster-*
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Frontend
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
frontend/.vite/
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
x-*
|
||||
93
README.md
Normal file
93
README.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Cluster - OCI Registry
|
||||
|
||||
A lightweight OCI (Open Container Initiative) registry implementation written in Go using Fiber v3.
|
||||
|
||||
## Features
|
||||
|
||||
- OCI Registry API v2 compliant
|
||||
- Blob upload/download with chunked upload support
|
||||
- Manifest handling (Docker and OCI formats)
|
||||
- Tag management and catalog listing
|
||||
- SQLite database for metadata storage
|
||||
- File system storage for blobs and manifests
|
||||
- RESTful API v1 for image management
|
||||
|
||||
## Architecture
|
||||
|
||||
- **main.go**: Application entry point
|
||||
- **api/**: API route definitions
|
||||
- **handler/**: HTTP request handlers
|
||||
- **controller/**: Business logic controllers
|
||||
- **internal/**: Internal packages (config, database, middleware, model, rerr, storage)
|
||||
|
||||
## Development
|
||||
|
||||
### Backend (Go)
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go mod tidy
|
||||
go build -o cluster .
|
||||
|
||||
# Run
|
||||
./cluster -debug -address 0.0.0.0:8080 -data-dir ./x-storage
|
||||
|
||||
# Or use Makefile
|
||||
make build
|
||||
make run
|
||||
```
|
||||
|
||||
### Frontend (React/TypeScript)
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
|
||||
# Install dependencies (requires pnpm)
|
||||
pnpm install
|
||||
|
||||
# Approve build scripts (required for esbuild)
|
||||
pnpm approve-builds
|
||||
|
||||
# Development server
|
||||
pnpm dev
|
||||
|
||||
# Build for production
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
**Note**: This project uses `pnpm` as the package manager. Install it if needed:
|
||||
```bash
|
||||
npm install -g pnpm
|
||||
# or
|
||||
curl -fsSL https://get.pnpm.io/install.sh | sh -
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### OCI Registry API v2
|
||||
|
||||
- `GET /v2/` - Version check
|
||||
- `POST /v2/{repo}/blobs/uploads/` - Start blob upload
|
||||
- `PATCH /v2/{repo}/blobs/uploads/{uuid}` - Upload blob chunk
|
||||
- `PUT /v2/{repo}/blobs/uploads/{uuid}?digest={digest}` - Complete blob upload
|
||||
- `GET /v2/{repo}/blobs/{digest}` - Get blob
|
||||
- `HEAD /v2/{repo}/blobs/{digest}` - Check blob existence
|
||||
- `PUT /v2/{repo}/manifests/{tag}` - Put manifest
|
||||
- `GET /v2/{repo}/manifests/{tag}` - Get manifest
|
||||
- `DELETE /v2/{repo}/manifests/{tag}` - Delete manifest
|
||||
- `GET /v2/{repo}/tags/list` - List tags
|
||||
- `GET /v2/_catalog` - List repositories
|
||||
|
||||
### API v1
|
||||
|
||||
- `GET /api/v1/registry/image/list` - List images
|
||||
|
||||
## Configuration
|
||||
|
||||
- `-debug`: Enable debug logging
|
||||
- `-address`: Server address (default: 0.0.0.0:8080)
|
||||
- `-data-dir`: Data directory for storage (default: ./x-storage)
|
||||
|
||||
## License
|
||||
|
||||
[Add your license here]
|
||||
78
dev.sh
Executable file
78
dev.sh
Executable file
@@ -0,0 +1,78 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run backend (Go) and frontend (Vite) together; stop both on Ctrl+C
|
||||
set -euo pipefail
|
||||
|
||||
# Always run from repo root
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
BACKEND_ADDR=${BACKEND_ADDR:-0.0.0.0:9119}
|
||||
DATA_DIR=${DATA_DIR:-./x-storage}
|
||||
|
||||
# Store PIDs for cleanup
|
||||
BACKEND_PID=""
|
||||
FRONTEND_PID=""
|
||||
|
||||
cleanup() {
|
||||
echo ""
|
||||
echo "[dev] Shutting down..."
|
||||
|
||||
# Kill backend if running
|
||||
if [ -n "$BACKEND_PID" ] && kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
echo "[dev] Stopping backend (PID: $BACKEND_PID)..."
|
||||
kill "$BACKEND_PID" 2>/dev/null || true
|
||||
wait "$BACKEND_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Kill frontend if running
|
||||
if [ -n "$FRONTEND_PID" ] && kill -0 "$FRONTEND_PID" 2>/dev/null; then
|
||||
echo "[dev] Stopping frontend (PID: $FRONTEND_PID)..."
|
||||
kill "$FRONTEND_PID" 2>/dev/null || true
|
||||
wait "$FRONTEND_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Kill any remaining background jobs
|
||||
if command -v xargs >/dev/null 2>&1; then
|
||||
jobs -p | xargs kill 2>/dev/null || true
|
||||
else
|
||||
# Fallback for systems without xargs -r
|
||||
for job in $(jobs -p); do
|
||||
kill "$job" 2>/dev/null || true
|
||||
done
|
||||
fi
|
||||
|
||||
# Wait a bit for graceful shutdown
|
||||
sleep 1
|
||||
|
||||
# Force kill if still running
|
||||
if [ -n "$BACKEND_PID" ] && kill -0 "$BACKEND_PID" 2>/dev/null; then
|
||||
kill -9 "$BACKEND_PID" 2>/dev/null || true
|
||||
fi
|
||||
if [ -n "$FRONTEND_PID" ] && kill -0 "$FRONTEND_PID" 2>/dev/null; then
|
||||
kill -9 "$FRONTEND_PID" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
echo "[dev] Shutdown complete"
|
||||
exit 0
|
||||
}
|
||||
|
||||
trap cleanup INT TERM EXIT
|
||||
|
||||
# Start backend
|
||||
echo "[dev] Starting backend on $BACKEND_ADDR (data-dir=$DATA_DIR)"
|
||||
go run . --debug --address "$BACKEND_ADDR" --data-dir "$DATA_DIR" &
|
||||
BACKEND_PID=$!
|
||||
|
||||
# Wait a moment for backend to start
|
||||
sleep 2
|
||||
|
||||
# Start frontend
|
||||
echo "[dev] Starting frontend dev server (Vite)"
|
||||
(cd frontend && pnpm run dev) &
|
||||
FRONTEND_PID=$!
|
||||
|
||||
echo "[dev] Backend PID: $BACKEND_PID, Frontend PID: $FRONTEND_PID"
|
||||
echo "[dev] Press Ctrl+C to stop both..."
|
||||
|
||||
# Wait for both processes to exit
|
||||
wait $BACKEND_PID $FRONTEND_PID
|
||||
18
frontend/.eslintrc.cjs
Normal file
18
frontend/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended',
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
}
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
77
frontend/README.md
Normal file
77
frontend/README.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Cluster Frontend
|
||||
|
||||
基于 React + TypeScript + Zustand + MUI 的前端项目。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **React 18** - UI 框架
|
||||
- **TypeScript** - 类型系统
|
||||
- **Vite** - 构建工具
|
||||
- **Zustand** - 状态管理
|
||||
- **Material-UI (MUI)** - UI 组件库
|
||||
|
||||
## 开发
|
||||
|
||||
### 安装依赖
|
||||
|
||||
```bash
|
||||
npm install
|
||||
# 或
|
||||
yarn install
|
||||
# 或
|
||||
pnpm install
|
||||
```
|
||||
|
||||
### 启动开发服务器
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# 或
|
||||
yarn dev
|
||||
# 或
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
开发服务器将在 `http://localhost:3000` 启动。
|
||||
|
||||
### 构建生产版本
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
# 或
|
||||
yarn build
|
||||
# 或
|
||||
pnpm build
|
||||
```
|
||||
|
||||
### 预览生产构建
|
||||
|
||||
```bash
|
||||
npm run preview
|
||||
# 或
|
||||
yarn preview
|
||||
# 或
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── stores/ # Zustand 状态管理
|
||||
│ ├── components/ # React 组件
|
||||
│ ├── theme.ts # MUI 主题配置
|
||||
│ ├── App.tsx # 主应用组件
|
||||
│ └── main.tsx # 应用入口
|
||||
├── public/ # 静态资源
|
||||
├── index.html # HTML 模板
|
||||
├── vite.config.ts # Vite 配置
|
||||
└── tsconfig.json # TypeScript 配置
|
||||
```
|
||||
|
||||
## API 代理
|
||||
|
||||
开发环境已配置 API 代理,所有 `/api/*` 请求会被代理到 `http://localhost:8080`(Go 后端服务)。
|
||||
|
||||
配置位置:`vite.config.ts`
|
||||
15
frontend/index.html
Normal file
15
frontend/index.html
Normal file
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- 霞鹜文楷字体 -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-webfont@1.1.0/style.css" />
|
||||
<title>Cluster</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
34
frontend/package.json
Normal file
34
frontend/package.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "cluster-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "^11.13.0",
|
||||
"@emotion/styled": "^11.13.0",
|
||||
"@mui/icons-material": "^5.16.7",
|
||||
"@mui/material": "^5.16.7",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.13.1",
|
||||
"@typescript-eslint/parser": "^7.13.1",
|
||||
"@vitejs/plugin-react": "^4.3.1",
|
||||
"eslint": "^8.57.0",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"eslint-plugin-react-refresh": "^0.4.7",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.21"
|
||||
}
|
||||
}
|
||||
2691
frontend/pnpm-lock.yaml
generated
Normal file
2691
frontend/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
55
frontend/src/App.tsx
Normal file
55
frontend/src/App.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Container, Typography, Box, AppBar, Toolbar, Button, Stack } from '@mui/material'
|
||||
import { Routes, Route, Link } from 'react-router-dom'
|
||||
import { useAppStore } from './stores/appStore'
|
||||
import RegistryImageList from './pages/RegistryImageList'
|
||||
|
||||
function App() {
|
||||
const { count, increment, decrement, reset } = useAppStore()
|
||||
|
||||
return (
|
||||
<Box sx={{ flexGrow: 1, minHeight: '100vh' }}>
|
||||
<AppBar position="static">
|
||||
<Toolbar>
|
||||
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
|
||||
Cluster
|
||||
</Typography>
|
||||
<Button color="inherit" component={Link} to="/">首页</Button>
|
||||
<Button color="inherit" component={Link} to="/registry/image">镜像列表</Button>
|
||||
</Toolbar>
|
||||
</AppBar>
|
||||
<Container maxWidth="lg" sx={{ mt: 4, mb: 4 }}>
|
||||
<Routes>
|
||||
<Route path="/registry/image" element={<RegistryImageList />} />
|
||||
<Route path="/" element={
|
||||
<Box>
|
||||
<Typography variant="h4" component="h1" gutterBottom>
|
||||
欢迎使用 Cluster
|
||||
</Typography>
|
||||
<Box sx={{ mt: 4 }}>
|
||||
<Typography variant="h6" gutterBottom>
|
||||
Zustand 状态管理示例
|
||||
</Typography>
|
||||
<Stack direction="row" spacing={2} alignItems="center" justifyContent="center" sx={{ mt: 2 }}>
|
||||
<Button variant="contained" onClick={decrement}>
|
||||
-
|
||||
</Button>
|
||||
<Typography variant="h5" sx={{ minWidth: 60 }}>
|
||||
{count}
|
||||
</Typography>
|
||||
<Button variant="contained" onClick={increment}>
|
||||
+
|
||||
</Button>
|
||||
<Button variant="outlined" onClick={reset} sx={{ ml: 2 }}>
|
||||
重置
|
||||
</Button>
|
||||
</Stack>
|
||||
</Box>
|
||||
</Box>
|
||||
} />
|
||||
</Routes>
|
||||
</Container>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
0
frontend/src/components/.gitkeep
Normal file
0
frontend/src/components/.gitkeep
Normal file
15
frontend/src/index.css
Normal file
15
frontend/src/index.css
Normal file
@@ -0,0 +1,15 @@
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'LXGW WenKai', '霞鹜文楷', Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
}
|
||||
18
frontend/src/main.tsx
Normal file
18
frontend/src/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import { CssBaseline, ThemeProvider } from '@mui/material'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.tsx'
|
||||
import { theme } from './theme'
|
||||
import './index.css'
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
)
|
||||
85
frontend/src/pages/RegistryImageList.tsx
Normal file
85
frontend/src/pages/RegistryImageList.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Box, Typography, Paper, Table, TableBody, TableCell, TableContainer, TableHead, TableRow, CircularProgress, Alert } from '@mui/material'
|
||||
|
||||
interface RegistryImage {
|
||||
id: number
|
||||
name: string
|
||||
upload_time: string
|
||||
size: number
|
||||
}
|
||||
|
||||
// Format bytes to human readable format
|
||||
function formatSize(bytes: number): string {
|
||||
if (bytes === 0) return '0 B'
|
||||
const k = 1024
|
||||
const sizes = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`
|
||||
}
|
||||
|
||||
export default function RegistryImageList() {
|
||||
const [images, setImages] = useState<RegistryImage[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let abort = false
|
||||
async function fetchImages() {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const res = await fetch('/api/v1/registry/image/list')
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
||||
const result = await res.json()
|
||||
// Backend returns: {status, msg, data: {images: [...]}}
|
||||
const list: RegistryImage[] = result.data?.images || []
|
||||
if (!abort) setImages(list)
|
||||
} catch (e: any) {
|
||||
if (!abort) setError(e.message)
|
||||
} finally {
|
||||
if (!abort) setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchImages()
|
||||
return () => { abort = true }
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Typography variant="h5" gutterBottom>镜像列表</Typography>
|
||||
{loading && <CircularProgress />}
|
||||
{error && <Alert severity="error">加载失败: {error}</Alert>}
|
||||
{!loading && !error && (
|
||||
<Paper>
|
||||
<TableContainer>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>ID</TableCell>
|
||||
<TableCell>名称</TableCell>
|
||||
<TableCell>上传时间</TableCell>
|
||||
<TableCell>大小</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{images.map(img => (
|
||||
<TableRow key={img.id} hover>
|
||||
<TableCell>{img.id}</TableCell>
|
||||
<TableCell>{img.name}</TableCell>
|
||||
<TableCell>{img.upload_time}</TableCell>
|
||||
<TableCell>{formatSize(img.size)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{images.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} align="center">暂无镜像</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TableContainer>
|
||||
</Paper>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
15
frontend/src/stores/appStore.ts
Normal file
15
frontend/src/stores/appStore.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { create } from 'zustand'
|
||||
|
||||
interface AppState {
|
||||
count: number
|
||||
increment: () => void
|
||||
decrement: () => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set) => ({
|
||||
count: 0,
|
||||
increment: () => set((state) => ({ count: state.count + 1 })),
|
||||
decrement: () => set((state) => ({ count: state.count - 1 })),
|
||||
reset: () => set({ count: 0 }),
|
||||
}))
|
||||
27
frontend/src/theme.ts
Normal file
27
frontend/src/theme.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { createTheme } from '@mui/material/styles'
|
||||
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: {
|
||||
main: '#1976d2',
|
||||
},
|
||||
secondary: {
|
||||
main: '#dc004e',
|
||||
},
|
||||
},
|
||||
typography: {
|
||||
fontFamily: [
|
||||
'"LXGW WenKai"',
|
||||
'"霞鹜文楷"',
|
||||
'Inter',
|
||||
'-apple-system',
|
||||
'BlinkMacSystemFont',
|
||||
'"Segoe UI"',
|
||||
'Roboto',
|
||||
'"Helvetica Neue"',
|
||||
'Arial',
|
||||
'sans-serif',
|
||||
].join(','),
|
||||
},
|
||||
})
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
25
frontend/tsconfig.json
Normal file
25
frontend/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
frontend/tsconfig.node.json
Normal file
11
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
18
frontend/vite.config.ts
Normal file
18
frontend/vite.config.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '0.0.0.0',
|
||||
port: 3000,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:9119',
|
||||
changeOrigin: true,
|
||||
// Removed rewrite so /api prefix is preserved for backend route /api/v1/...
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
35
go.mod
Normal file
35
go.mod
Normal file
@@ -0,0 +1,35 @@
|
||||
module gitea.loveuer.com/loveuer/cluster
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/glebarez/sqlite v1.11.0
|
||||
github.com/gofiber/fiber/v3 v3.0.0-beta.2
|
||||
github.com/spf13/cobra v1.10.1
|
||||
gorm.io/gorm v1.31.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/andybalholm/brotli v1.2.0 // 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
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/compress v1.18.0 // indirect
|
||||
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // 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
|
||||
golang.org/x/text v0.30.0 // indirect
|
||||
modernc.org/libc v1.22.5 // indirect
|
||||
modernc.org/mathutil v1.5.0 // indirect
|
||||
modernc.org/memory v1.5.0 // indirect
|
||||
modernc.org/sqlite v1.23.1 // indirect
|
||||
)
|
||||
73
go.sum
Normal file
73
go.sum
Normal file
@@ -0,0 +1,73 @@
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
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/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=
|
||||
github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
|
||||
github.com/glebarez/go-sqlite v1.21.2 h1:3a6LFC4sKahUunAmynQKLZceZCOzUthkRkEAl9gAXWo=
|
||||
github.com/glebarez/go-sqlite v1.21.2/go.mod h1:sfxdZyhQjTM2Wry3gVYWaW072Ri1WMdWJi0k6+3382k=
|
||||
github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw=
|
||||
github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-beta.2 h1:mVVgt8PTaHGup3NGl/+7U7nEoZaXJ5OComV4E+HpAao=
|
||||
github.com/gofiber/fiber/v3 v3.0.0-beta.2/go.mod h1:w7sdfTY0okjZ1oVH6rSOGvuACUIt0By1iK0HKUb3uqM=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.1 h1:b77K5Rk9+Pjdxz4HlwEBnS7u5nikhx7armQB8xPds4s=
|
||||
github.com/gofiber/utils/v2 v2.0.0-rc.1/go.mod h1:Y1g08g7gvST49bbjHJ1AVqcsmg93912R/tbKWhn6V3E=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ=
|
||||
github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
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=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
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/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/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/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/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/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.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=
|
||||
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=
|
||||
modernc.org/mathutil v1.5.0/go.mod h1:mZW8CKdRPY1v87qxC/wUdX5O1qDzXMP5TH3wjfpga6E=
|
||||
modernc.org/memory v1.5.0 h1:N+/8c5rE6EqugZwHii4IFsaJ7MUhoWX07J5tC/iI5Ds=
|
||||
modernc.org/memory v1.5.0/go.mod h1:PkUhL0Mugw21sHPeskwZW4D6VscE/GQJOnIpCnW6pSU=
|
||||
modernc.org/sqlite v1.23.1 h1:nrSBg4aRQQwq59JpvGEQ15tNxoO5pX/kUjcRNwSAGQM=
|
||||
modernc.org/sqlite v1.23.1/go.mod h1:OrDj17Mggn6MhE+iPbBNf7RGKODDE9NFT0f3EwDzJqk=
|
||||
61
internal/api/api.go
Normal file
61
internal/api/api.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/middleware"
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/module/registry"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) error {
|
||||
var (
|
||||
err error
|
||||
ln net.Listener
|
||||
cfg = fiber.Config{
|
||||
BodyLimit: 1024 * 1024 * 1024 * 10, // 10GB limit for large image layers
|
||||
}
|
||||
)
|
||||
|
||||
app := fiber.New(cfg)
|
||||
|
||||
app.Use(middleware.Logger())
|
||||
app.Use(middleware.Recovery())
|
||||
app.Use(middleware.CORS())
|
||||
|
||||
// oci image apis
|
||||
{
|
||||
app.All("/v2/*", registry.Registry(ctx, db, store))
|
||||
}
|
||||
|
||||
// registry image apis
|
||||
{
|
||||
registryAPI := app.Group("/api/v1/registry")
|
||||
registryAPI.Get("/image/list", registry.RegistryImageList(ctx, db, store))
|
||||
}
|
||||
|
||||
ln, err = net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on %s: %w", address, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := app.Listener(ln); err != nil {
|
||||
log.Fatalf("Fiber server failed on %s: %v", address, err)
|
||||
}
|
||||
}()
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
if err := app.Shutdown(); err != nil {
|
||||
log.Fatalf("Failed to shutdown: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
}
|
||||
49
internal/cmd/cmd.go
Normal file
49
internal/cmd/cmd.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/api"
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/opt"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/database/db"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func Run(ctx context.Context) error {
|
||||
_cmd := &cobra.Command{
|
||||
Use: "cluster",
|
||||
Short: "Cluster is a lightweight OCI registry implementation written in Go using Fiber v3.",
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
var (
|
||||
err error
|
||||
)
|
||||
|
||||
if err = opt.Init(cmd.Context()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = db.Init(cmd.Context(), opt.GlobalDataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = store.Init(cmd.Context(), opt.GlobalDataDir); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = api.Init(cmd.Context(), opt.GlobalAddress, db.Default, store.Default); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
<-cmd.Context().Done()
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
_cmd.PersistentFlags().BoolVar(&opt.GlobalDebug, "debug", false, "Enable debug mode")
|
||||
_cmd.PersistentFlags().StringVarP(&opt.GlobalAddress, "address", "A", "0.0.0.0:9119", "API server listen address")
|
||||
_cmd.PersistentFlags().StringVarP(&opt.GlobalDataDir, "data-dir", "D", "./x-storage", "Data directory for storing all data")
|
||||
|
||||
return _cmd.Execute()
|
||||
}
|
||||
21
internal/middleware/cors.go
Normal file
21
internal/middleware/cors.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件
|
||||
func CORS() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
c.Set("Access-Control-Allow-Origin", "*")
|
||||
c.Set("Access-Control-Allow-Credentials", "true")
|
||||
c.Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
|
||||
c.Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH, HEAD")
|
||||
|
||||
if c.Method() == "OPTIONS" {
|
||||
return c.SendStatus(fiber.StatusNoContent)
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
31
internal/middleware/logger.go
Normal file
31
internal/middleware/logger.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// Logger 日志中间件
|
||||
func Logger() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
start := time.Now()
|
||||
err := c.Next()
|
||||
latency := time.Since(start)
|
||||
|
||||
fmt.Printf("%s - [%s] \"%s %s %s %d %s \"%s\" %s\"\n",
|
||||
c.IP(),
|
||||
time.Now().Format(time.RFC1123),
|
||||
c.Method(),
|
||||
c.Path(),
|
||||
c.Protocol(),
|
||||
c.Response().StatusCode(),
|
||||
latency,
|
||||
c.Get("User-Agent"),
|
||||
"",
|
||||
)
|
||||
|
||||
return err
|
||||
}
|
||||
}
|
||||
24
internal/middleware/recovery.go
Normal file
24
internal/middleware/recovery.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// Recovery 恢复中间件
|
||||
func Recovery() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"errors": []fiber.Map{
|
||||
{
|
||||
"code": "INTERNAL_ERROR",
|
||||
"message": "Internal server error",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}()
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
13
internal/middleware/repo.go
Normal file
13
internal/middleware/repo.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
// RepoMiddleware 仓库名中间件(如果需要的话)
|
||||
func RepoMiddleware() fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
// 可以在这里处理仓库名相关的逻辑
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
70
internal/model/model.go
Normal file
70
internal/model/model.go
Normal file
@@ -0,0 +1,70 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Repository ????
|
||||
type Repository struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Name string `gorm:"uniqueIndex;not null" json:"name"` // ?????? "library/nginx"
|
||||
}
|
||||
|
||||
// Blob blob ??
|
||||
type Blob struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Digest string `gorm:"uniqueIndex;not null" json:"digest"` // SHA256 digest
|
||||
Size int64 `gorm:"not null" json:"size"` // ??????
|
||||
MediaType string `json:"media_type"` // ????
|
||||
Repository string `gorm:"index" json:"repository"` // ???????????????
|
||||
}
|
||||
|
||||
// Manifest manifest ??
|
||||
type Manifest struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Repository string `gorm:"index;not null" json:"repository"` // ????
|
||||
Tag string `gorm:"index;not null" json:"tag"` // tag ??
|
||||
Digest string `gorm:"uniqueIndex;not null" json:"digest"` // manifest digest
|
||||
MediaType string `json:"media_type"` // ????
|
||||
Size int64 `gorm:"not null" json:"size"` // manifest ??
|
||||
Content []byte `gorm:"type:blob" json:"-"` // manifest ???JSON?
|
||||
}
|
||||
|
||||
// Tag tag ??????????
|
||||
type Tag struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Repository string `gorm:"index;not null" json:"repository"` // ????
|
||||
Tag string `gorm:"index;not null" json:"tag"` // tag ??
|
||||
Digest string `gorm:"not null" json:"digest"` // ??? manifest digest
|
||||
}
|
||||
|
||||
// BlobUpload ????? blob ??
|
||||
type BlobUpload struct {
|
||||
ID uint `gorm:"primarykey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
UUID string `gorm:"uniqueIndex;not null" json:"uuid"` // ???? UUID
|
||||
Repository string `gorm:"index;not null" json:"repository"` // ????
|
||||
Path string `gorm:"not null" json:"path"` // ??????
|
||||
Size int64 `gorm:"default:0" json:"size"` // ?????
|
||||
}
|
||||
376
internal/module/registry/blob.go
Normal file
376
internal/module/registry/blob.go
Normal file
@@ -0,0 +1,376 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleBlobs ?? blob ????
|
||||
// POST /v2/{repo}/blobs/uploads/ - ????
|
||||
// PATCH /v2/{repo}/blobs/uploads/{uuid} - ?????
|
||||
// PUT /v2/{repo}/blobs/uploads/{uuid}?digest={digest} - ????
|
||||
// GET /v2/{repo}/blobs/{digest} - ?? blob
|
||||
// HEAD /v2/{repo}/blobs/{digest} - ?? blob ????
|
||||
func HandleBlobs(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
path := c.Path()
|
||||
method := c.Method()
|
||||
|
||||
// ????: /v2/{repo}/blobs/...
|
||||
// ??????????? "test/redis"
|
||||
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
||||
parts := strings.Split(pathWithoutV2, "/")
|
||||
if len(parts) < 2 {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
|
||||
}
|
||||
|
||||
// ?? "blobs" ????????????????
|
||||
blobsIndex := -1
|
||||
for i, part := range parts {
|
||||
if part == "blobs" {
|
||||
blobsIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if blobsIndex < 1 {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path: blobs not found")
|
||||
}
|
||||
|
||||
// ???? blobs ???????
|
||||
repo := strings.Join(parts[:blobsIndex], "/")
|
||||
// ???? parts??????????? parts[0] ? "blobs"
|
||||
parts = parts[blobsIndex:]
|
||||
|
||||
switch method {
|
||||
case "POST":
|
||||
// POST /v2/{repo}/blobs/uploads/ - ????
|
||||
// parts ??? ["blobs", "uploads", ""] ? ["blobs", "uploads"]
|
||||
if len(parts) >= 2 && parts[0] == "blobs" && parts[1] == "uploads" {
|
||||
return handleBlobUploadStart(c, db, store, repo)
|
||||
}
|
||||
|
||||
case "PATCH":
|
||||
// PATCH /v2/{repo}/blobs/uploads/{uuid} - ?????
|
||||
// parts ??? ["blobs", "uploads", "uuid"]
|
||||
if len(parts) >= 3 && parts[0] == "blobs" && parts[1] == "uploads" {
|
||||
uuid := parts[2]
|
||||
return handleBlobUploadChunk(c, db, store, repo, uuid)
|
||||
}
|
||||
|
||||
case "PUT":
|
||||
// PUT /v2/{repo}/blobs/uploads/{uuid}?digest={digest} - ????
|
||||
// parts ??? ["blobs", "uploads", "uuid"]
|
||||
if len(parts) >= 3 && parts[0] == "blobs" && parts[1] == "uploads" {
|
||||
uuid := parts[2]
|
||||
digest := c.Query("digest")
|
||||
if digest == "" {
|
||||
return resp.R400(c, "MISSING_DIGEST", nil, "digest parameter is required")
|
||||
}
|
||||
return handleBlobUploadComplete(c, db, store, repo, uuid, digest)
|
||||
}
|
||||
|
||||
case "GET":
|
||||
// GET /v2/{repo}/blobs/{digest} - ?? blob
|
||||
// parts ??? ["blobs", "digest"]
|
||||
if len(parts) >= 2 && parts[0] == "blobs" {
|
||||
digest := parts[1]
|
||||
return handleBlobDownload(c, db, store, repo, digest)
|
||||
}
|
||||
|
||||
case "HEAD":
|
||||
// HEAD /v2/{repo}/blobs/{digest} - ?? blob ????
|
||||
// parts ??? ["blobs", "digest"]
|
||||
if len(parts) >= 2 && parts[0] == "blobs" {
|
||||
digest := parts[1]
|
||||
return handleBlobHead(c, db, store, repo, digest)
|
||||
}
|
||||
}
|
||||
|
||||
return resp.R404(c, "NOT_FOUND", nil, "endpoint not found")
|
||||
}
|
||||
|
||||
// handleBlobUploadStart ?? blob ??
|
||||
func handleBlobUploadStart(c fiber.Ctx, db *gorm.DB, store store.Store, repo string) error {
|
||||
// ?? UUID
|
||||
uuidBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(uuidBytes); err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
uuid := hex.EncodeToString(uuidBytes)
|
||||
|
||||
// ??????
|
||||
upload := &model.BlobUpload{
|
||||
UUID: uuid,
|
||||
Repository: repo,
|
||||
Path: uuid, // ?? UUID ??????
|
||||
Size: 0,
|
||||
}
|
||||
|
||||
if err := db.Create(upload).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ??????
|
||||
w, err := store.CreateUpload(c.Context(), uuid)
|
||||
if err != nil {
|
||||
db.Delete(upload)
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
w.Close()
|
||||
|
||||
// ???? URL
|
||||
uploadURL := fmt.Sprintf("/v2/%s/blobs/uploads/%s", repo, uuid)
|
||||
c.Set("Location", uploadURL)
|
||||
c.Set("Docker-Upload-UUID", uuid)
|
||||
c.Set("Range", "0-0")
|
||||
return c.SendStatus(202)
|
||||
}
|
||||
|
||||
// handleBlobUploadChunk ?? blob ???
|
||||
func handleBlobUploadChunk(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, uuid string) error {
|
||||
// ??????
|
||||
var upload model.BlobUpload
|
||||
if err := db.Where("uuid = ? AND repository = ?", uuid, repo).First(&upload).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "UPLOAD_NOT_FOUND", nil, "upload session not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ?????
|
||||
body := c.Body()
|
||||
if len(body) == 0 {
|
||||
return resp.R400(c, "EMPTY_BODY", nil, "request body is empty")
|
||||
}
|
||||
|
||||
// ??????? bytes.NewReader ????????
|
||||
n, err := store.AppendUpload(c.Context(), uuid, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ??????
|
||||
upload.Size += n
|
||||
if err := db.Save(&upload).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ???? URL ???
|
||||
uploadURL := fmt.Sprintf("/v2/%s/blobs/uploads/%s", repo, uuid)
|
||||
c.Set("Location", uploadURL)
|
||||
c.Set("Docker-Upload-UUID", uuid)
|
||||
c.Set("Range", fmt.Sprintf("0-%d", upload.Size-1))
|
||||
return c.SendStatus(202)
|
||||
}
|
||||
|
||||
// handleBlobUploadComplete ?? blob ??
|
||||
func handleBlobUploadComplete(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, uuid string, digest string) error {
|
||||
// ??????
|
||||
var upload model.BlobUpload
|
||||
if err := db.Where("uuid = ? AND repository = ?", uuid, repo).First(&upload).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "UPLOAD_NOT_FOUND", nil, "upload session not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ??????????????PUT ???????????
|
||||
body := c.Body()
|
||||
if len(body) > 0 {
|
||||
if _, err := store.AppendUpload(c.Context(), uuid, bytes.NewReader(body)); err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ?????????????
|
||||
if err := store.FinalizeUpload(c.Context(), uuid, digest); err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ????????
|
||||
size, err := store.GetBlobSize(c.Context(), digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ????? blob ??
|
||||
var blob model.Blob
|
||||
if err := db.Where("digest = ?", digest).First(&blob).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
blob = model.Blob{
|
||||
Digest: digest,
|
||||
Size: size,
|
||||
Repository: repo,
|
||||
}
|
||||
if err := db.Create(&blob).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ??????
|
||||
db.Delete(&upload)
|
||||
store.DeleteUpload(c.Context(), uuid)
|
||||
|
||||
// ?? blob URL
|
||||
blobURL := fmt.Sprintf("/v2/%s/blobs/%s", repo, digest)
|
||||
c.Set("Location", blobURL)
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
c.Set("Docker-Content-Digest", digest)
|
||||
return c.SendStatus(201)
|
||||
}
|
||||
|
||||
// parseRangeHeader parses Range header and returns start and end positions
|
||||
func parseRangeHeader(rangeHeader string, size int64) (start, end int64, valid bool) {
|
||||
if rangeHeader == "" {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
|
||||
// Range header format: "bytes=start-end" or "bytes=start-"
|
||||
if !strings.HasPrefix(rangeHeader, "bytes=") {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
|
||||
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=")
|
||||
parts := strings.Split(rangeSpec, "-")
|
||||
if len(parts) != 2 {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
|
||||
var err error
|
||||
if parts[0] == "" {
|
||||
// Suffix range: "bytes=-suffix"
|
||||
suffix, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || suffix <= 0 {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
start = size - suffix
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end = size - 1
|
||||
} else if parts[1] == "" {
|
||||
// Start range: "bytes=start-"
|
||||
start, err = strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || start < 0 || start >= size {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
end = size - 1
|
||||
} else {
|
||||
// Full range: "bytes=start-end"
|
||||
start, err = strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || start < 0 || start >= size {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
end, err = strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || end < start || end >= size {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
}
|
||||
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// handleBlobDownload ?? blob
|
||||
func handleBlobDownload(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, digest string) error {
|
||||
// Check if blob exists
|
||||
exists, err := store.BlobExists(c.Context(), digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
if !exists {
|
||||
return resp.R404(c, "BLOB_NOT_FOUND", nil, "blob not found")
|
||||
}
|
||||
|
||||
// Get blob size
|
||||
size, err := store.GetBlobSize(c.Context(), digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// Read blob
|
||||
reader, err := store.ReadBlob(c.Context(), digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Check for Range request
|
||||
rangeHeader := c.Get("Range")
|
||||
start, end, hasRange := parseRangeHeader(rangeHeader, size)
|
||||
|
||||
if hasRange {
|
||||
// Handle Range request
|
||||
// Seek to start position
|
||||
if seeker, ok := reader.(io.Seeker); ok {
|
||||
if _, err := seeker.Seek(start, io.SeekStart); err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
// If not seekable, read and discard bytes
|
||||
if _, err := io.CopyN(io.Discard, reader, start); err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create limited reader
|
||||
limitedReader := io.LimitReader(reader, end-start+1)
|
||||
|
||||
// Set partial content headers
|
||||
c.Set("Content-Type", "application/octet-stream")
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
||||
c.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
|
||||
c.Set("Accept-Ranges", "bytes")
|
||||
c.Set("Docker-Content-Digest", digest)
|
||||
c.Status(206) // Partial Content
|
||||
|
||||
// Send partial content
|
||||
return c.SendStream(limitedReader)
|
||||
}
|
||||
|
||||
// Full blob download
|
||||
c.Set("Content-Type", "application/octet-stream")
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
c.Set("Accept-Ranges", "bytes")
|
||||
c.Set("Docker-Content-Digest", digest)
|
||||
|
||||
// Send full blob stream
|
||||
return c.SendStream(reader)
|
||||
}
|
||||
|
||||
// handleBlobHead ?? blob ????
|
||||
func handleBlobHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, digest string) error {
|
||||
// Check if blob exists
|
||||
exists, err := store.BlobExists(c.Context(), digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
if !exists {
|
||||
return resp.R404(c, "BLOB_NOT_FOUND", nil, "blob not found")
|
||||
}
|
||||
|
||||
// Get blob size
|
||||
size, err := store.GetBlobSize(c.Context(), digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// Set response headers
|
||||
c.Set("Content-Type", "application/octet-stream")
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
c.Set("Accept-Ranges", "bytes")
|
||||
c.Set("Docker-Content-Digest", digest)
|
||||
return c.SendStatus(200)
|
||||
}
|
||||
66
internal/module/registry/catalog.go
Normal file
66
internal/module/registry/catalog.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleCatalog ????????
|
||||
// GET /v2/_catalog?n={limit}&last={last}
|
||||
func HandleCatalog(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
path := c.Path()
|
||||
|
||||
// ????: /v2/_catalog
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
|
||||
if len(parts) < 1 || parts[0] != "_catalog" {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
|
||||
}
|
||||
|
||||
// ??????
|
||||
nStr := c.Query("n", "100")
|
||||
n, err := strconv.Atoi(nStr)
|
||||
if err != nil || n <= 0 {
|
||||
n = 100
|
||||
}
|
||||
last := c.Query("last")
|
||||
|
||||
// ????
|
||||
var repos []model.Repository
|
||||
query := db.Order("name ASC").Limit(n + 1)
|
||||
|
||||
if last != "" {
|
||||
query = query.Where("name > ?", last)
|
||||
}
|
||||
|
||||
if err := query.Find(&repos).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ????
|
||||
repoNames := make([]string, 0, len(repos))
|
||||
hasMore := false
|
||||
for i, repo := range repos {
|
||||
if i >= n {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
repoNames = append(repoNames, repo.Name)
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"repositories": repoNames,
|
||||
}
|
||||
|
||||
// ??????????????
|
||||
if hasMore && len(repoNames) > 0 {
|
||||
response["last"] = repoNames[len(repoNames)-1]
|
||||
}
|
||||
|
||||
return resp.R200(c, response)
|
||||
}
|
||||
55
internal/module/registry/handler.list.go
Normal file
55
internal/module/registry/handler.list.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegistryImageList returns the list of images/repositories
|
||||
func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
var repositories []model.Repository
|
||||
|
||||
// Query all repositories from the database
|
||||
if err := db.Find(&repositories).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// Convert to the expected format for the frontend
|
||||
var result []map[string]interface{}
|
||||
for _, repo := range repositories {
|
||||
// Calculate total size of all blobs for this repository
|
||||
var totalSize int64
|
||||
var sizeResult struct {
|
||||
Total int64
|
||||
}
|
||||
err := db.Model(&model.Blob{}).
|
||||
Where("repository = ?", repo.Name).
|
||||
Select("COALESCE(SUM(size), 0) as total").
|
||||
Scan(&sizeResult).Error
|
||||
if err == nil {
|
||||
totalSize = sizeResult.Total
|
||||
}
|
||||
|
||||
// Format updated_at to second precision
|
||||
uploadTime := repo.UpdatedAt.Format("2006-01-02 15:04:05")
|
||||
|
||||
repoMap := map[string]interface{}{
|
||||
"id": repo.ID,
|
||||
"name": repo.Name,
|
||||
"upload_time": uploadTime,
|
||||
"size": totalSize,
|
||||
}
|
||||
result = append(result, repoMap)
|
||||
}
|
||||
|
||||
return resp.R200(c, map[string]interface{}{
|
||||
"images": result,
|
||||
})
|
||||
}
|
||||
}
|
||||
419
internal/module/registry/manifest.go
Normal file
419
internal/module/registry/manifest.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// isDigestFormat checks if a string is in digest format (e.g., sha256:abc123...)
|
||||
func isDigestFormat(s string) bool {
|
||||
parts := strings.SplitN(s, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
algo := parts[0]
|
||||
hash := parts[1]
|
||||
|
||||
// Check algorithm
|
||||
if algo != "sha256" {
|
||||
// Could be extended to support other algorithms like sha512
|
||||
return false
|
||||
}
|
||||
|
||||
// Check that hash is a valid hex string of expected length (64 for sha256)
|
||||
if len(hash) != 64 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify it's all hex characters
|
||||
for _, r := range hash {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleManifest ?? manifest ????
|
||||
// PUT /v2/{repo}/manifests/{tag} - ?? manifest
|
||||
// GET /v2/{repo}/manifests/{tag} - ?? manifest
|
||||
// DELETE /v2/{repo}/manifests/{tag} - ?? manifest
|
||||
func HandleManifest(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
path := c.Path()
|
||||
method := c.Method()
|
||||
|
||||
// ????: /v2/{repo}/manifests/{tag}
|
||||
// ??????????? "test/redis"
|
||||
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
||||
parts := strings.Split(pathWithoutV2, "/")
|
||||
if len(parts) < 2 {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
|
||||
}
|
||||
|
||||
// ?? "manifests" ???
|
||||
manifestsIndex := -1
|
||||
for i, part := range parts {
|
||||
if part == "manifests" {
|
||||
manifestsIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if manifestsIndex < 1 || manifestsIndex >= len(parts)-1 {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path: manifests not found")
|
||||
}
|
||||
|
||||
// ???? manifests ???????
|
||||
repo := strings.Join(parts[:manifestsIndex], "/")
|
||||
// tag ? manifests ?????
|
||||
tag := parts[manifestsIndex+1]
|
||||
|
||||
switch method {
|
||||
case "PUT":
|
||||
return handleManifestPut(c, db, store, repo, tag)
|
||||
case "GET":
|
||||
return handleManifestGet(c, db, store, repo, tag)
|
||||
case "HEAD":
|
||||
return handleManifestHead(c, db, store, repo, tag)
|
||||
case "DELETE":
|
||||
return handleManifestDelete(c, db, store, repo, tag)
|
||||
}
|
||||
|
||||
return resp.R404(c, "NOT_FOUND", nil, "method not allowed")
|
||||
}
|
||||
|
||||
// handleManifestPut ?? manifest
|
||||
func handleManifestPut(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
||||
// ?? manifest ??
|
||||
content := c.Body()
|
||||
if len(content) == 0 {
|
||||
return resp.R400(c, "EMPTY_BODY", nil, "manifest content is empty")
|
||||
}
|
||||
|
||||
// ?? digest
|
||||
hasher := sha256.New()
|
||||
hasher.Write(content)
|
||||
digest := "sha256:" + hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
// ?? Content-Type
|
||||
mediaType := c.Get("Content-Type")
|
||||
if mediaType == "" {
|
||||
// ??? manifest ????
|
||||
var mf map[string]interface{}
|
||||
if err := json.Unmarshal(content, &mf); err == nil {
|
||||
if mt, ok := mf["mediaType"].(string); ok {
|
||||
mediaType = mt
|
||||
} else {
|
||||
// ???? Docker manifest v2
|
||||
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
|
||||
}
|
||||
} else {
|
||||
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
|
||||
}
|
||||
}
|
||||
|
||||
// ?? manifest ????????
|
||||
var manifestData map[string]interface{}
|
||||
if err := json.Unmarshal(content, &manifestData); err != nil {
|
||||
return resp.R400(c, "INVALID_MANIFEST", nil, "invalid manifest format")
|
||||
}
|
||||
|
||||
// ??????
|
||||
var repository model.Repository
|
||||
if err := db.Where("name = ?", repo).First(&repository).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
repository = model.Repository{Name: repo}
|
||||
if err := db.Create(&repository).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ?? manifest ?????
|
||||
if err := store.WriteManifest(c.Context(), digest, content); err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ?? manifest ?????
|
||||
var manifest model.Manifest
|
||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// ???? manifest ??
|
||||
manifest = model.Manifest{
|
||||
Repository: repo,
|
||||
Tag: tag,
|
||||
Digest: digest,
|
||||
MediaType: mediaType,
|
||||
Size: int64(len(content)),
|
||||
Content: content,
|
||||
}
|
||||
if err := db.Create(&manifest).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
// ???? manifest ? tag ??
|
||||
manifest.Tag = tag
|
||||
manifest.Repository = repo
|
||||
if err := db.Save(&manifest).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ????? tag ??
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
tagRecord = model.Tag{
|
||||
Repository: repo,
|
||||
Tag: tag,
|
||||
Digest: digest,
|
||||
}
|
||||
if err := db.Create(&tagRecord).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
tagRecord.Digest = digest
|
||||
if err := db.Save(&tagRecord).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ?????
|
||||
c.Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", repo, tag))
|
||||
c.Set("Docker-Content-Digest", digest)
|
||||
c.Set("Content-Type", mediaType)
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||
return c.SendStatus(201)
|
||||
}
|
||||
|
||||
// handleManifestGet ?? manifest
|
||||
func handleManifestGet(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
||||
var manifest model.Manifest
|
||||
|
||||
// ?? tag ??????????????????????
|
||||
if isDigestFormat(tag) {
|
||||
// ?? digest ???????????? repository
|
||||
digest := tag
|
||||
|
||||
// ?? manifest ???
|
||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ???? manifest ?????????? repository ??
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
// ?? tag ???? tag ?????????
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ?? manifest ??
|
||||
if err := db.Where("digest = ?", tagRecord.Digest).First(&manifest).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check Accept header if provided
|
||||
acceptHeader := c.Get("Accept")
|
||||
if acceptHeader != "" {
|
||||
// Parse Accept header to check if client accepts the manifest's media type
|
||||
acceptTypes := strings.Split(acceptHeader, ",")
|
||||
accepted := false
|
||||
for _, at := range acceptTypes {
|
||||
// Remove quality values (e.g., "application/vnd.docker.distribution.manifest.v2+json;q=0.9")
|
||||
mediaType := strings.TrimSpace(strings.Split(at, ";")[0])
|
||||
if mediaType == manifest.MediaType || mediaType == "*/*" {
|
||||
accepted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !accepted {
|
||||
// Check for wildcard or common Docker manifest types
|
||||
for _, at := range acceptTypes {
|
||||
mediaType := strings.TrimSpace(strings.Split(at, ";")[0])
|
||||
if strings.Contains(mediaType, "manifest") || mediaType == "*/*" {
|
||||
accepted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: We still return the manifest even if not explicitly accepted,
|
||||
// as some clients may not send proper Accept headers
|
||||
}
|
||||
|
||||
// Read manifest content
|
||||
content, err := store.ReadManifest(c.Context(), manifest.Digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// Set response headers
|
||||
c.Set("Content-Type", manifest.MediaType)
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||
c.Set("Docker-Content-Digest", manifest.Digest)
|
||||
return c.Send(content)
|
||||
}
|
||||
|
||||
// handleManifestHead ?? manifest ????
|
||||
func handleManifestHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
||||
var manifest model.Manifest
|
||||
|
||||
// ?? tag ??????????????????????
|
||||
if isDigestFormat(tag) {
|
||||
// ?? digest ???????????? repository
|
||||
digest := tag
|
||||
|
||||
// ?? manifest ???
|
||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ???? manifest ?????????? repository ??
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
// ?? tag ???? tag ?????????
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ?? manifest ??
|
||||
if err := db.Where("digest = ?", tagRecord.Digest).First(&manifest).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ?????
|
||||
c.Set("Content-Type", manifest.MediaType)
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", manifest.Size))
|
||||
c.Set("Docker-Content-Digest", manifest.Digest)
|
||||
return c.SendStatus(200)
|
||||
}
|
||||
|
||||
// handleManifestDelete ?? manifest
|
||||
func handleManifestDelete(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
||||
var digest string
|
||||
|
||||
if isDigestFormat(tag) {
|
||||
// ?? digest ???????????? repository
|
||||
digest = tag
|
||||
|
||||
// ???? manifest ?????????? repository ??
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ???????? tag ??? manifest
|
||||
var count int64
|
||||
if err := db.Model(&model.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ??? tag ??????? manifest ??
|
||||
if count == 0 {
|
||||
var manifest model.Manifest
|
||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
if err := db.Delete(&manifest).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
// ?? tag ?????????????????
|
||||
// ??? manifest ???????????
|
||||
return resp.R400(c, "CANNOT_DELETE_DIGEST_REFERENCED_BY_TAGS", nil, "cannot delete manifest referenced by tags")
|
||||
}
|
||||
} else {
|
||||
// ?? tag ???? tag ?????????
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
digest = tagRecord.Digest
|
||||
|
||||
// ?? tag ??
|
||||
if err := db.Delete(&tagRecord).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ???????? tag ??? manifest
|
||||
var count int64
|
||||
if err := db.Model(&model.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ?????? tag ????? manifest ??
|
||||
if count == 0 {
|
||||
var manifest model.Manifest
|
||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err == nil {
|
||||
if err := db.Delete(&manifest).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.SendStatus(202)
|
||||
}
|
||||
16
internal/module/registry/referrer.go
Normal file
16
internal/module/registry/referrer.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleReferrers ?? referrers ???OCI ???
|
||||
// GET /v2/{repo}/referrers/{digest}
|
||||
func HandleReferrers(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
// TODO: ?? OCI referrers API
|
||||
// ????????????? OCI ? referrers ??
|
||||
return resp.R501(c, "NOT_IMPLEMENTED", nil, "referrers API not implemented yet")
|
||||
}
|
||||
115
internal/module/registry/registry.go
Normal file
115
internal/module/registry/registry.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Registry(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||
// ???????
|
||||
if err := db.AutoMigrate(
|
||||
&model.Repository{},
|
||||
&model.Blob{},
|
||||
&model.Manifest{},
|
||||
&model.Tag{},
|
||||
&model.BlobUpload{},
|
||||
); err != nil {
|
||||
log.Fatalf("failed to migrate database: %v", err)
|
||||
}
|
||||
|
||||
if err := store.CreatePartition(ctx, "registry"); err != nil {
|
||||
log.Fatalf("failed to create registry partition: %v", err)
|
||||
}
|
||||
|
||||
return func(c fiber.Ctx) error {
|
||||
if isBlob(c) {
|
||||
return HandleBlobs(c, db, store)
|
||||
}
|
||||
|
||||
if isManifest(c) {
|
||||
return HandleManifest(c, db, store)
|
||||
}
|
||||
|
||||
if isTags(c) {
|
||||
return HandleTags(c, db, store)
|
||||
}
|
||||
|
||||
if isCatalog(c) {
|
||||
return HandleCatalog(c, db, store)
|
||||
}
|
||||
|
||||
if isReferrers(c) {
|
||||
return HandleReferrers(c, db, store)
|
||||
}
|
||||
|
||||
// Handle root v2 endpoint
|
||||
if c.Path() == "/v2/" {
|
||||
c.Set("Docker-Distribution-API-Version", "registry/2.0")
|
||||
return c.SendStatus(200)
|
||||
}
|
||||
|
||||
c.Set("Docker-Distribution-API-Version", "registry/2.0")
|
||||
|
||||
log.Printf("[Warn] Registry: unknown endpoint - path = %s, method = %s, headers = %v", c.Path(), c.Method(), &c.Request().Header)
|
||||
|
||||
return resp.R404(c, "UNKNOWN_ENDPOINT", nil, "endpoint not found")
|
||||
}
|
||||
}
|
||||
|
||||
func isBlob(c fiber.Ctx) bool {
|
||||
elem := strings.Split(c.Path(), "/")
|
||||
elem = elem[1:]
|
||||
if elem[len(elem)-1] == "" {
|
||||
elem = elem[:len(elem)-1]
|
||||
}
|
||||
if len(elem) < 3 {
|
||||
return false
|
||||
}
|
||||
return elem[len(elem)-2] == "blobs" || (elem[len(elem)-3] == "blobs" &&
|
||||
elem[len(elem)-2] == "uploads")
|
||||
}
|
||||
|
||||
func isManifest(c fiber.Ctx) bool {
|
||||
elems := strings.Split(c.Path(), "/")
|
||||
elems = elems[1:]
|
||||
if len(elems) < 4 {
|
||||
return false
|
||||
}
|
||||
return elems[len(elems)-2] == "manifests"
|
||||
}
|
||||
|
||||
func isTags(c fiber.Ctx) bool {
|
||||
elems := strings.Split(c.Path(), "/")
|
||||
elems = elems[1:]
|
||||
if len(elems) < 4 {
|
||||
return false
|
||||
}
|
||||
return elems[len(elems)-2] == "tags"
|
||||
}
|
||||
|
||||
func isCatalog(c fiber.Ctx) bool {
|
||||
elems := strings.Split(c.Path(), "/")
|
||||
elems = elems[1:]
|
||||
if len(elems) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
return elems[len(elems)-1] == "_catalog"
|
||||
}
|
||||
|
||||
func isReferrers(c fiber.Ctx) bool {
|
||||
elems := strings.Split(c.Path(), "/")
|
||||
elems = elems[1:]
|
||||
if len(elems) < 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
return elems[len(elems)-2] == "referrers"
|
||||
}
|
||||
84
internal/module/registry/tag.go
Normal file
84
internal/module/registry/tag.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"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"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleTags ?? tag ????
|
||||
// GET /v2/{repo}/tags/list?n={limit}&last={last}
|
||||
func HandleTags(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
path := c.Path()
|
||||
|
||||
// ????: /v2/{repo}/tags/list
|
||||
// ??????????? "test/redis"
|
||||
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
||||
parts := strings.Split(pathWithoutV2, "/")
|
||||
if len(parts) < 3 {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
|
||||
}
|
||||
|
||||
// ?? "tags" ???
|
||||
tagsIndex := -1
|
||||
for i, part := range parts {
|
||||
if part == "tags" {
|
||||
tagsIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if tagsIndex < 1 || tagsIndex >= len(parts)-1 || parts[tagsIndex+1] != "list" {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
|
||||
}
|
||||
|
||||
// ???? tags ???????
|
||||
repo := strings.Join(parts[:tagsIndex], "/")
|
||||
|
||||
// ??????
|
||||
nStr := c.Query("n", "100")
|
||||
n, err := strconv.Atoi(nStr)
|
||||
if err != nil || n <= 0 {
|
||||
n = 100
|
||||
}
|
||||
last := c.Query("last")
|
||||
|
||||
// ?? tags
|
||||
var tags []model.Tag
|
||||
query := db.Where("repository = ?", repo).Order("tag ASC").Limit(n + 1)
|
||||
|
||||
if last != "" {
|
||||
query = query.Where("tag > ?", last)
|
||||
}
|
||||
|
||||
if err := query.Find(&tags).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ????
|
||||
tagNames := make([]string, 0, len(tags))
|
||||
hasMore := false
|
||||
for i, tag := range tags {
|
||||
if i >= n {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
tagNames = append(tagNames, tag.Tag)
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"name": repo,
|
||||
"tags": tagNames,
|
||||
}
|
||||
|
||||
// ??????????????
|
||||
if hasMore && len(tagNames) > 0 {
|
||||
response["last"] = tagNames[len(tagNames)-1]
|
||||
}
|
||||
|
||||
return resp.R200(c, response)
|
||||
}
|
||||
20
internal/opt/opt.go
Normal file
20
internal/opt/opt.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package opt
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
)
|
||||
|
||||
var (
|
||||
GlobalDebug bool
|
||||
GlobalAddress string
|
||||
GlobalDataDir string
|
||||
)
|
||||
|
||||
func Init(ctx context.Context) error {
|
||||
if err := os.MkdirAll(GlobalDataDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
24
main.go
Normal file
24
main.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/cmd"
|
||||
)
|
||||
|
||||
func init() {
|
||||
time.LoadLocation("Asia/Shanghai")
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
|
||||
defer cancel()
|
||||
|
||||
if err := cmd.Run(ctx); err != nil {
|
||||
log.Fatalf("Failed to run command: %v", err)
|
||||
}
|
||||
}
|
||||
27
pkg/database/db/init.go
Normal file
27
pkg/database/db/init.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
var (
|
||||
Default *gorm.DB
|
||||
)
|
||||
|
||||
func Init(ctx context.Context, dataDir string) error {
|
||||
var (
|
||||
err error
|
||||
dbPath = filepath.Join(dataDir, "cluster.db")
|
||||
)
|
||||
|
||||
Default, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
56
pkg/resp/err.go
Normal file
56
pkg/resp/err.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package resp
|
||||
|
||||
import "net/http"
|
||||
|
||||
type Error struct {
|
||||
Status int `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Err error `json:"err"`
|
||||
Data any `json:"data"`
|
||||
}
|
||||
|
||||
func (e *Error) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
func (e *Error) _r() *res {
|
||||
data := &res{
|
||||
Status: e.Status,
|
||||
Msg: e.Msg,
|
||||
Data: e.Data,
|
||||
Err: e.Err,
|
||||
}
|
||||
|
||||
if data.Status < 0 || data.Status > 999 {
|
||||
data.Status = 500
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
func NewError(err error, args ...any) *Error {
|
||||
e := &Error{
|
||||
Status: http.StatusInternalServerError,
|
||||
Err: err,
|
||||
}
|
||||
|
||||
if len(args) > 0 {
|
||||
if status, ok := args[0].(int); ok {
|
||||
e.Status = status
|
||||
}
|
||||
}
|
||||
|
||||
e.Msg = Msg(e.Status)
|
||||
|
||||
if len(args) > 1 {
|
||||
if msg, ok := args[1].(string); ok {
|
||||
e.Msg = msg
|
||||
}
|
||||
}
|
||||
|
||||
if len(args) > 2 {
|
||||
e.Data = args[2]
|
||||
}
|
||||
|
||||
return e
|
||||
}
|
||||
5
pkg/resp/i18n.go
Normal file
5
pkg/resp/i18n.go
Normal file
@@ -0,0 +1,5 @@
|
||||
package resp
|
||||
|
||||
func t(msg string) string {
|
||||
return msg
|
||||
}
|
||||
37
pkg/resp/msg.go
Normal file
37
pkg/resp/msg.go
Normal file
@@ -0,0 +1,37 @@
|
||||
package resp
|
||||
|
||||
const (
|
||||
Msg200 = "操作成功"
|
||||
Msg201 = "操作需要审核, 请继续"
|
||||
Msg202 = "操作未完成, 请继续"
|
||||
Msg400 = "参数错误"
|
||||
Msg400Duplicate = "目标已存在, 请勿重复创建"
|
||||
Msg401 = "该账号登录已失效, 请重新登录"
|
||||
Msg401NoMulti = "用户已在其他地方登录"
|
||||
Msg403 = "权限不足"
|
||||
Msg404 = "资源不存在"
|
||||
Msg500 = "服务器开小差了"
|
||||
Msg501 = "服务不可用"
|
||||
Msg503 = "服务不可用或正在升级, 请联系管理员"
|
||||
)
|
||||
|
||||
func Msg(status int) string {
|
||||
switch status {
|
||||
case 400:
|
||||
return Msg400
|
||||
case 401:
|
||||
return Msg401
|
||||
case 403:
|
||||
return Msg403
|
||||
case 404:
|
||||
return Msg404
|
||||
case 500:
|
||||
return Msg500
|
||||
case 501:
|
||||
return Msg501
|
||||
case 503:
|
||||
return Msg503
|
||||
}
|
||||
|
||||
return "未知错误"
|
||||
}
|
||||
185
pkg/resp/resp.go
Normal file
185
pkg/resp/resp.go
Normal file
@@ -0,0 +1,185 @@
|
||||
package resp
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type res struct {
|
||||
Status int `json:"status"`
|
||||
Msg string `json:"msg"`
|
||||
Data any `json:"data"`
|
||||
Err any `json:"err"`
|
||||
}
|
||||
|
||||
func R200(c fiber.Ctx, data any, msgs ...string) error {
|
||||
r := &res{
|
||||
Status: 200,
|
||||
Msg: Msg200,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
if len(msgs) > 0 && msgs[0] != "" {
|
||||
r.Msg = msgs[0]
|
||||
}
|
||||
|
||||
return c.JSON(r)
|
||||
}
|
||||
|
||||
func R201(c fiber.Ctx, data any, msgs ...string) error {
|
||||
r := &res{
|
||||
Status: 201,
|
||||
Msg: Msg201,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
if len(msgs) > 0 && msgs[0] != "" {
|
||||
r.Msg = msgs[0]
|
||||
}
|
||||
|
||||
return c.JSON(r)
|
||||
}
|
||||
|
||||
func R202(c fiber.Ctx, data any, msgs ...string) error {
|
||||
r := &res{
|
||||
Status: 202,
|
||||
Msg: Msg202,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
if len(msgs) > 0 && msgs[0] != "" {
|
||||
r.Msg = msgs[0]
|
||||
}
|
||||
|
||||
return c.JSON(r)
|
||||
}
|
||||
|
||||
func RC(c fiber.Ctx, status int, args ...any) error {
|
||||
return _r(c, &res{Status: status}, args...)
|
||||
}
|
||||
|
||||
func RE(c fiber.Ctx, err error) error {
|
||||
var re *Error
|
||||
|
||||
if errors.As(err, &re) {
|
||||
return RC(c, re.Status, re.Msg, re.Data, re.Err)
|
||||
}
|
||||
|
||||
estr := strings.ToLower(err.Error())
|
||||
if strings.Contains(estr, "duplicate") {
|
||||
return R400(c, Msg400Duplicate, nil, estr)
|
||||
}
|
||||
|
||||
return R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
func _r(c fiber.Ctx, r *res, args ...any) error {
|
||||
length := len(args)
|
||||
|
||||
if length == 0 {
|
||||
goto END
|
||||
}
|
||||
|
||||
if length >= 1 {
|
||||
if msg, ok := args[0].(string); ok {
|
||||
r.Msg = msg
|
||||
}
|
||||
}
|
||||
|
||||
if length >= 2 {
|
||||
r.Data = args[1]
|
||||
}
|
||||
|
||||
if length >= 3 {
|
||||
if ee, ok := args[2].(error); ok {
|
||||
r.Err = ee.Error()
|
||||
} else {
|
||||
r.Err = args[2]
|
||||
}
|
||||
}
|
||||
END:
|
||||
|
||||
if r.Msg == "" {
|
||||
r.Msg = Msg(r.Status)
|
||||
}
|
||||
|
||||
// todo: i18n r.Msg
|
||||
// r.Msg = t(r.Msg)
|
||||
|
||||
return c.Status(r.Status).JSON(r)
|
||||
}
|
||||
|
||||
// R400
|
||||
//
|
||||
// args[0]: should be msg to display to user(defaulted)
|
||||
// args[1]: could be extra data to send with(no default)
|
||||
// args[2]: could be error msg to send to with debug mode
|
||||
func R400(c fiber.Ctx, args ...any) error {
|
||||
r := &res{
|
||||
Status: 400,
|
||||
}
|
||||
|
||||
return _r(c, r, args...)
|
||||
}
|
||||
|
||||
// R401
|
||||
//
|
||||
// args[0]: should be msg to display to user(defaulted)
|
||||
// args[1]: could be extra data to send with(no default)
|
||||
// args[2]: could be error msg to send to with debug mode
|
||||
func R401(c fiber.Ctx, args ...any) error {
|
||||
r := &res{
|
||||
Status: 401,
|
||||
}
|
||||
|
||||
return _r(c, r, args...)
|
||||
}
|
||||
|
||||
// R403
|
||||
//
|
||||
// args[0]: should be msg to display to user(defaulted)
|
||||
// args[1]: could be extra data to send with(no default)
|
||||
// args[2]: could be error msg to send to with debug mode
|
||||
func R403(c fiber.Ctx, args ...any) error {
|
||||
r := &res{
|
||||
Status: 403,
|
||||
}
|
||||
|
||||
return _r(c, r, args...)
|
||||
}
|
||||
|
||||
func R404(c fiber.Ctx, args ...any) error {
|
||||
r := &res{
|
||||
Status: 404,
|
||||
}
|
||||
|
||||
return _r(c, r, args...)
|
||||
}
|
||||
|
||||
// R500
|
||||
//
|
||||
// args[0]: should be msg to display to user(defaulted)
|
||||
// args[1]: could be extra data to send with(no default)
|
||||
// args[2]: could be error msg to send to with debug mode
|
||||
func R500(c fiber.Ctx, args ...any) error {
|
||||
r := &res{
|
||||
Status: 500,
|
||||
}
|
||||
|
||||
return _r(c, r, args...)
|
||||
}
|
||||
|
||||
// R501
|
||||
//
|
||||
// args[0]: should be msg to display to user(defaulted)
|
||||
// args[1]: could be extra data to send with(no default)
|
||||
// args[2]: could be error msg to send to with debug mode
|
||||
func R501(c fiber.Ctx, args ...any) error {
|
||||
r := &res{
|
||||
Status: 501,
|
||||
}
|
||||
|
||||
return _r(c, r, args...)
|
||||
}
|
||||
75
pkg/resp/sse.go
Normal file
75
pkg/resp/sse.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package resp
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/gofiber/fiber/v3"
|
||||
)
|
||||
|
||||
type SSEManager struct {
|
||||
c fiber.Ctx
|
||||
event string
|
||||
ch chan string
|
||||
id int64
|
||||
}
|
||||
|
||||
func (m *SSEManager) Send(msg string) {
|
||||
m.ch <- msg
|
||||
}
|
||||
|
||||
func (m *SSEManager) JSON(data any) {
|
||||
bs, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
m.ch <- err.Error()
|
||||
return
|
||||
}
|
||||
|
||||
m.ch <- string(bs)
|
||||
}
|
||||
|
||||
func (m *SSEManager) Writer() func(w *bufio.Writer) {
|
||||
return func(w *bufio.Writer) {
|
||||
for msg := range m.ch {
|
||||
fmt.Fprintf(w, "event: %s\nid: %d\ntimestamp: %d\ndata: %s\n\n", m.event, m.id, time.Now().UnixMilli(), msg)
|
||||
w.Flush()
|
||||
m.id++
|
||||
}
|
||||
w.Flush()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *SSEManager) Close() {
|
||||
close(m.ch)
|
||||
}
|
||||
|
||||
// SSE create a new SSEManager
|
||||
// example:
|
||||
//
|
||||
// func someHandler(c fiber.Ctx) error {
|
||||
// m := resp.SSE(c, "test")
|
||||
// go func() {
|
||||
// defer m.Close()
|
||||
// for i := range 10 {
|
||||
// m.Send("test" + strconv.Itoa(i))
|
||||
// time.Sleep(1 * time.Second)
|
||||
// }
|
||||
// }()
|
||||
//
|
||||
// return c.SendStreamWriter(m.Writer())
|
||||
// }
|
||||
func SSE(c fiber.Ctx, event string) *SSEManager {
|
||||
c.Set("Content-Type", "text/event-stream")
|
||||
c.Set("Cache-Control", "no-cache")
|
||||
c.Set("Connection", "keep-alive")
|
||||
c.Set("Transfer-Encoding", "chunked")
|
||||
|
||||
return &SSEManager{
|
||||
c: c,
|
||||
event: event,
|
||||
id: 0,
|
||||
ch: make(chan string, 1),
|
||||
}
|
||||
}
|
||||
341
pkg/store/store.go
Normal file
341
pkg/store/store.go
Normal file
@@ -0,0 +1,341 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Store interface {
|
||||
CreatePartition(ctx context.Context, name string) error
|
||||
// Blob ??
|
||||
WriteBlob(ctx context.Context, digest string, r io.Reader) error
|
||||
ReadBlob(ctx context.Context, digest string) (io.ReadCloser, error)
|
||||
BlobExists(ctx context.Context, digest string) (bool, error)
|
||||
GetBlobSize(ctx context.Context, digest string) (int64, error)
|
||||
// Manifest ??
|
||||
WriteManifest(ctx context.Context, digest string, content []byte) error
|
||||
ReadManifest(ctx context.Context, digest string) ([]byte, error)
|
||||
ManifestExists(ctx context.Context, digest string) (bool, error)
|
||||
// Upload ??
|
||||
CreateUpload(ctx context.Context, uuid string) (io.WriteCloser, error)
|
||||
AppendUpload(ctx context.Context, uuid string, r io.Reader) (int64, error)
|
||||
GetUploadSize(ctx context.Context, uuid string) (int64, error)
|
||||
FinalizeUpload(ctx context.Context, uuid string, digest string) error
|
||||
DeleteUpload(ctx context.Context, uuid string) error
|
||||
}
|
||||
|
||||
type fileStore struct {
|
||||
baseDir string
|
||||
}
|
||||
|
||||
var (
|
||||
Default Store
|
||||
)
|
||||
|
||||
func Init(ctx context.Context, dataDir string) error {
|
||||
Default = &fileStore{
|
||||
baseDir: dataDir,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fileStore) CreatePartition(ctx context.Context, name string) error {
|
||||
dirs := []string{
|
||||
filepath.Join(s.baseDir, name, "blobs"),
|
||||
filepath.Join(s.baseDir, name, "manifests"),
|
||||
filepath.Join(s.baseDir, name, "uploads"),
|
||||
}
|
||||
for _, dir := range dirs {
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return fmt.Errorf("failed to create directory %s: %w", dir, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// blobPath ?? digest ?? blob ????
|
||||
// ??: blobs/sha256/abc/def.../digest
|
||||
func (s *fileStore) blobPath(digest string) (string, error) {
|
||||
// ?? digest???: sha256:abc123...
|
||||
parts := strings.SplitN(digest, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid digest format: %s", digest)
|
||||
}
|
||||
algo := parts[0]
|
||||
hash := parts[1]
|
||||
|
||||
if algo != "sha256" {
|
||||
return "", fmt.Errorf("unsupported digest algorithm: %s", algo)
|
||||
}
|
||||
|
||||
// ??? 2 ????????????? 2 ?????????
|
||||
if len(hash) < 4 {
|
||||
return "", fmt.Errorf("invalid hash length: %s", hash)
|
||||
}
|
||||
|
||||
path := filepath.Join(s.baseDir, "registry", "blobs", algo, hash[:2], hash[2:4], hash)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *fileStore) WriteBlob(ctx context.Context, digest string, r io.Reader) error {
|
||||
path, err := s.blobPath(digest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ???????
|
||||
if _, err := os.Stat(path); err == nil {
|
||||
return nil // ????????
|
||||
}
|
||||
|
||||
// ????
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create blob directory: %w", err)
|
||||
}
|
||||
|
||||
// ????
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create blob file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
// ???? digest ??
|
||||
hasher := sha256.New()
|
||||
tee := io.TeeReader(r, hasher)
|
||||
|
||||
if _, err := io.Copy(f, tee); err != nil {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("failed to write blob: %w", err)
|
||||
}
|
||||
|
||||
// ?? digest
|
||||
calculated := "sha256:" + hex.EncodeToString(hasher.Sum(nil))
|
||||
if calculated != digest {
|
||||
os.Remove(path)
|
||||
return fmt.Errorf("digest mismatch: expected %s, got %s", digest, calculated)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fileStore) ReadBlob(ctx context.Context, digest string) (io.ReadCloser, error) {
|
||||
path, err := s.blobPath(digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("blob not found: %w", err)
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *fileStore) BlobExists(ctx context.Context, digest string) (bool, error) {
|
||||
path, err := s.blobPath(digest)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = os.Stat(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
func (s *fileStore) GetBlobSize(ctx context.Context, digest string) (int64, error) {
|
||||
path, err := s.blobPath(digest)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
// manifestPath ?? digest ?? manifest ????
|
||||
func (s *fileStore) manifestPath(digest string) (string, error) {
|
||||
parts := strings.SplitN(digest, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return "", fmt.Errorf("invalid digest format: %s", digest)
|
||||
}
|
||||
algo := parts[0]
|
||||
hash := parts[1]
|
||||
|
||||
if algo != "sha256" {
|
||||
return "", fmt.Errorf("unsupported digest algorithm: %s", algo)
|
||||
}
|
||||
|
||||
path := filepath.Join(s.baseDir, "registry", "manifests", algo, hash[:2], hash[2:4], hash)
|
||||
return path, nil
|
||||
}
|
||||
|
||||
func (s *fileStore) WriteManifest(ctx context.Context, digest string, content []byte) error {
|
||||
path, err := s.manifestPath(digest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ????
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create manifest directory: %w", err)
|
||||
}
|
||||
|
||||
// ?? digest
|
||||
hasher := sha256.New()
|
||||
hasher.Write(content)
|
||||
calculated := "sha256:" + hex.EncodeToString(hasher.Sum(nil))
|
||||
if calculated != digest {
|
||||
return fmt.Errorf("digest mismatch: expected %s, got %s", digest, calculated)
|
||||
}
|
||||
|
||||
// ????
|
||||
if err := os.WriteFile(path, content, 0644); err != nil {
|
||||
return fmt.Errorf("failed to write manifest: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fileStore) ReadManifest(ctx context.Context, digest string) ([]byte, error) {
|
||||
path, err := s.manifestPath(digest)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("manifest not found: %w", err)
|
||||
}
|
||||
|
||||
return content, nil
|
||||
}
|
||||
|
||||
func (s *fileStore) ManifestExists(ctx context.Context, digest string) (bool, error) {
|
||||
path, err := s.manifestPath(digest)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
_, err = os.Stat(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if os.IsNotExist(err) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
// uploadPath ??????????
|
||||
func (s *fileStore) uploadPath(uuid string) string {
|
||||
return filepath.Join(s.baseDir, "registry", "uploads", uuid)
|
||||
}
|
||||
|
||||
func (s *fileStore) CreateUpload(ctx context.Context, uuid string) (io.WriteCloser, error) {
|
||||
path := s.uploadPath(uuid)
|
||||
|
||||
// ????
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return nil, fmt.Errorf("failed to create upload directory: %w", err)
|
||||
}
|
||||
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create upload file: %w", err)
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (s *fileStore) AppendUpload(ctx context.Context, uuid string, r io.Reader) (int64, error) {
|
||||
path := s.uploadPath(uuid)
|
||||
|
||||
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to open upload file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
n, err := io.Copy(f, r)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to write to upload: %w", err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func (s *fileStore) GetUploadSize(ctx context.Context, uuid string) (int64, error) {
|
||||
path := s.uploadPath(uuid)
|
||||
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return info.Size(), nil
|
||||
}
|
||||
|
||||
func (s *fileStore) FinalizeUpload(ctx context.Context, uuid string, digest string) error {
|
||||
uploadPath := s.uploadPath(uuid)
|
||||
blobPath, err := s.blobPath(digest)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ???? blob ????????????
|
||||
if _, err := os.Stat(blobPath); err == nil {
|
||||
os.Remove(uploadPath)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ??????
|
||||
if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {
|
||||
return fmt.Errorf("failed to create blob directory: %w", err)
|
||||
}
|
||||
|
||||
// ?? digest
|
||||
f, err := os.Open(uploadPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open upload file: %w", err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
hasher := sha256.New()
|
||||
if _, err := io.Copy(hasher, f); err != nil {
|
||||
return fmt.Errorf("failed to calculate digest: %w", err)
|
||||
}
|
||||
|
||||
calculated := "sha256:" + hex.EncodeToString(hasher.Sum(nil))
|
||||
if calculated != digest {
|
||||
return fmt.Errorf("digest mismatch: expected %s, got %s", digest, calculated)
|
||||
}
|
||||
|
||||
// ????
|
||||
if err := os.Rename(uploadPath, blobPath); err != nil {
|
||||
return fmt.Errorf("failed to finalize upload: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fileStore) DeleteUpload(ctx context.Context, uuid string) error {
|
||||
path := s.uploadPath(uuid)
|
||||
return os.Remove(path)
|
||||
}
|
||||
Reference in New Issue
Block a user