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:
376
internal/module/registry/blob.go
Normal file
376
internal/module/registry/blob.go
Normal file
@@ -0,0 +1,376 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleBlobs ?? blob ????
|
||||
// POST /v2/{repo}/blobs/uploads/ - ????
|
||||
// PATCH /v2/{repo}/blobs/uploads/{uuid} - ?????
|
||||
// PUT /v2/{repo}/blobs/uploads/{uuid}?digest={digest} - ????
|
||||
// GET /v2/{repo}/blobs/{digest} - ?? blob
|
||||
// HEAD /v2/{repo}/blobs/{digest} - ?? blob ????
|
||||
func HandleBlobs(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
path := c.Path()
|
||||
method := c.Method()
|
||||
|
||||
// ????: /v2/{repo}/blobs/...
|
||||
// ??????????? "test/redis"
|
||||
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
||||
parts := strings.Split(pathWithoutV2, "/")
|
||||
if len(parts) < 2 {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
|
||||
}
|
||||
|
||||
// ?? "blobs" ????????????????
|
||||
blobsIndex := -1
|
||||
for i, part := range parts {
|
||||
if part == "blobs" {
|
||||
blobsIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if blobsIndex < 1 {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path: blobs not found")
|
||||
}
|
||||
|
||||
// ???? blobs ???????
|
||||
repo := strings.Join(parts[:blobsIndex], "/")
|
||||
// ???? parts??????????? parts[0] ? "blobs"
|
||||
parts = parts[blobsIndex:]
|
||||
|
||||
switch method {
|
||||
case "POST":
|
||||
// POST /v2/{repo}/blobs/uploads/ - ????
|
||||
// parts ??? ["blobs", "uploads", ""] ? ["blobs", "uploads"]
|
||||
if len(parts) >= 2 && parts[0] == "blobs" && parts[1] == "uploads" {
|
||||
return handleBlobUploadStart(c, db, store, repo)
|
||||
}
|
||||
|
||||
case "PATCH":
|
||||
// PATCH /v2/{repo}/blobs/uploads/{uuid} - ?????
|
||||
// parts ??? ["blobs", "uploads", "uuid"]
|
||||
if len(parts) >= 3 && parts[0] == "blobs" && parts[1] == "uploads" {
|
||||
uuid := parts[2]
|
||||
return handleBlobUploadChunk(c, db, store, repo, uuid)
|
||||
}
|
||||
|
||||
case "PUT":
|
||||
// PUT /v2/{repo}/blobs/uploads/{uuid}?digest={digest} - ????
|
||||
// parts ??? ["blobs", "uploads", "uuid"]
|
||||
if len(parts) >= 3 && parts[0] == "blobs" && parts[1] == "uploads" {
|
||||
uuid := parts[2]
|
||||
digest := c.Query("digest")
|
||||
if digest == "" {
|
||||
return resp.R400(c, "MISSING_DIGEST", nil, "digest parameter is required")
|
||||
}
|
||||
return handleBlobUploadComplete(c, db, store, repo, uuid, digest)
|
||||
}
|
||||
|
||||
case "GET":
|
||||
// GET /v2/{repo}/blobs/{digest} - ?? blob
|
||||
// parts ??? ["blobs", "digest"]
|
||||
if len(parts) >= 2 && parts[0] == "blobs" {
|
||||
digest := parts[1]
|
||||
return handleBlobDownload(c, db, store, repo, digest)
|
||||
}
|
||||
|
||||
case "HEAD":
|
||||
// HEAD /v2/{repo}/blobs/{digest} - ?? blob ????
|
||||
// parts ??? ["blobs", "digest"]
|
||||
if len(parts) >= 2 && parts[0] == "blobs" {
|
||||
digest := parts[1]
|
||||
return handleBlobHead(c, db, store, repo, digest)
|
||||
}
|
||||
}
|
||||
|
||||
return resp.R404(c, "NOT_FOUND", nil, "endpoint not found")
|
||||
}
|
||||
|
||||
// handleBlobUploadStart ?? blob ??
|
||||
func handleBlobUploadStart(c fiber.Ctx, db *gorm.DB, store store.Store, repo string) error {
|
||||
// ?? UUID
|
||||
uuidBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(uuidBytes); err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
uuid := hex.EncodeToString(uuidBytes)
|
||||
|
||||
// ??????
|
||||
upload := &model.BlobUpload{
|
||||
UUID: uuid,
|
||||
Repository: repo,
|
||||
Path: uuid, // ?? UUID ??????
|
||||
Size: 0,
|
||||
}
|
||||
|
||||
if err := db.Create(upload).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ??????
|
||||
w, err := store.CreateUpload(c.Context(), uuid)
|
||||
if err != nil {
|
||||
db.Delete(upload)
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
w.Close()
|
||||
|
||||
// ???? URL
|
||||
uploadURL := fmt.Sprintf("/v2/%s/blobs/uploads/%s", repo, uuid)
|
||||
c.Set("Location", uploadURL)
|
||||
c.Set("Docker-Upload-UUID", uuid)
|
||||
c.Set("Range", "0-0")
|
||||
return c.SendStatus(202)
|
||||
}
|
||||
|
||||
// handleBlobUploadChunk ?? blob ???
|
||||
func handleBlobUploadChunk(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, uuid string) error {
|
||||
// ??????
|
||||
var upload model.BlobUpload
|
||||
if err := db.Where("uuid = ? AND repository = ?", uuid, repo).First(&upload).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "UPLOAD_NOT_FOUND", nil, "upload session not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ?????
|
||||
body := c.Body()
|
||||
if len(body) == 0 {
|
||||
return resp.R400(c, "EMPTY_BODY", nil, "request body is empty")
|
||||
}
|
||||
|
||||
// ??????? bytes.NewReader ????????
|
||||
n, err := store.AppendUpload(c.Context(), uuid, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ??????
|
||||
upload.Size += n
|
||||
if err := db.Save(&upload).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ???? URL ???
|
||||
uploadURL := fmt.Sprintf("/v2/%s/blobs/uploads/%s", repo, uuid)
|
||||
c.Set("Location", uploadURL)
|
||||
c.Set("Docker-Upload-UUID", uuid)
|
||||
c.Set("Range", fmt.Sprintf("0-%d", upload.Size-1))
|
||||
return c.SendStatus(202)
|
||||
}
|
||||
|
||||
// handleBlobUploadComplete ?? blob ??
|
||||
func handleBlobUploadComplete(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, uuid string, digest string) error {
|
||||
// ??????
|
||||
var upload model.BlobUpload
|
||||
if err := db.Where("uuid = ? AND repository = ?", uuid, repo).First(&upload).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "UPLOAD_NOT_FOUND", nil, "upload session not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ??????????????PUT ???????????
|
||||
body := c.Body()
|
||||
if len(body) > 0 {
|
||||
if _, err := store.AppendUpload(c.Context(), uuid, bytes.NewReader(body)); err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ?????????????
|
||||
if err := store.FinalizeUpload(c.Context(), uuid, digest); err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ????????
|
||||
size, err := store.GetBlobSize(c.Context(), digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ????? blob ??
|
||||
var blob model.Blob
|
||||
if err := db.Where("digest = ?", digest).First(&blob).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
blob = model.Blob{
|
||||
Digest: digest,
|
||||
Size: size,
|
||||
Repository: repo,
|
||||
}
|
||||
if err := db.Create(&blob).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ??????
|
||||
db.Delete(&upload)
|
||||
store.DeleteUpload(c.Context(), uuid)
|
||||
|
||||
// ?? blob URL
|
||||
blobURL := fmt.Sprintf("/v2/%s/blobs/%s", repo, digest)
|
||||
c.Set("Location", blobURL)
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
c.Set("Docker-Content-Digest", digest)
|
||||
return c.SendStatus(201)
|
||||
}
|
||||
|
||||
// parseRangeHeader parses Range header and returns start and end positions
|
||||
func parseRangeHeader(rangeHeader string, size int64) (start, end int64, valid bool) {
|
||||
if rangeHeader == "" {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
|
||||
// Range header format: "bytes=start-end" or "bytes=start-"
|
||||
if !strings.HasPrefix(rangeHeader, "bytes=") {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
|
||||
rangeSpec := strings.TrimPrefix(rangeHeader, "bytes=")
|
||||
parts := strings.Split(rangeSpec, "-")
|
||||
if len(parts) != 2 {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
|
||||
var err error
|
||||
if parts[0] == "" {
|
||||
// Suffix range: "bytes=-suffix"
|
||||
suffix, err := strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || suffix <= 0 {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
start = size - suffix
|
||||
if start < 0 {
|
||||
start = 0
|
||||
}
|
||||
end = size - 1
|
||||
} else if parts[1] == "" {
|
||||
// Start range: "bytes=start-"
|
||||
start, err = strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || start < 0 || start >= size {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
end = size - 1
|
||||
} else {
|
||||
// Full range: "bytes=start-end"
|
||||
start, err = strconv.ParseInt(parts[0], 10, 64)
|
||||
if err != nil || start < 0 || start >= size {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
end, err = strconv.ParseInt(parts[1], 10, 64)
|
||||
if err != nil || end < start || end >= size {
|
||||
return 0, size - 1, false
|
||||
}
|
||||
}
|
||||
|
||||
return start, end, true
|
||||
}
|
||||
|
||||
// handleBlobDownload ?? blob
|
||||
func handleBlobDownload(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, digest string) error {
|
||||
// Check if blob exists
|
||||
exists, err := store.BlobExists(c.Context(), digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
if !exists {
|
||||
return resp.R404(c, "BLOB_NOT_FOUND", nil, "blob not found")
|
||||
}
|
||||
|
||||
// Get blob size
|
||||
size, err := store.GetBlobSize(c.Context(), digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// Read blob
|
||||
reader, err := store.ReadBlob(c.Context(), digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
// Check for Range request
|
||||
rangeHeader := c.Get("Range")
|
||||
start, end, hasRange := parseRangeHeader(rangeHeader, size)
|
||||
|
||||
if hasRange {
|
||||
// Handle Range request
|
||||
// Seek to start position
|
||||
if seeker, ok := reader.(io.Seeker); ok {
|
||||
if _, err := seeker.Seek(start, io.SeekStart); err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
// If not seekable, read and discard bytes
|
||||
if _, err := io.CopyN(io.Discard, reader, start); err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Create limited reader
|
||||
limitedReader := io.LimitReader(reader, end-start+1)
|
||||
|
||||
// Set partial content headers
|
||||
c.Set("Content-Type", "application/octet-stream")
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", end-start+1))
|
||||
c.Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
|
||||
c.Set("Accept-Ranges", "bytes")
|
||||
c.Set("Docker-Content-Digest", digest)
|
||||
c.Status(206) // Partial Content
|
||||
|
||||
// Send partial content
|
||||
return c.SendStream(limitedReader)
|
||||
}
|
||||
|
||||
// Full blob download
|
||||
c.Set("Content-Type", "application/octet-stream")
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
c.Set("Accept-Ranges", "bytes")
|
||||
c.Set("Docker-Content-Digest", digest)
|
||||
|
||||
// Send full blob stream
|
||||
return c.SendStream(reader)
|
||||
}
|
||||
|
||||
// handleBlobHead ?? blob ????
|
||||
func handleBlobHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, digest string) error {
|
||||
// Check if blob exists
|
||||
exists, err := store.BlobExists(c.Context(), digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
if !exists {
|
||||
return resp.R404(c, "BLOB_NOT_FOUND", nil, "blob not found")
|
||||
}
|
||||
|
||||
// Get blob size
|
||||
size, err := store.GetBlobSize(c.Context(), digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// Set response headers
|
||||
c.Set("Content-Type", "application/octet-stream")
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", size))
|
||||
c.Set("Accept-Ranges", "bytes")
|
||||
c.Set("Docker-Content-Digest", digest)
|
||||
return c.SendStatus(200)
|
||||
}
|
||||
66
internal/module/registry/catalog.go
Normal file
66
internal/module/registry/catalog.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleCatalog ????????
|
||||
// GET /v2/_catalog?n={limit}&last={last}
|
||||
func HandleCatalog(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
path := c.Path()
|
||||
|
||||
// ????: /v2/_catalog
|
||||
parts := strings.Split(strings.TrimPrefix(path, "/v2/"), "/")
|
||||
if len(parts) < 1 || parts[0] != "_catalog" {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
|
||||
}
|
||||
|
||||
// ??????
|
||||
nStr := c.Query("n", "100")
|
||||
n, err := strconv.Atoi(nStr)
|
||||
if err != nil || n <= 0 {
|
||||
n = 100
|
||||
}
|
||||
last := c.Query("last")
|
||||
|
||||
// ????
|
||||
var repos []model.Repository
|
||||
query := db.Order("name ASC").Limit(n + 1)
|
||||
|
||||
if last != "" {
|
||||
query = query.Where("name > ?", last)
|
||||
}
|
||||
|
||||
if err := query.Find(&repos).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ????
|
||||
repoNames := make([]string, 0, len(repos))
|
||||
hasMore := false
|
||||
for i, repo := range repos {
|
||||
if i >= n {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
repoNames = append(repoNames, repo.Name)
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"repositories": repoNames,
|
||||
}
|
||||
|
||||
// ??????????????
|
||||
if hasMore && len(repoNames) > 0 {
|
||||
response["last"] = repoNames[len(repoNames)-1]
|
||||
}
|
||||
|
||||
return resp.R200(c, response)
|
||||
}
|
||||
55
internal/module/registry/handler.list.go
Normal file
55
internal/module/registry/handler.list.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// RegistryImageList returns the list of images/repositories
|
||||
func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
var repositories []model.Repository
|
||||
|
||||
// Query all repositories from the database
|
||||
if err := db.Find(&repositories).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// Convert to the expected format for the frontend
|
||||
var result []map[string]interface{}
|
||||
for _, repo := range repositories {
|
||||
// Calculate total size of all blobs for this repository
|
||||
var totalSize int64
|
||||
var sizeResult struct {
|
||||
Total int64
|
||||
}
|
||||
err := db.Model(&model.Blob{}).
|
||||
Where("repository = ?", repo.Name).
|
||||
Select("COALESCE(SUM(size), 0) as total").
|
||||
Scan(&sizeResult).Error
|
||||
if err == nil {
|
||||
totalSize = sizeResult.Total
|
||||
}
|
||||
|
||||
// Format updated_at to second precision
|
||||
uploadTime := repo.UpdatedAt.Format("2006-01-02 15:04:05")
|
||||
|
||||
repoMap := map[string]interface{}{
|
||||
"id": repo.ID,
|
||||
"name": repo.Name,
|
||||
"upload_time": uploadTime,
|
||||
"size": totalSize,
|
||||
}
|
||||
result = append(result, repoMap)
|
||||
}
|
||||
|
||||
return resp.R200(c, map[string]interface{}{
|
||||
"images": result,
|
||||
})
|
||||
}
|
||||
}
|
||||
419
internal/module/registry/manifest.go
Normal file
419
internal/module/registry/manifest.go
Normal file
@@ -0,0 +1,419 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// isDigestFormat checks if a string is in digest format (e.g., sha256:abc123...)
|
||||
func isDigestFormat(s string) bool {
|
||||
parts := strings.SplitN(s, ":", 2)
|
||||
if len(parts) != 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
algo := parts[0]
|
||||
hash := parts[1]
|
||||
|
||||
// Check algorithm
|
||||
if algo != "sha256" {
|
||||
// Could be extended to support other algorithms like sha512
|
||||
return false
|
||||
}
|
||||
|
||||
// Check that hash is a valid hex string of expected length (64 for sha256)
|
||||
if len(hash) != 64 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Verify it's all hex characters
|
||||
for _, r := range hash {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleManifest ?? manifest ????
|
||||
// PUT /v2/{repo}/manifests/{tag} - ?? manifest
|
||||
// GET /v2/{repo}/manifests/{tag} - ?? manifest
|
||||
// DELETE /v2/{repo}/manifests/{tag} - ?? manifest
|
||||
func HandleManifest(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
path := c.Path()
|
||||
method := c.Method()
|
||||
|
||||
// ????: /v2/{repo}/manifests/{tag}
|
||||
// ??????????? "test/redis"
|
||||
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
||||
parts := strings.Split(pathWithoutV2, "/")
|
||||
if len(parts) < 2 {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
|
||||
}
|
||||
|
||||
// ?? "manifests" ???
|
||||
manifestsIndex := -1
|
||||
for i, part := range parts {
|
||||
if part == "manifests" {
|
||||
manifestsIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if manifestsIndex < 1 || manifestsIndex >= len(parts)-1 {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path: manifests not found")
|
||||
}
|
||||
|
||||
// ???? manifests ???????
|
||||
repo := strings.Join(parts[:manifestsIndex], "/")
|
||||
// tag ? manifests ?????
|
||||
tag := parts[manifestsIndex+1]
|
||||
|
||||
switch method {
|
||||
case "PUT":
|
||||
return handleManifestPut(c, db, store, repo, tag)
|
||||
case "GET":
|
||||
return handleManifestGet(c, db, store, repo, tag)
|
||||
case "HEAD":
|
||||
return handleManifestHead(c, db, store, repo, tag)
|
||||
case "DELETE":
|
||||
return handleManifestDelete(c, db, store, repo, tag)
|
||||
}
|
||||
|
||||
return resp.R404(c, "NOT_FOUND", nil, "method not allowed")
|
||||
}
|
||||
|
||||
// handleManifestPut ?? manifest
|
||||
func handleManifestPut(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
||||
// ?? manifest ??
|
||||
content := c.Body()
|
||||
if len(content) == 0 {
|
||||
return resp.R400(c, "EMPTY_BODY", nil, "manifest content is empty")
|
||||
}
|
||||
|
||||
// ?? digest
|
||||
hasher := sha256.New()
|
||||
hasher.Write(content)
|
||||
digest := "sha256:" + hex.EncodeToString(hasher.Sum(nil))
|
||||
|
||||
// ?? Content-Type
|
||||
mediaType := c.Get("Content-Type")
|
||||
if mediaType == "" {
|
||||
// ??? manifest ????
|
||||
var mf map[string]interface{}
|
||||
if err := json.Unmarshal(content, &mf); err == nil {
|
||||
if mt, ok := mf["mediaType"].(string); ok {
|
||||
mediaType = mt
|
||||
} else {
|
||||
// ???? Docker manifest v2
|
||||
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
|
||||
}
|
||||
} else {
|
||||
mediaType = "application/vnd.docker.distribution.manifest.v2+json"
|
||||
}
|
||||
}
|
||||
|
||||
// ?? manifest ????????
|
||||
var manifestData map[string]interface{}
|
||||
if err := json.Unmarshal(content, &manifestData); err != nil {
|
||||
return resp.R400(c, "INVALID_MANIFEST", nil, "invalid manifest format")
|
||||
}
|
||||
|
||||
// ??????
|
||||
var repository model.Repository
|
||||
if err := db.Where("name = ?", repo).First(&repository).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
repository = model.Repository{Name: repo}
|
||||
if err := db.Create(&repository).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ?? manifest ?????
|
||||
if err := store.WriteManifest(c.Context(), digest, content); err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ?? manifest ?????
|
||||
var manifest model.Manifest
|
||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// ???? manifest ??
|
||||
manifest = model.Manifest{
|
||||
Repository: repo,
|
||||
Tag: tag,
|
||||
Digest: digest,
|
||||
MediaType: mediaType,
|
||||
Size: int64(len(content)),
|
||||
Content: content,
|
||||
}
|
||||
if err := db.Create(&manifest).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
// ???? manifest ? tag ??
|
||||
manifest.Tag = tag
|
||||
manifest.Repository = repo
|
||||
if err := db.Save(&manifest).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ????? tag ??
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
tagRecord = model.Tag{
|
||||
Repository: repo,
|
||||
Tag: tag,
|
||||
Digest: digest,
|
||||
}
|
||||
if err := db.Create(&tagRecord).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
tagRecord.Digest = digest
|
||||
if err := db.Save(&tagRecord).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ?????
|
||||
c.Set("Location", fmt.Sprintf("/v2/%s/manifests/%s", repo, tag))
|
||||
c.Set("Docker-Content-Digest", digest)
|
||||
c.Set("Content-Type", mediaType)
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||
return c.SendStatus(201)
|
||||
}
|
||||
|
||||
// handleManifestGet ?? manifest
|
||||
func handleManifestGet(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
||||
var manifest model.Manifest
|
||||
|
||||
// ?? tag ??????????????????????
|
||||
if isDigestFormat(tag) {
|
||||
// ?? digest ???????????? repository
|
||||
digest := tag
|
||||
|
||||
// ?? manifest ???
|
||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ???? manifest ?????????? repository ??
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
// ?? tag ???? tag ?????????
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ?? manifest ??
|
||||
if err := db.Where("digest = ?", tagRecord.Digest).First(&manifest).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Check Accept header if provided
|
||||
acceptHeader := c.Get("Accept")
|
||||
if acceptHeader != "" {
|
||||
// Parse Accept header to check if client accepts the manifest's media type
|
||||
acceptTypes := strings.Split(acceptHeader, ",")
|
||||
accepted := false
|
||||
for _, at := range acceptTypes {
|
||||
// Remove quality values (e.g., "application/vnd.docker.distribution.manifest.v2+json;q=0.9")
|
||||
mediaType := strings.TrimSpace(strings.Split(at, ";")[0])
|
||||
if mediaType == manifest.MediaType || mediaType == "*/*" {
|
||||
accepted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !accepted {
|
||||
// Check for wildcard or common Docker manifest types
|
||||
for _, at := range acceptTypes {
|
||||
mediaType := strings.TrimSpace(strings.Split(at, ";")[0])
|
||||
if strings.Contains(mediaType, "manifest") || mediaType == "*/*" {
|
||||
accepted = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Note: We still return the manifest even if not explicitly accepted,
|
||||
// as some clients may not send proper Accept headers
|
||||
}
|
||||
|
||||
// Read manifest content
|
||||
content, err := store.ReadManifest(c.Context(), manifest.Digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// Set response headers
|
||||
c.Set("Content-Type", manifest.MediaType)
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", len(content)))
|
||||
c.Set("Docker-Content-Digest", manifest.Digest)
|
||||
return c.Send(content)
|
||||
}
|
||||
|
||||
// handleManifestHead ?? manifest ????
|
||||
func handleManifestHead(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
||||
var manifest model.Manifest
|
||||
|
||||
// ?? tag ??????????????????????
|
||||
if isDigestFormat(tag) {
|
||||
// ?? digest ???????????? repository
|
||||
digest := tag
|
||||
|
||||
// ?? manifest ???
|
||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ???? manifest ?????????? repository ??
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
// ?? tag ???? tag ?????????
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ?? manifest ??
|
||||
if err := db.Where("digest = ?", tagRecord.Digest).First(&manifest).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
// ?????
|
||||
c.Set("Content-Type", manifest.MediaType)
|
||||
c.Set("Content-Length", fmt.Sprintf("%d", manifest.Size))
|
||||
c.Set("Docker-Content-Digest", manifest.Digest)
|
||||
return c.SendStatus(200)
|
||||
}
|
||||
|
||||
// handleManifestDelete ?? manifest
|
||||
func handleManifestDelete(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, tag string) error {
|
||||
var digest string
|
||||
|
||||
if isDigestFormat(tag) {
|
||||
// ?? digest ???????????? repository
|
||||
digest = tag
|
||||
|
||||
// ???? manifest ?????????? repository ??
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND digest = ?", repo, digest).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found in this repository")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ???????? tag ??? manifest
|
||||
var count int64
|
||||
if err := db.Model(&model.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ??? tag ??????? manifest ??
|
||||
if count == 0 {
|
||||
var manifest model.Manifest
|
||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
if err := db.Delete(&manifest).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
// ?? tag ?????????????????
|
||||
// ??? manifest ???????????
|
||||
return resp.R400(c, "CANNOT_DELETE_DIGEST_REFERENCED_BY_TAGS", nil, "cannot delete manifest referenced by tags")
|
||||
}
|
||||
} else {
|
||||
// ?? tag ???? tag ?????????
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND tag = ?", repo, tag).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "MANIFEST_NOT_FOUND", nil, "manifest not found")
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
digest = tagRecord.Digest
|
||||
|
||||
// ?? tag ??
|
||||
if err := db.Delete(&tagRecord).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ???????? tag ??? manifest
|
||||
var count int64
|
||||
if err := db.Model(&model.Tag{}).Where("digest = ?", digest).Count(&count).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ?????? tag ????? manifest ??
|
||||
if count == 0 {
|
||||
var manifest model.Manifest
|
||||
if err := db.Where("digest = ?", digest).First(&manifest).Error; err == nil {
|
||||
if err := db.Delete(&manifest).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.SendStatus(202)
|
||||
}
|
||||
16
internal/module/registry/referrer.go
Normal file
16
internal/module/registry/referrer.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleReferrers ?? referrers ???OCI ???
|
||||
// GET /v2/{repo}/referrers/{digest}
|
||||
func HandleReferrers(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
// TODO: ?? OCI referrers API
|
||||
// ????????????? OCI ? referrers ??
|
||||
return resp.R501(c, "NOT_IMPLEMENTED", nil, "referrers API not implemented yet")
|
||||
}
|
||||
115
internal/module/registry/registry.go
Normal file
115
internal/module/registry/registry.go
Normal file
@@ -0,0 +1,115 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Registry(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||
// ???????
|
||||
if err := db.AutoMigrate(
|
||||
&model.Repository{},
|
||||
&model.Blob{},
|
||||
&model.Manifest{},
|
||||
&model.Tag{},
|
||||
&model.BlobUpload{},
|
||||
); err != nil {
|
||||
log.Fatalf("failed to migrate database: %v", err)
|
||||
}
|
||||
|
||||
if err := store.CreatePartition(ctx, "registry"); err != nil {
|
||||
log.Fatalf("failed to create registry partition: %v", err)
|
||||
}
|
||||
|
||||
return func(c fiber.Ctx) error {
|
||||
if isBlob(c) {
|
||||
return HandleBlobs(c, db, store)
|
||||
}
|
||||
|
||||
if isManifest(c) {
|
||||
return HandleManifest(c, db, store)
|
||||
}
|
||||
|
||||
if isTags(c) {
|
||||
return HandleTags(c, db, store)
|
||||
}
|
||||
|
||||
if isCatalog(c) {
|
||||
return HandleCatalog(c, db, store)
|
||||
}
|
||||
|
||||
if isReferrers(c) {
|
||||
return HandleReferrers(c, db, store)
|
||||
}
|
||||
|
||||
// Handle root v2 endpoint
|
||||
if c.Path() == "/v2/" {
|
||||
c.Set("Docker-Distribution-API-Version", "registry/2.0")
|
||||
return c.SendStatus(200)
|
||||
}
|
||||
|
||||
c.Set("Docker-Distribution-API-Version", "registry/2.0")
|
||||
|
||||
log.Printf("[Warn] Registry: unknown endpoint - path = %s, method = %s, headers = %v", c.Path(), c.Method(), &c.Request().Header)
|
||||
|
||||
return resp.R404(c, "UNKNOWN_ENDPOINT", nil, "endpoint not found")
|
||||
}
|
||||
}
|
||||
|
||||
func isBlob(c fiber.Ctx) bool {
|
||||
elem := strings.Split(c.Path(), "/")
|
||||
elem = elem[1:]
|
||||
if elem[len(elem)-1] == "" {
|
||||
elem = elem[:len(elem)-1]
|
||||
}
|
||||
if len(elem) < 3 {
|
||||
return false
|
||||
}
|
||||
return elem[len(elem)-2] == "blobs" || (elem[len(elem)-3] == "blobs" &&
|
||||
elem[len(elem)-2] == "uploads")
|
||||
}
|
||||
|
||||
func isManifest(c fiber.Ctx) bool {
|
||||
elems := strings.Split(c.Path(), "/")
|
||||
elems = elems[1:]
|
||||
if len(elems) < 4 {
|
||||
return false
|
||||
}
|
||||
return elems[len(elems)-2] == "manifests"
|
||||
}
|
||||
|
||||
func isTags(c fiber.Ctx) bool {
|
||||
elems := strings.Split(c.Path(), "/")
|
||||
elems = elems[1:]
|
||||
if len(elems) < 4 {
|
||||
return false
|
||||
}
|
||||
return elems[len(elems)-2] == "tags"
|
||||
}
|
||||
|
||||
func isCatalog(c fiber.Ctx) bool {
|
||||
elems := strings.Split(c.Path(), "/")
|
||||
elems = elems[1:]
|
||||
if len(elems) < 2 {
|
||||
return false
|
||||
}
|
||||
|
||||
return elems[len(elems)-1] == "_catalog"
|
||||
}
|
||||
|
||||
func isReferrers(c fiber.Ctx) bool {
|
||||
elems := strings.Split(c.Path(), "/")
|
||||
elems = elems[1:]
|
||||
if len(elems) < 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
return elems[len(elems)-2] == "referrers"
|
||||
}
|
||||
84
internal/module/registry/tag.go
Normal file
84
internal/module/registry/tag.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/resp"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/store"
|
||||
"github.com/gofiber/fiber/v3"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// HandleTags ?? tag ????
|
||||
// GET /v2/{repo}/tags/list?n={limit}&last={last}
|
||||
func HandleTags(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
path := c.Path()
|
||||
|
||||
// ????: /v2/{repo}/tags/list
|
||||
// ??????????? "test/redis"
|
||||
pathWithoutV2 := strings.TrimPrefix(path, "/v2/")
|
||||
parts := strings.Split(pathWithoutV2, "/")
|
||||
if len(parts) < 3 {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
|
||||
}
|
||||
|
||||
// ?? "tags" ???
|
||||
tagsIndex := -1
|
||||
for i, part := range parts {
|
||||
if part == "tags" {
|
||||
tagsIndex = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if tagsIndex < 1 || tagsIndex >= len(parts)-1 || parts[tagsIndex+1] != "list" {
|
||||
return resp.R404(c, "INVALID_PATH", nil, "invalid path")
|
||||
}
|
||||
|
||||
// ???? tags ???????
|
||||
repo := strings.Join(parts[:tagsIndex], "/")
|
||||
|
||||
// ??????
|
||||
nStr := c.Query("n", "100")
|
||||
n, err := strconv.Atoi(nStr)
|
||||
if err != nil || n <= 0 {
|
||||
n = 100
|
||||
}
|
||||
last := c.Query("last")
|
||||
|
||||
// ?? tags
|
||||
var tags []model.Tag
|
||||
query := db.Where("repository = ?", repo).Order("tag ASC").Limit(n + 1)
|
||||
|
||||
if last != "" {
|
||||
query = query.Where("tag > ?", last)
|
||||
}
|
||||
|
||||
if err := query.Find(&tags).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// ????
|
||||
tagNames := make([]string, 0, len(tags))
|
||||
hasMore := false
|
||||
for i, tag := range tags {
|
||||
if i >= n {
|
||||
hasMore = true
|
||||
break
|
||||
}
|
||||
tagNames = append(tagNames, tag.Tag)
|
||||
}
|
||||
|
||||
response := map[string]interface{}{
|
||||
"name": repo,
|
||||
"tags": tagNames,
|
||||
}
|
||||
|
||||
// ??????????????
|
||||
if hasMore && len(tagNames) > 0 {
|
||||
response["last"] = tagNames[len(tagNames)-1]
|
||||
}
|
||||
|
||||
return resp.R200(c, response)
|
||||
}
|
||||
Reference in New Issue
Block a user