mirror of
https://github.com/hauler-dev/hauler.git
synced 2026-08-21 21:36:50 +00:00
990 lines
30 KiB
Go
990 lines
30 KiB
Go
package content
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"maps"
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"slices"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/google/go-containerregistry/pkg/name"
|
|
"golang.org/x/sync/semaphore"
|
|
"golang.org/x/sync/singleflight"
|
|
|
|
ccontent "github.com/containerd/containerd/v2/core/content"
|
|
"github.com/containerd/containerd/v2/core/remotes"
|
|
"github.com/opencontainers/go-digest"
|
|
"github.com/opencontainers/image-spec/specs-go"
|
|
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
|
|
|
"hauler.dev/go/hauler/v2/pkg/consts"
|
|
"hauler.dev/go/hauler/v2/pkg/reference"
|
|
)
|
|
|
|
var _ Target = (*OCI)(nil)
|
|
|
|
// indexCheckpointInterval bounds fsync frequency on the per-artifact save
|
|
// path: index.json is rewritten in full on every AddIndex, so fsyncing each
|
|
// one costs O(N^2) bytes across a sync (~5 GB for 5,000 artifacts) while
|
|
// holding o.mu. Coalescing trades a bounded power-loss/panic exposure window
|
|
// for that cost -- an interrupted sync is simply re-run.
|
|
const indexCheckpointInterval = 30 * time.Second
|
|
|
|
// ErrDigestMismatch is returned by WriteBlob (and wrapped with details) when
|
|
// the content actually streamed from open() does not hash to the expected
|
|
// digest. Callers can retry: the final blob path is never touched on this
|
|
// error, so a fresh WriteBlob call will re-download cleanly.
|
|
var ErrDigestMismatch = errors.New("content: digest mismatch")
|
|
|
|
// ctxReader wraps an io.Reader so Read returns ctx.Err() once ctx is done,
|
|
// instead of delegating. This makes an in-flight WriteBlob copy abort on
|
|
// cancellation, since the underlying reader (v1.Layer's Compressed(), which
|
|
// this package doesn't control) has no cancellation hook of its own.
|
|
type ctxReader struct {
|
|
ctx context.Context
|
|
r io.Reader
|
|
}
|
|
|
|
func (c *ctxReader) Read(p []byte) (int, error) {
|
|
if err := c.ctx.Err(); err != nil {
|
|
return 0, err
|
|
}
|
|
return c.r.Read(p)
|
|
}
|
|
|
|
type OCI struct {
|
|
root string
|
|
index *ocispec.Index
|
|
nameMap *sync.Map // map[string]ocispec.Descriptor
|
|
sf singleflight.Group
|
|
|
|
// blobSem bounds blob writes in flight across this OCI's two write
|
|
// paths (WriteBlob, ociPusher.Push), scoped per-store. Acquire is
|
|
// ctx-aware, unlike errgroup.SetLimit, so it returns promptly on
|
|
// cancellation instead of leaving a goroutine parked.
|
|
blobSem *semaphore.Weighted
|
|
|
|
// blobConcurrency is the ceiling blobSem was built with, retained so
|
|
// reporting can render "peak-inflight=N/ceiling" (semaphore.Weighted
|
|
// doesn't expose its own capacity).
|
|
blobConcurrency int
|
|
|
|
// stats accumulates disk-contention counters for this store. See
|
|
// stats.go.
|
|
stats IOStats
|
|
|
|
// mu guards index, index.json on disk, and nameMap descriptors'
|
|
// annotation maps. Exported methods are thin lock-then-Locked-variant
|
|
// wrappers; the *Locked methods assume the caller holds mu, letting
|
|
// internal chains (e.g. ociPusher.Push's load-modify-save) re-enter
|
|
// without double-locking (see Walk's doc comment for the related hazard).
|
|
mu sync.Mutex
|
|
|
|
// lastDurableSave is when the index was last fsync'd, guarded by mu.
|
|
// The zero value makes the first save of a run durable, which gives an
|
|
// early checkpoint for free.
|
|
lastDurableSave time.Time
|
|
|
|
// now is time.Now, replaced in tests so checkpoint-interval behavior
|
|
// can be exercised without sleeping.
|
|
now func() time.Time
|
|
}
|
|
|
|
// lock acquires o.mu, recording time spent blocked into IOStats, so every
|
|
// caller measures index-serialization cost in one place. Unlock isn't
|
|
// wrapped since it never blocks.
|
|
func (o *OCI) lock() {
|
|
start := time.Now()
|
|
o.mu.Lock()
|
|
o.stats.IndexLockWaitNanos.Add(int64(time.Since(start)))
|
|
}
|
|
|
|
// OCIOption configures an OCI store at construction time.
|
|
type OCIOption func(*OCI)
|
|
|
|
// WithBlobConcurrency overrides consts.DefaultBlobConcurrency for this OCI's
|
|
// blobSem. n <= 0 is a no-op (keeps the default) rather than an error, so
|
|
// callers can pass a possibly-zero value unconditionally.
|
|
func WithBlobConcurrency(n int) OCIOption {
|
|
return func(o *OCI) {
|
|
if n > 0 {
|
|
o.blobSem = semaphore.NewWeighted(int64(n))
|
|
o.blobConcurrency = n
|
|
}
|
|
}
|
|
}
|
|
|
|
func NewOCI(root string, opts ...OCIOption) (*OCI, error) {
|
|
o := &OCI{
|
|
root: root,
|
|
nameMap: &sync.Map{},
|
|
blobSem: semaphore.NewWeighted(consts.DefaultBlobConcurrency),
|
|
blobConcurrency: consts.DefaultBlobConcurrency,
|
|
now: time.Now,
|
|
}
|
|
for _, opt := range opts {
|
|
opt(o)
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
// Stats returns this store's I/O contention counters. The returned pointer
|
|
// is live -- counters keep incrementing as work proceeds. Call Snapshot on
|
|
// it to take a stable reading.
|
|
func (o *OCI) Stats() *IOStats {
|
|
return &o.stats
|
|
}
|
|
|
|
// BlobConcurrency returns the ceiling blobSem was constructed with.
|
|
func (o *OCI) BlobConcurrency() int {
|
|
return o.blobConcurrency
|
|
}
|
|
|
|
// AddIndex adds a descriptor to the index and updates it
|
|
//
|
|
// The descriptor must use AnnotationRefName to identify itself
|
|
func (o *OCI) AddIndex(desc ocispec.Descriptor) error {
|
|
// Pure validation/parsing -- doesn't touch shared state -- stays outside
|
|
// the lock.
|
|
if _, ok := desc.Annotations[ocispec.AnnotationRefName]; !ok {
|
|
return fmt.Errorf("descriptor must contain a reference from the annotation: %s", ocispec.AnnotationRefName)
|
|
}
|
|
|
|
key, err := reference.Parse(desc.Annotations[ocispec.AnnotationRefName])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if strings.TrimSpace(key.String()) == "--" {
|
|
return nil
|
|
}
|
|
|
|
var mapKey string
|
|
switch key.(type) {
|
|
case name.Digest:
|
|
mapKey = fmt.Sprintf("%s-%s", key.Context().String(), desc.Annotations[consts.KindAnnotationName])
|
|
case name.Tag:
|
|
mapKey = fmt.Sprintf("%s-%s", key.String(), desc.Annotations[consts.KindAnnotationName])
|
|
default:
|
|
return nil
|
|
}
|
|
|
|
o.lock()
|
|
defer o.mu.Unlock()
|
|
|
|
// Skip the write when the stored descriptor is already byte-identical:
|
|
// index.json rewrites aren't otherwise batched (O(N^2) bytes as the
|
|
// index grows), only their fsync is (see indexCheckpointInterval).
|
|
if existing, ok := o.nameMap.Load(mapKey); ok {
|
|
if descriptorsEqual(existing.(ocispec.Descriptor), desc) {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
o.nameMap.Store(mapKey, desc)
|
|
return o.saveIndexCheckpointLocked()
|
|
}
|
|
|
|
// descriptorsEqual reports whether two descriptors are equal in every field
|
|
// AddIndex's callers in this codebase populate: MediaType, Digest, Size,
|
|
// URLs, ArtifactType, Platform, Data, and Annotations (compared by
|
|
// contents, not map identity).
|
|
func descriptorsEqual(a, b ocispec.Descriptor) bool {
|
|
if a.MediaType != b.MediaType || a.Digest != b.Digest || a.Size != b.Size || a.ArtifactType != b.ArtifactType {
|
|
return false
|
|
}
|
|
if !maps.Equal(a.Annotations, b.Annotations) {
|
|
return false
|
|
}
|
|
if !slices.Equal(a.URLs, b.URLs) {
|
|
return false
|
|
}
|
|
if !bytes.Equal(a.Data, b.Data) {
|
|
return false
|
|
}
|
|
if (a.Platform == nil) != (b.Platform == nil) {
|
|
return false
|
|
}
|
|
if a.Platform != nil {
|
|
pa, pb := a.Platform, b.Platform
|
|
if pa.Architecture != pb.Architecture || pa.OS != pb.OS || pa.OSVersion != pb.OSVersion || pa.Variant != pb.Variant {
|
|
return false
|
|
}
|
|
if !slices.Equal(pa.OSFeatures, pb.OSFeatures) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
// LoadIndex will load the index from disk.
|
|
func (o *OCI) LoadIndex() error {
|
|
o.lock()
|
|
defer o.mu.Unlock()
|
|
return o.loadIndexLocked()
|
|
}
|
|
|
|
// loadIndexLocked is LoadIndex's implementation. Callers must hold o.mu.
|
|
func (o *OCI) loadIndexLocked() error {
|
|
path := o.path(ocispec.ImageIndexFile)
|
|
idx, err := os.Open(path)
|
|
if err != nil {
|
|
if !os.IsNotExist(err) {
|
|
return err
|
|
}
|
|
o.index = &ocispec.Index{
|
|
Versioned: specs.Versioned{
|
|
SchemaVersion: 2,
|
|
},
|
|
MediaType: ocispec.MediaTypeImageIndex,
|
|
}
|
|
return nil
|
|
}
|
|
defer idx.Close()
|
|
|
|
if err := json.NewDecoder(idx).Decode(&o.index); err != nil {
|
|
return err
|
|
}
|
|
|
|
for _, desc := range o.index.Manifests {
|
|
key, err := reference.Parse(desc.Annotations[ocispec.AnnotationRefName])
|
|
if err != nil {
|
|
// skip malformed entries rather than making the entire store unreadable
|
|
continue
|
|
}
|
|
|
|
// Set default kind if missing... normalize legacy dev.cosignproject.cosign values
|
|
kind := desc.Annotations[consts.KindAnnotationName]
|
|
kind = consts.NormalizeLegacyKind(kind)
|
|
if kind == "" {
|
|
kind = consts.KindAnnotationImage
|
|
}
|
|
|
|
// Write normalized kind into a copy of Annotations so Walk() callers
|
|
// see it, without mutating the slice element's shared map.
|
|
normalized := make(map[string]string, len(desc.Annotations)+1)
|
|
maps.Copy(normalized, desc.Annotations)
|
|
normalized[consts.KindAnnotationName] = kind
|
|
desc.Annotations = normalized
|
|
|
|
if strings.TrimSpace(key.String()) != "--" {
|
|
switch key.(type) {
|
|
case name.Digest:
|
|
o.nameMap.Store(fmt.Sprintf("%s-%s", key.Context().String(), kind), desc)
|
|
case name.Tag:
|
|
o.nameMap.Store(fmt.Sprintf("%s-%s", key.String(), kind), desc)
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// SaveIndex will update the index on disk.
|
|
func (o *OCI) SaveIndex() error {
|
|
o.lock()
|
|
defer o.mu.Unlock()
|
|
return o.saveIndexLocked(true)
|
|
}
|
|
|
|
// saveIndexLocked is SaveIndex's implementation. Callers must hold o.mu.
|
|
//
|
|
// The write is atomic: temp file (uniquely named via os.CreateTemp, since
|
|
// two hauler processes share no in-process mutex) in the same directory,
|
|
// then renamed into place.
|
|
//
|
|
// durable controls both the temp file's fsync and, after rename, an fsync of
|
|
// the containing directory (see syncDir); when false the write is still
|
|
// atomic but may not survive power loss until a later save catches up --
|
|
// see indexCheckpointInterval.
|
|
func (o *OCI) saveIndexLocked(durable bool) error {
|
|
var descs []ocispec.Descriptor
|
|
o.nameMap.Range(func(name, desc interface{}) bool {
|
|
n := desc.(ocispec.Descriptor).Annotations[ocispec.AnnotationRefName]
|
|
d := desc.(ocispec.Descriptor)
|
|
|
|
if d.Annotations == nil {
|
|
d.Annotations = make(map[string]string)
|
|
}
|
|
d.Annotations[ocispec.AnnotationRefName] = n
|
|
descs = append(descs, d)
|
|
return true
|
|
})
|
|
|
|
// sort index to ensure that images come before any signatures and attestations.
|
|
sort.SliceStable(descs, func(i, j int) bool {
|
|
kindI := descs[i].Annotations["kind"]
|
|
kindJ := descs[j].Annotations["kind"]
|
|
|
|
// Objects with the prefix of KindAnnotationImage should be at the top.
|
|
if strings.HasPrefix(kindI, consts.KindAnnotationImage) && !strings.HasPrefix(kindJ, consts.KindAnnotationImage) {
|
|
return true
|
|
} else if !strings.HasPrefix(kindI, consts.KindAnnotationImage) && strings.HasPrefix(kindJ, consts.KindAnnotationImage) {
|
|
return false
|
|
}
|
|
return false // Default: maintain the order.
|
|
})
|
|
|
|
o.index.Manifests = descs
|
|
data, err := json.Marshal(o.index)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
indexPath := o.path(ocispec.ImageIndexFile)
|
|
dir := filepath.Dir(indexPath)
|
|
|
|
tmp, err := os.CreateTemp(dir, "index-*.json")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
// Unconditional cleanup: harmless ENOENT after a successful rename, and
|
|
// it's the cleanup path for every error branch below -- same idiom as
|
|
// writeBlobOnce.
|
|
defer os.Remove(tmpPath)
|
|
|
|
if _, err := tmp.Write(data); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
// chmod before rename: CreateTemp files are 0600, but index.json must be
|
|
// readable by other consumers (e.g. `hauler store serve` as another user).
|
|
if err := tmp.Chmod(0644); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if durable {
|
|
if err := tmp.Sync(); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tmpPath, indexPath); err != nil {
|
|
return err
|
|
}
|
|
// Track the rename unconditionally -- it succeeded regardless of whether
|
|
// the durable branch below (which can still fail on syncDir) completes.
|
|
// IndexDurableWrites stays inside that branch since it must count only
|
|
// fsyncs that actually completed.
|
|
o.stats.IndexWrites.Add(1)
|
|
o.stats.IndexBytesWritten.Add(int64(len(data)))
|
|
if durable {
|
|
if err := syncDir(dir); err != nil {
|
|
return err
|
|
}
|
|
o.lastDurableSave = o.now()
|
|
o.stats.IndexDurableWrites.Add(1)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// saveIndexCheckpointLocked saves the index, fsync'ing only when at least
|
|
// indexCheckpointInterval has elapsed since the last durable save. Used by
|
|
// the per-artifact callers (AddIndex, ociPusher.Push); callers that are
|
|
// explicit checkpoints call saveIndexLocked(true) directly. Callers must
|
|
// hold o.mu.
|
|
func (o *OCI) saveIndexCheckpointLocked() error {
|
|
return o.saveIndexLocked(o.now().Sub(o.lastDurableSave) >= indexCheckpointInterval)
|
|
}
|
|
|
|
// syncDir fsyncs a directory so a rename into it survives power loss;
|
|
// fsyncing the file alone doesn't guarantee the directory entry pointing at
|
|
// it (containerd's local content store does the same after a blob rename).
|
|
// A no-op on Windows, which has no equivalent. EINVAL/ENOTSUP are tolerated
|
|
// (some NFS/SMB mounts, common for an air-gapped store root, don't support
|
|
// directory fsync) rather than turning a previously working command into a
|
|
// hard failure; every other error is still fatal.
|
|
func syncDir(dir string) error {
|
|
if runtime.GOOS == "windows" {
|
|
return nil
|
|
}
|
|
d, err := os.Open(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := d.Sync(); err != nil {
|
|
d.Close()
|
|
if errors.Is(err, syscall.EINVAL) || errors.Is(err, syscall.ENOTSUP) {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|
|
return d.Close()
|
|
}
|
|
|
|
// Resolve attempts to resolve the reference into a name and descriptor.
|
|
//
|
|
// The argument `ref` should be a scheme-less URI representing the remote.
|
|
// Structurally, it has a host and path. The "host" can be used to directly
|
|
// reference a specific host or be matched against a specific handler.
|
|
//
|
|
// The returned name should be used to identify the referenced entity.
|
|
// Dependending on the remote namespace, this may be immutable or mutable.
|
|
// While the name may differ from ref, it should itself be a valid ref.
|
|
//
|
|
// If the resolution fails, an error will be returned.
|
|
func (o *OCI) Resolve(ctx context.Context, ref string) (ocispec.Descriptor, error) {
|
|
o.lock()
|
|
defer o.mu.Unlock()
|
|
|
|
if err := o.loadIndexLocked(); err != nil {
|
|
return ocispec.Descriptor{}, err
|
|
}
|
|
d, ok := o.nameMap.Load(ref)
|
|
if !ok {
|
|
return ocispec.Descriptor{}, fmt.Errorf("reference %s not found", ref)
|
|
}
|
|
desc := d.(ocispec.Descriptor)
|
|
return desc, nil
|
|
}
|
|
|
|
// Fetcher returns a new fetcher for the provided reference.
|
|
// All content fetched from the returned fetcher will be
|
|
// from the namespace referred to by ref.
|
|
func (o *OCI) Fetcher(ctx context.Context, ref string) (remotes.Fetcher, error) {
|
|
o.lock()
|
|
defer o.mu.Unlock()
|
|
|
|
if err := o.loadIndexLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
if _, ok := o.nameMap.Load(ref); !ok {
|
|
return nil, nil
|
|
}
|
|
return o, nil
|
|
}
|
|
|
|
// Fetch is intentionally lock-free: it only touches the filesystem and the
|
|
// immutable root field, never index/nameMap. A lock here would deadlock
|
|
// store.Layout.CleanUp, which calls Fetch from inside an OCI.Walk callback
|
|
// (see Walk's doc comment for the general hazard).
|
|
func (o *OCI) Fetch(ctx context.Context, desc ocispec.Descriptor) (io.ReadCloser, error) {
|
|
readerAt, err := o.blobReaderAt(desc)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return readerAt, nil
|
|
}
|
|
|
|
// FetchManifest is intentionally lock-free -- see Fetch's doc comment.
|
|
func (o *OCI) FetchManifest(ctx context.Context, manifest ocispec.Manifest) (io.ReadCloser, error) {
|
|
readerAt, err := o.manifestBlobReaderAt(manifest)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return readerAt, nil
|
|
}
|
|
|
|
// Pusher returns a new pusher for the provided reference
|
|
// The returned Pusher should satisfy content.Ingester and concurrent attempts
|
|
// to push the same blob using the Ingester API should result in ErrUnavailable.
|
|
func (o *OCI) Pusher(ctx context.Context, ref string) (remotes.Pusher, error) {
|
|
o.lock()
|
|
defer o.mu.Unlock()
|
|
|
|
if err := o.loadIndexLocked(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var baseRef, hash string
|
|
parts := strings.SplitN(ref, "@", 2)
|
|
baseRef = parts[0]
|
|
|
|
if len(parts) > 1 {
|
|
hash = parts[1]
|
|
}
|
|
|
|
return &ociPusher{
|
|
oci: o,
|
|
ref: baseRef,
|
|
digest: hash,
|
|
}, nil
|
|
}
|
|
|
|
// Walk loads the index, snapshots nameMap under o.mu -- deep-copying each
|
|
// descriptor's annotations -- then releases the lock before invoking fn per
|
|
// entry. This is load-bearing: store.Layout.CopyAll's self-sync path
|
|
// re-enters Copy/Resolve/Fetcher/Pusher on this same OCI from inside a Walk
|
|
// callback (CleanUp does the same via Fetch), and a plain mutex held across
|
|
// fn would deadlock on that re-entrant Lock() from the same goroutine.
|
|
//
|
|
// The deep copy means in-place mutation of a callback's descriptor silently
|
|
// no-ops instead of corrupting nameMap's shared map; use UpdateAnnotations
|
|
// to persist changes.
|
|
func (o *OCI) Walk(fn func(reference string, desc ocispec.Descriptor) error) error {
|
|
o.lock()
|
|
if err := o.loadIndexLocked(); err != nil {
|
|
o.mu.Unlock()
|
|
return err
|
|
}
|
|
|
|
type entry struct {
|
|
key string
|
|
desc ocispec.Descriptor
|
|
}
|
|
var snapshot []entry
|
|
o.nameMap.Range(func(key, value interface{}) bool {
|
|
d := value.(ocispec.Descriptor)
|
|
cp := make(map[string]string, len(d.Annotations))
|
|
maps.Copy(cp, d.Annotations)
|
|
d.Annotations = cp
|
|
snapshot = append(snapshot, entry{key: key.(string), desc: d})
|
|
return true
|
|
})
|
|
o.mu.Unlock()
|
|
|
|
var errst []string
|
|
for _, e := range snapshot {
|
|
if err := fn(e.key, e.desc); err != nil {
|
|
errst = append(errst, err.Error())
|
|
}
|
|
}
|
|
if errst != nil {
|
|
return fmt.Errorf("%s", strings.Join(errst, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// blobReaderAt, manifestBlobReaderAt, blobWriterAt, and ensureBlob are
|
|
// lock-free too -- see Fetch's doc comment.
|
|
func (o *OCI) blobReaderAt(desc ocispec.Descriptor) (*os.File, error) {
|
|
blobPath, err := o.ensureBlob(desc.Digest.Algorithm().String(), desc.Digest.Hex())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return os.Open(blobPath)
|
|
}
|
|
|
|
func (o *OCI) manifestBlobReaderAt(manifest ocispec.Manifest) (*os.File, error) {
|
|
blobPath, err := o.ensureBlob(string(manifest.Config.Digest.Algorithm().String()), manifest.Config.Digest.Hex())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return os.Open(blobPath)
|
|
}
|
|
|
|
func (o *OCI) blobWriterAt(desc ocispec.Descriptor) (*os.File, error) {
|
|
blobPath, err := o.ensureBlob(desc.Digest.Algorithm().String(), desc.Digest.Hex())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return os.OpenFile(blobPath, os.O_WRONLY|os.O_CREATE, 0644)
|
|
}
|
|
|
|
func (o *OCI) ensureBlob(alg string, hex string) (string, error) {
|
|
dir := o.path(ocispec.ImageBlobsDir, alg)
|
|
if err := os.MkdirAll(dir, os.ModePerm); err != nil && !os.IsExist(err) {
|
|
return "", err
|
|
}
|
|
return filepath.Join(dir, hex), nil
|
|
}
|
|
|
|
// WriteBlob atomically and verifiably writes a blob to the store's blob
|
|
// directory, deduplicating concurrent writers of the same digest. It is
|
|
// lock-free with respect to o.mu -- see Fetch's doc comment. open is a
|
|
// thunk, not a reader, since singleflight must not start the download until
|
|
// it wins the flight, and a retry needs a fresh reader.
|
|
//
|
|
// A file already at the final path with a matching size short-circuits
|
|
// WriteBlob without re-hashing, so re-syncs don't become O(store size) in
|
|
// disk reads. Otherwise content streams, deduplicated via singleflight, into
|
|
// a temp file hashed inline, then chmod'd, fsync'd, and renamed into place.
|
|
// On any error the temp file is removed and the final path untouched, so a
|
|
// failing writer can't corrupt a peer's completed blob and a retry
|
|
// re-downloads cleanly.
|
|
//
|
|
// singleflight.Do hands the flight winner's error to every waiter, even ones
|
|
// on a distinct, still-live ctx -- if that error is context.Canceled but
|
|
// this caller's ctx isn't done, WriteBlob retries once on the caller's own
|
|
// ctx rather than propagate a cancellation that wasn't its own.
|
|
func (o *OCI) WriteBlob(ctx context.Context, expected digest.Digest, size int64, open func() (io.ReadCloser, error)) error {
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
dir := o.path(ocispec.ImageBlobsDir, expected.Algorithm().String())
|
|
if err := os.MkdirAll(dir, os.ModePerm); err != nil && !os.IsExist(err) {
|
|
return err
|
|
}
|
|
blobPath := filepath.Join(dir, expected.Hex())
|
|
|
|
// Fast path: trust existing content by size alone, checked and returned
|
|
// before touching blobSem so a cache hit stays free regardless of
|
|
// semaphore saturation.
|
|
if info, err := os.Stat(blobPath); err == nil {
|
|
if size > 0 {
|
|
if info.Size() == size {
|
|
o.stats.BlobsCached.Add(1)
|
|
return nil
|
|
}
|
|
} else if info.Size() > 0 {
|
|
o.stats.BlobsCached.Add(1)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
err := o.writeBlobShared(ctx, dir, blobPath, expected, size, open)
|
|
if err != nil && errors.Is(err, context.Canceled) && ctx.Err() == nil {
|
|
// See WriteBlob's doc comment: retrying either hits the fast path or
|
|
// this goroutine becomes the new flight leader.
|
|
err = o.writeBlobShared(ctx, dir, blobPath, expected, size, open)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// writeBlobShared dedupes concurrent writers of expected via this OCI's
|
|
// singleflight.Group so only one actually streams content.
|
|
func (o *OCI) writeBlobShared(ctx context.Context, dir, blobPath string, expected digest.Digest, size int64, open func() (io.ReadCloser, error)) error {
|
|
_, err, _ := o.sf.Do(expected.String(), func() (interface{}, error) {
|
|
// Acquired inside the singleflight func, not around sf.Do: losers
|
|
// merely waiting on Do() must not hold a permit for someone else's
|
|
// write.
|
|
start := time.Now()
|
|
if err := o.blobSem.Acquire(ctx, 1); err != nil {
|
|
return nil, err
|
|
}
|
|
o.stats.addSemWait(time.Since(start))
|
|
o.stats.enterBlob()
|
|
defer func() {
|
|
o.stats.exitBlob()
|
|
o.blobSem.Release(1)
|
|
}()
|
|
return nil, o.writeBlobOnce(ctx, dir, blobPath, expected, size, open)
|
|
})
|
|
return err
|
|
}
|
|
|
|
// writeBlobOnce performs the temp-file-then-rename write. Only ever invoked
|
|
// by the singleflight winner, which already holds a blobSem permit.
|
|
func (o *OCI) writeBlobOnce(ctx context.Context, dir, blobPath string, expected digest.Digest, size int64, open func() (io.ReadCloser, error)) (err error) {
|
|
// Re-check under the flight: a prior, already-completed flight may have
|
|
// written this blob while we were waiting to start.
|
|
if info, statErr := os.Stat(blobPath); statErr == nil {
|
|
if size > 0 && info.Size() == size {
|
|
o.stats.BlobsCached.Add(1)
|
|
return nil
|
|
}
|
|
if size <= 0 && info.Size() > 0 {
|
|
o.stats.BlobsCached.Add(1)
|
|
return nil
|
|
}
|
|
}
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
|
|
tmp, err := os.CreateTemp(dir, expected.Hex()+".tmp-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tmpPath := tmp.Name()
|
|
// Unconditional cleanup: harmless ENOENT after a successful rename, and
|
|
// it's the cleanup path for every error branch below.
|
|
defer os.Remove(tmpPath)
|
|
|
|
rc, err := open()
|
|
if err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
|
|
// See ctxReader's doc comment: this makes io.Copy below abort between
|
|
// chunks instead of running an already-cancelled download to completion.
|
|
cr := &ctxReader{ctx: ctx, r: rc}
|
|
|
|
dg := digest.Canonical.Digester()
|
|
n, copyErr := io.Copy(io.MultiWriter(tmp, dg.Hash()), cr)
|
|
closeReadErr := rc.Close()
|
|
|
|
if copyErr != nil {
|
|
tmp.Close()
|
|
return copyErr
|
|
}
|
|
if closeReadErr != nil {
|
|
tmp.Close()
|
|
return closeReadErr
|
|
}
|
|
if size > 0 && n != size {
|
|
tmp.Close()
|
|
return fmt.Errorf("content: short/long write for %s: wrote %d bytes, expected %d: %w", expected, n, size, ErrDigestMismatch)
|
|
}
|
|
|
|
got := dg.Digest()
|
|
if got != expected {
|
|
tmp.Close()
|
|
return fmt.Errorf("content: digest mismatch for blob: expected %s, got %s (%d bytes): %w", expected, got, n, ErrDigestMismatch)
|
|
}
|
|
|
|
// Commit: chmod 0600->0644 (see saveIndexLocked), fsync, then rename.
|
|
if err := tmp.Chmod(0644); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
tmp.Close()
|
|
return err
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
// os.Rename silently replaces an existing target, which is correct: the
|
|
// content is digest-identical to whatever's already at blobPath.
|
|
if err := os.Rename(tmpPath, blobPath); err != nil {
|
|
return err
|
|
}
|
|
o.stats.BlobsWritten.Add(1)
|
|
o.stats.BlobBytesWritten.Add(n)
|
|
return nil
|
|
}
|
|
|
|
// path and IndexExists are lock-free too -- see Fetch's doc comment.
|
|
func (o *OCI) path(elem ...string) string {
|
|
complete := []string{string(o.root)}
|
|
return filepath.Join(append(complete, elem...)...)
|
|
}
|
|
|
|
// IndexExists reports whether the store's OCI layout index.json exists on disk.
|
|
func (o *OCI) IndexExists() bool {
|
|
_, err := os.Stat(o.path(ocispec.ImageIndexFile))
|
|
return err == nil
|
|
}
|
|
|
|
type ociPusher struct {
|
|
oci *OCI
|
|
ref string
|
|
digest string
|
|
}
|
|
|
|
// Push returns a content writer for the given resource identified
|
|
// by the descriptor.
|
|
func (p *ociPusher) Push(ctx context.Context, d ocispec.Descriptor) (ccontent.Writer, error) {
|
|
switch d.MediaType {
|
|
case ocispec.MediaTypeImageManifest, ocispec.MediaTypeImageIndex, consts.DockerManifestSchema2, consts.DockerManifestListSchema2:
|
|
// if the hash of the content matches that which was provided as the hash for the root, mark it
|
|
if p.digest != "" && p.digest == d.Digest.String() {
|
|
// Single critical section (Locked variants, to avoid deadlocking
|
|
// on this same lock) so no other save can land in between.
|
|
p.oci.lock()
|
|
if err := p.oci.loadIndexLocked(); err != nil {
|
|
p.oci.mu.Unlock()
|
|
return nil, err
|
|
}
|
|
// Use compound key format: "reference-kind"; normalize legacy values.
|
|
kind := d.Annotations[consts.KindAnnotationName]
|
|
kind = consts.NormalizeLegacyKind(kind)
|
|
if kind == "" {
|
|
kind = consts.KindAnnotationImage
|
|
}
|
|
// Copy annotations map to avoid mutating the caller's descriptor,
|
|
// then write the normalized kind so Walk() callers see dev.hauler/... values.
|
|
normalizedAnnotations := make(map[string]string, len(d.Annotations)+1)
|
|
maps.Copy(normalizedAnnotations, d.Annotations)
|
|
normalizedAnnotations[consts.KindAnnotationName] = kind
|
|
d.Annotations = normalizedAnnotations
|
|
key := fmt.Sprintf("%s-%s", p.ref, kind)
|
|
p.oci.nameMap.Store(key, d)
|
|
err := p.oci.saveIndexCheckpointLocked()
|
|
p.oci.mu.Unlock()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
blobPath, err := p.oci.ensureBlob(d.Digest.Algorithm().String(), d.Digest.Hex())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if _, err := os.Stat(blobPath); err == nil {
|
|
// Already exists: discard but validate digest. Returned before
|
|
// touching blobSem -- same reasoning as WriteBlob's fast path.
|
|
return NewIoContentWriter(nopCloser{io.Discard}, WithOutputHash(d.Digest.String())), nil
|
|
}
|
|
|
|
// Shares WriteBlob's bound, but held for the writer's whole lifetime
|
|
// (Push through Close) since the caller streams via separate Write
|
|
// calls rather than one owned loop.
|
|
start := time.Now()
|
|
if err := p.oci.blobSem.Acquire(ctx, 1); err != nil {
|
|
return nil, err
|
|
}
|
|
p.oci.stats.addSemWait(time.Since(start))
|
|
p.oci.stats.enterBlob()
|
|
|
|
w, err := newOCIBlobWriter(filepath.Dir(blobPath), blobPath, d.Digest.String())
|
|
if err != nil {
|
|
p.oci.stats.exitBlob()
|
|
p.oci.blobSem.Release(1)
|
|
return nil, err
|
|
}
|
|
w.releaseSem = func() {
|
|
p.oci.stats.exitBlob()
|
|
p.oci.blobSem.Release(1)
|
|
}
|
|
return w, nil
|
|
}
|
|
|
|
// ociBlobWriter streams pushed content into a temp file and, on successful
|
|
// digest verification at Close, renames it into place -- same on-error
|
|
// invariant as content.OCI.WriteBlob (see its doc comment).
|
|
type ociBlobWriter struct {
|
|
tmp *os.File
|
|
tmpPath string
|
|
finalPath string
|
|
digester digest.Digester
|
|
status ccontent.Status
|
|
outputHash string
|
|
|
|
// releaseSem releases the blobSem permit acquired by ociPusher.Push. nil
|
|
// when constructed outside Push (e.g. in tests).
|
|
releaseSem func()
|
|
}
|
|
|
|
var _ ccontent.Writer = (*ociBlobWriter)(nil)
|
|
|
|
func newOCIBlobWriter(dir, finalPath, outputHash string) (*ociBlobWriter, error) {
|
|
tmp, err := os.CreateTemp(dir, filepath.Base(finalPath)+".tmp-*")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &ociBlobWriter{
|
|
tmp: tmp,
|
|
tmpPath: tmp.Name(),
|
|
finalPath: finalPath,
|
|
digester: digest.Canonical.Digester(),
|
|
outputHash: outputHash,
|
|
}, nil
|
|
}
|
|
|
|
func (w *ociBlobWriter) Write(p []byte) (int, error) {
|
|
n, err := w.tmp.Write(p)
|
|
if n > 0 {
|
|
w.digester.Hash().Write(p[:n])
|
|
}
|
|
return n, err
|
|
}
|
|
|
|
// Close verifies the digest and, only on success, chmods, syncs, closes, and
|
|
// renames the temp file into place; on failure the temp file is left for
|
|
// the deferred os.Remove and the final path is untouched.
|
|
func (w *ociBlobWriter) Close() (err error) {
|
|
if w.releaseSem != nil {
|
|
defer w.releaseSem() // released on every path below, success or failure
|
|
}
|
|
defer os.Remove(w.tmpPath) // unconditional: harmless ENOENT after a successful rename
|
|
|
|
if w.outputHash != "" {
|
|
if computed := w.digester.Digest().String(); computed != w.outputHash {
|
|
w.tmp.Close()
|
|
return fmt.Errorf("digest mismatch: expected %s, got %s", w.outputHash, computed)
|
|
}
|
|
}
|
|
|
|
if err := w.tmp.Chmod(0644); err != nil {
|
|
w.tmp.Close()
|
|
return err
|
|
}
|
|
if err := w.tmp.Sync(); err != nil {
|
|
w.tmp.Close()
|
|
return err
|
|
}
|
|
if err := w.tmp.Close(); err != nil {
|
|
return err
|
|
}
|
|
|
|
// os.Rename silently replaces an existing target, which is correct here:
|
|
// the content is digest-verified to match what's expected at finalPath.
|
|
return os.Rename(w.tmpPath, w.finalPath)
|
|
}
|
|
|
|
func (w *ociBlobWriter) Digest() digest.Digest {
|
|
return w.digester.Digest()
|
|
}
|
|
|
|
func (w *ociBlobWriter) Commit(ctx context.Context, size int64, expected digest.Digest, opts ...ccontent.Opt) error {
|
|
return nil
|
|
}
|
|
|
|
func (w *ociBlobWriter) Status() (ccontent.Status, error) {
|
|
return w.status, nil
|
|
}
|
|
|
|
func (w *ociBlobWriter) Truncate(size int64) error {
|
|
return fmt.Errorf("truncate not supported")
|
|
}
|
|
|
|
// RemoveFromIndex removes ref from nameMap only; callers (e.g.
|
|
// store.Layout.RemoveArtifact) call SaveIndex separately afterward.
|
|
func (o *OCI) RemoveFromIndex(ref string) {
|
|
o.lock()
|
|
defer o.mu.Unlock()
|
|
o.nameMap.Delete(ref)
|
|
}
|
|
|
|
// UpdateAnnotations locates every descriptor for which match returns true,
|
|
// replaces its annotations with a copy that has had apply run over it, and
|
|
// persists the index, returning the number matched. The whole pass runs as
|
|
// a single critical section so a concurrent UpdateAnnotations or Push can't
|
|
// interleave a save in between; when nothing matches, index.json is not
|
|
// re-saved.
|
|
func (o *OCI) UpdateAnnotations(match func(ocispec.Descriptor) bool, apply func(map[string]string)) (int, error) {
|
|
o.lock()
|
|
defer o.mu.Unlock()
|
|
|
|
if err := o.loadIndexLocked(); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
matched := 0
|
|
o.nameMap.Range(func(key, value interface{}) bool {
|
|
d := value.(ocispec.Descriptor)
|
|
if !match(d) {
|
|
return true
|
|
}
|
|
cp := make(map[string]string, len(d.Annotations))
|
|
maps.Copy(cp, d.Annotations)
|
|
apply(cp)
|
|
d.Annotations = cp
|
|
o.nameMap.Store(key, d)
|
|
matched++
|
|
return true
|
|
})
|
|
|
|
if matched == 0 {
|
|
return 0, nil
|
|
}
|
|
return matched, o.saveIndexLocked(true)
|
|
}
|
|
|
|
// ResolvePath returns the absolute path for a given relative path within the OCI root
|
|
func (o *OCI) ResolvePath(elem string) string {
|
|
if elem == "" {
|
|
return o.root
|
|
}
|
|
return filepath.Join(o.root, elem)
|
|
}
|
|
|
|
// nopCloser wraps an io.Writer to implement io.WriteCloser
|
|
type nopCloser struct {
|
|
io.Writer
|
|
}
|
|
|
|
func (nopCloser) Close() error { return nil }
|