wip: oci image management
This commit is contained in:
47
.gitignore
vendored
Normal file
47
.gitignore
vendored
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# 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
|
||||||
|
storage/
|
||||||
|
x-storage/
|
||||||
|
*.db
|
||||||
|
main
|
||||||
|
/tmp/cluster-test
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*~
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
frontend/node_modules/
|
||||||
|
frontend/dist/
|
||||||
|
frontend/.vite/
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
cluster-test
|
||||||
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]
|
||||||
19
api/routes.go
Normal file
19
api/routes.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/api/v1"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/handler"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupRoutes 设置所有路由
|
||||||
|
func SetupRoutes(app *fiber.App, store storage.Storage) {
|
||||||
|
// REST API v1
|
||||||
|
api := app.Group("/api/v1")
|
||||||
|
v1.SetupRoutes(api)
|
||||||
|
|
||||||
|
// OCI Registry API v2 - 使用通配符路由处理所有 /v2/* 路径
|
||||||
|
app.All("/v2/*", handler.RegistryV2(store))
|
||||||
|
}
|
||||||
13
api/v1/routes.go
Normal file
13
api/v1/routes.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/handler"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SetupRoutes 设置 v1 API 路由
|
||||||
|
func SetupRoutes(api fiber.Router) {
|
||||||
|
reg := api.Group("/registry")
|
||||||
|
reg.Get("/image/list", handler.ListImages)
|
||||||
|
}
|
||||||
27
controller/blob.go
Normal file
27
controller/blob.go
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetBlob 获取 blob
|
||||||
|
func GetBlob(store storage.Storage, repo, digest string) (io.ReadCloser, int64, error) {
|
||||||
|
return store.GetBlob(repo, digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeadBlob 检查 blob 是否存在并返回大小
|
||||||
|
func HeadBlob(store storage.Storage, repo, digest string) (bool, int64, error) {
|
||||||
|
exists, err := store.BlobExists(repo, digest)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := store.GetBlobSize(repo, digest)
|
||||||
|
if err != nil {
|
||||||
|
return false, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return true, size, nil
|
||||||
|
}
|
||||||
57
controller/manifest.go
Normal file
57
controller/manifest.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetManifest 获取 manifest
|
||||||
|
func GetManifest(store storage.Storage, repo, reference string) ([]byte, string, string, error) {
|
||||||
|
data, mediaType, err := store.GetManifest(repo, reference)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
digest := calculateDigest(data)
|
||||||
|
return data, mediaType, digest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutManifest 推送 manifest
|
||||||
|
func PutManifest(store storage.Storage, repo, reference string, data []byte, mediaType string) (string, error) {
|
||||||
|
if err := store.PutManifest(repo, reference, data, mediaType); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
digest := calculateDigest(data)
|
||||||
|
return digest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteManifest 删除 manifest
|
||||||
|
func DeleteManifest(store storage.Storage, repo, reference string) error {
|
||||||
|
return store.DeleteManifest(repo, reference)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeadManifest 检查 manifest 是否存在并返回元数据
|
||||||
|
func HeadManifest(store storage.Storage, repo, reference string) (bool, []byte, string, string, error) {
|
||||||
|
exists, err := store.ManifestExists(repo, reference)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return false, nil, "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 manifest 以设置正确的 headers
|
||||||
|
data, mediaType, err := store.GetManifest(repo, reference)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil, "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
digest := calculateDigest(data)
|
||||||
|
return true, data, mediaType, digest, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateDigest 计算 SHA256 digest
|
||||||
|
func calculateDigest(data []byte) string {
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
return "sha256:" + hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
14
controller/registry.go
Normal file
14
controller/registry.go
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListImages 返回所有仓库列表
|
||||||
|
func ListImages() ([]database.Repository, error) {
|
||||||
|
var repos []database.Repository
|
||||||
|
if err := database.DB.Find(&repos).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return repos, nil
|
||||||
|
}
|
||||||
64
controller/upload.go
Normal file
64
controller/upload.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package controller
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartBlobUpload 开始 blob 上传
|
||||||
|
func StartBlobUpload(store storage.Storage, repo string) (string, error) {
|
||||||
|
return store.StartBlobUpload(repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatchBlobUpload 上传 blob 数据块
|
||||||
|
func PatchBlobUpload(store storage.Storage, uuid string, data io.Reader) (int64, error) {
|
||||||
|
if err := store.PutBlobUploadChunk(uuid, data); err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前上传大小
|
||||||
|
size, err := store.GetBlobUpload(uuid)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return size, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutBlobUpload 完成 blob 上传
|
||||||
|
func PutBlobUpload(store storage.Storage, repo, uuid, digest string, data interface{}, requestPath string) (string, error) {
|
||||||
|
// 如果有请求体,先追加数据
|
||||||
|
if data != nil {
|
||||||
|
if reader, ok := data.(io.Reader); ok {
|
||||||
|
if err := store.PutBlobUploadChunk(uuid, reader); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成上传
|
||||||
|
if err := store.CompleteBlobUpload(repo, uuid, digest); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理上传文件
|
||||||
|
store.DeleteBlobUpload(uuid)
|
||||||
|
|
||||||
|
// 返回 blob 位置
|
||||||
|
// 从 /v2/{name}/blobs/uploads/{uuid} 转换为 /v2/{name}/blobs/{digest}
|
||||||
|
pathParts := strings.Split(requestPath, "/")
|
||||||
|
if len(pathParts) >= 4 {
|
||||||
|
// 构建新的路径: /v2/{name}/blobs/{digest}
|
||||||
|
location := "/v2/" + pathParts[2] + "/blobs/" + digest
|
||||||
|
return location, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return requestPath, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlobUpload 获取上传状态
|
||||||
|
func GetBlobUpload(store storage.Storage, uuid string) (int64, error) {
|
||||||
|
return store.GetBlobUpload(uuid)
|
||||||
|
}
|
||||||
39
dev.sh
Executable file
39
dev.sh
Executable file
@@ -0,0 +1,39 @@
|
|||||||
|
#!/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:8080}
|
||||||
|
DATA_DIR=${DATA_DIR:-./x-storage}
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
echo "\n[dev] Shutting down..."
|
||||||
|
# Kill all background jobs spawned by this script
|
||||||
|
jobs -p | xargs -r kill 2>/dev/null || true
|
||||||
|
# Extra wait to ensure graceful shutdown
|
||||||
|
wait || true
|
||||||
|
}
|
||||||
|
trap cleanup INT TERM
|
||||||
|
|
||||||
|
# Start backend
|
||||||
|
(
|
||||||
|
echo "[dev] Starting backend on $BACKEND_ADDR (data-dir=$DATA_DIR)"
|
||||||
|
go run cmd/server/main.go -debug -address "$BACKEND_ADDR" -data-dir "$DATA_DIR"
|
||||||
|
) &
|
||||||
|
BACKEND_PID=$!
|
||||||
|
|
||||||
|
# Start frontend
|
||||||
|
(
|
||||||
|
echo "[dev] Starting frontend dev server (Vite)"
|
||||||
|
npm --prefix frontend 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 (trap will handle Ctrl+C)
|
||||||
|
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>,
|
||||||
|
)
|
||||||
75
frontend/src/pages/RegistryImageList.tsx
Normal file
75
frontend/src/pages/RegistryImageList.tsx
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
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
|
||||||
|
created_at?: string
|
||||||
|
updated_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
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 data = await res.json()
|
||||||
|
const list: RegistryImage[] = 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.created_at?.replace('T', ' ').replace('Z', '')}</TableCell>
|
||||||
|
<TableCell>{img.updated_at?.replace('T', ' ').replace('Z', '')}</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:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
// Removed rewrite so /api prefix is preserved for backend route /api/v1/...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
54
go.mod
Normal file
54
go.mod
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
module gitea.loveuer.com/loveuer/cluster
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.11.0
|
||||||
|
github.com/gofiber/fiber/v3 v3.0.0-beta.2
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
gorm.io/driver/sqlite v1.5.5
|
||||||
|
gorm.io/gorm v1.25.9
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/andybalholm/brotli v1.2.0 // indirect
|
||||||
|
github.com/bytedance/sonic v1.14.0 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.2 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
|
github.com/gofiber/utils/v2 v2.0.0-rc.1 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/compress v1.18.0 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
|
github.com/quic-go/qpack v0.5.1 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.54.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.0 // indirect
|
||||||
|
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||||
|
github.com/valyala/fasthttp v1.65.0 // indirect
|
||||||
|
go.uber.org/mock v0.5.0 // indirect
|
||||||
|
golang.org/x/arch v0.20.0 // indirect
|
||||||
|
golang.org/x/crypto v0.42.0 // indirect
|
||||||
|
golang.org/x/mod v0.27.0 // indirect
|
||||||
|
golang.org/x/net v0.44.0 // indirect
|
||||||
|
golang.org/x/sync v0.17.0 // indirect
|
||||||
|
golang.org/x/sys v0.36.0 // indirect
|
||||||
|
golang.org/x/text v0.29.0 // indirect
|
||||||
|
golang.org/x/tools v0.36.0 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.9 // indirect
|
||||||
|
)
|
||||||
123
go.sum
Normal file
123
go.sum
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||||
|
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||||
|
github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ=
|
||||||
|
github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA=
|
||||||
|
github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/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/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
|
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
|
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4=
|
||||||
|
github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
|
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||||
|
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||||
|
github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
|
||||||
|
github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
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/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
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/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/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
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/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
|
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
|
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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
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/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
|
||||||
|
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
|
||||||
|
github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg=
|
||||||
|
github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY=
|
||||||
|
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/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
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/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA=
|
||||||
|
github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
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=
|
||||||
|
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
|
||||||
|
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
|
||||||
|
golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c=
|
||||||
|
golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk=
|
||||||
|
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||||
|
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||||
|
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
|
||||||
|
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
|
||||||
|
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
|
||||||
|
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
|
||||||
|
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||||
|
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.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.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||||
|
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||||
|
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
|
||||||
|
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
|
||||||
|
google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw=
|
||||||
|
google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/sqlite v1.5.5 h1:7MDMtUZhV065SilG62E0MquljeArQZNfJnjd9i9gx3E=
|
||||||
|
gorm.io/driver/sqlite v1.5.5/go.mod h1:6NgQ7sQWAIFsPrJJl1lSNSu2TABh0ZZ/zm5fosATavE=
|
||||||
|
gorm.io/gorm v1.25.9 h1:wct0gxZIELDk8+ZqF/MVnHLkA1rvYlBWUMv2EdsK1g8=
|
||||||
|
gorm.io/gorm v1.25.9/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||||
76
handler/blob.go
Normal file
76
handler/blob.go
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/controller"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetBlob 获取 blob
|
||||||
|
func GetBlob(store storage.Storage) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
repo := c.Locals("repo_name")
|
||||||
|
repoStr := ""
|
||||||
|
if repo != nil {
|
||||||
|
repoStr = repo.(string)
|
||||||
|
}
|
||||||
|
if repoStr == "" {
|
||||||
|
repoStr = strings.TrimPrefix(c.Params("name"), "/")
|
||||||
|
}
|
||||||
|
digest := c.Locals("digest")
|
||||||
|
digestStr := ""
|
||||||
|
if digest != nil {
|
||||||
|
digestStr = digest.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader, size, err := controller.GetBlob(store, repoStr, digestStr)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||||
|
"errors": []fiber.Map{
|
||||||
|
{
|
||||||
|
"code": "BLOB_UNKNOWN",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
c.Set("Content-Type", "application/octet-stream")
|
||||||
|
c.Set("Content-Length", strconv.FormatInt(size, 10))
|
||||||
|
c.Set("Docker-Content-Digest", digestStr)
|
||||||
|
return c.SendStream(reader, int(size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeadBlob 检查 blob 是否存在
|
||||||
|
func HeadBlob(store storage.Storage) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
repo := c.Locals("repo_name")
|
||||||
|
repoStr := ""
|
||||||
|
if repo != nil {
|
||||||
|
repoStr = repo.(string)
|
||||||
|
}
|
||||||
|
if repoStr == "" {
|
||||||
|
repoStr = strings.TrimPrefix(c.Params("name"), "/")
|
||||||
|
}
|
||||||
|
digest := c.Locals("digest")
|
||||||
|
digestStr := ""
|
||||||
|
if digest != nil {
|
||||||
|
digestStr = digest.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, size, err := controller.HeadBlob(store, repoStr, digestStr)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("Content-Length", strconv.FormatInt(size, 10))
|
||||||
|
c.Set("Docker-Content-Digest", digestStr)
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
handler/blob_handler.go
Normal file
37
handler/blob_handler.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
)
|
||||||
|
|
||||||
|
type blobHandlerImpl struct {
|
||||||
|
store storage.Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBlobHandler creates a new blob handler
|
||||||
|
func NewBlobHandler(store storage.Storage) BlobHandler {
|
||||||
|
return &blobHandlerImpl{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *blobHandlerImpl) Get(ctx context.Context, repo string, h model.Hash) (io.ReadCloser, error) {
|
||||||
|
reader, _, err := b.store.GetBlob(repo, h.String())
|
||||||
|
return reader, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *blobHandlerImpl) Put(ctx context.Context, repo string, h model.Hash, rc io.ReadCloser) error {
|
||||||
|
defer rc.Close()
|
||||||
|
return b.store.PutBlob(repo, h.String(), rc)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *blobHandlerImpl) Stat(ctx context.Context, repo string, h model.Hash) (int64, error) {
|
||||||
|
return b.store.GetBlobSize(repo, h.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (b *blobHandlerImpl) Delete(ctx context.Context, repo string, h model.Hash) error {
|
||||||
|
// TODO: implement delete
|
||||||
|
return nil
|
||||||
|
}
|
||||||
8
handler/globals.go
Normal file
8
handler/globals.go
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
// Global handlers
|
||||||
|
var (
|
||||||
|
blobHandler BlobHandler
|
||||||
|
uploadHandler UploadHandler
|
||||||
|
m ManifestHandler
|
||||||
|
)
|
||||||
34
handler/interfaces.go
Normal file
34
handler/interfaces.go
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BlobHandler handles blob operations
|
||||||
|
type BlobHandler interface {
|
||||||
|
Get(ctx context.Context, repo string, h model.Hash) (io.ReadCloser, error)
|
||||||
|
Put(ctx context.Context, repo string, h model.Hash, rc io.ReadCloser) error
|
||||||
|
Stat(ctx context.Context, repo string, h model.Hash) (int64, error)
|
||||||
|
Delete(ctx context.Context, repo string, h model.Hash) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadHandler handles upload operations
|
||||||
|
type UploadHandler interface {
|
||||||
|
UploadId() string
|
||||||
|
Write(ctx context.Context, sessionID string, r io.Reader, start, end int) (int, *rerr.RepositoryError)
|
||||||
|
Done(ctx context.Context, blobHandler BlobHandler, sessionID string, r io.Reader, contentLength int, repo string, h model.Hash) *rerr.RepositoryError
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManifestHandler handles manifest operations
|
||||||
|
type ManifestHandler interface {
|
||||||
|
Get(ctx context.Context, repo, tag string) (io.ReadCloser, string, *rerr.RepositoryError)
|
||||||
|
Put(ctx context.Context, repo, tag, digest string, mf *model.RepoSimpleManifest) error
|
||||||
|
Delete(ctx context.Context, repo, tag string) error
|
||||||
|
Tags(ctx context.Context, repo string, n, last int, prefix string) (*model.Tag, *rerr.RepositoryError)
|
||||||
|
Catalog(ctx context.Context, n, last int, prefix string) (*model.Catalog, *rerr.RepositoryError)
|
||||||
|
Referrers(ctx context.Context, repo, target string) (*model.IndexManifest, *rerr.RepositoryError)
|
||||||
|
}
|
||||||
153
handler/manifest.go
Normal file
153
handler/manifest.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/controller"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetManifest 获取 manifest
|
||||||
|
func GetManifest(store storage.Storage) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
repo := c.Locals("repo_name")
|
||||||
|
repoStr := ""
|
||||||
|
if repo != nil {
|
||||||
|
repoStr = repo.(string)
|
||||||
|
}
|
||||||
|
if repoStr == "" {
|
||||||
|
repoStr = strings.TrimPrefix(c.Params("name"), "/")
|
||||||
|
}
|
||||||
|
reference := c.Locals("reference")
|
||||||
|
referenceStr := ""
|
||||||
|
if reference != nil {
|
||||||
|
referenceStr = reference.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, mediaType, digest, err := controller.GetManifest(store, repoStr, referenceStr)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||||
|
"errors": []fiber.Map{
|
||||||
|
{
|
||||||
|
"code": "MANIFEST_UNKNOWN",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("Content-Type", mediaType)
|
||||||
|
c.Set("Content-Length", strconv.FormatInt(int64(len(data)), 10))
|
||||||
|
c.Set("Docker-Content-Digest", digest)
|
||||||
|
return c.Send(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutManifest 推送 manifest
|
||||||
|
func PutManifest(store storage.Storage) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
repo := c.Locals("repo_name")
|
||||||
|
repoStr := ""
|
||||||
|
if repo != nil {
|
||||||
|
repoStr = repo.(string)
|
||||||
|
}
|
||||||
|
if repoStr == "" {
|
||||||
|
repoStr = c.Params("name")
|
||||||
|
}
|
||||||
|
reference := c.Locals("reference")
|
||||||
|
referenceStr := ""
|
||||||
|
if reference != nil {
|
||||||
|
referenceStr = reference.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取请求体
|
||||||
|
data := c.Body()
|
||||||
|
|
||||||
|
// 获取 Content-Type
|
||||||
|
mediaType := c.Get("Content-Type")
|
||||||
|
if mediaType == "" {
|
||||||
|
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
|
||||||
|
}
|
||||||
|
|
||||||
|
digest, err := controller.PutManifest(store, repoStr, referenceStr, data, mediaType)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"errors": []fiber.Map{
|
||||||
|
{
|
||||||
|
"code": "INTERNAL_ERROR",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回 Location 和 Digest
|
||||||
|
c.Set("Location", c.Path())
|
||||||
|
c.Set("Docker-Content-Digest", digest)
|
||||||
|
return c.SendStatus(fiber.StatusCreated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteManifest 删除 manifest
|
||||||
|
func DeleteManifest(store storage.Storage) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
repo := c.Locals("repo_name")
|
||||||
|
repoStr := ""
|
||||||
|
if repo != nil {
|
||||||
|
repoStr = repo.(string)
|
||||||
|
}
|
||||||
|
if repoStr == "" {
|
||||||
|
repoStr = c.Params("name")
|
||||||
|
}
|
||||||
|
reference := c.Locals("reference")
|
||||||
|
referenceStr := ""
|
||||||
|
if reference != nil {
|
||||||
|
referenceStr = reference.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := controller.DeleteManifest(store, repoStr, referenceStr); err != nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||||
|
"errors": []fiber.Map{
|
||||||
|
{
|
||||||
|
"code": "MANIFEST_UNKNOWN",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusAccepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeadManifest 检查 manifest 是否存在
|
||||||
|
func HeadManifest(store storage.Storage) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
repo := c.Locals("repo_name")
|
||||||
|
repoStr := ""
|
||||||
|
if repo != nil {
|
||||||
|
repoStr = repo.(string)
|
||||||
|
}
|
||||||
|
if repoStr == "" {
|
||||||
|
repoStr = strings.TrimPrefix(c.Params("name"), "/")
|
||||||
|
}
|
||||||
|
reference := c.Locals("reference")
|
||||||
|
referenceStr := ""
|
||||||
|
if reference != nil {
|
||||||
|
referenceStr = reference.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
exists, data, mediaType, digest, err := controller.HeadManifest(store, repoStr, referenceStr)
|
||||||
|
if err != nil || !exists {
|
||||||
|
return c.SendStatus(fiber.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("Content-Type", mediaType)
|
||||||
|
c.Set("Content-Length", strconv.FormatInt(int64(len(data)), 10))
|
||||||
|
c.Set("Docker-Content-Digest", digest)
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
106
handler/manifest_handler.go
Normal file
106
handler/manifest_handler.go
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/database"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type manifestHandlerImpl struct {
|
||||||
|
store storage.Storage
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManifestHandler creates a new manifest handler
|
||||||
|
func NewManifestHandler(store storage.Storage) ManifestHandler {
|
||||||
|
return &manifestHandlerImpl{store: store}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manifestHandlerImpl) Get(ctx context.Context, repo, tag string) (io.ReadCloser, string, *rerr.RepositoryError) {
|
||||||
|
data, mediaType, err := m.store.GetManifest(repo, tag)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", &rerr.RepositoryError{
|
||||||
|
Status: 404,
|
||||||
|
Code: "MANIFEST_UNKNOWN",
|
||||||
|
Message: "manifest not found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return io.NopCloser(bytes.NewReader(data)), mediaType, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manifestHandlerImpl) Put(ctx context.Context, repo, tag, digest string, mf *model.RepoSimpleManifest) error {
|
||||||
|
return m.store.PutManifest(repo, tag, mf.Blob, mf.ContentType)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manifestHandlerImpl) Delete(ctx context.Context, repo, tag string) error {
|
||||||
|
return m.store.DeleteManifest(repo, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manifestHandlerImpl) Tags(ctx context.Context, repo string, n, last int, prefix string) (*model.Tag, *rerr.RepositoryError) {
|
||||||
|
var repository model.RegistryRepository
|
||||||
|
if err := database.DB.Where("name = ?", repo).First(&repository).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return &model.Tag{Name: repo, Tags: []string{}}, nil
|
||||||
|
}
|
||||||
|
return nil, rerr.ErrInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var tags []model.RegistryTag
|
||||||
|
query := database.DB.Where("repository_id = ?", repository.ID)
|
||||||
|
if prefix != "" {
|
||||||
|
query = query.Where("name LIKE ?", prefix+"%")
|
||||||
|
}
|
||||||
|
if last > 0 {
|
||||||
|
query = query.Where("id > ?", last)
|
||||||
|
}
|
||||||
|
query = query.Order("id ASC").Limit(n)
|
||||||
|
|
||||||
|
if err := query.Find(&tags).Error; err != nil {
|
||||||
|
return nil, rerr.ErrInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagNames := make([]string, len(tags))
|
||||||
|
for i, tag := range tags {
|
||||||
|
tagNames[i] = tag.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.Tag{Name: repo, Tags: tagNames}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manifestHandlerImpl) Catalog(ctx context.Context, n, last int, prefix string) (*model.Catalog, *rerr.RepositoryError) {
|
||||||
|
var repositories []model.RegistryRepository
|
||||||
|
query := database.DB
|
||||||
|
if prefix != "" {
|
||||||
|
query = query.Where("name LIKE ?", prefix+"%")
|
||||||
|
}
|
||||||
|
if last > 0 {
|
||||||
|
query = query.Where("id > ?", last)
|
||||||
|
}
|
||||||
|
query = query.Order("id ASC").Limit(n)
|
||||||
|
|
||||||
|
if err := query.Find(&repositories).Error; err != nil {
|
||||||
|
return nil, rerr.ErrInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
repoNames := make([]string, len(repositories))
|
||||||
|
for i, repo := range repositories {
|
||||||
|
repoNames[i] = repo.Name
|
||||||
|
}
|
||||||
|
|
||||||
|
return &model.Catalog{Repositories: repoNames}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *manifestHandlerImpl) Referrers(ctx context.Context, repo, target string) (*model.IndexManifest, *rerr.RepositoryError) {
|
||||||
|
// For now, return an empty index manifest
|
||||||
|
return &model.IndexManifest{
|
||||||
|
SchemaVersion: 2,
|
||||||
|
MediaType: "application/vnd.oci.image.index.v1+json",
|
||||||
|
Manifests: []model.IndexManifestEntry{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
113
handler/registry.go
Normal file
113
handler/registry.go
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/controller"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryV2 handles all OCI Registry API v2 requests
|
||||||
|
func RegistryV2(store storage.Storage) fiber.Handler {
|
||||||
|
// Initialize handlers
|
||||||
|
blobHandler = NewBlobHandler(store)
|
||||||
|
uploadHandler = NewUploadHandler()
|
||||||
|
m = NewManifestHandler(store)
|
||||||
|
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
if isBlob(c) {
|
||||||
|
return handleBlobs(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isManifest(c) {
|
||||||
|
return handleManifest(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isTags(c) {
|
||||||
|
return handleTags(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isCatalog(c) {
|
||||||
|
return handleCatalog(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
if isReferrers(c) {
|
||||||
|
return handleReferrers(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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] RegistryV2: unknown endpoint - path = %s, method = %s", c.Path(), c.Method())
|
||||||
|
|
||||||
|
return c.Status(404).JSON(fiber.Map{
|
||||||
|
"errors": []fiber.Map{{"code": "NOT_FOUND", "message": "endpoint not found"}},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListImages 返回所有仓库列表
|
||||||
|
func ListImages(c fiber.Ctx) error {
|
||||||
|
repos, err := controller.ListImages()
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return c.JSON(fiber.Map{"images": repos})
|
||||||
|
}
|
||||||
288
handler/registry_blob.go
Normal file
288
handler/registry_blob.go
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/database"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ErrNotFound = errors.New("not found")
|
||||||
|
|
||||||
|
func handleBlobs(c fiber.Ctx) error {
|
||||||
|
elem := strings.Split(c.Path(), "/")
|
||||||
|
elem = elem[1:]
|
||||||
|
if elem[len(elem)-1] == "" {
|
||||||
|
elem = elem[:len(elem)-1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must have a path of form /v2/{name}/blobs/{upload,sha256:}
|
||||||
|
if len(elem) < 4 {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "NAME_INVALID",
|
||||||
|
Message: "blobs must be attached to a repo",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
target := elem[len(elem)-1]
|
||||||
|
service := elem[len(elem)-2]
|
||||||
|
digest := c.Query("digest")
|
||||||
|
contentRange := c.Get("Content-Range")
|
||||||
|
rangeHeader := c.Get("Range")
|
||||||
|
repo := strings.Join(elem[1:len(elem)-2], "/")
|
||||||
|
|
||||||
|
switch c.Method() {
|
||||||
|
case http.MethodHead:
|
||||||
|
h, err := model.NewHash(target)
|
||||||
|
if err != nil {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "NAME_INVALID",
|
||||||
|
Message: "invalid digest",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := blobHandler.Stat(c.Context(), repo, h)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return rerr.Error(c, rerr.ErrBlobUnknown)
|
||||||
|
} else if err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("Content-Length", fmt.Sprint(size))
|
||||||
|
c.Set("Docker-Content-Digest", h.String())
|
||||||
|
return c.Send(nil)
|
||||||
|
|
||||||
|
case http.MethodGet:
|
||||||
|
h, err := model.NewHash(target)
|
||||||
|
if err != nil {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "NAME_INVALID",
|
||||||
|
Message: "invalid digest",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := blobHandler.Stat(c.Context(), repo, h)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return rerr.Error(c, rerr.ErrBlobUnknown)
|
||||||
|
} else if err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
rc, err := blobHandler.Get(c.Context(), repo, h)
|
||||||
|
if errors.Is(err, ErrNotFound) {
|
||||||
|
return rerr.Error(c, rerr.ErrBlobUnknown)
|
||||||
|
} else if err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
defer rc.Close()
|
||||||
|
|
||||||
|
r := rc
|
||||||
|
if rangeHeader != "" {
|
||||||
|
start, end := int64(0), int64(0)
|
||||||
|
if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusRequestedRangeNotSatisfiable,
|
||||||
|
Code: "BLOB_UNKNOWN",
|
||||||
|
Message: "We don't understand your Range",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
n := (end + 1) - start
|
||||||
|
if ra, ok := r.(io.ReaderAt); ok {
|
||||||
|
if end+1 > size {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusRequestedRangeNotSatisfiable,
|
||||||
|
Code: "BLOB_UNKNOWN",
|
||||||
|
Message: fmt.Sprintf("range end %d > %d size", end+1, size),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
sr := io.NewSectionReader(ra, start, n)
|
||||||
|
r = io.NopCloser(sr)
|
||||||
|
} else {
|
||||||
|
if _, err := io.CopyN(io.Discard, r, start); err != nil {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusRequestedRangeNotSatisfiable,
|
||||||
|
Code: "BLOB_UNKNOWN",
|
||||||
|
Message: fmt.Sprintf("Failed to discard %d bytes", start),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
lr := io.LimitReader(r, n)
|
||||||
|
r = io.NopCloser(lr)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
|
||||||
|
c.Set("Content-Length", fmt.Sprint(n))
|
||||||
|
c.Set("Docker-Content-Digest", h.String())
|
||||||
|
c.Status(http.StatusPartialContent)
|
||||||
|
} else {
|
||||||
|
c.Set("Content-Length", fmt.Sprint(size))
|
||||||
|
c.Set("Docker-Content-Digest", h.String())
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = io.Copy(c, r)
|
||||||
|
return err
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
if target != "uploads" {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "METHOD_UNKNOWN",
|
||||||
|
Message: fmt.Sprintf("POST to /blobs must be followed by /uploads, got %s", target),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if digest != "" {
|
||||||
|
h, err := model.NewHash(digest)
|
||||||
|
if err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrDigestInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
vrc := io.NopCloser(bytes.NewReader(c.Body()))
|
||||||
|
defer vrc.Close()
|
||||||
|
|
||||||
|
if err = blobHandler.Put(c.Context(), repo, h, vrc); err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
c.Set("Docker-Content-Digest", h.String())
|
||||||
|
return c.SendStatus(http.StatusCreated)
|
||||||
|
}
|
||||||
|
|
||||||
|
id := uploadHandler.UploadId()
|
||||||
|
|
||||||
|
// Get or create repository
|
||||||
|
var repository model.RegistryRepository
|
||||||
|
if err := database.DB.Where("name = ?", repo).First(&repository).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
repository = model.RegistryRepository{Name: repo}
|
||||||
|
if err := database.DB.Create(&repository).Error; err != nil {
|
||||||
|
if strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||||
|
if err := database.DB.Where("name = ?", repo).First(&repository).Error; err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create upload session
|
||||||
|
uploadPath := model.GetUploadPath("./x-storage", id)
|
||||||
|
if err := os.MkdirAll(path.Dir(uploadPath), 0755); err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
session := model.RegistryUploadSession{
|
||||||
|
RepositoryID: repository.ID,
|
||||||
|
SessionID: id,
|
||||||
|
Path: uploadPath,
|
||||||
|
Size: 0,
|
||||||
|
}
|
||||||
|
if err := database.DB.Create(&session).Error; err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("Location", "/"+path.Join("v2", path.Join(elem[1:len(elem)-2]...), "blobs/uploads", id))
|
||||||
|
c.Set("Range", "0-0")
|
||||||
|
|
||||||
|
return c.SendStatus(http.StatusAccepted)
|
||||||
|
|
||||||
|
case http.MethodPatch:
|
||||||
|
if service != "uploads" {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "METHOD_UNKNOWN",
|
||||||
|
Message: fmt.Sprintf("PATCH to /blobs must be followed by /uploads, got %s", service),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
start, end := 0, 0
|
||||||
|
if contentRange != "" {
|
||||||
|
if _, err := fmt.Sscanf(contentRange, "%d-%d", &start, &end); err != nil {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusRequestedRangeNotSatisfiable,
|
||||||
|
Code: "BLOB_UPLOAD_UNKNOWN",
|
||||||
|
Message: "We don't understand your Content-Range",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedEnd := start + len(c.Body()) - 1
|
||||||
|
if end != expectedEnd {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusRequestedRangeNotSatisfiable,
|
||||||
|
Code: "BLOB_UPLOAD_INVALID",
|
||||||
|
Message: fmt.Sprintf("blob upload content range mismatch: expected end %d, got %d", expectedEnd, end),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
end = start + len(c.Body()) - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
length, re := uploadHandler.Write(c.Context(), target, bytes.NewReader(c.Body()), start, end)
|
||||||
|
if re != nil {
|
||||||
|
return rerr.Error(c, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("Location", "/"+path.Join("v2", path.Join(elem[1:len(elem)-3]...), "blobs/uploads", target))
|
||||||
|
c.Set("Range", fmt.Sprintf("0-%d", length-1))
|
||||||
|
return c.SendStatus(http.StatusNoContent)
|
||||||
|
|
||||||
|
case http.MethodPut:
|
||||||
|
if service != "uploads" {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "METHOD_UNKNOWN",
|
||||||
|
Message: fmt.Sprintf("PUT to /blobs must be followed by /uploads, got %s", service),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if digest == "" {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "DIGEST_INVALID",
|
||||||
|
Message: "digest not specified",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, err := model.NewHash(digest)
|
||||||
|
if err != nil {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "NAME_INVALID",
|
||||||
|
Message: "invalid digest",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
re := uploadHandler.Done(c.Context(), blobHandler, target, bytes.NewReader(c.Body()), len(c.Body()), repo, hash)
|
||||||
|
if re != nil {
|
||||||
|
return rerr.Error(c, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("Docker-Content-Digest", hash.String())
|
||||||
|
return c.SendStatus(http.StatusCreated)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "METHOD_UNKNOWN",
|
||||||
|
Message: "We don't understand your method + url",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
33
handler/registry_catalog.go
Normal file
33
handler/registry_catalog.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleCatalog(c fiber.Ctx) error {
|
||||||
|
if c.Method() != "GET" {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "METHOD_UNKNOWN",
|
||||||
|
Message: "We don't understand your method + url",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
nStr := c.Query("n")
|
||||||
|
n := 10000
|
||||||
|
if nStr != "" {
|
||||||
|
n, _ = strconv.Atoi(nStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
list, re := m.Catalog(c.Context(), n, 0, "")
|
||||||
|
if re != nil {
|
||||||
|
return rerr.Error(c, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(list)
|
||||||
|
}
|
||||||
92
handler/registry_manifest.go
Normal file
92
handler/registry_manifest.go
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleManifest(c fiber.Ctx) error {
|
||||||
|
elem := strings.Split(c.Path(), "/")
|
||||||
|
elem = elem[1:]
|
||||||
|
target := elem[len(elem)-1]
|
||||||
|
repo := strings.Join(elem[1:len(elem)-2], "/")
|
||||||
|
|
||||||
|
switch c.Method() {
|
||||||
|
case http.MethodGet:
|
||||||
|
reader, contentType, re := m.Get(c.Context(), repo, target)
|
||||||
|
if re != nil {
|
||||||
|
return rerr.Error(c, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
bs, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
h, _, _ := model.SHA256(bytes.NewReader(bs))
|
||||||
|
c.Set("Docker-Content-Digest", h.String())
|
||||||
|
c.Set("Content-Type", contentType)
|
||||||
|
c.Set("Content-Length", fmt.Sprint(len(bs)))
|
||||||
|
return c.Send(bs)
|
||||||
|
|
||||||
|
case http.MethodHead:
|
||||||
|
reader, contentType, re := m.Get(c.Context(), repo, target)
|
||||||
|
if re != nil {
|
||||||
|
return rerr.Error(c, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
bs, err := io.ReadAll(reader)
|
||||||
|
if err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
h, _, _ := model.SHA256(bytes.NewReader(bs))
|
||||||
|
c.Set("Docker-Content-Digest", h.String())
|
||||||
|
c.Set("Content-Type", contentType)
|
||||||
|
c.Set("Content-Length", fmt.Sprint(len(bs)))
|
||||||
|
return c.Send(bs)
|
||||||
|
|
||||||
|
case http.MethodPut:
|
||||||
|
buf := &bytes.Buffer{}
|
||||||
|
if _, err := io.Copy(buf, bytes.NewReader(c.Body())); err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, _, err := model.SHA256(bytes.NewReader(buf.Bytes()))
|
||||||
|
if err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
digest := hash.String()
|
||||||
|
|
||||||
|
mf := model.RepoSimpleManifest{
|
||||||
|
Blob: buf.Bytes(),
|
||||||
|
ContentType: c.Get("Content-Type"),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.Put(c.Context(), repo, target, digest, &mf); err != nil {
|
||||||
|
return rerr.Error(c, rerr.ErrInternal(err))
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("Docker-Content-Digest", digest)
|
||||||
|
return c.SendStatus(http.StatusCreated)
|
||||||
|
|
||||||
|
case http.MethodDelete:
|
||||||
|
return c.SendStatus(http.StatusAccepted)
|
||||||
|
|
||||||
|
default:
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "METHOD_UNKNOWN",
|
||||||
|
Message: "We don't understand your method + url",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
32
handler/registry_referrers.go
Normal file
32
handler/registry_referrers.go
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleReferrers(c fiber.Ctx) error {
|
||||||
|
if c.Method() != "GET" {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "METHOD_UNKNOWN",
|
||||||
|
Message: "We don't understand your method + url",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
elem := strings.Split(c.Path(), "/")
|
||||||
|
elem = elem[1:]
|
||||||
|
repo := strings.Join(elem[1:len(elem)-2], "/")
|
||||||
|
target := elem[len(elem)-1]
|
||||||
|
|
||||||
|
index, re := m.Referrers(c.Context(), repo, target)
|
||||||
|
if re != nil {
|
||||||
|
return rerr.Error(c, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(index)
|
||||||
|
}
|
||||||
49
handler/registry_tag.go
Normal file
49
handler/registry_tag.go
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func handleTags(c fiber.Ctx) error {
|
||||||
|
if c.Method() != "GET" {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "METHOD_UNKNOWN",
|
||||||
|
Message: "We don't understand your method + url",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type Req struct {
|
||||||
|
Last int `json:"last" query:"last"`
|
||||||
|
N int `json:"n" query:"n"`
|
||||||
|
}
|
||||||
|
|
||||||
|
elem := strings.Split(c.Path(), "/")
|
||||||
|
elem = elem[1:]
|
||||||
|
repo := strings.Join(elem[1:len(elem)-2], "/")
|
||||||
|
|
||||||
|
var req Req
|
||||||
|
if err := c.Bind().Query(&req); err != nil {
|
||||||
|
return rerr.Error(c, &rerr.RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "BAD_REQUEST",
|
||||||
|
Message: err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.N <= 0 {
|
||||||
|
req.N = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
list, re := m.Tags(c.Context(), repo, req.N, req.Last, "")
|
||||||
|
if re != nil {
|
||||||
|
return rerr.Error(c, re)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(list)
|
||||||
|
}
|
||||||
173
handler/upload.go
Normal file
173
handler/upload.go
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/controller"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartBlobUpload 开始 blob 上传
|
||||||
|
func StartBlobUpload(store storage.Storage) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
repo := c.Locals("repo_name")
|
||||||
|
repoStr := ""
|
||||||
|
if repo != nil {
|
||||||
|
repoStr = repo.(string)
|
||||||
|
}
|
||||||
|
if repoStr == "" {
|
||||||
|
repoStr = strings.TrimPrefix(c.Params("name"), "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
uuid, err := controller.StartBlobUpload(store, repoStr)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"errors": []fiber.Map{
|
||||||
|
{
|
||||||
|
"code": "INTERNAL_ERROR",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回上传 URL
|
||||||
|
location := c.Path() + "/" + uuid
|
||||||
|
c.Set("Location", location)
|
||||||
|
c.Set("Docker-Upload-UUID", uuid)
|
||||||
|
c.Set("Range", "0-0")
|
||||||
|
return c.SendStatus(fiber.StatusAccepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatchBlobUpload 上传 blob 数据块
|
||||||
|
func PatchBlobUpload(store storage.Storage) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
uuid := c.Locals("uuid")
|
||||||
|
uuidStr := ""
|
||||||
|
if uuid != nil {
|
||||||
|
uuidStr = uuid.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Range header
|
||||||
|
rangeHeader := c.Get("Content-Range")
|
||||||
|
if rangeHeader == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"errors": []fiber.Map{
|
||||||
|
{
|
||||||
|
"code": "BAD_REQUEST",
|
||||||
|
"message": "Content-Range header required",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取数据
|
||||||
|
data := bytes.NewReader(c.Body())
|
||||||
|
size, err := controller.PatchBlobUpload(store, uuidStr, data)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||||
|
"errors": []fiber.Map{
|
||||||
|
{
|
||||||
|
"code": "INTERNAL_ERROR",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
location := c.Path()
|
||||||
|
c.Set("Location", location)
|
||||||
|
c.Set("Docker-Upload-UUID", uuidStr)
|
||||||
|
c.Set("Range", "0-"+strconv.FormatInt(size-1, 10))
|
||||||
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutBlobUpload 完成 blob 上传
|
||||||
|
func PutBlobUpload(store storage.Storage) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
repo := c.Locals("repo_name")
|
||||||
|
repoStr := ""
|
||||||
|
if repo != nil {
|
||||||
|
repoStr = repo.(string)
|
||||||
|
}
|
||||||
|
if repoStr == "" {
|
||||||
|
repoStr = strings.TrimPrefix(c.Params("name"), "/")
|
||||||
|
}
|
||||||
|
uuid := c.Locals("uuid")
|
||||||
|
uuidStr := ""
|
||||||
|
if uuid != nil {
|
||||||
|
uuidStr = uuid.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 digest
|
||||||
|
digest := c.Query("digest")
|
||||||
|
if digest == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"errors": []fiber.Map{
|
||||||
|
{
|
||||||
|
"code": "BAD_REQUEST",
|
||||||
|
"message": "digest query parameter required",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有请求体,先追加数据
|
||||||
|
var data interface{} = nil
|
||||||
|
if len(c.Body()) > 0 {
|
||||||
|
data = bytes.NewReader(c.Body())
|
||||||
|
}
|
||||||
|
|
||||||
|
location, err := controller.PutBlobUpload(store, repoStr, uuidStr, digest, data, c.Path())
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
|
||||||
|
"errors": []fiber.Map{
|
||||||
|
{
|
||||||
|
"code": "BAD_REQUEST",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Set("Location", location)
|
||||||
|
c.Set("Content-Length", "0")
|
||||||
|
c.Set("Docker-Content-Digest", digest)
|
||||||
|
return c.SendStatus(fiber.StatusCreated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlobUpload 获取上传状态
|
||||||
|
func GetBlobUpload(store storage.Storage) fiber.Handler {
|
||||||
|
return func(c fiber.Ctx) error {
|
||||||
|
uuid := c.Locals("uuid")
|
||||||
|
uuidStr := ""
|
||||||
|
if uuid != nil {
|
||||||
|
uuidStr = uuid.(string)
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := controller.GetBlobUpload(store, uuidStr)
|
||||||
|
if err != nil {
|
||||||
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
|
||||||
|
"errors": []fiber.Map{
|
||||||
|
{
|
||||||
|
"code": "UPLOAD_UNKNOWN",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
location := c.Path()
|
||||||
|
c.Set("Location", location)
|
||||||
|
c.Set("Docker-Upload-UUID", uuidStr)
|
||||||
|
c.Set("Range", "0-"+strconv.FormatInt(size-1, 10))
|
||||||
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
99
handler/upload_handler.go
Normal file
99
handler/upload_handler.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/database"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/rerr"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type uploadHandlerImpl struct{}
|
||||||
|
|
||||||
|
// NewUploadHandler creates a new upload handler
|
||||||
|
func NewUploadHandler() UploadHandler {
|
||||||
|
return &uploadHandlerImpl{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *uploadHandlerImpl) UploadId() string {
|
||||||
|
return uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *uploadHandlerImpl) Write(ctx context.Context, sessionID string, r io.Reader, start, end int) (int, *rerr.RepositoryError) {
|
||||||
|
// Get upload session
|
||||||
|
var session model.RegistryUploadSession
|
||||||
|
if err := database.DB.Where("session_id = ?", sessionID).First(&session).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, &rerr.RepositoryError{
|
||||||
|
Status: 404,
|
||||||
|
Code: "BLOB_UPLOAD_UNKNOWN",
|
||||||
|
Message: "upload session not found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, rerr.ErrInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open file for writing
|
||||||
|
file, err := os.OpenFile(session.Path, os.O_WRONLY|os.O_CREATE, 0644)
|
||||||
|
if err != nil {
|
||||||
|
return 0, rerr.ErrInternal(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Seek to start position
|
||||||
|
if _, err := file.Seek(int64(start), 0); err != nil {
|
||||||
|
return 0, rerr.ErrInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write data
|
||||||
|
n, err := io.CopyN(file, r, int64(end-start+1))
|
||||||
|
if err != nil && err != io.EOF {
|
||||||
|
return 0, rerr.ErrInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session size
|
||||||
|
session.Size = int64(start) + n
|
||||||
|
if err := database.DB.Save(&session).Error; err != nil {
|
||||||
|
return 0, rerr.ErrInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(n), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (u *uploadHandlerImpl) Done(ctx context.Context, blobHandler BlobHandler, sessionID string, r io.Reader, contentLength int, repo string, h model.Hash) *rerr.RepositoryError {
|
||||||
|
// Get upload session
|
||||||
|
var session model.RegistryUploadSession
|
||||||
|
if err := database.DB.Where("session_id = ?", sessionID).First(&session).Error; err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return &rerr.RepositoryError{
|
||||||
|
Status: 404,
|
||||||
|
Code: "BLOB_UPLOAD_UNKNOWN",
|
||||||
|
Message: "upload session not found",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rerr.ErrInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read from the uploaded file instead of the request body
|
||||||
|
file, err := os.Open(session.Path)
|
||||||
|
if err != nil {
|
||||||
|
return rerr.ErrInternal(err)
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Store blob
|
||||||
|
if err := blobHandler.Put(ctx, repo, h, file); err != nil {
|
||||||
|
return rerr.ErrInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up upload session
|
||||||
|
os.Remove(session.Path)
|
||||||
|
database.DB.Delete(&session)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
11
handler/version.go
Normal file
11
handler/version.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VersionCheck API 版本检查
|
||||||
|
func VersionCheck(c fiber.Ctx) error {
|
||||||
|
c.Set("Docker-Distribution-API-Version", "registry/2.0")
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
}
|
||||||
19
internal/api/v1/registry/images.go
Normal file
19
internal/api/v1/registry/images.go
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
package registry
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ListImages returns all repositories as images
|
||||||
|
func ListImages(c *gin.Context) {
|
||||||
|
var repos []database.Repository
|
||||||
|
if err := database.DB.Find(&repos).Error; err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"images": repos})
|
||||||
|
}
|
||||||
56
internal/config/config.go
Normal file
56
internal/config/config.go
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
Server ServerConfig `json:"server"`
|
||||||
|
Storage StorageConfig `json:"storage"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ServerConfig struct {
|
||||||
|
Address string `json:"address"` // 监听地址,如 :8080
|
||||||
|
Debug bool `json:"debug"` // 是否开启调试模式
|
||||||
|
}
|
||||||
|
|
||||||
|
type StorageConfig struct {
|
||||||
|
RootPath string `json:"root_path"` // 数据存储目录
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadFromFlags 从命令行参数加载配置
|
||||||
|
func LoadFromFlags(debug bool, address, dataDir string) (*Config, error) {
|
||||||
|
cfg := &Config{
|
||||||
|
Server: ServerConfig{
|
||||||
|
Address: address,
|
||||||
|
Debug: debug,
|
||||||
|
},
|
||||||
|
Storage: StorageConfig{
|
||||||
|
RootPath: dataDir,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 确保存储目录存在
|
||||||
|
if err := os.MkdirAll(cfg.Storage.RootPath, 0755); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create data directory %s: %w", cfg.Storage.RootPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load 从环境变量加载配置(保留向后兼容)
|
||||||
|
func Load() (*Config, error) {
|
||||||
|
address := getEnv("ADDRESS", ":8080")
|
||||||
|
dataDir := getEnv("DATA_DIR", "./storage")
|
||||||
|
debug := getEnv("DEBUG", "false") == "true"
|
||||||
|
|
||||||
|
return LoadFromFlags(debug, address, dataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func getEnv(key, defaultValue string) string {
|
||||||
|
if value := os.Getenv(key); value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return defaultValue
|
||||||
|
}
|
||||||
114
internal/database/database.go
Normal file
114
internal/database/database.go
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DB 数据库连接
|
||||||
|
var DB *gorm.DB
|
||||||
|
|
||||||
|
// Init 初始化数据库
|
||||||
|
func Init(dataDir string) error {
|
||||||
|
// 确保数据目录存在
|
||||||
|
if err := os.MkdirAll(dataDir, 0755); err != nil {
|
||||||
|
return fmt.Errorf("failed to create data directory %s: %w", dataDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(dataDir, "cluster.db")
|
||||||
|
|
||||||
|
// 检查数据库文件是否存在
|
||||||
|
dbExists := false
|
||||||
|
if _, err := os.Stat(dbPath); err == nil {
|
||||||
|
dbExists = true
|
||||||
|
log.Printf("Database file already exists: %s", dbPath)
|
||||||
|
} else if !os.IsNotExist(err) {
|
||||||
|
return fmt.Errorf("failed to check database file: %w", err)
|
||||||
|
} else {
|
||||||
|
log.Printf("Creating new database file: %s", dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 配置 GORM logger
|
||||||
|
gormLogger := logger.Default
|
||||||
|
if os.Getenv("GORM_LOG_LEVEL") == "silent" {
|
||||||
|
gormLogger = gormLogger.LogMode(logger.Silent)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开数据库连接
|
||||||
|
var err error
|
||||||
|
DB, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{
|
||||||
|
Logger: gormLogger,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取底层 sql.DB 以设置连接池
|
||||||
|
sqlDB, err := DB.DB()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get database instance: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置连接池参数
|
||||||
|
sqlDB.SetMaxIdleConns(10)
|
||||||
|
sqlDB.SetMaxOpenConns(100)
|
||||||
|
sqlDB.SetConnMaxLifetime(time.Hour)
|
||||||
|
|
||||||
|
// 启用外键约束
|
||||||
|
if err := DB.Exec("PRAGMA foreign_keys = ON").Error; err != nil {
|
||||||
|
return fmt.Errorf("failed to enable foreign keys: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自动迁移表结构
|
||||||
|
if err := autoMigrate(); err != nil {
|
||||||
|
return fmt.Errorf("failed to migrate tables: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !dbExists {
|
||||||
|
log.Printf("Database initialized successfully: %s", dbPath)
|
||||||
|
} else {
|
||||||
|
log.Printf("Database tables verified: %s", dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 关闭数据库连接
|
||||||
|
func Close() error {
|
||||||
|
if DB != nil {
|
||||||
|
sqlDB, err := DB.DB()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return sqlDB.Close()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoMigrate 自动迁移表结构
|
||||||
|
func autoMigrate() error {
|
||||||
|
models := []interface{}{
|
||||||
|
&Repository{},
|
||||||
|
&Manifest{},
|
||||||
|
&Blob{},
|
||||||
|
&Tag{},
|
||||||
|
&BlobUpload{},
|
||||||
|
&ManifestBlob{},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, model := range models {
|
||||||
|
if err := DB.AutoMigrate(model); err != nil {
|
||||||
|
return fmt.Errorf("failed to migrate model %T: %w", model, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Database migration completed successfully")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
116
internal/database/models.go
Normal file
116
internal/database/models.go
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Repository 仓库模型
|
||||||
|
type Repository struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex;not null" json:"name"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
|
||||||
|
// 关联关系
|
||||||
|
Manifests []Manifest `gorm:"foreignKey:RepositoryID;constraint:OnDelete:CASCADE" json:"manifests,omitempty"`
|
||||||
|
Tags []Tag `gorm:"foreignKey:RepositoryID;constraint:OnDelete:CASCADE" json:"tags,omitempty"`
|
||||||
|
BlobUploads []BlobUpload `gorm:"foreignKey:RepositoryID;constraint:OnDelete:CASCADE" json:"blob_uploads,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (Repository) TableName() string {
|
||||||
|
return "repositories"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Manifest Manifest 模型
|
||||||
|
type Manifest struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
RepositoryID uint `gorm:"index;not null" json:"repository_id"`
|
||||||
|
Digest string `gorm:"index;not null" json:"digest"`
|
||||||
|
MediaType string `gorm:"not null" json:"media_type"`
|
||||||
|
Size int64 `gorm:"not null" json:"size"`
|
||||||
|
DataPath string `gorm:"not null" json:"data_path"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
|
||||||
|
// 唯一约束:同一仓库中 digest 唯一
|
||||||
|
_ struct{} `gorm:"uniqueIndex:idx_repo_digest"`
|
||||||
|
|
||||||
|
// 关联关系
|
||||||
|
Repository Repository `gorm:"foreignKey:RepositoryID" json:"repository,omitempty"`
|
||||||
|
Tags []Tag `gorm:"foreignKey:ManifestID;constraint:OnDelete:CASCADE" json:"tags,omitempty"`
|
||||||
|
ManifestBlobs []ManifestBlob `gorm:"foreignKey:ManifestID;constraint:OnDelete:CASCADE" json:"manifest_blobs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (Manifest) TableName() string {
|
||||||
|
return "manifests"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Blob Blob 模型
|
||||||
|
type Blob struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
Digest string `gorm:"uniqueIndex;not null" json:"digest"`
|
||||||
|
Size int64 `gorm:"not null" json:"size"`
|
||||||
|
DataPath string `gorm:"not null" json:"data_path"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
|
||||||
|
// 关联关系
|
||||||
|
ManifestBlobs []ManifestBlob `gorm:"foreignKey:BlobID;constraint:OnDelete:CASCADE" json:"manifest_blobs,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (Blob) TableName() string {
|
||||||
|
return "blobs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag 标签模型
|
||||||
|
type Tag struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
RepositoryID uint `gorm:"uniqueIndex:idx_repo_name;not null" json:"repository_id"`
|
||||||
|
Name string `gorm:"uniqueIndex:idx_repo_name;not null" json:"name"`
|
||||||
|
ManifestID uint `gorm:"index;not null" json:"manifest_id"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
|
||||||
|
// 关联关系
|
||||||
|
Repository Repository `gorm:"foreignKey:RepositoryID" json:"repository,omitempty"`
|
||||||
|
Manifest Manifest `gorm:"foreignKey:ManifestID" json:"manifest,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (Tag) TableName() string {
|
||||||
|
return "tags"
|
||||||
|
}
|
||||||
|
|
||||||
|
// BlobUpload Blob 上传会话模型
|
||||||
|
type BlobUpload struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
|
||||||
|
RepositoryID uint `gorm:"index;not null" json:"repository_id"`
|
||||||
|
Size int64 `gorm:"default:0" json:"size"`
|
||||||
|
DataPath string `gorm:"not null" json:"data_path"`
|
||||||
|
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
|
||||||
|
|
||||||
|
// 关联关系
|
||||||
|
Repository Repository `gorm:"foreignKey:RepositoryID" json:"repository,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (BlobUpload) TableName() string {
|
||||||
|
return "blob_uploads"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ManifestBlob Manifest 和 Blob 关联模型
|
||||||
|
type ManifestBlob struct {
|
||||||
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
|
ManifestID uint `gorm:"uniqueIndex:idx_manifest_blob;index;not null" json:"manifest_id"`
|
||||||
|
BlobID uint `gorm:"uniqueIndex:idx_manifest_blob;index;not null" json:"blob_id"`
|
||||||
|
|
||||||
|
// 关联关系
|
||||||
|
Manifest Manifest `gorm:"foreignKey:ManifestID" json:"manifest,omitempty"`
|
||||||
|
Blob Blob `gorm:"foreignKey:BlobID" json:"blob,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TableName 指定表名
|
||||||
|
func (ManifestBlob) TableName() string {
|
||||||
|
return "manifest_blobs"
|
||||||
|
}
|
||||||
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
162
internal/model/registry.go
Normal file
162
internal/model/registry.go
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Hash represents a content hash
|
||||||
|
type Hash string
|
||||||
|
|
||||||
|
// NewHash creates a new Hash from a string
|
||||||
|
func NewHash(s string) (Hash, error) {
|
||||||
|
if !strings.HasPrefix(s, "sha256:") {
|
||||||
|
return "", fmt.Errorf("invalid hash format: %s", s)
|
||||||
|
}
|
||||||
|
return Hash(s), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// String returns the string representation of the hash
|
||||||
|
func (h Hash) String() string {
|
||||||
|
return string(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hex returns the hex part of the hash
|
||||||
|
func (h Hash) Hex() (string, error) {
|
||||||
|
if !strings.HasPrefix(string(h), "sha256:") {
|
||||||
|
return "", fmt.Errorf("invalid hash format: %s", h)
|
||||||
|
}
|
||||||
|
return strings.TrimPrefix(string(h), "sha256:"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SHA256 computes the SHA256 hash of the given reader
|
||||||
|
func SHA256(r io.Reader) (Hash, int64, error) {
|
||||||
|
hasher := sha256.New()
|
||||||
|
n, err := io.Copy(hasher, r)
|
||||||
|
if err != nil {
|
||||||
|
return "", 0, err
|
||||||
|
}
|
||||||
|
return Hash("sha256:" + hex.EncodeToString(hasher.Sum(nil))), n, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryRepository represents a repository in the registry
|
||||||
|
type RegistryRepository struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Name string `gorm:"uniqueIndex;not null" json:"name"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryManifest represents a manifest in the registry
|
||||||
|
type RegistryManifest struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
RepositoryID uint `gorm:"not null;index;uniqueIndex:idx_repo_digest" json:"repository_id"`
|
||||||
|
Repository RegistryRepository `gorm:"foreignKey:RepositoryID" json:"repository"`
|
||||||
|
Digest string `gorm:"not null;uniqueIndex:idx_repo_digest" json:"digest"`
|
||||||
|
Tag string `gorm:"index" json:"tag"`
|
||||||
|
ContentType string `gorm:"not null" json:"content_type"`
|
||||||
|
Size int64 `gorm:"not null" json:"size"`
|
||||||
|
Blob []byte `gorm:"type:blob" json:"blob"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryBlob represents a blob in the registry
|
||||||
|
type RegistryBlob struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
Digest string `gorm:"uniqueIndex;not null" json:"digest"`
|
||||||
|
Size int64 `gorm:"not null" json:"size"`
|
||||||
|
Path string `gorm:"not null" json:"path"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryTag represents a tag in the registry
|
||||||
|
type RegistryTag struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
RepositoryID uint `gorm:"not null;index" json:"repository_id"`
|
||||||
|
Repository RegistryRepository `gorm:"foreignKey:RepositoryID" json:"repository"`
|
||||||
|
Name string `gorm:"not null;index" json:"name"`
|
||||||
|
ManifestID uint `gorm:"not null;index" json:"manifest_id"`
|
||||||
|
Manifest RegistryManifest `gorm:"foreignKey:ManifestID" json:"manifest"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RegistryUploadSession represents an upload session
|
||||||
|
type RegistryUploadSession struct {
|
||||||
|
ID uint `gorm:"primaryKey" json:"id"`
|
||||||
|
RepositoryID uint `gorm:"not null;index" json:"repository_id"`
|
||||||
|
Repository RegistryRepository `gorm:"foreignKey:RepositoryID" json:"repository"`
|
||||||
|
SessionID string `gorm:"uniqueIndex;not null" json:"session_id"`
|
||||||
|
Path string `gorm:"not null" json:"path"`
|
||||||
|
Size int64 `gorm:"not null" json:"size"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag represents a tag list response
|
||||||
|
type Tag struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Tags []string `json:"tags"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Catalog represents a catalog response
|
||||||
|
type Catalog struct {
|
||||||
|
Repositories []string `json:"repositories"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RepoSimpleManifest represents a simple manifest
|
||||||
|
type RepoSimpleManifest struct {
|
||||||
|
Blob []byte `json:"blob"`
|
||||||
|
ContentType string `json:"content_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexManifest represents an index manifest
|
||||||
|
type IndexManifest struct {
|
||||||
|
SchemaVersion int `json:"schemaVersion"`
|
||||||
|
MediaType string `json:"mediaType"`
|
||||||
|
Manifests []IndexManifestEntry `json:"manifests"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexManifestEntry represents an entry in an index manifest
|
||||||
|
type IndexManifestEntry struct {
|
||||||
|
MediaType string `json:"mediaType"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Digest string `json:"digest"`
|
||||||
|
Platform *Platform `json:"platform,omitempty"`
|
||||||
|
Annotations map[string]string `json:"annotations,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform represents a platform specification
|
||||||
|
type Platform struct {
|
||||||
|
Architecture string `json:"architecture"`
|
||||||
|
OS string `json:"os"`
|
||||||
|
Variant string `json:"variant,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlobPath returns the file path for a blob
|
||||||
|
func GetBlobPath(baseDir, digest string) string {
|
||||||
|
hash := strings.TrimPrefix(digest, "sha256:")
|
||||||
|
if len(hash) < 4 {
|
||||||
|
return filepath.Join(baseDir, "registry", "blobs", "sha256", hash)
|
||||||
|
}
|
||||||
|
return filepath.Join(baseDir, "registry", "blobs", "sha256", hash[:2], hash[2:4], hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUploadPath returns the file path for an upload session
|
||||||
|
func GetUploadPath(baseDir, sessionID string) string {
|
||||||
|
return filepath.Join(baseDir, "registry", "uploads", sessionID)
|
||||||
|
}
|
||||||
65
internal/registry/handlers/blob.go
Normal file
65
internal/registry/handlers/blob.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetBlob 获取 blob
|
||||||
|
func GetBlob(store storage.Storage) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
repo := strings.TrimPrefix(c.Param("name"), "/")
|
||||||
|
digest := c.Param("digest")
|
||||||
|
|
||||||
|
reader, size, err := store.GetBlob(repo, digest)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "BLOB_UNKNOWN",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
c.Header("Content-Type", "application/octet-stream")
|
||||||
|
c.Header("Content-Length", strconv.FormatInt(size, 10))
|
||||||
|
c.Header("Docker-Content-Digest", digest)
|
||||||
|
c.DataFromReader(http.StatusOK, size, "application/octet-stream", reader, nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeadBlob 检查 blob 是否存在
|
||||||
|
func HeadBlob(store storage.Storage) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
repo := c.GetString("repo_name")
|
||||||
|
if repo == "" {
|
||||||
|
repo = strings.TrimPrefix(c.Param("name"), "/")
|
||||||
|
}
|
||||||
|
digest := c.Param("digest")
|
||||||
|
|
||||||
|
exists, err := store.BlobExists(repo, digest)
|
||||||
|
if err != nil || !exists {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
size, err := store.GetBlobSize(repo, digest)
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Length", strconv.FormatInt(size, 10))
|
||||||
|
c.Header("Docker-Content-Digest", digest)
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
147
internal/registry/handlers/manifest.go
Normal file
147
internal/registry/handlers/manifest.go
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetManifest 获取 manifest
|
||||||
|
func GetManifest(store storage.Storage) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
repo := c.GetString("repo_name")
|
||||||
|
if repo == "" {
|
||||||
|
repo = strings.TrimPrefix(c.Param("name"), "/")
|
||||||
|
}
|
||||||
|
reference := c.Param("reference")
|
||||||
|
|
||||||
|
data, mediaType, err := store.GetManifest(repo, reference)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "MANIFEST_UNKNOWN",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", mediaType)
|
||||||
|
c.Header("Content-Length", strconv.FormatInt(int64(len(data)), 10))
|
||||||
|
c.Header("Docker-Content-Digest", calculateDigest(data))
|
||||||
|
c.Data(http.StatusOK, mediaType, data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutManifest 推送 manifest
|
||||||
|
func PutManifest(store storage.Storage) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
repo := c.Param("name")
|
||||||
|
reference := c.Param("reference")
|
||||||
|
|
||||||
|
// 读取请求体
|
||||||
|
data, err := io.ReadAll(c.Request.Body)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "BAD_REQUEST",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Content-Type
|
||||||
|
mediaType := c.GetHeader("Content-Type")
|
||||||
|
if mediaType == "" {
|
||||||
|
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 存储 manifest
|
||||||
|
if err := store.PutManifest(repo, reference, data, mediaType); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "INTERNAL_ERROR",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回 Location 和 Digest
|
||||||
|
digest := calculateDigest(data)
|
||||||
|
c.Header("Location", c.Request.URL.Path)
|
||||||
|
c.Header("Docker-Content-Digest", digest)
|
||||||
|
c.Status(http.StatusCreated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteManifest 删除 manifest
|
||||||
|
func DeleteManifest(store storage.Storage) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
repo := c.Param("name")
|
||||||
|
reference := c.Param("reference")
|
||||||
|
|
||||||
|
if err := store.DeleteManifest(repo, reference); err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "MANIFEST_UNKNOWN",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Status(http.StatusAccepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeadManifest 检查 manifest 是否存在
|
||||||
|
func HeadManifest(store storage.Storage) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
repo := c.GetString("repo_name")
|
||||||
|
if repo == "" {
|
||||||
|
repo = strings.TrimPrefix(c.Param("name"), "/")
|
||||||
|
}
|
||||||
|
reference := c.Param("reference")
|
||||||
|
|
||||||
|
exists, err := store.ManifestExists(repo, reference)
|
||||||
|
if err != nil || !exists {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 manifest 以设置正确的 headers
|
||||||
|
data, mediaType, err := store.GetManifest(repo, reference)
|
||||||
|
if err != nil {
|
||||||
|
c.Status(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Header("Content-Type", mediaType)
|
||||||
|
c.Header("Content-Length", strconv.FormatInt(int64(len(data)), 10))
|
||||||
|
c.Header("Docker-Content-Digest", calculateDigest(data))
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateDigest 计算 SHA256 digest
|
||||||
|
func calculateDigest(data []byte) string {
|
||||||
|
hash := sha256.Sum256(data)
|
||||||
|
return "sha256:" + hex.EncodeToString(hash[:])
|
||||||
|
}
|
||||||
192
internal/registry/handlers/upload.go
Normal file
192
internal/registry/handlers/upload.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StartBlobUpload 开始 blob 上传
|
||||||
|
func StartBlobUpload(store storage.Storage) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
repo := c.GetString("repo_name")
|
||||||
|
if repo == "" {
|
||||||
|
repo = strings.TrimPrefix(c.Param("name"), "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
uuid, err := store.StartBlobUpload(repo)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "INTERNAL_ERROR",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回上传 URL
|
||||||
|
location := c.Request.URL.Path + "/" + uuid
|
||||||
|
c.Header("Location", location)
|
||||||
|
c.Header("Docker-Upload-UUID", uuid)
|
||||||
|
c.Header("Range", "0-0")
|
||||||
|
c.Status(http.StatusAccepted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatchBlobUpload 上传 blob 数据块
|
||||||
|
func PatchBlobUpload(store storage.Storage) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
|
// 获取 Range header
|
||||||
|
rangeHeader := c.GetHeader("Content-Range")
|
||||||
|
if rangeHeader == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "BAD_REQUEST",
|
||||||
|
"message": "Content-Range header required",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取数据
|
||||||
|
data := c.Request.Body
|
||||||
|
if err := store.PutBlobUploadChunk(uuid, data); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "INTERNAL_ERROR",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前上传大小
|
||||||
|
size, err := store.GetBlobUpload(uuid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "UPLOAD_UNKNOWN",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
location := c.Request.URL.Path
|
||||||
|
c.Header("Location", location)
|
||||||
|
c.Header("Docker-Upload-UUID", uuid)
|
||||||
|
c.Header("Range", "0-"+strconv.FormatInt(size-1, 10))
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutBlobUpload 完成 blob 上传
|
||||||
|
func PutBlobUpload(store storage.Storage) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
repo := c.GetString("repo_name")
|
||||||
|
if repo == "" {
|
||||||
|
repo = strings.TrimPrefix(c.Param("name"), "/")
|
||||||
|
}
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
|
// 获取 digest
|
||||||
|
digest := c.Query("digest")
|
||||||
|
if digest == "" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "BAD_REQUEST",
|
||||||
|
"message": "digest query parameter required",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果有请求体,先追加数据
|
||||||
|
if c.Request.ContentLength > 0 {
|
||||||
|
if err := store.PutBlobUploadChunk(uuid, c.Request.Body); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "INTERNAL_ERROR",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完成上传
|
||||||
|
if err := store.CompleteBlobUpload(repo, uuid, digest); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "BAD_REQUEST",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理上传文件
|
||||||
|
store.DeleteBlobUpload(uuid)
|
||||||
|
|
||||||
|
// 返回 blob 位置
|
||||||
|
// 从 /v2/{name}/blobs/uploads/{uuid} 转换为 /v2/{name}/blobs/{digest}
|
||||||
|
pathParts := strings.Split(c.Request.URL.Path, "/")
|
||||||
|
if len(pathParts) >= 4 {
|
||||||
|
// 构建新的路径: /v2/{name}/blobs/{digest}
|
||||||
|
location := "/v2/" + pathParts[2] + "/blobs/" + digest
|
||||||
|
c.Header("Location", location)
|
||||||
|
} else {
|
||||||
|
c.Header("Location", c.Request.URL.Path)
|
||||||
|
}
|
||||||
|
c.Header("Content-Length", "0")
|
||||||
|
c.Header("Docker-Content-Digest", digest)
|
||||||
|
c.Status(http.StatusCreated)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBlobUpload 获取上传状态
|
||||||
|
func GetBlobUpload(store storage.Storage) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
uuid := c.Param("uuid")
|
||||||
|
|
||||||
|
size, err := store.GetBlobUpload(uuid)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"errors": []gin.H{
|
||||||
|
{
|
||||||
|
"code": "UPLOAD_UNKNOWN",
|
||||||
|
"message": err.Error(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
location := c.Request.URL.Path
|
||||||
|
c.Header("Location", location)
|
||||||
|
c.Header("Docker-Upload-UUID", uuid)
|
||||||
|
c.Header("Range", "0-"+strconv.FormatInt(size-1, 10))
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
13
internal/registry/handlers/version.go
Normal file
13
internal/registry/handlers/version.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VersionCheck API 版本检查
|
||||||
|
func VersionCheck(c *gin.Context) {
|
||||||
|
c.Header("Docker-Distribution-API-Version", "registry/2.0")
|
||||||
|
c.Status(http.StatusOK)
|
||||||
|
}
|
||||||
102
internal/rerr/error.go
Normal file
102
internal/rerr/error.go
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
package rerr
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RepositoryError represents an error in the registry
|
||||||
|
type RepositoryError struct {
|
||||||
|
Status int `json:"status"`
|
||||||
|
Code string `json:"code"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *RepositoryError) Error() string {
|
||||||
|
return e.Message
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error responses
|
||||||
|
var (
|
||||||
|
ErrBlobUnknown = &RepositoryError{
|
||||||
|
Status: http.StatusNotFound,
|
||||||
|
Code: "BLOB_UNKNOWN",
|
||||||
|
Message: "blob unknown to registry",
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrDigestInvalid = &RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "DIGEST_INVALID",
|
||||||
|
Message: "provided digest did not match uploaded content",
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrDigestMismatch = &RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "DIGEST_MISMATCH",
|
||||||
|
Message: "provided digest did not match uploaded content",
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrManifestUnknown = &RepositoryError{
|
||||||
|
Status: http.StatusNotFound,
|
||||||
|
Code: "MANIFEST_UNKNOWN",
|
||||||
|
Message: "manifest unknown",
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrManifestInvalid = &RepositoryError{
|
||||||
|
Status: http.StatusBadRequest,
|
||||||
|
Code: "MANIFEST_INVALID",
|
||||||
|
Message: "manifest invalid",
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrNameUnknown = &RepositoryError{
|
||||||
|
Status: http.StatusNotFound,
|
||||||
|
Code: "NAME_UNKNOWN",
|
||||||
|
Message: "repository name not known to registry",
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrUnauthorized = &RepositoryError{
|
||||||
|
Status: http.StatusUnauthorized,
|
||||||
|
Code: "UNAUTHORIZED",
|
||||||
|
Message: "authentication required",
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrDenied = &RepositoryError{
|
||||||
|
Status: http.StatusForbidden,
|
||||||
|
Code: "DENIED",
|
||||||
|
Message: "requested access to the resource is denied",
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrUnsupported = &RepositoryError{
|
||||||
|
Status: http.StatusMethodNotAllowed,
|
||||||
|
Code: "UNSUPPORTED",
|
||||||
|
Message: "The operation is unsupported",
|
||||||
|
}
|
||||||
|
|
||||||
|
ErrTooManyRequests = &RepositoryError{
|
||||||
|
Status: http.StatusTooManyRequests,
|
||||||
|
Code: "TOOMANYREQUESTS",
|
||||||
|
Message: "too many requests",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrInternal creates an internal server error
|
||||||
|
func ErrInternal(err error) *RepositoryError {
|
||||||
|
return &RepositoryError{
|
||||||
|
Status: http.StatusInternalServerError,
|
||||||
|
Code: "INTERNAL_SERVER_ERROR",
|
||||||
|
Message: err.Error(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error sends a repository error response
|
||||||
|
func Error(c fiber.Ctx, err *RepositoryError) error {
|
||||||
|
return c.Status(err.Status).JSON(fiber.Map{
|
||||||
|
"errors": []fiber.Map{
|
||||||
|
{
|
||||||
|
"code": err.Code,
|
||||||
|
"message": err.Message,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
94
main.go
Normal file
94
main.go
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"flag"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/api"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/config"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/database"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/middleware"
|
||||||
|
"gitea.loveuer.com/loveuer/cluster/internal/registry/storage"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v3"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 解析命令行参数
|
||||||
|
var (
|
||||||
|
debug = flag.Bool("debug", false, "Enable debug mode")
|
||||||
|
address = flag.String("address", ":8080", "API server listen address")
|
||||||
|
dataDir = flag.String("data-dir", "./storage", "Data directory for storing all data")
|
||||||
|
)
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
// 加载配置
|
||||||
|
cfg, err := config.LoadFromFlags(*debug, *address, *dataDir)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to load config: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("Starting server with config: debug=%v, address=%s, data-dir=%s",
|
||||||
|
cfg.Server.Debug, cfg.Server.Address, cfg.Storage.RootPath)
|
||||||
|
|
||||||
|
// 初始化数据库
|
||||||
|
if err := database.Init(cfg.Storage.RootPath); err != nil {
|
||||||
|
log.Fatalf("Failed to initialize database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化存储(使用 GORM 元数据 + 文件系统)
|
||||||
|
store, err := storage.NewGormStorage(cfg.Storage.RootPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to initialize storage: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建 Fiber 应用
|
||||||
|
config := fiber.Config{}
|
||||||
|
if !cfg.Server.Debug {
|
||||||
|
// Fiber v3 默认不显示启动消息
|
||||||
|
}
|
||||||
|
app := fiber.New(config)
|
||||||
|
|
||||||
|
// 全局中间件
|
||||||
|
app.Use(middleware.Logger())
|
||||||
|
app.Use(middleware.Recovery())
|
||||||
|
app.Use(middleware.CORS())
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
app.Get("/health", func(c fiber.Ctx) error {
|
||||||
|
return c.JSON(fiber.Map{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置路由
|
||||||
|
api.SetupRoutes(app, store)
|
||||||
|
|
||||||
|
// 启动服务器(在 goroutine 中)
|
||||||
|
go func() {
|
||||||
|
log.Printf("Server listening on %s", cfg.Server.Address)
|
||||||
|
if err := app.Listen(cfg.Server.Address); err != nil {
|
||||||
|
log.Fatalf("Failed to start server: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 等待中断信号以优雅关闭服务器
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
<-quit
|
||||||
|
|
||||||
|
log.Println("Shutting down server...")
|
||||||
|
|
||||||
|
// 优雅关闭,等待 5 秒
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := app.ShutdownWithContext(ctx); err != nil {
|
||||||
|
log.Fatalf("Server forced to shutdown: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Println("Server exited")
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user