init
This commit is contained in:
7
internal/cache/doc.go
vendored
Normal file
7
internal/cache/doc.go
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
// Package cache defines the disk-backed store responsible for translating hub
|
||||
// requests into StoragePath/<hub>/<path> files. The store exposes read/write
|
||||
// primitives with safe semantics (temp file + rename) and surfaces file info
|
||||
// (size, modtime) for higher layers to implement conditional revalidation.
|
||||
// Proxy handlers depend on this package to stream cached responses or trigger
|
||||
// upstream fetches without duplicating filesystem logic.
|
||||
package cache
|
||||
240
internal/cache/fs_store.go
vendored
Normal file
240
internal/cache/fs_store.go
vendored
Normal file
@@ -0,0 +1,240 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// NewStore 以 basePath 为根目录构建磁盘缓存,整站复用一份实例。
|
||||
func NewStore(basePath string) (Store, error) {
|
||||
if basePath == "" {
|
||||
return nil, errors.New("storage path required")
|
||||
}
|
||||
|
||||
abs, err := filepath.Abs(basePath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve storage path: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(abs, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("create storage path: %w", err)
|
||||
}
|
||||
|
||||
return &fileStore{
|
||||
basePath: abs,
|
||||
locks: make(map[string]*entryLock),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fileStore 通过 entryLock 避免同一 Locator 并发写入,同时复用 basePath。
|
||||
type fileStore struct {
|
||||
basePath string
|
||||
|
||||
mu sync.Mutex
|
||||
locks map[string]*entryLock
|
||||
}
|
||||
|
||||
type entryLock struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
func (s *fileStore) Get(ctx context.Context, locator Locator) (*ReadResult, error) {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
filePath, err := s.path(locator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
info, err := os.Stat(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if info.IsDir() {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
|
||||
f, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, ErrNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
Locator: locator,
|
||||
FilePath: filePath,
|
||||
SizeBytes: info.Size(),
|
||||
ModTime: info.ModTime(),
|
||||
}
|
||||
|
||||
return &ReadResult{
|
||||
Entry: entry,
|
||||
Reader: f,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *fileStore) Put(ctx context.Context, locator Locator, body io.Reader, opts PutOptions) (*Entry, error) {
|
||||
unlock, err := s.lockEntry(locator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
filePath, err := s.path(locator)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tempFile, err := os.CreateTemp(filepath.Dir(filePath), ".cache-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tempName := tempFile.Name()
|
||||
|
||||
written, err := copyWithContext(ctx, tempFile, body)
|
||||
closeErr := tempFile.Close()
|
||||
if err == nil {
|
||||
err = closeErr
|
||||
}
|
||||
if err != nil {
|
||||
os.Remove(tempName)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := os.Rename(tempName, filePath); err != nil {
|
||||
os.Remove(tempName)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
modTime := opts.ModTime
|
||||
if modTime.IsZero() {
|
||||
modTime = time.Now().UTC()
|
||||
}
|
||||
if err := os.Chtimes(filePath, modTime, modTime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entry := Entry{
|
||||
Locator: locator,
|
||||
FilePath: filePath,
|
||||
SizeBytes: written,
|
||||
ModTime: modTime,
|
||||
}
|
||||
return &entry, nil
|
||||
}
|
||||
|
||||
func (s *fileStore) Remove(ctx context.Context, locator Locator) error {
|
||||
unlock, err := s.lockEntry(locator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer unlock()
|
||||
|
||||
filePath, err := s.path(locator)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.Remove(filePath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *fileStore) lockEntry(locator Locator) (func(), error) {
|
||||
key := locatorKey(locator)
|
||||
s.mu.Lock()
|
||||
lock := s.locks[key]
|
||||
if lock == nil {
|
||||
lock = &entryLock{}
|
||||
s.locks[key] = lock
|
||||
}
|
||||
lock.refs++
|
||||
s.mu.Unlock()
|
||||
|
||||
lock.mu.Lock()
|
||||
return func() {
|
||||
lock.mu.Unlock()
|
||||
s.mu.Lock()
|
||||
lock.refs--
|
||||
if lock.refs == 0 {
|
||||
delete(s.locks, key)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *fileStore) path(locator Locator) (string, error) {
|
||||
if locator.HubName == "" {
|
||||
return "", errors.New("hub name required")
|
||||
}
|
||||
|
||||
rel := locator.Path
|
||||
if rel == "" || rel == "/" {
|
||||
rel = "root"
|
||||
}
|
||||
rel = path.Clean("/" + rel)
|
||||
rel = strings.TrimPrefix(rel, "/")
|
||||
if rel == "" {
|
||||
rel = "root"
|
||||
}
|
||||
|
||||
filePath := filepath.Join(s.basePath, locator.HubName, filepath.FromSlash(rel))
|
||||
if !strings.HasPrefix(filePath, filepath.Join(s.basePath, locator.HubName)) {
|
||||
return "", errors.New("invalid cache path")
|
||||
}
|
||||
return filePath, nil
|
||||
}
|
||||
|
||||
func copyWithContext(ctx context.Context, dst io.Writer, src io.Reader) (int64, error) {
|
||||
var copied int64
|
||||
buf := make([]byte, 32*1024)
|
||||
for {
|
||||
if err := ctx.Err(); err != nil {
|
||||
return copied, err
|
||||
}
|
||||
n, err := src.Read(buf)
|
||||
if n > 0 {
|
||||
w, wErr := dst.Write(buf[:n])
|
||||
copied += int64(w)
|
||||
if wErr != nil {
|
||||
return copied, wErr
|
||||
}
|
||||
if w < n {
|
||||
return copied, io.ErrShortWrite
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, io.EOF) {
|
||||
return copied, nil
|
||||
}
|
||||
return copied, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func locatorKey(locator Locator) string {
|
||||
return locator.HubName + "::" + locator.Path
|
||||
}
|
||||
53
internal/cache/store.go
vendored
Normal file
53
internal/cache/store.go
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store 负责管理磁盘缓存的读写。磁盘布局遵循:
|
||||
//
|
||||
// <StoragePath>/<HubName>/<path> # 实际正文
|
||||
//
|
||||
// 每个条目仅由正文文件组成,文件的 ModTime/Size 由文件系统提供。
|
||||
type Store interface {
|
||||
// Get 返回一个可流式读取的缓存条目。若不存在则返回 ErrNotFound。
|
||||
Get(ctx context.Context, locator Locator) (*ReadResult, error)
|
||||
|
||||
// Put 将上游响应写入缓存,并产出新的 Entry 描述。实现需通过临时文件 + rename
|
||||
// 保证写入原子性,并在失败时清理临时文件。可选地根据 opts.ModTime 设置文件时间戳。
|
||||
Put(ctx context.Context, locator Locator, body io.Reader, opts PutOptions) (*Entry, error)
|
||||
|
||||
// Remove 删除正文文件,通常用于上游错误或复合策略清理。
|
||||
Remove(ctx context.Context, locator Locator) error
|
||||
}
|
||||
|
||||
// PutOptions 控制写入过程中的可选属性。
|
||||
type PutOptions struct {
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// Locator 唯一定位一个缓存条目(Hub + 相对路径),所有路径均为 URL 路径风格。
|
||||
type Locator struct {
|
||||
HubName string
|
||||
Path string
|
||||
}
|
||||
|
||||
// Entry 表示一次缓存命中结果,包含绝对文件路径及文件信息。
|
||||
type Entry struct {
|
||||
Locator Locator `json:"locator"`
|
||||
FilePath string `json:"file_path"`
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
ModTime time.Time
|
||||
}
|
||||
|
||||
// ReadResult 组合 Entry 与正文 Reader,便于代理层直接将 Body 流式返回。
|
||||
type ReadResult struct {
|
||||
Entry Entry
|
||||
Reader io.ReadSeekCloser
|
||||
}
|
||||
|
||||
// ErrNotFound 表示缓存不存在。
|
||||
var ErrNotFound = errors.New("cache entry not found")
|
||||
95
internal/cache/store_test.go
vendored
Normal file
95
internal/cache/store_test.go
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
package cache
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestStorePutAndGet(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
locator := Locator{HubName: "docker", Path: "/v2/library/sample/manifests/latest"}
|
||||
|
||||
modTime := time.Now().Add(-time.Hour).UTC()
|
||||
payload := []byte("payload")
|
||||
if _, err := store.Put(context.Background(), locator, bytes.NewReader(payload), PutOptions{ModTime: modTime}); err != nil {
|
||||
t.Fatalf("put error: %v", err)
|
||||
}
|
||||
|
||||
result, err := store.Get(context.Background(), locator)
|
||||
if err != nil {
|
||||
t.Fatalf("get error: %v", err)
|
||||
}
|
||||
defer result.Reader.Close()
|
||||
|
||||
body, err := io.ReadAll(result.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("read cached body error: %v", err)
|
||||
}
|
||||
if string(body) != string(payload) {
|
||||
t.Fatalf("cached payload mismatch: %s", string(body))
|
||||
}
|
||||
if result.Entry.SizeBytes != int64(len(payload)) {
|
||||
t.Fatalf("size mismatch: %d", result.Entry.SizeBytes)
|
||||
}
|
||||
if !result.Entry.ModTime.Equal(modTime) {
|
||||
t.Fatalf("modtime mismatch: expected %v got %v", modTime, result.Entry.ModTime)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreGetMissing(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
_, err := store.Get(context.Background(), Locator{HubName: "docker", Path: "/missing"})
|
||||
if err == nil || err != ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreRemove(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
locator := Locator{HubName: "docker", Path: "/cache/remove"}
|
||||
if _, err := store.Put(context.Background(), locator, bytes.NewReader([]byte("data")), PutOptions{}); err != nil {
|
||||
t.Fatalf("put error: %v", err)
|
||||
}
|
||||
if err := store.Remove(context.Background(), locator); err != nil {
|
||||
t.Fatalf("remove error: %v", err)
|
||||
}
|
||||
if _, err := store.Get(context.Background(), locator); err == nil || err != ErrNotFound {
|
||||
t.Fatalf("expected not found after remove, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreIgnoresDirectories(t *testing.T) {
|
||||
store := newTestStore(t)
|
||||
locator := Locator{HubName: "ghcr", Path: "/v2"}
|
||||
|
||||
fs, ok := store.(*fileStore)
|
||||
if !ok {
|
||||
t.Fatalf("unexpected store type %T", store)
|
||||
}
|
||||
|
||||
filePath, err := fs.path(locator)
|
||||
if err != nil {
|
||||
t.Fatalf("path error: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(filePath, 0o755); err != nil {
|
||||
t.Fatalf("mkdir error: %v", err)
|
||||
}
|
||||
|
||||
if _, err := store.Get(context.Background(), locator); err == nil || err != ErrNotFound {
|
||||
t.Fatalf("expected ErrNotFound for directory, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newTestStore returns a Store backed by a temporary directory.
|
||||
func newTestStore(t *testing.T) Store {
|
||||
t.Helper()
|
||||
store, err := NewStore(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create store: %v", err)
|
||||
}
|
||||
return store
|
||||
}
|
||||
Reference in New Issue
Block a user