wip: oci image management
This commit is contained in:
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,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user