feat: add registry config, image upload/download, and OCI format support
Backend: - Add registry_address configuration API (GET/POST) - Add tar image upload with OCI and Docker format support - Add image download with streaming optimization - Fix blob download using c.Send (Fiber v3 SendStream bug) - Add registry_address prefix stripping for all OCI v2 endpoints - Add AGENTS.md for project documentation Frontend: - Add settings store with Snackbar notifications - Add image upload dialog with progress bar - Add download state tracking with multi-stage feedback - Replace alert() with MUI Snackbar messages - Display image names without registry_address prefix 🤖 Generated with [Qoder](https://qoder.com)
This commit is contained in:
@@ -7,19 +7,21 @@ import (
|
||||
"net"
|
||||
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/middleware"
|
||||
"gitea.loveuer.com/loveuer/cluster/internal/model"
|
||||
"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 {
|
||||
func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) (func(context.Context) error, error) {
|
||||
var (
|
||||
err error
|
||||
ln net.Listener
|
||||
cfg = fiber.Config{
|
||||
BodyLimit: 1024 * 1024 * 1024 * 10, // 10GB limit for large image layers
|
||||
}
|
||||
fn func(context.Context) error
|
||||
)
|
||||
|
||||
app := fiber.New(cfg)
|
||||
@@ -28,6 +30,12 @@ func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) e
|
||||
app.Use(middleware.Recovery())
|
||||
app.Use(middleware.CORS())
|
||||
|
||||
// Ensure database migration for RegistryConfig
|
||||
// This is done here to ensure the table exists before config APIs are called
|
||||
if err := db.AutoMigrate(&model.RegistryConfig{}); err != nil {
|
||||
log.Printf("Warning: failed to migrate RegistryConfig: %v", err)
|
||||
}
|
||||
|
||||
// oci image apis
|
||||
{
|
||||
app.All("/v2/*", registry.Registry(ctx, db, store))
|
||||
@@ -37,25 +45,32 @@ func Init(ctx context.Context, address string, db *gorm.DB, store store.Store) e
|
||||
{
|
||||
registryAPI := app.Group("/api/v1/registry")
|
||||
registryAPI.Get("/image/list", registry.RegistryImageList(ctx, db, store))
|
||||
registryAPI.Get("/image/download/*", registry.RegistryImageDownload(ctx, db, store))
|
||||
registryAPI.Post("/image/upload", registry.RegistryImageUpload(ctx, db, store))
|
||||
// registry config apis
|
||||
registryAPI.Get("/config", registry.RegistryConfigGet(ctx, db, store))
|
||||
registryAPI.Post("/config", registry.RegistryConfigSet(ctx, db, store))
|
||||
}
|
||||
|
||||
ln, err = net.Listen("tcp", address)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to listen on %s: %w", address, err)
|
||||
return fn, fmt.Errorf("failed to listen on %s: %w", address, err)
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := app.Listener(ln); err != nil {
|
||||
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)
|
||||
fn = func(_ctx context.Context) error {
|
||||
log.Println("[W] service shutdown...")
|
||||
if err = app.ShutdownWithContext(_ctx); err != nil {
|
||||
return fmt.Errorf("[E] service shutdown failed, err = %w", err)
|
||||
}
|
||||
}()
|
||||
|
||||
return nil
|
||||
return nil
|
||||
}
|
||||
|
||||
return fn, nil
|
||||
}
|
||||
|
||||
@@ -2,11 +2,13 @@ package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"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"
|
||||
"gitea.loveuer.com/loveuer/cluster/pkg/tool"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
@@ -16,7 +18,9 @@ func Run(ctx context.Context) error {
|
||||
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
|
||||
err error
|
||||
stopFns = []func(context.Context) error{}
|
||||
stopApi func(context.Context) error
|
||||
)
|
||||
|
||||
if err = opt.Init(cmd.Context()); err != nil {
|
||||
@@ -31,11 +35,16 @@ func Run(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = api.Init(cmd.Context(), opt.GlobalAddress, db.Default, store.Default); err != nil {
|
||||
if stopApi, err = api.Init(cmd.Context(), opt.GlobalAddress, db.Default, store.Default); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
stopFns = append(stopFns, stopApi)
|
||||
|
||||
<-cmd.Context().Done()
|
||||
log.Println("[W] 收到退出信号,开始退出...")
|
||||
|
||||
tool.MustStop(tool.Timeout(5), stopFns...)
|
||||
|
||||
return nil
|
||||
},
|
||||
@@ -45,5 +54,5 @@ func Run(ctx context.Context) error {
|
||||
_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()
|
||||
return _cmd.ExecuteContext(ctx)
|
||||
}
|
||||
|
||||
@@ -68,3 +68,14 @@ type BlobUpload struct {
|
||||
Path string `gorm:"not null" json:"path"` // ??????
|
||||
Size int64 `gorm:"default:0" json:"size"` // ?????
|
||||
}
|
||||
|
||||
// RegistryConfig registry ?????
|
||||
type RegistryConfig 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:"-"`
|
||||
|
||||
Key string `gorm:"uniqueIndex;not null" json:"key"` // ???? key
|
||||
Value string `gorm:"type:text" json:"value"` // ???? value
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -48,6 +49,16 @@ func HandleBlobs(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
|
||||
// ???? blobs ???????
|
||||
repo := strings.Join(parts[:blobsIndex], "/")
|
||||
|
||||
// Strip registry_address prefix from repo if present
|
||||
var registryConfig model.RegistryConfig
|
||||
registryAddress := ""
|
||||
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
||||
registryAddress = registryConfig.Value
|
||||
}
|
||||
if registryAddress != "" && strings.HasPrefix(repo, registryAddress+"/") {
|
||||
repo = strings.TrimPrefix(repo, registryAddress+"/")
|
||||
}
|
||||
// ???? parts??????????? parts[0] ? "blobs"
|
||||
parts = parts[blobsIndex:]
|
||||
|
||||
@@ -285,42 +296,53 @@ func parseRangeHeader(rangeHeader string, size int64) (start, end int64, valid b
|
||||
|
||||
// handleBlobDownload ?? blob
|
||||
func handleBlobDownload(c fiber.Ctx, db *gorm.DB, store store.Store, repo string, digest string) error {
|
||||
log.Printf("[BlobDownload] Start: repo=%s, digest=%s", repo, digest)
|
||||
|
||||
// Check if blob exists
|
||||
exists, err := store.BlobExists(c.Context(), digest)
|
||||
if err != nil {
|
||||
log.Printf("[BlobDownload] BlobExists error: %v", err)
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
if !exists {
|
||||
log.Printf("[BlobDownload] Blob not found: %s", digest)
|
||||
return resp.R404(c, "BLOB_NOT_FOUND", nil, "blob not found")
|
||||
}
|
||||
|
||||
// Get blob size
|
||||
size, err := store.GetBlobSize(c.Context(), digest)
|
||||
if err != nil {
|
||||
log.Printf("[BlobDownload] GetBlobSize error: %v", err)
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
log.Printf("[BlobDownload] Blob size: %d bytes", size)
|
||||
|
||||
// Read blob
|
||||
reader, err := store.ReadBlob(c.Context(), digest)
|
||||
if err != nil {
|
||||
log.Printf("[BlobDownload] ReadBlob error: %v", err)
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
defer reader.Close()
|
||||
log.Printf("[BlobDownload] Reader opened successfully")
|
||||
|
||||
// Check for Range request
|
||||
rangeHeader := c.Get("Range")
|
||||
start, end, hasRange := parseRangeHeader(rangeHeader, size)
|
||||
|
||||
if hasRange {
|
||||
log.Printf("[BlobDownload] Range request: %d-%d/%d", start, end, size)
|
||||
// Handle Range request
|
||||
// Seek to start position
|
||||
if seeker, ok := reader.(io.Seeker); ok {
|
||||
if _, err := seeker.Seek(start, io.SeekStart); err != nil {
|
||||
log.Printf("[BlobDownload] Seek error: %v", err)
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else {
|
||||
// If not seekable, read and discard bytes
|
||||
if _, err := io.CopyN(io.Discard, reader, start); err != nil {
|
||||
log.Printf("[BlobDownload] CopyN discard error: %v", err)
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
@@ -336,18 +358,32 @@ func handleBlobDownload(c fiber.Ctx, db *gorm.DB, store store.Store, repo string
|
||||
c.Set("Docker-Content-Digest", digest)
|
||||
c.Status(206) // Partial Content
|
||||
|
||||
// Send partial content
|
||||
return c.SendStream(limitedReader)
|
||||
log.Printf("[BlobDownload] Sending partial content")
|
||||
// Read all content and send
|
||||
content, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
log.Printf("[BlobDownload] ReadAll error: %v", err)
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
return c.Send(content)
|
||||
}
|
||||
|
||||
// Full blob download
|
||||
log.Printf("[BlobDownload] Full blob download, setting 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)
|
||||
|
||||
// Send full blob stream
|
||||
return c.SendStream(reader)
|
||||
log.Printf("[BlobDownload] About to read all content, size=%d", size)
|
||||
// Read all content and send
|
||||
content, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
log.Printf("[BlobDownload] ReadAll error: %v", err)
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
log.Printf("[BlobDownload] Read %d bytes, sending...", len(content))
|
||||
return c.Send(content)
|
||||
}
|
||||
|
||||
// handleBlobHead ?? blob ????
|
||||
|
||||
81
internal/module/registry/handler.config.go
Normal file
81
internal/module/registry/handler.config.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// RegistryConfigGet returns the registry configuration
|
||||
func RegistryConfigGet(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
var configs []model.RegistryConfig
|
||||
if err := db.Find(&configs).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
|
||||
// Convert to map for easier frontend access
|
||||
configMap := make(map[string]string)
|
||||
for _, config := range configs {
|
||||
configMap[config.Key] = config.Value
|
||||
}
|
||||
|
||||
return resp.R200(c, map[string]interface{}{
|
||||
"configs": configMap,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// RegistryConfigSet sets a registry configuration value
|
||||
func RegistryConfigSet(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
var req struct {
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// Parse JSON body
|
||||
body := c.Body()
|
||||
if len(body) == 0 {
|
||||
return resp.R400(c, "EMPTY_BODY", nil, "request body is empty")
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return resp.R400(c, "INVALID_REQUEST", nil, "invalid request body")
|
||||
}
|
||||
|
||||
if req.Key == "" {
|
||||
return resp.R400(c, "MISSING_KEY", nil, "key is required")
|
||||
}
|
||||
|
||||
// Find or create config
|
||||
var config model.RegistryConfig
|
||||
err := db.Where("key = ?", req.Key).First(&config).Error
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Create new config
|
||||
config = model.RegistryConfig{
|
||||
Key: req.Key,
|
||||
Value: req.Value,
|
||||
}
|
||||
if err := db.Create(&config).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
} else if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
} else {
|
||||
// Update existing config
|
||||
config.Value = req.Value
|
||||
if err := db.Save(&config).Error; err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
|
||||
return resp.R200(c, map[string]interface{}{
|
||||
"config": config,
|
||||
})
|
||||
}
|
||||
}
|
||||
370
internal/module/registry/handler.download.go
Normal file
370
internal/module/registry/handler.download.go
Normal file
@@ -0,0 +1,370 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// RegistryImageDownload downloads an image as a tar file
|
||||
func RegistryImageDownload(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
startTime := time.Now()
|
||||
|
||||
// Get image name from wildcard parameter (Fiber automatically decodes URL)
|
||||
fullImageName := c.Params("*")
|
||||
if fullImageName == "" {
|
||||
return resp.R400(c, "MISSING_IMAGE_NAME", nil, "image name is required")
|
||||
}
|
||||
|
||||
// Additional URL decode in case Fiber didn't decode it
|
||||
decodedImageName, err := url.PathUnescape(fullImageName)
|
||||
if err == nil {
|
||||
fullImageName = decodedImageName
|
||||
}
|
||||
|
||||
log.Printf("[Download] Start downloading: %s", fullImageName)
|
||||
|
||||
// Get current registry_address to strip it from the request
|
||||
var registryConfig model.RegistryConfig
|
||||
registryAddress := ""
|
||||
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
||||
registryAddress = registryConfig.Value
|
||||
}
|
||||
if registryAddress == "" {
|
||||
registryAddress = "localhost:9119"
|
||||
}
|
||||
|
||||
// Strip registry_address prefix if present
|
||||
// e.g., "test.com/docker.io/redis:latest" -> "docker.io/redis:latest"
|
||||
imageName := fullImageName
|
||||
if strings.HasPrefix(imageName, registryAddress+"/") {
|
||||
imageName = strings.TrimPrefix(imageName, registryAddress+"/")
|
||||
}
|
||||
|
||||
// Parse image name (repository:tag) to extract repository and tag
|
||||
var repository, tag string
|
||||
if strings.Contains(imageName, ":") {
|
||||
parts := strings.SplitN(imageName, ":", 2)
|
||||
repository = parts[0]
|
||||
tag = parts[1]
|
||||
} else {
|
||||
// If no tag specified, default to "latest"
|
||||
repository = imageName
|
||||
tag = "latest"
|
||||
}
|
||||
|
||||
log.Printf("[Download] Parsed - repository: %s, tag: %s", repository, tag)
|
||||
|
||||
// Find the repository
|
||||
t1 := time.Now()
|
||||
var repositoryModel model.Repository
|
||||
if err := db.Where("name = ?", repository).First(&repositoryModel).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "IMAGE_NOT_FOUND", nil, fmt.Sprintf("image %s not found", repository))
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
log.Printf("[Download] DB query repository: %v", time.Since(t1))
|
||||
|
||||
// Find the tag record
|
||||
t2 := time.Now()
|
||||
var tagRecord model.Tag
|
||||
if err := db.Where("repository = ? AND tag = ?", repository, tag).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
// Try to get the first available tag
|
||||
if err := db.Where("repository = ?", repository).First(&tagRecord).Error; err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return resp.R404(c, "TAG_NOT_FOUND", nil, fmt.Sprintf("no tag found for image %s", repository))
|
||||
}
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
// Update tag to the found tag
|
||||
tag = tagRecord.Tag
|
||||
} else {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
log.Printf("[Download] DB query tag: %v", time.Since(t2))
|
||||
|
||||
// Get the manifest
|
||||
t3 := time.Now()
|
||||
var manifest model.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)
|
||||
}
|
||||
log.Printf("[Download] DB query manifest: %v", time.Since(t3))
|
||||
|
||||
// Read manifest content - try from database first, then from store
|
||||
t4 := time.Now()
|
||||
var manifestContent []byte
|
||||
if len(manifest.Content) > 0 {
|
||||
manifestContent = manifest.Content
|
||||
} else {
|
||||
var err error
|
||||
manifestContent, err = store.ReadManifest(c.Context(), manifest.Digest)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, err)
|
||||
}
|
||||
}
|
||||
log.Printf("[Download] Read manifest content: %v", time.Since(t4))
|
||||
|
||||
// Parse manifest to extract layer digests
|
||||
t5 := time.Now()
|
||||
var manifestData map[string]interface{}
|
||||
if err := json.Unmarshal(manifestContent, &manifestData); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to parse manifest: %w", err))
|
||||
}
|
||||
|
||||
// Debug: check manifest keys
|
||||
manifestKeys := make([]string, 0, len(manifestData))
|
||||
for k := range manifestData {
|
||||
manifestKeys = append(manifestKeys, k)
|
||||
}
|
||||
if _, ok := manifestData["layers"]; !ok {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("manifest keys: %v, no layers key found", manifestKeys))
|
||||
}
|
||||
|
||||
// Extract layers from manifest
|
||||
layers, err := extractLayers(manifestData)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to extract layers: %w", err))
|
||||
}
|
||||
if len(layers) == 0 {
|
||||
// Debug: check if layers key exists
|
||||
if layersValue, ok := manifestData["layers"]; ok {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("no layers found in manifest, but layers key exists: %T", layersValue))
|
||||
}
|
||||
return resp.R500(c, "", nil, fmt.Errorf("no layers found in manifest"))
|
||||
}
|
||||
log.Printf("[Download] Parse manifest and extract %d layers: %v", len(layers), time.Since(t5))
|
||||
log.Printf("[Download] Preparation completed in %v, starting tar generation", time.Since(startTime))
|
||||
|
||||
// Set response headers for file download
|
||||
filename := fmt.Sprintf("%s-%s.tar", strings.ReplaceAll(repository, "/", "_"), tag)
|
||||
c.Set("Content-Type", "application/x-tar")
|
||||
c.Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
// Create a pipe for streaming tar content
|
||||
pr, pw := io.Pipe()
|
||||
|
||||
// Use buffered writer for better performance
|
||||
bufWriter := bufio.NewWriterSize(pw, 1024*1024) // 1MB buffer
|
||||
tarWriter := tar.NewWriter(bufWriter)
|
||||
|
||||
// Write tar content in a goroutine
|
||||
go func() {
|
||||
defer pw.Close()
|
||||
defer tarWriter.Close()
|
||||
defer bufWriter.Flush()
|
||||
|
||||
// Get config digest from manifest
|
||||
configDigest := ""
|
||||
if configValue, ok := manifestData["config"].(map[string]interface{}); ok {
|
||||
if digest, ok := configValue["digest"].(string); ok {
|
||||
configDigest = digest
|
||||
}
|
||||
}
|
||||
|
||||
// Build Docker save format manifest.json (array format)
|
||||
manifestItems := []map[string]interface{}{
|
||||
{
|
||||
"Config": strings.TrimPrefix(configDigest, "sha256:") + ".json",
|
||||
"RepoTags": []string{repository + ":" + tag},
|
||||
"Layers": make([]string, 0, len(layers)),
|
||||
},
|
||||
}
|
||||
|
||||
// Add layer paths to manifest
|
||||
for _, layerDigest := range layers {
|
||||
layerPath := strings.TrimPrefix(layerDigest, "sha256:") + "/layer"
|
||||
manifestItems[0]["Layers"] = append(manifestItems[0]["Layers"].([]string), layerPath)
|
||||
}
|
||||
|
||||
// Convert manifest to JSON
|
||||
manifestJSON, err := json.Marshal(manifestItems)
|
||||
if err != nil {
|
||||
pw.CloseWithError(fmt.Errorf("failed to marshal manifest: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Write manifest.json (Docker save format)
|
||||
if err := writeTarFile(tarWriter, "manifest.json", manifestJSON); err != nil {
|
||||
pw.CloseWithError(fmt.Errorf("failed to write manifest: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Write repositories file (Docker save format)
|
||||
repositoriesMap := map[string]map[string]string{
|
||||
repository: {
|
||||
tag: strings.TrimPrefix(tagRecord.Digest, "sha256:"),
|
||||
},
|
||||
}
|
||||
repositoriesJSON, err := json.Marshal(repositoriesMap)
|
||||
if err != nil {
|
||||
pw.CloseWithError(fmt.Errorf("failed to marshal repositories: %w", err))
|
||||
return
|
||||
}
|
||||
if err := writeTarFile(tarWriter, "repositories", repositoriesJSON); err != nil {
|
||||
pw.CloseWithError(fmt.Errorf("failed to write repositories: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
// Write config file if exists
|
||||
if configDigest != "" {
|
||||
configReader, err := store.ReadBlob(c.Context(), configDigest)
|
||||
if err == nil {
|
||||
configFileName := strings.TrimPrefix(configDigest, "sha256:") + ".json"
|
||||
if err := writeTarFileStream(tarWriter, configFileName, configReader); err != nil {
|
||||
configReader.Close()
|
||||
pw.CloseWithError(fmt.Errorf("failed to write config: %w", err))
|
||||
return
|
||||
}
|
||||
configReader.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Write all layer blobs in Docker save format (digest/layer)
|
||||
for _, layerDigest := range layers {
|
||||
// Read blob
|
||||
blobReader, err := store.ReadBlob(c.Context(), layerDigest)
|
||||
if err != nil {
|
||||
pw.CloseWithError(fmt.Errorf("failed to read blob %s: %w", layerDigest, err))
|
||||
return
|
||||
}
|
||||
|
||||
// Write blob to tar in Docker save format: {digest}/layer
|
||||
digestOnly := strings.TrimPrefix(layerDigest, "sha256:")
|
||||
layerPath := digestOnly + "/layer"
|
||||
|
||||
if err := writeTarFileStream(tarWriter, layerPath, blobReader); err != nil {
|
||||
blobReader.Close()
|
||||
pw.CloseWithError(fmt.Errorf("failed to write blob: %w", err))
|
||||
return
|
||||
}
|
||||
blobReader.Close()
|
||||
}
|
||||
|
||||
// Close tar writer and pipe
|
||||
if err := tarWriter.Close(); err != nil {
|
||||
pw.CloseWithError(fmt.Errorf("failed to close tar writer: %w", err))
|
||||
return
|
||||
}
|
||||
|
||||
if err := bufWriter.Flush(); err != nil {
|
||||
pw.CloseWithError(fmt.Errorf("failed to flush buffer: %w", err))
|
||||
return
|
||||
}
|
||||
}()
|
||||
|
||||
// Stream the tar content to response
|
||||
return c.SendStream(pr)
|
||||
}
|
||||
}
|
||||
|
||||
// extractLayers extracts layer digests from manifest
|
||||
func extractLayers(manifestData map[string]interface{}) ([]string, error) {
|
||||
var layers []string
|
||||
|
||||
// Try Docker manifest v2 schema 2 format or OCI manifest format
|
||||
if layersValue, ok := manifestData["layers"]; ok {
|
||||
if layersArray, ok := layersValue.([]interface{}); ok {
|
||||
for _, layer := range layersArray {
|
||||
if layerMap, ok := layer.(map[string]interface{}); ok {
|
||||
if digest, ok := layerMap["digest"].(string); ok {
|
||||
layers = append(layers, digest)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(layers) > 0 {
|
||||
return layers, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try manifest list format (multi-arch)
|
||||
if _, ok := manifestData["manifests"].([]interface{}); ok {
|
||||
// For manifest list, we would need to fetch the actual manifest
|
||||
// For now, return error
|
||||
return nil, fmt.Errorf("manifest list format not supported for direct download")
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("no layers found in manifest")
|
||||
}
|
||||
|
||||
// writeTarFile writes a file to tar archive
|
||||
func writeTarFile(tw *tar.Writer, filename string, content []byte) error {
|
||||
header := &tar.Header{
|
||||
Name: filename,
|
||||
Size: int64(len(content)),
|
||||
Mode: 0644,
|
||||
}
|
||||
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if _, err := tw.Write(content); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeTarFileStream writes a file to tar archive from an io.Reader (streaming)
|
||||
func writeTarFileStream(tw *tar.Writer, filename string, reader io.Reader) error {
|
||||
// First, we need to get the size by reading into a buffer
|
||||
// For true streaming without reading all content, we'd need to know size beforehand
|
||||
// Since we're reading from files, we can use io.Copy with a CountingReader
|
||||
|
||||
// Create a temporary buffer to count size
|
||||
var buf []byte
|
||||
var err error
|
||||
|
||||
// If reader is a *os.File, we can get size directly
|
||||
if file, ok := reader.(interface{ Stat() (interface{ Size() int64 }, error) }); ok {
|
||||
if stat, err := file.Stat(); err == nil {
|
||||
size := stat.Size()
|
||||
header := &tar.Header{
|
||||
Name: filename,
|
||||
Size: size,
|
||||
Mode: 0644,
|
||||
}
|
||||
|
||||
if err := tw.WriteHeader(header); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Stream copy without loading to memory
|
||||
if _, err := io.Copy(tw, reader); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: read all content (for readers that don't support Stat)
|
||||
buf, err = io.ReadAll(reader)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return writeTarFile(tw, filename, buf)
|
||||
}
|
||||
@@ -13,6 +13,16 @@ import (
|
||||
// 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 {
|
||||
// Get current registry_address setting
|
||||
var registryConfig model.RegistryConfig
|
||||
registryAddress := ""
|
||||
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
||||
registryAddress = registryConfig.Value
|
||||
}
|
||||
if registryAddress == "" {
|
||||
registryAddress = "localhost:9119"
|
||||
}
|
||||
|
||||
var repositories []model.Repository
|
||||
|
||||
// Query all repositories from the database
|
||||
@@ -23,6 +33,17 @@ func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fibe
|
||||
// Convert to the expected format for the frontend
|
||||
var result []map[string]interface{}
|
||||
for _, repo := range repositories {
|
||||
// Get all tags for this repository
|
||||
var tags []model.Tag
|
||||
if err := db.Where("repository = ?", repo.Name).Find(&tags).Error; err != nil {
|
||||
continue // Skip this repository if we can't get tags
|
||||
}
|
||||
|
||||
// If no tags, skip this repository
|
||||
if len(tags) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Calculate total size of all blobs for this repository
|
||||
var totalSize int64
|
||||
var sizeResult struct {
|
||||
@@ -39,13 +60,21 @@ func RegistryImageList(ctx context.Context, db *gorm.DB, store store.Store) fibe
|
||||
// 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,
|
||||
// Create an entry for each tag with full image name
|
||||
// Dynamically prepend registry_address to the repository name
|
||||
for _, tag := range tags {
|
||||
fullRepoName := registryAddress + "/" + repo.Name
|
||||
fullImageName := fullRepoName + ":" + tag.Tag
|
||||
repoMap := map[string]interface{}{
|
||||
"id": repo.ID,
|
||||
"name": fullImageName, // Full image name: registry_address/repo:tag
|
||||
"repository": repo.Name, // Original repository name (without registry_address)
|
||||
"tag": tag.Tag, // Tag name
|
||||
"upload_time": uploadTime,
|
||||
"size": totalSize,
|
||||
}
|
||||
result = append(result, repoMap)
|
||||
}
|
||||
result = append(result, repoMap)
|
||||
}
|
||||
|
||||
return resp.R200(c, map[string]interface{}{
|
||||
|
||||
416
internal/module/registry/handler.upload.go
Normal file
416
internal/module/registry/handler.upload.go
Normal file
@@ -0,0 +1,416 @@
|
||||
package registry
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"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"
|
||||
)
|
||||
|
||||
type DockerManifestItem struct {
|
||||
Config string `json:"Config"`
|
||||
RepoTags []string `json:"RepoTags"`
|
||||
Layers []string `json:"Layers"`
|
||||
}
|
||||
|
||||
type OCIIndex struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
MediaType string `json:"mediaType"`
|
||||
Manifests []OCIManifestDescriptor `json:"manifests"`
|
||||
}
|
||||
|
||||
type OCIManifestDescriptor struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
Annotations map[string]string `json:"annotations,omitempty"`
|
||||
}
|
||||
|
||||
type OCIManifest struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
MediaType string `json:"mediaType,omitempty"`
|
||||
Config OCIDescriptor `json:"config"`
|
||||
Layers []OCIDescriptor `json:"layers"`
|
||||
}
|
||||
|
||||
type OCIDescriptor struct {
|
||||
MediaType string `json:"mediaType"`
|
||||
Digest string `json:"digest"`
|
||||
Size int64 `json:"size"`
|
||||
}
|
||||
|
||||
func RegistryImageUpload(ctx context.Context, db *gorm.DB, store store.Store) fiber.Handler {
|
||||
return func(c fiber.Ctx) error {
|
||||
file, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
return resp.R400(c, "MISSING_FILE", nil, "file is required")
|
||||
}
|
||||
|
||||
if !strings.HasSuffix(file.Filename, ".tar") {
|
||||
return resp.R400(c, "INVALID_FILE_TYPE", nil, "only .tar files are allowed")
|
||||
}
|
||||
|
||||
fileReader, err := file.Open()
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to open file: %w", err))
|
||||
}
|
||||
defer fileReader.Close()
|
||||
|
||||
tarReader := tar.NewReader(fileReader)
|
||||
|
||||
var manifestItems []DockerManifestItem
|
||||
var ociIndex *OCIIndex
|
||||
blobContents := make(map[string][]byte)
|
||||
layerContents := make(map[string][]byte)
|
||||
var configContent []byte
|
||||
var configDigest string
|
||||
var indexJSON []byte
|
||||
|
||||
for {
|
||||
header, err := tarReader.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to read tar: %w", err))
|
||||
}
|
||||
|
||||
content, err := io.ReadAll(tarReader)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to read file content: %w", err))
|
||||
}
|
||||
|
||||
switch header.Name {
|
||||
case "manifest.json":
|
||||
if err := json.Unmarshal(content, &manifestItems); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to parse manifest.json: %w", err))
|
||||
}
|
||||
case "index.json":
|
||||
indexJSON = content
|
||||
if err := json.Unmarshal(content, &ociIndex); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to parse index.json: %w", err))
|
||||
}
|
||||
default:
|
||||
if strings.HasSuffix(header.Name, ".json") && header.Name != "manifest.json" && header.Name != "index.json" {
|
||||
configContent = content
|
||||
hash := sha256.Sum256(content)
|
||||
configDigest = "sha256:" + hex.EncodeToString(hash[:])
|
||||
} else if strings.HasSuffix(header.Name, "/layer") {
|
||||
layerContents[header.Name] = content
|
||||
} else if strings.Contains(header.Name, "blobs/sha256/") && !strings.HasSuffix(header.Name, "/") {
|
||||
blobContents[header.Name] = content
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle OCI format
|
||||
if ociIndex != nil && len(ociIndex.Manifests) > 0 {
|
||||
return handleOCIFormat(c, db, store, ociIndex, blobContents, indexJSON)
|
||||
}
|
||||
|
||||
// Handle Docker format
|
||||
if len(manifestItems) == 0 {
|
||||
return resp.R400(c, "INVALID_TAR", nil, "manifest.json or index.json not found in tar file")
|
||||
}
|
||||
|
||||
manifestItem := manifestItems[0]
|
||||
if len(manifestItem.RepoTags) == 0 {
|
||||
return resp.R400(c, "INVALID_MANIFEST", nil, "no RepoTags found in manifest")
|
||||
}
|
||||
|
||||
// Extract original repository and tag from tar file
|
||||
// e.g., "docker.io/redis:latest" -> repo: "docker.io/redis", tag: "latest"
|
||||
originalRepoTag := manifestItem.RepoTags[0]
|
||||
parts := strings.SplitN(originalRepoTag, ":", 2)
|
||||
originalRepo := parts[0]
|
||||
tag := "latest"
|
||||
if len(parts) == 2 {
|
||||
tag = parts[1]
|
||||
}
|
||||
|
||||
// Store only the original repository name (without registry_address prefix)
|
||||
// This allows registry_address to be changed without breaking existing images
|
||||
repoName := originalRepo
|
||||
|
||||
if err := db.FirstOrCreate(&model.Repository{}, model.Repository{Name: repoName}).Error; err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to create repository: %w", err))
|
||||
}
|
||||
|
||||
if err := store.CreatePartition(c.Context(), "registry"); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to create partition: %w", err))
|
||||
}
|
||||
|
||||
if configContent != nil {
|
||||
if err := store.WriteBlob(c.Context(), configDigest, strings.NewReader(string(configContent))); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write config blob: %w", err))
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Blob{
|
||||
Digest: configDigest,
|
||||
Size: int64(len(configContent)),
|
||||
MediaType: "application/vnd.docker.container.image.v1+json",
|
||||
Repository: repoName,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to save config blob metadata: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
layerDigests := make([]map[string]interface{}, 0, len(manifestItem.Layers))
|
||||
for _, layerPath := range manifestItem.Layers {
|
||||
content, ok := layerContents[layerPath]
|
||||
if !ok {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("layer %s not found in tar", layerPath))
|
||||
}
|
||||
|
||||
hash := sha256.Sum256(content)
|
||||
digest := "sha256:" + hex.EncodeToString(hash[:])
|
||||
|
||||
if err := store.WriteBlob(c.Context(), digest, strings.NewReader(string(content))); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write layer blob: %w", err))
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Blob{
|
||||
Digest: digest,
|
||||
Size: int64(len(content)),
|
||||
MediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
Repository: repoName,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to save layer blob metadata: %w", err))
|
||||
}
|
||||
|
||||
layerDigests = append(layerDigests, map[string]interface{}{
|
||||
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
"size": len(content),
|
||||
"digest": digest,
|
||||
})
|
||||
}
|
||||
|
||||
manifestData := map[string]interface{}{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": map[string]interface{}{
|
||||
"mediaType": "application/vnd.docker.container.image.v1+json",
|
||||
"size": len(configContent),
|
||||
"digest": configDigest,
|
||||
},
|
||||
"layers": layerDigests,
|
||||
}
|
||||
|
||||
manifestJSON, err := json.Marshal(manifestData)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to marshal manifest: %w", err))
|
||||
}
|
||||
|
||||
manifestHash := sha256.Sum256(manifestJSON)
|
||||
manifestDigest := "sha256:" + hex.EncodeToString(manifestHash[:])
|
||||
|
||||
if err := store.WriteManifest(c.Context(), manifestDigest, manifestJSON); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write manifest: %w", err))
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Manifest{
|
||||
Repository: repoName,
|
||||
Tag: tag,
|
||||
Digest: manifestDigest,
|
||||
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||
Size: int64(len(manifestJSON)),
|
||||
Content: manifestJSON,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to save manifest: %w", err))
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Tag{
|
||||
Repository: repoName,
|
||||
Tag: tag,
|
||||
Digest: manifestDigest,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to save tag: %w", err))
|
||||
}
|
||||
|
||||
return resp.R200(c, map[string]interface{}{
|
||||
"message": "upload success",
|
||||
"repository": repoName,
|
||||
"tag": tag,
|
||||
"digest": manifestDigest,
|
||||
"original_tag": originalRepoTag,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func handleOCIFormat(c fiber.Ctx, db *gorm.DB, store store.Store, ociIndex *OCIIndex, blobContents map[string][]byte, indexJSON []byte) error {
|
||||
// Get image name and tag from index annotations
|
||||
if len(ociIndex.Manifests) == 0 {
|
||||
return resp.R400(c, "INVALID_OCI_INDEX", nil, "no manifests found in index.json")
|
||||
}
|
||||
|
||||
manifestDesc := ociIndex.Manifests[0]
|
||||
imageName := ""
|
||||
tag := "latest"
|
||||
|
||||
if manifestDesc.Annotations != nil {
|
||||
if name, ok := manifestDesc.Annotations["io.containerd.image.name"]; ok {
|
||||
imageName = name
|
||||
} else if name, ok := manifestDesc.Annotations["org.opencontainers.image.ref.name"]; ok {
|
||||
tag = name
|
||||
}
|
||||
}
|
||||
|
||||
if imageName == "" {
|
||||
return resp.R400(c, "INVALID_OCI_INDEX", nil, "image name not found in annotations")
|
||||
}
|
||||
|
||||
// Extract repo and tag from image name
|
||||
parts := strings.SplitN(imageName, ":", 2)
|
||||
repoName := parts[0]
|
||||
if len(parts) == 2 {
|
||||
tag = parts[1]
|
||||
}
|
||||
|
||||
// Create repository
|
||||
if err := db.FirstOrCreate(&model.Repository{}, model.Repository{Name: repoName}).Error; err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to create repository: %w", err))
|
||||
}
|
||||
|
||||
if err := store.CreatePartition(c.Context(), "registry"); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to create partition: %w", err))
|
||||
}
|
||||
|
||||
// Store index.json as a blob
|
||||
indexHash := sha256.Sum256(indexJSON)
|
||||
indexDigest := "sha256:" + hex.EncodeToString(indexHash[:])
|
||||
if err := store.WriteBlob(c.Context(), indexDigest, strings.NewReader(string(indexJSON))); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write index blob: %w", err))
|
||||
}
|
||||
if err := db.Create(&model.Blob{
|
||||
Digest: indexDigest,
|
||||
Size: int64(len(indexJSON)),
|
||||
MediaType: "application/vnd.oci.image.index.v1+json",
|
||||
Repository: repoName,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to save index blob metadata: %w", err))
|
||||
}
|
||||
|
||||
// Process the manifest blob
|
||||
manifestBlobPath := "blobs/sha256/" + strings.TrimPrefix(manifestDesc.Digest, "sha256:")
|
||||
manifestContent, ok := blobContents[manifestBlobPath]
|
||||
if !ok {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("manifest blob %s not found in tar", manifestBlobPath))
|
||||
}
|
||||
|
||||
// Parse OCI manifest
|
||||
var ociManifest OCIManifest
|
||||
if err := json.Unmarshal(manifestContent, &ociManifest); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to parse OCI manifest: %w", err))
|
||||
}
|
||||
|
||||
// Store config blob
|
||||
configBlobPath := "blobs/sha256/" + strings.TrimPrefix(ociManifest.Config.Digest, "sha256:")
|
||||
configContent, ok := blobContents[configBlobPath]
|
||||
if !ok {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("config blob %s not found in tar", configBlobPath))
|
||||
}
|
||||
|
||||
if err := store.WriteBlob(c.Context(), ociManifest.Config.Digest, strings.NewReader(string(configContent))); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write config blob: %w", err))
|
||||
}
|
||||
if err := db.Create(&model.Blob{
|
||||
Digest: ociManifest.Config.Digest,
|
||||
Size: ociManifest.Config.Size,
|
||||
MediaType: ociManifest.Config.MediaType,
|
||||
Repository: repoName,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to save config blob metadata: %w", err))
|
||||
}
|
||||
|
||||
// Store layer blobs
|
||||
for _, layer := range ociManifest.Layers {
|
||||
layerBlobPath := "blobs/sha256/" + strings.TrimPrefix(layer.Digest, "sha256:")
|
||||
layerContent, ok := blobContents[layerBlobPath]
|
||||
if !ok {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("layer blob %s not found in tar", layerBlobPath))
|
||||
}
|
||||
|
||||
if err := store.WriteBlob(c.Context(), layer.Digest, strings.NewReader(string(layerContent))); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write layer blob: %w", err))
|
||||
}
|
||||
if err := db.Create(&model.Blob{
|
||||
Digest: layer.Digest,
|
||||
Size: layer.Size,
|
||||
MediaType: layer.MediaType,
|
||||
Repository: repoName,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to save layer blob metadata: %w", err))
|
||||
}
|
||||
}
|
||||
|
||||
// Convert OCI manifest to Docker manifest v2 format for compatibility
|
||||
manifestData := map[string]interface{}{
|
||||
"schemaVersion": 2,
|
||||
"mediaType": "application/vnd.docker.distribution.manifest.v2+json",
|
||||
"config": map[string]interface{}{
|
||||
"mediaType": "application/vnd.docker.container.image.v1+json",
|
||||
"size": ociManifest.Config.Size,
|
||||
"digest": ociManifest.Config.Digest,
|
||||
},
|
||||
"layers": []map[string]interface{}{},
|
||||
}
|
||||
|
||||
layers := []map[string]interface{}{}
|
||||
for _, layer := range ociManifest.Layers {
|
||||
layers = append(layers, map[string]interface{}{
|
||||
"mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
|
||||
"size": layer.Size,
|
||||
"digest": layer.Digest,
|
||||
})
|
||||
}
|
||||
manifestData["layers"] = layers
|
||||
|
||||
manifestJSON, err := json.Marshal(manifestData)
|
||||
if err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to marshal manifest: %w", err))
|
||||
}
|
||||
|
||||
manifestHash := sha256.Sum256(manifestJSON)
|
||||
manifestDigest := "sha256:" + hex.EncodeToString(manifestHash[:])
|
||||
|
||||
if err := store.WriteManifest(c.Context(), manifestDigest, manifestJSON); err != nil {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to write manifest: %w", err))
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Manifest{
|
||||
Repository: repoName,
|
||||
Tag: tag,
|
||||
Digest: manifestDigest,
|
||||
MediaType: "application/vnd.docker.distribution.manifest.v2+json",
|
||||
Size: int64(len(manifestJSON)),
|
||||
Content: manifestJSON,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to save manifest: %w", err))
|
||||
}
|
||||
|
||||
if err := db.Create(&model.Tag{
|
||||
Repository: repoName,
|
||||
Tag: tag,
|
||||
Digest: manifestDigest,
|
||||
}).Error; err != nil && !strings.Contains(err.Error(), "UNIQUE constraint failed") {
|
||||
return resp.R500(c, "", nil, fmt.Errorf("failed to save tag: %w", err))
|
||||
}
|
||||
|
||||
return resp.R200(c, map[string]interface{}{
|
||||
"message": "upload success (OCI format)",
|
||||
"repository": repoName,
|
||||
"tag": tag,
|
||||
"digest": manifestDigest,
|
||||
"original_tag": imageName,
|
||||
})
|
||||
}
|
||||
@@ -75,6 +75,17 @@ func HandleManifest(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
|
||||
// ???? manifests ???????
|
||||
repo := strings.Join(parts[:manifestsIndex], "/")
|
||||
|
||||
// Strip registry_address prefix from repo if present
|
||||
var registryConfig model.RegistryConfig
|
||||
registryAddress := ""
|
||||
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
||||
registryAddress = registryConfig.Value
|
||||
}
|
||||
if registryAddress != "" && strings.HasPrefix(repo, registryAddress+"/") {
|
||||
repo = strings.TrimPrefix(repo, registryAddress+"/")
|
||||
}
|
||||
|
||||
// tag ? manifests ?????
|
||||
tag := parts[manifestsIndex+1]
|
||||
|
||||
|
||||
@@ -13,13 +13,13 @@ import (
|
||||
)
|
||||
|
||||
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{},
|
||||
&model.RegistryConfig{},
|
||||
); err != nil {
|
||||
log.Fatalf("failed to migrate database: %v", err)
|
||||
}
|
||||
|
||||
@@ -38,6 +38,16 @@ func HandleTags(c fiber.Ctx, db *gorm.DB, store store.Store) error {
|
||||
|
||||
// ???? tags ???????
|
||||
repo := strings.Join(parts[:tagsIndex], "/")
|
||||
|
||||
// Strip registry_address prefix from repo if present
|
||||
var registryConfig model.RegistryConfig
|
||||
registryAddress := ""
|
||||
if err := db.Where("key = ?", "registry_address").First(®istryConfig).Error; err == nil {
|
||||
registryAddress = registryConfig.Value
|
||||
}
|
||||
if registryAddress != "" && strings.HasPrefix(repo, registryAddress+"/") {
|
||||
repo = strings.TrimPrefix(repo, registryAddress+"/")
|
||||
}
|
||||
|
||||
// ??????
|
||||
nStr := c.Query("n", "100")
|
||||
|
||||
Reference in New Issue
Block a user