pkg-proxy/internal/storage/filesystem.go
Andrew Nesbitt beddf8357a
Fix startup message and add connectivity check for S3 storage (#57)
* Fix startup message and add connectivity check for S3 storage

When S3 storage is configured, the startup log incorrectly showed the
default local path (./cache/artifacts) instead of the actual S3 URL.
This also adds a lightweight connectivity check at startup so bad
credentials or endpoints fail immediately rather than on first request.

Add URL() and Close() to the Storage interface so all backends report
their URL and can be cleaned up properly. Rename the stats JSON field
from storage_path to storage_url. Close storage in error paths and
during graceful shutdown.

Fixes #49

* Fix Windows test assertion for file:// URL format

OpenBucket normalizes Windows paths to file:///C:/path (three slashes)
but the test expected file://C:/path (two slashes).
2026-04-03 14:06:51 +01:00

182 lines
4 KiB
Go

package storage
import (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"os"
"path/filepath"
)
// Filesystem implements Storage using the local filesystem.
type Filesystem struct {
root string
}
// NewFilesystem creates a new filesystem storage rooted at the given directory.
// The directory will be created if it does not exist.
func NewFilesystem(root string) (*Filesystem, error) {
absRoot, err := filepath.Abs(root)
if err != nil {
return nil, fmt.Errorf("resolving root path: %w", err)
}
if err := os.MkdirAll(absRoot, dirPermissions); err != nil {
return nil, fmt.Errorf("creating root directory: %w", err)
}
return &Filesystem{root: absRoot}, nil
}
func (fs *Filesystem) fullPath(path string) string {
return filepath.Join(fs.root, filepath.FromSlash(path))
}
func (fs *Filesystem) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) {
fullPath := fs.fullPath(path)
dir := filepath.Dir(fullPath)
if err := os.MkdirAll(dir, dirPermissions); err != nil {
return 0, "", fmt.Errorf("creating directory: %w", err)
}
// Write to temp file first for atomic operation
tmpFile, err := os.CreateTemp(dir, ".tmp-*")
if err != nil {
return 0, "", fmt.Errorf("creating temp file: %w", err)
}
tmpPath := tmpFile.Name()
// Clean up temp file on error
success := false
defer func() {
if !success {
_ = tmpFile.Close()
_ = os.Remove(tmpPath)
}
}()
// Write content and compute hash
h := sha256.New()
w := io.MultiWriter(tmpFile, h)
size, err := io.Copy(w, r)
if err != nil {
return 0, "", fmt.Errorf("writing content: %w", err)
}
if err := tmpFile.Close(); err != nil {
return 0, "", fmt.Errorf("closing temp file: %w", err)
}
// Atomic rename
if err := os.Rename(tmpPath, fullPath); err != nil {
return 0, "", fmt.Errorf("renaming temp file: %w", err)
}
success = true
hash := hex.EncodeToString(h.Sum(nil))
return size, hash, nil
}
func (fs *Filesystem) Open(ctx context.Context, path string) (io.ReadCloser, error) {
fullPath := fs.fullPath(path)
f, err := os.Open(fullPath)
if err != nil {
if os.IsNotExist(err) {
return nil, ErrNotFound
}
return nil, fmt.Errorf("opening file: %w", err)
}
return f, nil
}
func (fs *Filesystem) Exists(ctx context.Context, path string) (bool, error) {
fullPath := fs.fullPath(path)
_, err := os.Stat(fullPath)
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("checking file: %w", err)
}
return true, nil
}
func (fs *Filesystem) Delete(ctx context.Context, path string) error {
fullPath := fs.fullPath(path)
err := os.Remove(fullPath)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("removing file: %w", err)
}
// Try to clean up empty parent directories
dir := filepath.Dir(fullPath)
for dir != fs.root {
if err := os.Remove(dir); err != nil {
break // Directory not empty or other error
}
dir = filepath.Dir(dir)
}
return nil
}
func (fs *Filesystem) Size(ctx context.Context, path string) (int64, error) {
fullPath := fs.fullPath(path)
info, err := os.Stat(fullPath)
if err != nil {
if os.IsNotExist(err) {
return 0, ErrNotFound
}
return 0, fmt.Errorf("stat file: %w", err)
}
return info.Size(), nil
}
func (fs *Filesystem) UsedSpace(ctx context.Context) (int64, error) {
var total int64
err := filepath.Walk(fs.root, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if !info.IsDir() {
total += info.Size()
}
return nil
})
if err != nil {
return 0, fmt.Errorf("walking directory: %w", err)
}
return total, nil
}
// Root returns the root directory of the storage.
func (fs *Filesystem) Root() string {
return fs.root
}
// FullPath returns the full filesystem path for a storage path.
// Useful for serving files directly or debugging.
func (fs *Filesystem) FullPath(path string) string {
return fs.fullPath(path)
}
func (fs *Filesystem) URL() string {
return "file://" + filepath.ToSlash(fs.root)
}
func (fs *Filesystem) Close() error {
return nil
}