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

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

27
pkg/database/db/init.go Normal file
View File

@@ -0,0 +1,27 @@
package db
import (
"context"
"path/filepath"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
var (
Default *gorm.DB
)
func Init(ctx context.Context, dataDir string) error {
var (
err error
dbPath = filepath.Join(dataDir, "cluster.db")
)
Default, err = gorm.Open(sqlite.Open(dbPath), &gorm.Config{})
if err != nil {
return err
}
return nil
}

56
pkg/resp/err.go Normal file
View File

@@ -0,0 +1,56 @@
package resp
import "net/http"
type Error struct {
Status int `json:"status"`
Msg string `json:"msg"`
Err error `json:"err"`
Data any `json:"data"`
}
func (e *Error) Error() string {
return e.Err.Error()
}
func (e *Error) _r() *res {
data := &res{
Status: e.Status,
Msg: e.Msg,
Data: e.Data,
Err: e.Err,
}
if data.Status < 0 || data.Status > 999 {
data.Status = 500
}
return data
}
func NewError(err error, args ...any) *Error {
e := &Error{
Status: http.StatusInternalServerError,
Err: err,
}
if len(args) > 0 {
if status, ok := args[0].(int); ok {
e.Status = status
}
}
e.Msg = Msg(e.Status)
if len(args) > 1 {
if msg, ok := args[1].(string); ok {
e.Msg = msg
}
}
if len(args) > 2 {
e.Data = args[2]
}
return e
}

5
pkg/resp/i18n.go Normal file
View File

@@ -0,0 +1,5 @@
package resp
func t(msg string) string {
return msg
}

37
pkg/resp/msg.go Normal file
View File

@@ -0,0 +1,37 @@
package resp
const (
Msg200 = "操作成功"
Msg201 = "操作需要审核, 请继续"
Msg202 = "操作未完成, 请继续"
Msg400 = "参数错误"
Msg400Duplicate = "目标已存在, 请勿重复创建"
Msg401 = "该账号登录已失效, 请重新登录"
Msg401NoMulti = "用户已在其他地方登录"
Msg403 = "权限不足"
Msg404 = "资源不存在"
Msg500 = "服务器开小差了"
Msg501 = "服务不可用"
Msg503 = "服务不可用或正在升级, 请联系管理员"
)
func Msg(status int) string {
switch status {
case 400:
return Msg400
case 401:
return Msg401
case 403:
return Msg403
case 404:
return Msg404
case 500:
return Msg500
case 501:
return Msg501
case 503:
return Msg503
}
return "未知错误"
}

185
pkg/resp/resp.go Normal file
View File

@@ -0,0 +1,185 @@
package resp
import (
"errors"
"strings"
"github.com/gofiber/fiber/v3"
)
type res struct {
Status int `json:"status"`
Msg string `json:"msg"`
Data any `json:"data"`
Err any `json:"err"`
}
func R200(c fiber.Ctx, data any, msgs ...string) error {
r := &res{
Status: 200,
Msg: Msg200,
Data: data,
}
if len(msgs) > 0 && msgs[0] != "" {
r.Msg = msgs[0]
}
return c.JSON(r)
}
func R201(c fiber.Ctx, data any, msgs ...string) error {
r := &res{
Status: 201,
Msg: Msg201,
Data: data,
}
if len(msgs) > 0 && msgs[0] != "" {
r.Msg = msgs[0]
}
return c.JSON(r)
}
func R202(c fiber.Ctx, data any, msgs ...string) error {
r := &res{
Status: 202,
Msg: Msg202,
Data: data,
}
if len(msgs) > 0 && msgs[0] != "" {
r.Msg = msgs[0]
}
return c.JSON(r)
}
func RC(c fiber.Ctx, status int, args ...any) error {
return _r(c, &res{Status: status}, args...)
}
func RE(c fiber.Ctx, err error) error {
var re *Error
if errors.As(err, &re) {
return RC(c, re.Status, re.Msg, re.Data, re.Err)
}
estr := strings.ToLower(err.Error())
if strings.Contains(estr, "duplicate") {
return R400(c, Msg400Duplicate, nil, estr)
}
return R500(c, "", nil, err)
}
func _r(c fiber.Ctx, r *res, args ...any) error {
length := len(args)
if length == 0 {
goto END
}
if length >= 1 {
if msg, ok := args[0].(string); ok {
r.Msg = msg
}
}
if length >= 2 {
r.Data = args[1]
}
if length >= 3 {
if ee, ok := args[2].(error); ok {
r.Err = ee.Error()
} else {
r.Err = args[2]
}
}
END:
if r.Msg == "" {
r.Msg = Msg(r.Status)
}
// todo: i18n r.Msg
// r.Msg = t(r.Msg)
return c.Status(r.Status).JSON(r)
}
// R400
//
// args[0]: should be msg to display to user(defaulted)
// args[1]: could be extra data to send with(no default)
// args[2]: could be error msg to send to with debug mode
func R400(c fiber.Ctx, args ...any) error {
r := &res{
Status: 400,
}
return _r(c, r, args...)
}
// R401
//
// args[0]: should be msg to display to user(defaulted)
// args[1]: could be extra data to send with(no default)
// args[2]: could be error msg to send to with debug mode
func R401(c fiber.Ctx, args ...any) error {
r := &res{
Status: 401,
}
return _r(c, r, args...)
}
// R403
//
// args[0]: should be msg to display to user(defaulted)
// args[1]: could be extra data to send with(no default)
// args[2]: could be error msg to send to with debug mode
func R403(c fiber.Ctx, args ...any) error {
r := &res{
Status: 403,
}
return _r(c, r, args...)
}
func R404(c fiber.Ctx, args ...any) error {
r := &res{
Status: 404,
}
return _r(c, r, args...)
}
// R500
//
// args[0]: should be msg to display to user(defaulted)
// args[1]: could be extra data to send with(no default)
// args[2]: could be error msg to send to with debug mode
func R500(c fiber.Ctx, args ...any) error {
r := &res{
Status: 500,
}
return _r(c, r, args...)
}
// R501
//
// args[0]: should be msg to display to user(defaulted)
// args[1]: could be extra data to send with(no default)
// args[2]: could be error msg to send to with debug mode
func R501(c fiber.Ctx, args ...any) error {
r := &res{
Status: 501,
}
return _r(c, r, args...)
}

75
pkg/resp/sse.go Normal file
View File

@@ -0,0 +1,75 @@
package resp
import (
"bufio"
"encoding/json"
"fmt"
"time"
"github.com/gofiber/fiber/v3"
)
type SSEManager struct {
c fiber.Ctx
event string
ch chan string
id int64
}
func (m *SSEManager) Send(msg string) {
m.ch <- msg
}
func (m *SSEManager) JSON(data any) {
bs, err := json.Marshal(data)
if err != nil {
m.ch <- err.Error()
return
}
m.ch <- string(bs)
}
func (m *SSEManager) Writer() func(w *bufio.Writer) {
return func(w *bufio.Writer) {
for msg := range m.ch {
fmt.Fprintf(w, "event: %s\nid: %d\ntimestamp: %d\ndata: %s\n\n", m.event, m.id, time.Now().UnixMilli(), msg)
w.Flush()
m.id++
}
w.Flush()
}
}
func (m *SSEManager) Close() {
close(m.ch)
}
// SSE create a new SSEManager
// example:
//
// func someHandler(c fiber.Ctx) error {
// m := resp.SSE(c, "test")
// go func() {
// defer m.Close()
// for i := range 10 {
// m.Send("test" + strconv.Itoa(i))
// time.Sleep(1 * time.Second)
// }
// }()
//
// return c.SendStreamWriter(m.Writer())
// }
func SSE(c fiber.Ctx, event string) *SSEManager {
c.Set("Content-Type", "text/event-stream")
c.Set("Cache-Control", "no-cache")
c.Set("Connection", "keep-alive")
c.Set("Transfer-Encoding", "chunked")
return &SSEManager{
c: c,
event: event,
id: 0,
ch: make(chan string, 1),
}
}

341
pkg/store/store.go Normal file
View File

@@ -0,0 +1,341 @@
package store
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
"strings"
)
type Store interface {
CreatePartition(ctx context.Context, name string) error
// Blob ??
WriteBlob(ctx context.Context, digest string, r io.Reader) error
ReadBlob(ctx context.Context, digest string) (io.ReadCloser, error)
BlobExists(ctx context.Context, digest string) (bool, error)
GetBlobSize(ctx context.Context, digest string) (int64, error)
// Manifest ??
WriteManifest(ctx context.Context, digest string, content []byte) error
ReadManifest(ctx context.Context, digest string) ([]byte, error)
ManifestExists(ctx context.Context, digest string) (bool, error)
// Upload ??
CreateUpload(ctx context.Context, uuid string) (io.WriteCloser, error)
AppendUpload(ctx context.Context, uuid string, r io.Reader) (int64, error)
GetUploadSize(ctx context.Context, uuid string) (int64, error)
FinalizeUpload(ctx context.Context, uuid string, digest string) error
DeleteUpload(ctx context.Context, uuid string) error
}
type fileStore struct {
baseDir string
}
var (
Default Store
)
func Init(ctx context.Context, dataDir string) error {
Default = &fileStore{
baseDir: dataDir,
}
return nil
}
func (s *fileStore) CreatePartition(ctx context.Context, name string) error {
dirs := []string{
filepath.Join(s.baseDir, name, "blobs"),
filepath.Join(s.baseDir, name, "manifests"),
filepath.Join(s.baseDir, name, "uploads"),
}
for _, dir := range dirs {
if err := os.MkdirAll(dir, 0755); err != nil {
return fmt.Errorf("failed to create directory %s: %w", dir, err)
}
}
return nil
}
// blobPath ?? digest ?? blob ????
// ??: blobs/sha256/abc/def.../digest
func (s *fileStore) blobPath(digest string) (string, error) {
// ?? digest???: sha256:abc123...
parts := strings.SplitN(digest, ":", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid digest format: %s", digest)
}
algo := parts[0]
hash := parts[1]
if algo != "sha256" {
return "", fmt.Errorf("unsupported digest algorithm: %s", algo)
}
// ??? 2 ????????????? 2 ?????????
if len(hash) < 4 {
return "", fmt.Errorf("invalid hash length: %s", hash)
}
path := filepath.Join(s.baseDir, "registry", "blobs", algo, hash[:2], hash[2:4], hash)
return path, nil
}
func (s *fileStore) WriteBlob(ctx context.Context, digest string, r io.Reader) error {
path, err := s.blobPath(digest)
if err != nil {
return err
}
// ???????
if _, err := os.Stat(path); err == nil {
return nil // ????????
}
// ????
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("failed to create blob directory: %w", err)
}
// ????
f, err := os.Create(path)
if err != nil {
return fmt.Errorf("failed to create blob file: %w", err)
}
defer f.Close()
// ???? digest ??
hasher := sha256.New()
tee := io.TeeReader(r, hasher)
if _, err := io.Copy(f, tee); err != nil {
os.Remove(path)
return fmt.Errorf("failed to write blob: %w", err)
}
// ?? digest
calculated := "sha256:" + hex.EncodeToString(hasher.Sum(nil))
if calculated != digest {
os.Remove(path)
return fmt.Errorf("digest mismatch: expected %s, got %s", digest, calculated)
}
return nil
}
func (s *fileStore) ReadBlob(ctx context.Context, digest string) (io.ReadCloser, error) {
path, err := s.blobPath(digest)
if err != nil {
return nil, err
}
f, err := os.Open(path)
if err != nil {
return nil, fmt.Errorf("blob not found: %w", err)
}
return f, nil
}
func (s *fileStore) BlobExists(ctx context.Context, digest string) (bool, error) {
path, err := s.blobPath(digest)
if err != nil {
return false, err
}
_, err = os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
func (s *fileStore) GetBlobSize(ctx context.Context, digest string) (int64, error) {
path, err := s.blobPath(digest)
if err != nil {
return 0, err
}
info, err := os.Stat(path)
if err != nil {
return 0, err
}
return info.Size(), nil
}
// manifestPath ?? digest ?? manifest ????
func (s *fileStore) manifestPath(digest string) (string, error) {
parts := strings.SplitN(digest, ":", 2)
if len(parts) != 2 {
return "", fmt.Errorf("invalid digest format: %s", digest)
}
algo := parts[0]
hash := parts[1]
if algo != "sha256" {
return "", fmt.Errorf("unsupported digest algorithm: %s", algo)
}
path := filepath.Join(s.baseDir, "registry", "manifests", algo, hash[:2], hash[2:4], hash)
return path, nil
}
func (s *fileStore) WriteManifest(ctx context.Context, digest string, content []byte) error {
path, err := s.manifestPath(digest)
if err != nil {
return err
}
// ????
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("failed to create manifest directory: %w", err)
}
// ?? digest
hasher := sha256.New()
hasher.Write(content)
calculated := "sha256:" + hex.EncodeToString(hasher.Sum(nil))
if calculated != digest {
return fmt.Errorf("digest mismatch: expected %s, got %s", digest, calculated)
}
// ????
if err := os.WriteFile(path, content, 0644); err != nil {
return fmt.Errorf("failed to write manifest: %w", err)
}
return nil
}
func (s *fileStore) ReadManifest(ctx context.Context, digest string) ([]byte, error) {
path, err := s.manifestPath(digest)
if err != nil {
return nil, err
}
content, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("manifest not found: %w", err)
}
return content, nil
}
func (s *fileStore) ManifestExists(ctx context.Context, digest string) (bool, error) {
path, err := s.manifestPath(digest)
if err != nil {
return false, err
}
_, err = os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
// uploadPath ??????????
func (s *fileStore) uploadPath(uuid string) string {
return filepath.Join(s.baseDir, "registry", "uploads", uuid)
}
func (s *fileStore) CreateUpload(ctx context.Context, uuid string) (io.WriteCloser, error) {
path := s.uploadPath(uuid)
// ????
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return nil, fmt.Errorf("failed to create upload directory: %w", err)
}
f, err := os.Create(path)
if err != nil {
return nil, fmt.Errorf("failed to create upload file: %w", err)
}
return f, nil
}
func (s *fileStore) AppendUpload(ctx context.Context, uuid string, r io.Reader) (int64, error) {
path := s.uploadPath(uuid)
f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
return 0, fmt.Errorf("failed to open upload file: %w", err)
}
defer f.Close()
n, err := io.Copy(f, r)
if err != nil {
return 0, fmt.Errorf("failed to write to upload: %w", err)
}
return n, nil
}
func (s *fileStore) GetUploadSize(ctx context.Context, uuid string) (int64, error) {
path := s.uploadPath(uuid)
info, err := os.Stat(path)
if err != nil {
return 0, err
}
return info.Size(), nil
}
func (s *fileStore) FinalizeUpload(ctx context.Context, uuid string, digest string) error {
uploadPath := s.uploadPath(uuid)
blobPath, err := s.blobPath(digest)
if err != nil {
return err
}
// ???? blob ????????????
if _, err := os.Stat(blobPath); err == nil {
os.Remove(uploadPath)
return nil
}
// ??????
if err := os.MkdirAll(filepath.Dir(blobPath), 0755); err != nil {
return fmt.Errorf("failed to create blob directory: %w", err)
}
// ?? digest
f, err := os.Open(uploadPath)
if err != nil {
return fmt.Errorf("failed to open upload file: %w", err)
}
defer f.Close()
hasher := sha256.New()
if _, err := io.Copy(hasher, f); err != nil {
return fmt.Errorf("failed to calculate digest: %w", err)
}
calculated := "sha256:" + hex.EncodeToString(hasher.Sum(nil))
if calculated != digest {
return fmt.Errorf("digest mismatch: expected %s, got %s", digest, calculated)
}
// ????
if err := os.Rename(uploadPath, blobPath); err != nil {
return fmt.Errorf("failed to finalize upload: %w", err)
}
return nil
}
func (s *fileStore) DeleteUpload(ctx context.Context, uuid string) error {
path := s.uploadPath(uuid)
return os.Remove(path)
}