feat: complete OCI registry implementation with docker push/pull support

A lightweight OCI (Open Container Initiative) registry implementation written in Go.
This commit is contained in:
loveuer
2025-11-09 22:46:27 +08:00
commit 29088a6b54
45 changed files with 5629 additions and 0 deletions

61
internal/api/api.go Normal file
View File

@@ -0,0 +1,61 @@
package api
import (
"context"
"fmt"
"log"
"net"
"gitea.loveuer.com/loveuer/cluster/internal/middleware"
"gitea.loveuer.com/loveuer/cluster/internal/module/registry"
"gitea.loveuer.com/loveuer/cluster/pkg/store"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) error {
var (
err error
ln net.Listener
cfg = fiber.Config{
BodyLimit: 1024 * 1024 * 1024 * 10, // 10GB limit for large image layers
}
)
app := fiber.New(cfg)
app.Use(middleware.Logger())
app.Use(middleware.Recovery())
app.Use(middleware.CORS())
// oci image apis
{
app.All("/v2/*", registry.Registry(ctx, db, store))
}
// registry image apis
{
registryAPI := app.Group("/api/v1/registry")
registryAPI.Get("/image/list", registry.RegistryImageList(ctx, db, store))
}
ln, err = net.Listen("tcp", address)
if err != nil {
return fmt.Errorf("failed to listen on %s: %w", address, err)
}
go func() {
if err := app.Listener(ln); err != nil {
log.Fatalf("Fiber server failed on %s: %v", address, err)
}
}()
go func() {
<-ctx.Done()
if err := app.Shutdown(); err != nil {
log.Fatalf("Failed to shutdown: %v", err)
}
}()
return nil
}

49
internal/cmd/cmd.go Normal file
View File

@@ -0,0 +1,49 @@
package cmd
import (
"context"
"gitea.loveuer.com/loveuer/cluster/internal/api"
"gitea.loveuer.com/loveuer/cluster/internal/opt"
"gitea.loveuer.com/loveuer/cluster/pkg/database/db"
"gitea.loveuer.com/loveuer/cluster/pkg/store"
"github.com/spf13/cobra"
)
func Run(ctx context.Context) error {
_cmd := &cobra.Command{
Use: "cluster",
Short: "Cluster is a lightweight OCI registry implementation written in Go using Fiber v3.",
RunE: func(cmd *cobra.Command, args []string) error {
var (
err error
)
if err = opt.Init(cmd.Context()); err != nil {
return err
}
if err = db.Init(cmd.Context(), opt.GlobalDataDir); err != nil {
return err
}
if err = store.Init(cmd.Context(), opt.GlobalDataDir); err != nil {
return err
}
if err = api.Init(cmd.Context(), opt.GlobalAddress, db.Default, store.Default); err != nil {
return err
}
<-cmd.Context().Done()
return nil
},
}
_cmd.PersistentFlags().BoolVar(&opt.GlobalDebug, "debug", false, "Enable debug mode")
_cmd.PersistentFlags().StringVarP(&opt.GlobalAddress, "address", "A", "0.0.0.0:9119", "API server listen address")
_cmd.PersistentFlags().StringVarP(&opt.GlobalDataDir, "data-dir", "D", "./x-storage", "Data directory for storing all data")
return _cmd.Execute()
}

View File

@@ -0,0 +1,21 @@
package middleware
import (
"github.com/gofiber/fiber/v3"
)
// CORS 跨域中间件
func CORS() fiber.Handler {
return func(c fiber.Ctx) error {
c.Set("Access-Control-Allow-Origin", "*")
c.Set("Access-Control-Allow-Credentials", "true")
c.Set("Access-Control-Allow-Headers", "Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization, accept, origin, Cache-Control, X-Requested-With")
c.Set("Access-Control-Allow-Methods", "POST, OPTIONS, GET, PUT, DELETE, PATCH, HEAD")
if c.Method() == "OPTIONS" {
return c.SendStatus(fiber.StatusNoContent)
}
return c.Next()
}
}

View File

@@ -0,0 +1,31 @@
package middleware
import (
"fmt"
"time"
"github.com/gofiber/fiber/v3"
)
// Logger 日志中间件
func Logger() fiber.Handler {
return func(c fiber.Ctx) error {
start := time.Now()
err := c.Next()
latency := time.Since(start)
fmt.Printf("%s - [%s] \"%s %s %s %d %s \"%s\" %s\"\n",
c.IP(),
time.Now().Format(time.RFC1123),
c.Method(),
c.Path(),
c.Protocol(),
c.Response().StatusCode(),
latency,
c.Get("User-Agent"),
"",
)
return err
}
}

View File

@@ -0,0 +1,24 @@
package middleware
import (
"github.com/gofiber/fiber/v3"
)
// Recovery 恢复中间件
func Recovery() fiber.Handler {
return func(c fiber.Ctx) error {
defer func() {
if r := recover(); r != nil {
c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"errors": []fiber.Map{
{
"code": "INTERNAL_ERROR",
"message": "Internal server error",
},
},
})
}
}()
return c.Next()
}
}

View File

@@ -0,0 +1,13 @@
package middleware
import (
"github.com/gofiber/fiber/v3"
)
// RepoMiddleware 仓库名中间件(如果需要的话)
func RepoMiddleware() fiber.Handler {
return func(c fiber.Ctx) error {
// 可以在这里处理仓库名相关的逻辑
return c.Next()
}
}

70
internal/model/model.go Normal file
View File

@@ -0,0 +1,70 @@
package model
import (
"time"
"gorm.io/gorm"
)
// Repository ????
type Repository struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Name string `gorm:"uniqueIndex;not null" json:"name"` // ?????? "library/nginx"
}
// Blob blob ??
type Blob struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Digest string `gorm:"uniqueIndex;not null" json:"digest"` // SHA256 digest
Size int64 `gorm:"not null" json:"size"` // ??????
MediaType string `json:"media_type"` // ????
Repository string `gorm:"index" json:"repository"` // ???????????????
}
// Manifest manifest ??
type Manifest struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Repository string `gorm:"index;not null" json:"repository"` // ????
Tag string `gorm:"index;not null" json:"tag"` // tag ??
Digest string `gorm:"uniqueIndex;not null" json:"digest"` // manifest digest
MediaType string `json:"media_type"` // ????
Size int64 `gorm:"not null" json:"size"` // manifest ??
Content []byte `gorm:"type:blob" json:"-"` // manifest ???JSON?
}
// Tag tag ??????????
type Tag struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
Repository string `gorm:"index;not null" json:"repository"` // ????
Tag string `gorm:"index;not null" json:"tag"` // tag ??
Digest string `gorm:"not null" json:"digest"` // ??? manifest digest
}
// BlobUpload ????? blob ??
type BlobUpload struct {
ID uint `gorm:"primarykey" json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
UUID string `gorm:"uniqueIndex;not null" json:"uuid"` // ???? UUID
Repository string `gorm:"index;not null" json:"repository"` // ????
Path string `gorm:"not null" json:"path"` // ??????
Size int64 `gorm:"default:0" json:"size"` // ?????
}

View File

@@ -0,0 +1,376 @@
package registry
import (
"bytes"
"crypto/rand"
"encoding/hex"
"fmt"
"io"
"strconv"
"strings"
"gitea.loveuer.com/loveuer/cluster/internal/model"
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
"gitea.loveuer.com/loveuer/cluster/pkg/store"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
// HandleBlobs ?? blob ????
// POST /v2/{repo}/blobs/uploads/ - ????
// PATCH /v2/{repo}/blobs/uploads/{uuid} - ?????
// PUT /v2/{repo}/blobs/uploads/{uuid}?digest={digest} - ????
// GET /v2/{repo}/blobs/{digest} - ?? blob
// HEAD /v2/{repo}/blobs/{digest} - ?? blob ????
func HandleBlobs(c fiber.Ctx, db *gorm.DB, store store.Store) error {
path := c.Path()
method := c.Method()
// ????: /v2/{repo}/blobs/...
// ??????????? "test/redis"
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
parts := strings.Split(pathWithoutV2, "/")
if len(parts) < 2 {
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
}
// ?? "blobs" ????????????????
blobsIndex := -1
for i, part := range parts {
if part == "blobs" {
blobsIndex = i
break
}
}
if blobsIndex < 1 {
return resp.R404(c, "INVALID_PATH", nil, "invalid path: blobs not found")
}
// ???? blobs ???????
repo := strings.Join(parts[:blobsIndex], "/")
// ???? parts??????????? parts[0] ? "blobs"
parts = parts[blobsIndex:]
switch method {
case "POST":
// POST /v2/{repo}/blobs/uploads/ - ????
// parts ??? ["blobs", "uploads", ""] ? ["blobs", "uploads"]
if len(parts) >= 2 && parts[0] == "blobs" && parts[1] == "uploads" {
return handleBlobUploadStart(c, db, store, repo)
}
case "PATCH":
// PATCH /v2/{repo}/blobs/uploads/{uuid} - ?????
// parts ??? ["blobs", "uploads", "uuid"]
if len(parts) >= 3 && parts[0] == "blobs" && parts[1] == "uploads" {
uuid := parts[2]
return handleBlobUploadChunk(c, db, store, repo, uuid)
}
case "PUT":
// PUT /v2/{repo}/blobs/uploads/{uuid}?digest={digest} - ????
// parts ??? ["blobs", "uploads", "uuid"]
if len(parts) >= 3 && parts[0] == "blobs" && parts[1] == "uploads" {
uuid := parts[2]
digest := c.Query("digest")
if digest == "" {
return resp.R400(c, "MISSING_DIGEST", nil, "digest parameter is required")
}
return handleBlobUploadComplete(c, db, store, repo, uuid, digest)
}
case "GET":
// GET /v2/{repo}/blobs/{digest} - ?? blob
// parts ??? ["blobs", "digest"]
if len(parts) >= 2 && parts[0] == "blobs" {
digest := parts[1]
return handleBlobDownload(c, db, store, repo, digest)
}
case "HEAD":
// HEAD /v2/{repo}/blobs/{digest} - ?? blob ????
// parts ??? ["blobs", "digest"]
if len(parts) >= 2 && parts[0] == "blobs" {
digest := parts[1]
return handleBlobHead(c, db, store, repo, digest)
}
}
return resp.R404(c, "NOT_FOUND", nil, "endpoint not found")
}
// handleBlobUploadStart ?? blob ??
func handleBlobUploadStart(c fiber.Ctx, db *gorm.DB, store store.Store, repo string) error {
// ?? UUID
uuidBytes := make([]byte, 16)
if _, err := rand.Read(uuidBytes); err != nil {
return resp.R500(c, "", nil, err)
}
uuid := hex.EncodeToString(uuidBytes)
// ??????
upload := &model.BlobUpload{
UUID: uuid,
Repository: repo,
Path: uuid, // ?? UUID ??????
Size: 0,
}
if err := db.Create(upload).Error; err != nil {
return resp.R500(c, "", nil, err)
}
// ??????
w, err := store.CreateUpload(c.Context(), uuid)
if err != nil {
db.Delete(upload)
return resp.R500(c, "", nil, err)
}
w.Close()
// ???? URL
uploadURL := fmt.Sprintf("/v2/%s/blobs/uploads/%s", repo, uuid)
c.Set("Location", uploadURL)
c.Set("Docker-Upload-UUID", uuid)
c.Set("Range", "0-0")
return c.SendStatus(202)
}
// handleBlobUploadChunk ?? blob ???
func handleBlobUploadChunk(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, uuid string) error {
// ??????
var upload model.BlobUpload
if err := db.Where("uuid = ? AND repository = ?", uuid, repo).First(&upload).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "UPLOAD_NOT_FOUND", nil, "upload session not found")
}
return resp.R500(c, "", nil, err)
}
// ?????
body := c.Body()
if len(body) == 0 {
return resp.R400(c, "EMPTY_BODY", nil, "request body is empty")
}
// ??????? bytes.NewReader ????????
n, err := store.AppendUpload(c.Context(), uuid, bytes.NewReader(body))
if err != nil {
return resp.R500(c, "", nil, err)
}
// ??????
upload.Size += n
if err := db.Save(&upload).Error; err != nil {
return resp.R500(c, "", nil, err)
}
// ???? URL ???
uploadURL := fmt.Sprintf("/v2/%s/blobs/uploads/%s", repo, uuid)
c.Set("Location", uploadURL)
c.Set("Docker-Upload-UUID", uuid)
c.Set("Range", fmt.Sprintf("0-%d", upload.Size-1))
return c.SendStatus(202)
}
// handleBlobUploadComplete ?? blob ??
func handleBlobUploadComplete(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, uuid string, digest string) error {
// ??????
var upload model.BlobUpload
if err := db.Where("uuid = ? AND repository = ?", uuid, repo).First(&upload).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "UPLOAD_NOT_FOUND", nil, "upload session not found")
}
return resp.R500(c, "", nil, err)
}
// ??????????????PUT ???????????
body := c.Body()
if len(body) > 0 {
if _, err := store.AppendUpload(c.Context(), uuid, bytes.NewReader(body)); err != nil {
return resp.R500(c, "", nil, err)
}
}
// ?????????????
if err := store.FinalizeUpload(c.Context(), uuid, digest); err != nil {
return resp.R500(c, "", nil, err)
}
// ????????
size, err := store.GetBlobSize(c.Context(), digest)
if err != nil {
return resp.R500(c, "", nil, err)
}
// ????? blob ??
var blob model.Blob
if err := db.Where("digest = ?", digest).First(&blob).Error; err != nil {
if err == gorm.ErrRecordNotFound {
blob = model.Blob{
Digest: digest,
Size: size,
Repository: repo,
}
if err := db.Create(&blob).Error; err != nil {
return resp.R500(c, "", nil, err)
}
} else {
return resp.R500(c, "", nil, err)
}
}
// ??????
db.Delete(&upload)
store.DeleteUpload(c.Context(), uuid)
// ?? blob URL
blobURL := fmt.Sprintf("/v2/%s/blobs/%s", repo, digest)
c.Set("Location", blobURL)
c.Set("Content-Length", fmt.Sprintf("%d", size))
c.Set("Docker-Content-Digest", digest)
return c.SendStatus(201)
}
// parseRangeHeader parses Range header and returns start and end positions
func parseRangeHeader(rangeHeader string, size int64) (start, end int64, valid bool) {
if rangeHeader == "" {
return 0, size - 1, false
}
// Range header format: "bytes=start-end" or "bytes=start-"
if !strings.HasPrefix(rangeHeader, "bytes=") {
return 0, size - 1, false
}
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=")
parts := strings.Split(rangeSpec, "-")
if len(parts) != 2 {
return 0, size - 1, false
}
var err error
if parts[0] == "" {
// Suffix range: "bytes=-suffix"
suffix, err := strconv.ParseInt(parts[1], 10, 64)
if err != nil || suffix <= 0 {
return 0, size - 1, false
}
start = size - suffix
if start < 0 {
start = 0
}
end = size - 1
} else if parts[1] == "" {
// Start range: "bytes=start-"
start, err = strconv.ParseInt(parts[0], 10, 64)
if err != nil || start < 0 || start >= size {
return 0, size - 1, false
}
end = size - 1
} else {
// Full range: "bytes=start-end"
start, err = strconv.ParseInt(parts[0], 10, 64)
if err != nil || start < 0 || start >= size {
return 0, size - 1, false
}
end, err = strconv.ParseInt(parts[1], 10, 64)
if err != nil || end < start || end >= size {
return 0, size - 1, false
}
}
return start, end, true
}
// handleBlobDownload ?? blob
func handleBlobDownload(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, digest string) error {
// Check if blob exists
exists, err := store.BlobExists(c.Context(), digest)
if err != nil {
return resp.R500(c, "", nil, err)
}
if !exists {
return resp.R404(c, "BLOB_NOT_FOUND", nil, "blob not found")
}
// Get blob size
size, err := store.GetBlobSize(c.Context(), digest)
if err != nil {
return resp.R500(c, "", nil, err)
}
// Read blob
reader, err := store.ReadBlob(c.Context(), digest)
if err != nil {
return resp.R500(c, "", nil, err)
}
defer reader.Close()
// Check for Range request
rangeHeader := c.Get("Range")
start, end, hasRange := parseRangeHeader(rangeHeader, size)
if hasRange {
// Handle Range request
// Seek to start position
if seeker, ok := reader.(io.Seeker); ok {
if _, err := seeker.Seek(start, io.SeekStart); err != nil {
return resp.R500(c, "", nil, err)
}
} else {
// If not seekable, read and discard bytes
if _, err := io.CopyN(io.Discard, reader, start); err != nil {
return resp.R500(c, "", nil, err)
}
}
// Create limited reader
limitedReader := io.LimitReader(reader, end-start+1)
// Set partial content headers
c.Set("Content-Type", "application/octet-stream")
c.Set("Content-Length", fmt.Sprintf("%d", end-start+1))
c.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
c.Set("Accept-Ranges", "bytes")
c.Set("Docker-Content-Digest", digest)
c.Status(206) // Partial Content
// Send partial content
return c.SendStream(limitedReader)
}
// Full blob download
c.Set("Content-Type", "application/octet-stream")
c.Set("Content-Length", fmt.Sprintf("%d", size))
c.Set("Accept-Ranges", "bytes")
c.Set("Docker-Content-Digest", digest)
// Send full blob stream
return c.SendStream(reader)
}
// handleBlobHead ?? blob ????
func handleBlobHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, digest string) error {
// Check if blob exists
exists, err := store.BlobExists(c.Context(), digest)
if err != nil {
return resp.R500(c, "", nil, err)
}
if !exists {
return resp.R404(c, "BLOB_NOT_FOUND", nil, "blob not found")
}
// Get blob size
size, err := store.GetBlobSize(c.Context(), digest)
if err != nil {
return resp.R500(c, "", nil, err)
}
// Set response headers
c.Set("Content-Type", "application/octet-stream")
c.Set("Content-Length", fmt.Sprintf("%d", size))
c.Set("Accept-Ranges", "bytes")
c.Set("Docker-Content-Digest", digest)
return c.SendStatus(200)
}

View File

@@ -0,0 +1,66 @@
package registry
import (
"strconv"
"strings"
"gitea.loveuer.com/loveuer/cluster/internal/model"
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
"gitea.loveuer.com/loveuer/cluster/pkg/store"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
// HandleCatalog ????????
// GET /v2/_catalog?n={limit}&last={last}
func HandleCatalog(c fiber.Ctx, db *gorm.DB, store store.Store) error {
path := c.Path()
// ????: /v2/_catalog
parts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
if len(parts) < 1 || parts[0] != "_catalog" {
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
}
// ??????
nStr := c.Query("n", "100")
n, err := strconv.Atoi(nStr)
if err != nil || n <= 0 {
n = 100
}
last := c.Query("last")
// ????
var repos []model.Repository
query := db.Order("name ASC").Limit(n + 1)
if last != "" {
query = query.Where("name > ?", last)
}
if err := query.Find(&repos).Error; err != nil {
return resp.R500(c, "", nil, err)
}
// ????
repoNames := make([]string, 0, len(repos))
hasMore := false
for i, repo := range repos {
if i >= n {
hasMore = true
break
}
repoNames = append(repoNames, repo.Name)
}
response := map[string]interface{}{
"repositories": repoNames,
}
// ??????????????
if hasMore && len(repoNames) > 0 {
response["last"] = repoNames[len(repoNames)-1]
}
return resp.R200(c, response)
}

View File

@@ -0,0 +1,55 @@
package registry
import (
"context"
"gitea.loveuer.com/loveuer/cluster/internal/model"
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
"gitea.loveuer.com/loveuer/cluster/pkg/store"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
// RegistryImageList returns the list of images/repositories
func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
return func(c fiber.Ctx) error {
var repositories []model.Repository
// Query all repositories from the database
if err := db.Find(&repositories).Error; err != nil {
return resp.R500(c, "", nil, err)
}
// Convert to the expected format for the frontend
var result []map[string]interface{}
for _, repo := range repositories {
// Calculate total size of all blobs for this repository
var totalSize int64
var sizeResult struct {
Total int64
}
err := db.Model(&model.Blob{}).
Where("repository = ?", repo.Name).
Select("COALESCE(SUM(size), 0) as total").
Scan(&sizeResult).Error
if err == nil {
totalSize = sizeResult.Total
}
// Format updated_at to second precision
uploadTime := repo.UpdatedAt.Format("2006-01-02 15:04:05")
repoMap := map[string]interface{}{
"id": repo.ID,
"name": repo.Name,
"upload_time": uploadTime,
"size": totalSize,
}
result = append(result, repoMap)
}
return resp.R200(c, map[string]interface{}{
"images": result,
})
}
}

View File

@@ -0,0 +1,419 @@
package registry
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"gitea.loveuer.com/loveuer/cluster/internal/model"
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
"gitea.loveuer.com/loveuer/cluster/pkg/store"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
// isDigestFormat checks if a string is in digest format (e.g., sha256:abc123...)
func isDigestFormat(s string) bool {
parts := strings.SplitN(s, ":", 2)
if len(parts) != 2 {
return false
}
algo := parts[0]
hash := parts[1]
// Check algorithm
if algo != "sha256" {
// Could be extended to support other algorithms like sha512
return false
}
// Check that hash is a valid hex string of expected length (64 for sha256)
if len(hash) != 64 {
return false
}
// Verify it's all hex characters
for _, r := range hash {
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
return false
}
}
return true
}
// HandleManifest ?? manifest ????
// PUT /v2/{repo}/manifests/{tag} - ?? manifest
// GET /v2/{repo}/manifests/{tag} - ?? manifest
// DELETE /v2/{repo}/manifests/{tag} - ?? manifest
func HandleManifest(c fiber.Ctx, db *gorm.DB, store store.Store) error {
path := c.Path()
method := c.Method()
// ????: /v2/{repo}/manifests/{tag}
// ??????????? "test/redis"
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
parts := strings.Split(pathWithoutV2, "/")
if len(parts) < 2 {
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
}
// ?? "manifests" ???
manifestsIndex := -1
for i, part := range parts {
if part == "manifests" {
manifestsIndex = i
break
}
}
if manifestsIndex < 1 || manifestsIndex >= len(parts)-1 {
return resp.R404(c, "INVALID_PATH", nil, "invalid path: manifests not found")
}
// ???? manifests ???????
repo := strings.Join(parts[:manifestsIndex], "/")
// tag ? manifests ?????
tag := parts[manifestsIndex+1]
switch method {
case "PUT":
return handleManifestPut(c, db, store, repo, tag)
case "GET":
return handleManifestGet(c, db, store, repo, tag)
case "HEAD":
return handleManifestHead(c, db, store, repo, tag)
case "DELETE":
return handleManifestDelete(c, db, store, repo, tag)
}
return resp.R404(c, "NOT_FOUND", nil, "method not allowed")
}
// handleManifestPut ?? manifest
func handleManifestPut(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
// ?? manifest ??
content := c.Body()
if len(content) == 0 {
return resp.R400(c, "EMPTY_BODY", nil, "manifest content is empty")
}
// ?? digest
hasher := sha256.New()
hasher.Write(content)
digest := "sha256:" + hex.EncodeToString(hasher.Sum(nil))
// ?? Content-Type
mediaType := c.Get("Content-Type")
if mediaType == "" {
// ??? manifest ????
var mf map[string]interface{}
if err := json.Unmarshal(content, &mf); err == nil {
if mt, ok := mf["mediaType"].(string); ok {
mediaType = mt
} else {
// ???? Docker manifest v2
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
}
} else {
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
}
}
// ?? manifest ????????
var manifestData map[string]interface{}
if err := json.Unmarshal(content, &manifestData); err != nil {
return resp.R400(c, "INVALID_MANIFEST", nil, "invalid manifest format")
}
// ??????
var repository model.Repository
if err := db.Where("name = ?", repo).First(&repository).Error; err != nil {
if err == gorm.ErrRecordNotFound {
repository = model.Repository{Name: repo}
if err := db.Create(&repository).Error; err != nil {
return resp.R500(c, "", nil, err)
}
} else {
return resp.R500(c, "", nil, err)
}
}
// ?? manifest ?????
if err := store.WriteManifest(c.Context(), digest, content); err != nil {
return resp.R500(c, "", nil, err)
}
// ?? manifest ?????
var manifest model.Manifest
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// ???? manifest ??
manifest = model.Manifest{
Repository: repo,
Tag: tag,
Digest: digest,
MediaType: mediaType,
Size: int64(len(content)),
Content: content,
}
if err := db.Create(&manifest).Error; err != nil {
return resp.R500(c, "", nil, err)
}
} else {
return resp.R500(c, "", nil, err)
}
} else {
// ???? manifest ? tag ??
manifest.Tag = tag
manifest.Repository = repo
if err := db.Save(&manifest).Error; err != nil {
return resp.R500(c, "", nil, err)
}
}
// ????? tag ??
var tagRecord model.Tag
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
if err == gorm.ErrRecordNotFound {
tagRecord = model.Tag{
Repository: repo,
Tag: tag,
Digest: digest,
}
if err := db.Create(&tagRecord).Error; err != nil {
return resp.R500(c, "", nil, err)
}
} else {
return resp.R500(c, "", nil, err)
}
} else {
tagRecord.Digest = digest
if err := db.Save(&tagRecord).Error; err != nil {
return resp.R500(c, "", nil, err)
}
}
// ?????
c.Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", repo, tag))
c.Set("Docker-Content-Digest", digest)
c.Set("Content-Type", mediaType)
c.Set("Content-Length", fmt.Sprintf("%d", len(content)))
return c.SendStatus(201)
}
// handleManifestGet ?? manifest
func handleManifestGet(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
var manifest model.Manifest
// ?? tag ??????????????????????
if isDigestFormat(tag) {
// ?? digest ???????????? repository
digest := tag
// ?? manifest ???
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
}
return resp.R500(c, "", nil, err)
}
// ???? manifest ?????????? repository ??
var tagRecord model.Tag
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
}
return resp.R500(c, "", nil, err)
}
} else {
// ?? tag ???? tag ?????????
var tagRecord model.Tag
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
}
return resp.R500(c, "", nil, err)
}
// ?? manifest ??
if err := db.Where("digest = ?", tagRecord.Digest).First(&manifest).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
}
return resp.R500(c, "", nil, err)
}
}
// Check Accept header if provided
acceptHeader := c.Get("Accept")
if acceptHeader != "" {
// Parse Accept header to check if client accepts the manifest's media type
acceptTypes := strings.Split(acceptHeader, ",")
accepted := false
for _, at := range acceptTypes {
// Remove quality values (e.g., "application/vnd.docker.distribution.manifest.v2+json;q=0.9")
mediaType := strings.TrimSpace(strings.Split(at, ";")[0])
if mediaType == manifest.MediaType || mediaType == "*/*" {
accepted = true
break
}
}
if !accepted {
// Check for wildcard or common Docker manifest types
for _, at := range acceptTypes {
mediaType := strings.TrimSpace(strings.Split(at, ";")[0])
if strings.Contains(mediaType, "manifest") || mediaType == "*/*" {
accepted = true
break
}
}
}
// Note: We still return the manifest even if not explicitly accepted,
// as some clients may not send proper Accept headers
}
// Read manifest content
content, err := store.ReadManifest(c.Context(), manifest.Digest)
if err != nil {
return resp.R500(c, "", nil, err)
}
// Set response headers
c.Set("Content-Type", manifest.MediaType)
c.Set("Content-Length", fmt.Sprintf("%d", len(content)))
c.Set("Docker-Content-Digest", manifest.Digest)
return c.Send(content)
}
// handleManifestHead ?? manifest ????
func handleManifestHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
var manifest model.Manifest
// ?? tag ??????????????????????
if isDigestFormat(tag) {
// ?? digest ???????????? repository
digest := tag
// ?? manifest ???
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
}
return resp.R500(c, "", nil, err)
}
// ???? manifest ?????????? repository ??
var tagRecord model.Tag
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
}
return resp.R500(c, "", nil, err)
}
} else {
// ?? tag ???? tag ?????????
var tagRecord model.Tag
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
}
return resp.R500(c, "", nil, err)
}
// ?? manifest ??
if err := db.Where("digest = ?", tagRecord.Digest).First(&manifest).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
}
return resp.R500(c, "", nil, err)
}
}
// ?????
c.Set("Content-Type", manifest.MediaType)
c.Set("Content-Length", fmt.Sprintf("%d", manifest.Size))
c.Set("Docker-Content-Digest", manifest.Digest)
return c.SendStatus(200)
}
// handleManifestDelete ?? manifest
func handleManifestDelete(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
var digest string
if isDigestFormat(tag) {
// ?? digest ???????????? repository
digest = tag
// ???? manifest ?????????? repository ??
var tagRecord model.Tag
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
}
return resp.R500(c, "", nil, err)
}
// ???????? tag ??? manifest
var count int64
if err := db.Model(&model.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil {
return resp.R500(c, "", nil, err)
}
// ??? tag ??????? manifest ??
if count == 0 {
var manifest model.Manifest
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
}
return resp.R500(c, "", nil, err)
}
if err := db.Delete(&manifest).Error; err != nil {
return resp.R500(c, "", nil, err)
}
} else {
// ?? tag ?????????????????
// ??? manifest ???????????
return resp.R400(c, "CANNOT_DELETE_DIGEST_REFERENCED_BY_TAGS", nil, "cannot delete manifest referenced by tags")
}
} else {
// ?? tag ???? tag ?????????
var tagRecord model.Tag
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
}
return resp.R500(c, "", nil, err)
}
digest = tagRecord.Digest
// ?? tag ??
if err := db.Delete(&tagRecord).Error; err != nil {
return resp.R500(c, "", nil, err)
}
// ???????? tag ??? manifest
var count int64
if err := db.Model(&model.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil {
return resp.R500(c, "", nil, err)
}
// ?????? tag ????? manifest ??
if count == 0 {
var manifest model.Manifest
if err := db.Where("digest = ?", digest).First(&manifest).Error; err == nil {
if err := db.Delete(&manifest).Error; err != nil {
return resp.R500(c, "", nil, err)
}
}
}
}
return c.SendStatus(202)
}

View File

@@ -0,0 +1,16 @@
package registry
import (
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
"gitea.loveuer.com/loveuer/cluster/pkg/store"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
// HandleReferrers ?? referrers ???OCI ???
// GET /v2/{repo}/referrers/{digest}
func HandleReferrers(c fiber.Ctx, db *gorm.DB, store store.Store) error {
// TODO: ?? OCI referrers API
// ????????????? OCI ? referrers ??
return resp.R501(c, "NOT_IMPLEMENTED", nil, "referrers API not implemented yet")
}

View File

@@ -0,0 +1,115 @@
package registry
import (
"context"
"log"
"strings"
"gitea.loveuer.com/loveuer/cluster/internal/model"
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
"gitea.loveuer.com/loveuer/cluster/pkg/store"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
func Registry(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
// ???????
if err := db.AutoMigrate(
&model.Repository{},
&model.Blob{},
&model.Manifest{},
&model.Tag{},
&model.BlobUpload{},
); err != nil {
log.Fatalf("failed to migrate database: %v", err)
}
if err := store.CreatePartition(ctx, "registry"); err != nil {
log.Fatalf("failed to create registry partition: %v", err)
}
return func(c fiber.Ctx) error {
if isBlob(c) {
return HandleBlobs(c, db, store)
}
if isManifest(c) {
return HandleManifest(c, db, store)
}
if isTags(c) {
return HandleTags(c, db, store)
}
if isCatalog(c) {
return HandleCatalog(c, db, store)
}
if isReferrers(c) {
return HandleReferrers(c, db, store)
}
// Handle root v2 endpoint
if c.Path() == "/v2/" {
c.Set("Docker-Distribution-API-Version", "registry/2.0")
return c.SendStatus(200)
}
c.Set("Docker-Distribution-API-Version", "registry/2.0")
log.Printf("[Warn] Registry: unknown endpoint - path = %s, method = %s, headers = %v", c.Path(), c.Method(), &c.Request().Header)
return resp.R404(c, "UNKNOWN_ENDPOINT", nil, "endpoint not found")
}
}
func isBlob(c fiber.Ctx) bool {
elem := strings.Split(c.Path(), "/")
elem = elem[1:]
if elem[len(elem)-1] == "" {
elem = elem[:len(elem)-1]
}
if len(elem) < 3 {
return false
}
return elem[len(elem)-2] == "blobs" || (elem[len(elem)-3] == "blobs" &&
elem[len(elem)-2] == "uploads")
}
func isManifest(c fiber.Ctx) bool {
elems := strings.Split(c.Path(), "/")
elems = elems[1:]
if len(elems) < 4 {
return false
}
return elems[len(elems)-2] == "manifests"
}
func isTags(c fiber.Ctx) bool {
elems := strings.Split(c.Path(), "/")
elems = elems[1:]
if len(elems) < 4 {
return false
}
return elems[len(elems)-2] == "tags"
}
func isCatalog(c fiber.Ctx) bool {
elems := strings.Split(c.Path(), "/")
elems = elems[1:]
if len(elems) < 2 {
return false
}
return elems[len(elems)-1] == "_catalog"
}
func isReferrers(c fiber.Ctx) bool {
elems := strings.Split(c.Path(), "/")
elems = elems[1:]
if len(elems) < 4 {
return false
}
return elems[len(elems)-2] == "referrers"
}

View File

@@ -0,0 +1,84 @@
package registry
import (
"strconv"
"strings"
"gitea.loveuer.com/loveuer/cluster/internal/model"
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
"gitea.loveuer.com/loveuer/cluster/pkg/store"
"github.com/gofiber/fiber/v3"
"gorm.io/gorm"
)
// HandleTags ?? tag ????
// GET /v2/{repo}/tags/list?n={limit}&last={last}
func HandleTags(c fiber.Ctx, db *gorm.DB, store store.Store) error {
path := c.Path()
// ????: /v2/{repo}/tags/list
// ??????????? "test/redis"
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
parts := strings.Split(pathWithoutV2, "/")
if len(parts) < 3 {
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
}
// ?? "tags" ???
tagsIndex := -1
for i, part := range parts {
if part == "tags" {
tagsIndex = i
break
}
}
if tagsIndex < 1 || tagsIndex >= len(parts)-1 || parts[tagsIndex+1] != "list" {
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
}
// ???? tags ???????
repo := strings.Join(parts[:tagsIndex], "/")
// ??????
nStr := c.Query("n", "100")
n, err := strconv.Atoi(nStr)
if err != nil || n <= 0 {
n = 100
}
last := c.Query("last")
// ?? tags
var tags []model.Tag
query := db.Where("repository = ?", repo).Order("tag ASC").Limit(n + 1)
if last != "" {
query = query.Where("tag > ?", last)
}
if err := query.Find(&tags).Error; err != nil {
return resp.R500(c, "", nil, err)
}
// ????
tagNames := make([]string, 0, len(tags))
hasMore := false
for i, tag := range tags {
if i >= n {
hasMore = true
break
}
tagNames = append(tagNames, tag.Tag)
}
response := map[string]interface{}{
"name": repo,
"tags": tagNames,
}
// ??????????????
if hasMore && len(tagNames) > 0 {
response["last"] = tagNames[len(tagNames)-1]
}
return resp.R200(c, response)
}

20
internal/opt/opt.go Normal file
View File

@@ -0,0 +1,20 @@
package opt
import (
"context"
"os"
)
var (
GlobalDebug bool
GlobalAddress string
GlobalDataDir string
)
func Init(ctx context.Context) error {
if err := os.MkdirAll(GlobalDataDir, 0755); err != nil {
return err
}
return nil
}