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:
loveuer
2025-11-09 22:46:27 +08:00
commit 29088a6b54
45 changed files with 5629 additions and 0 deletions

51
.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

55
frontend/src/App.tsx Normal file
View 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

View File

15
frontend/src/index.css Normal file
View 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
View 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>,
)

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

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

@@ -0,0 +1 @@
/// <reference types="vite/client" />

25
frontend/tsconfig.json Normal file
View 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" }]
}

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

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

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

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

View 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
View 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"` // ?????
}

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

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

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

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

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

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

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

@@ -0,0 +1,5 @@
package resp
func t(msg string) string {
return msg
}

37
pkg/resp/msg.go Normal file
View 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
View 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
View 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
View 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)
}