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:
loveuer
2025-11-10 16:28:58 +08:00
parent 29088a6b54
commit 9780a2b028
35 changed files with 3065 additions and 91 deletions

View File

@@ -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(&registryConfig).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 ????

View 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,
})
}
}

View 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(&registryConfig).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)
}

View File

@@ -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(&registryConfig).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{}{

View 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,
})
}

View File

@@ -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(&registryConfig).Error; err == nil {
registryAddress = registryConfig.Value
}
if registryAddress != "" && strings.HasPrefix(repo, registryAddress+"/") {
repo = strings.TrimPrefix(repo, registryAddress+"/")
}
// tag ? manifests ?????
tag := parts[manifestsIndex+1]

View File

@@ -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)
}

View File

@@ -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(&registryConfig).Error; err == nil {
registryAddress = registryConfig.Value
}
if registryAddress != "" && strings.HasPrefix(repo, registryAddress+"/") {
repo = strings.TrimPrefix(repo, registryAddress+"/")
}
// ??????
nStr := c.Query("n", "100")