2026-01-20 21:52:44 +00:00
|
|
|
// Package storage provides artifact storage backends for the proxy cache.
|
2026-01-29 16:13:16 +00:00
|
|
|
//
|
|
|
|
|
// Storage backends are accessed via gocloud.dev/blob URLs:
|
|
|
|
|
//
|
|
|
|
|
// - file:///path/to/dir - Local filesystem storage
|
|
|
|
|
// - s3://bucket-name - Amazon S3
|
|
|
|
|
// - s3://bucket?endpoint=http://localhost:9000 - S3-compatible (MinIO)
|
2026-08-29 11:23:07 +01:00
|
|
|
// - gs://bucket-name - Google Cloud Storage (supports GKE Workload Identity
|
|
|
|
|
// via Application Default Credentials)
|
|
|
|
|
// - azblob://container-name - Azure Blob Storage
|
2026-01-29 16:13:16 +00:00
|
|
|
//
|
|
|
|
|
// Use OpenBucket to create a storage backend from a URL.
|
2026-01-20 21:52:44 +00:00
|
|
|
package storage
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"context"
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
"crypto/rand"
|
|
|
|
|
"encoding/hex"
|
2026-01-20 21:52:44 +00:00
|
|
|
"errors"
|
|
|
|
|
"io"
|
2026-04-27 12:04:38 +01:00
|
|
|
"time"
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
2026-03-18 10:59:29 +00:00
|
|
|
const dirPermissions = 0755
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
var (
|
|
|
|
|
ErrNotFound = errors.New("artifact not found")
|
2026-04-27 12:04:38 +01:00
|
|
|
|
|
|
|
|
// ErrSignedURLUnsupported is returned by SignedURL when the backend
|
|
|
|
|
// cannot generate presigned URLs (e.g. local filesystem).
|
|
|
|
|
ErrSignedURLUnsupported = errors.New("signed URLs not supported by storage backend")
|
2026-01-20 21:52:44 +00:00
|
|
|
)
|
|
|
|
|
|
2026-05-04 12:15:16 +02:00
|
|
|
// ObjectInfo contains metadata for a stored object.
|
|
|
|
|
type ObjectInfo struct {
|
|
|
|
|
Path string
|
|
|
|
|
Size int64
|
|
|
|
|
ModTime time.Time
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// Storage defines the interface for artifact storage backends.
|
|
|
|
|
type Storage interface {
|
|
|
|
|
// Store writes content from r to the given path.
|
|
|
|
|
// Returns the number of bytes written and the SHA256 hash of the content.
|
|
|
|
|
Store(ctx context.Context, path string, r io.Reader) (size int64, hash string, err error)
|
|
|
|
|
|
|
|
|
|
// Open returns a reader for the content at path.
|
|
|
|
|
// The caller must close the reader when done.
|
Support HTTP byte-range requests for local file cached artifact downloads (#404)
* feat(storage): expose efficient seek for local files
Open file-backed artifacts with os.Open so local readers support
efficient seeking. Leave cloud storage readers unchanged.
Keep the legacy sidecar mapping test focused on fileblob's reader:
Blob.Open now reads local files directly and no longer consults the
sidecar. Continue checking sidecar cleanup and post-store reads.
* feat(handler): preserve efficient seek through integrity checks
Keep the cache result's single Reader field and preserve io.Seeker
when the underlying storage reader supports efficient seeking.
Sequential reads continue through whole-object integrity verification.
A successful seek switches subsequent reads to the source for range
responses, where full-object verification is not possible.
Test both modes and verify the source is closed once.
* feat(handler): add request-aware byte range responses
Add a request-aware artifact serving helper for bounded, open-ended,
and suffix byte ranges, including If-Range handling, 206 responses,
and 416 responses for valid unsatisfiable ranges.
Ignore malformed and multi-range requests by serving the full artifact.
Advertise byte-range support only for seekable readers, preserving HEAD,
redirect, and non-seekable behavior. Clarify that net/http recovers
ErrAbortHandler per request and keeps the server running.
* feat(handler): enable byte ranges across artifact downloads
Forward each artifact request to the shared range-aware response helper,
including cached generic release assets and OCI blobs. This lets the
handler honor ranges only when its reader supports efficient seeking,
without changing direct-storage redirect behavior.
Add warm-cache endpoint tests that assert partial response headers and
bytes without an upstream refetch. Document supported range behavior,
unsupported reader fallback, and the integrity limitation of partial reads.
2026-10-04 09:48:41 -07:00
|
|
|
// The reader may also implement io.Seeker when seeking is efficient for the
|
|
|
|
|
// storage backend. Backends must not expose io.Seeker when seeking requires
|
|
|
|
|
// reading and discarding the bytes before the requested position.
|
2026-01-20 21:52:44 +00:00
|
|
|
// Returns ErrNotFound if the path does not exist.
|
|
|
|
|
Open(ctx context.Context, path string) (io.ReadCloser, error)
|
|
|
|
|
|
|
|
|
|
// Exists returns true if content exists at path.
|
|
|
|
|
Exists(ctx context.Context, path string) (bool, error)
|
|
|
|
|
|
|
|
|
|
// Delete removes the content at path.
|
|
|
|
|
// Returns nil if the path does not exist.
|
|
|
|
|
Delete(ctx context.Context, path string) error
|
|
|
|
|
|
|
|
|
|
// Size returns the size in bytes of content at path.
|
|
|
|
|
// Returns ErrNotFound if the path does not exist.
|
|
|
|
|
Size(ctx context.Context, path string) (int64, error)
|
|
|
|
|
|
2026-04-27 12:04:38 +01:00
|
|
|
// SignedURL returns a presigned URL granting time-limited GET access to path.
|
|
|
|
|
// Returns ErrSignedURLUnsupported if the backend cannot generate presigned URLs.
|
|
|
|
|
SignedURL(ctx context.Context, path string, expiry time.Duration) (string, error)
|
|
|
|
|
|
2026-01-20 21:52:44 +00:00
|
|
|
// UsedSpace returns the total bytes used by all stored content.
|
|
|
|
|
UsedSpace(ctx context.Context) (int64, error)
|
2026-04-03 14:06:51 +01:00
|
|
|
|
|
|
|
|
// URL returns the storage backend URL (e.g. "file:///path" or "s3://bucket").
|
|
|
|
|
URL() string
|
|
|
|
|
|
|
|
|
|
// Close releases any resources held by the storage backend.
|
|
|
|
|
Close() error
|
2026-01-20 21:52:44 +00:00
|
|
|
}
|
|
|
|
|
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
// ArtifactPath builds the storage path artifacts were cached under before each
|
2026-09-29 10:24:22 -07:00
|
|
|
// fetch got its own; records from then still point at such paths. Only tests
|
|
|
|
|
// call it, to build such records.
|
2026-01-20 21:52:44 +00:00
|
|
|
// Format: {ecosystem}/{namespace}/{name}/{version}/{filename}
|
|
|
|
|
// For packages without namespace: {ecosystem}/{name}/{version}/{filename}
|
|
|
|
|
func ArtifactPath(ecosystem, namespace, name, version, filename string) string {
|
|
|
|
|
if namespace != "" {
|
|
|
|
|
return ecosystem + "/" + namespace + "/" + name + "/" + version + "/" + filename
|
|
|
|
|
}
|
|
|
|
|
return ecosystem + "/" + name + "/" + version + "/" + filename
|
|
|
|
|
}
|
Give each fetch its own storage path
Every fetch of an artifact wrote one path, so fetches with different URLs
or digests, which do not share a coalescing key, could overwrite each
other's bytes, serve the wrong ones, or delete them on a digest mismatch
while the other was about to open them.
storeArtifact now writes {ecosystem}/{name}/{version}/{fetch id}/{filename},
with a random id per fetch. Most ecosystems learn the digest only after
the fetch, and storage has no rename, so the id is the one rule that fits
all of them. Existing records keep their paths and stay readable. On a
file:// bucket, Delete now removes an emptied fetch directory, which
fileblob leaves behind, and no other directory, since another fetch may
be creating its own inside it.
A path a record stops pointing at is queued in pending_deletes rather
than deleted, since a request that read the record may still open it. A
loop deletes queued paths after max(1h, direct_serve_ttl), whether or not
max_size is set. UpsertArtifact and clears apply only if the record still
holds the path the caller read, so racing commits each queue the path
they replaced and a clear never orphans a newer commit. Eviction still
deletes inline, and counts space as freed only when the record still
pointed at what it deleted.
2026-09-23 14:40:13 -07:00
|
|
|
|
|
|
|
|
// FetchPath builds the storage path for one fetch of an artifact:
|
|
|
|
|
// {ecosystem}/{name}/{version}/{fetchID}/{filename}. Each fetch writes its own
|
|
|
|
|
// object, so no fetch overwrites or deletes another's.
|
|
|
|
|
func FetchPath(ecosystem, name, version, fetchID, filename string) string {
|
|
|
|
|
return ecosystem + "/" + name + "/" + version + "/" + fetchID + "/" + filename
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// fetchIDBytes sizes a fetch id: 16 hex characters keeps paths short, which
|
|
|
|
|
// matters on Windows, and a collision between fetches of one artifact version
|
|
|
|
|
// is out of reach.
|
|
|
|
|
const fetchIDBytes = 8
|
|
|
|
|
|
|
|
|
|
// NewFetchID returns a random id for FetchPath.
|
|
|
|
|
func NewFetchID() string {
|
|
|
|
|
b := make([]byte, fetchIDBytes)
|
|
|
|
|
_, _ = rand.Read(b)
|
|
|
|
|
return hex.EncodeToString(b)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// isFetchDir reports whether a directory name has the shape of a fetch id.
|
|
|
|
|
// Only one fetch ever writes into such a directory.
|
|
|
|
|
func isFetchDir(name string) bool {
|
|
|
|
|
if len(name) != 2*fetchIDBytes {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for _, c := range name {
|
|
|
|
|
if (c < '0' || c > '9') && (c < 'a' || c > 'f') {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|