Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 10 additions & 27 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,40 +36,23 @@ jobs:
- run: docker run --rm --network=container:containerd-registry gcr.io/go-containerregistry/crane catalog localhost:5000 | grep -E '^totally-fake-never-exists$'
- run: docker run --rm --network=container:containerd-registry gcr.io/go-containerregistry/crane ls localhost:5000/totally-fake-never-exists | grep -E '^true-yoloci$'

# TODO hacking in https://github.com/opencontainers/distribution-spec/pull/588 for Brandon's test rewrite 👀
# TODO https://github.com/sudo-bmitch/distribution-spec/tree/pr-conformance-v2
- name: OCI conformance tests
env:
# https://github.com/opencontainers/distribution-spec/tree/HEAD/conformance#readme
OCI_ROOT_URL: http://localhost:5000
OCI_NAMESPACE: oci-conformance/repo
OCI_CROSSMOUNT_NAMESPACE: conformance/mount
OCI_HIDE_SKIPPED_WORKFLOWS: 0
OCI_TEST_PULL: 1
OCI_TEST_PUSH: 1
OCI_TEST_CONTENT_DISCOVERY: 1
OCI_TEST_CONTENT_MANAGEMENT: 1
OCI_CONFIGURATION: oci-conformance.yml
run: |
git init distribution-spec
cd distribution-spec
git remote add origin https://github.com/opencontainers/distribution-spec.git
git fetch origin 5e57cc0a07ea002e507a65d4757e823f133fcb52: # main
git checkout FETCH_HEAD

git config user.name 'Hack'
git config user.email 'The@Planet'

# https://github.com/opencontainers/distribution-spec/commit/eadcef7ba0055c6893e679e47bb54fb13374fa12
git fetch origin eadcef7ba0055c6893e679e47bb54fb13374fa12:
git merge FETCH_HEAD

cd conformance
commit="$(git rev-parse HEAD)"
CGO_ENABLED=0 go test -c -trimpath -o oci-conformance -ldflags="-X github.com/opencontainers/distribution-spec/conformance.Version=$commit"
./oci-conformance
git init dist-spec
git -C dist-spec remote add origin https://github.com/opencontainers/distribution-spec.git
git -C dist-spec fetch origin d4010d8a469b7c89a1d0938379654a39805f77f9: # main
git -C dist-spec checkout FETCH_HEAD
( cd dist-spec/conformance && CGO_ENABLED=0 go build -o conformance )
./dist-spec/conformance/conformance
- uses: actions/upload-artifact@v7
with:
name: oci-conformance
archive: false
path: distribution-spec/conformance/report.html
path: results/report.html
if-no-files-found: error
if: always()

Expand Down
4 changes: 3 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
FROM --platform=$BUILDPLATFORM golang:1.25 AS build

ENV CGO_ENABLED 0
ENV GOCACHE /go/cache

WORKDIR /app
COPY go.mod go.sum ./
Expand All @@ -10,7 +11,8 @@ COPY . .
ARG TARGETOS TARGETARCH TARGETVARIANT
ENV GOOS=$TARGETOS GOARCH=$TARGETARCH VARIANT=$TARGETVARIANT

RUN set -eux; \
RUN --mount=type=cache,target=$GOCACHE \
set -eux; \
case "$GOARCH" in \
arm) export GOARM="${VARIANT#v}" ;; \
amd64) export GOAMD64="$VARIANT" ;; \
Expand Down
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -62,3 +62,7 @@ require (
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)

// TODO temporary hacks -- hopefully can upstream conformance fixes eventually 🙈
// https://github.com/cue-labs/oci/compare/main...tianon:cuelabs-oci:conformance
replace cuelabs.dev/go/oci/ociregistry => github.com/tianon/cuelabs-oci/ociregistry v0.0.0-20260320221224-66cf54feb22c
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819 h1:Zh+Ur3OsoWpvALHPLT45nOekHkgOt+IOfutBbPqM17I=
cuelabs.dev/go/oci/ociregistry v0.0.0-20251212221603-3adeb8663819/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8=
cyphar.com/go-pathrs v0.2.4 h1:iD/mge36swa1UFKdINkr1Frkpp6wZsy3YYEildj9cLY=
cyphar.com/go-pathrs v0.2.4/go.mod h1:y8f1EMG7r+hCuFf/rXsKqMJrJAUoADZGNh5/vZPKcGc=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
Expand Down Expand Up @@ -138,6 +136,8 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/tianon/cuelabs-oci/ociregistry v0.0.0-20260320221224-66cf54feb22c h1:acchZJNdZ7GKYLOegXArB+8BlzUWjqbm+1SlLnmSPoI=
github.com/tianon/cuelabs-oci/ociregistry v0.0.0-20260320221224-66cf54feb22c/go.mod h1:WjmQxb+W6nVNCgj8nXrF24lIz95AHwnSl36tpjDZSU8=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
Expand Down
113 changes: 100 additions & 13 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ package main

import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
"io"
"iter"
"log"
"net/http"
"os"
Expand Down Expand Up @@ -40,7 +42,7 @@ type containerdRegistry struct {
client *containerd.Client
}

func (r containerdRegistry) Repositories(ctx context.Context, startAfter string) ociregistry.Seq[string] {
func (r containerdRegistry) Repositories(ctx context.Context, startAfter string) iter.Seq2[string, error] {
is := r.client.ImageService()

images, err := is.List(ctx)
Expand Down Expand Up @@ -80,7 +82,7 @@ func (r containerdRegistry) Repositories(ctx context.Context, startAfter string)
return ociregistry.SliceSeq[string](names)
}

func (r containerdRegistry) Tags(ctx context.Context, repo string, startAfter string) ociregistry.Seq[string] {
func (r containerdRegistry) Tags(ctx context.Context, repo string, startAfter string) iter.Seq2[string, error] {
is := r.client.ImageService()

images, err := is.List(ctx, "name~="+strconv.Quote("^"+regexp.QuoteMeta(repo)+":"))
Expand Down Expand Up @@ -117,6 +119,61 @@ func (r containerdRegistry) Tags(ctx context.Context, repo string, startAfter st
return ociregistry.SliceSeq[string](tags)
}

func (r containerdRegistry) Referrers(ctx context.Context, repo string, digest ociregistry.Digest, artifactType string) iter.Seq2[ociregistry.Descriptor, error] {
yieldBreak := errors.New("break " + string(digest))
return func(yield func(ociregistry.Descriptor, error) bool) {
cs := r.client.ContentStore()
err := cs.Walk(ctx, func(info content.Info) error {
// TODO we should really pull "artifactType" and "mediaType" up into labels so we don't have to fetch/parse the manifest here to get them -- maybe even annotations?
desc := ociregistry.Descriptor{
Digest: info.Digest,
Size: info.Size,
}
ra, err := cs.ReaderAt(ctx, desc)
if err != nil {
if !yield(ociregistry.Descriptor{}, err) {
return yieldBreak
}
return nil
}
// wrap in a LimitedReader here to make sure we don't read an enormous amount of valid but useless JSON that DoS's us
reader := io.LimitReader(content.NewReader(ra), manifestSizeLimit)
fields := struct {
MediaType string `json:"mediaType"`
ArtifactType string `json:"artifactType"`
Config struct {
// for the fallback ("If the artifactType is empty or missing in the image manifest, the value of artifactType MUST be set to the config descriptor mediaType value.")
MediaType string `json:"mediaType"`
} `json:"config"`
Annotations map[string]string `json:"annotations"`
}{}
if err := json.NewDecoder(reader).Decode(&fields); err != nil {
if !yield(ociregistry.Descriptor{}, err) {
return yieldBreak
}
return nil
}
desc.MediaType = fields.MediaType
desc.ArtifactType = cmp.Or(fields.ArtifactType, fields.Config.MediaType)
desc.Annotations = fields.Annotations
if artifactType != "" && desc.ArtifactType != artifactType {
return nil
}
if !yield(desc, nil) {
return yieldBreak
}
return nil
}, `labels."containerd.io/gc.bref.content.subject"==`+strconv.Quote(string(digest)))
if err == yieldBreak {
return
}
if err != nil {
yield(ociregistry.Descriptor{}, err)
return
}
}
}

type containerdBlobReader struct {
client *containerd.Client
ctx context.Context
Expand All @@ -130,7 +187,7 @@ func (br *containerdBlobReader) validate() error {
info, err := br.client.ContentStore().Info(br.ctx, br.desc.Digest)
if err != nil {
if errdefs.IsNotFound(err) {
return ociregistry.ErrBlobUnknown
return errors.Join(err, ociregistry.ErrBlobUnknown)
}
return err
}
Expand Down Expand Up @@ -223,7 +280,11 @@ func (r containerdRegistry) GetBlobRange(ctx context.Context, repo string, diges
requestedSize := offset1 - offset0
if offset1 < 0 || offset0+requestedSize > br.desc.Size {
// "If offset1 is negative or exceeds the actual size of the blob, GetBlobRange will return all the data starting from offset0."
return br, nil
requestedSize = br.desc.Size - offset0
}
if offset0 < 0 { // TODO https://github.com/cue-labs/oci/issues/47
offset0 = br.desc.Size - offset1
requestedSize = offset1
}

ra, err := br.ensureReaderAt()
Expand All @@ -245,7 +306,7 @@ func (r containerdRegistry) GetManifest(ctx context.Context, repo string, digest
ra, err := r.client.ContentStore().ReaderAt(ctx, desc)
if err != nil {
if errdefs.IsNotFound(err) {
return nil, ociregistry.ErrManifestUnknown
return nil, errors.Join(err, ociregistry.ErrManifestUnknown)
}
return nil, err
}
Expand Down Expand Up @@ -290,7 +351,7 @@ func (r containerdRegistry) GetTag(ctx context.Context, repo string, tagName str
}

// TODO differentiate ErrNameUnknown (repo unknown) from ErrManifestUnknown ?
return nil, ociregistry.ErrManifestUnknown
return nil, errors.Join(err, ociregistry.ErrManifestUnknown)
}
return nil, err
}
Expand Down Expand Up @@ -330,16 +391,34 @@ func (r containerdRegistry) ResolveTag(ctx context.Context, repo string, tagName

func (r containerdRegistry) DeleteBlob(ctx context.Context, repo string, digest ociregistry.Digest) error {
// TODO should we stop this from removing things that are still tagged or children of tagged?
return r.client.ContentStore().Delete(ctx, digest)
if err := r.client.ContentStore().Delete(ctx, digest); err != nil {
if errdefs.IsNotFound(err) {
return errors.Join(err, ociregistry.ErrBlobUnknown)
}
return err
}
return nil
}

func (r containerdRegistry) DeleteManifest(ctx context.Context, repo string, digest ociregistry.Digest) error {
// TODO should we stop this from removing things that are still tagged or children of tagged?
return r.client.ContentStore().Delete(ctx, digest)
if err := r.client.ContentStore().Delete(ctx, digest); err != nil {
if errdefs.IsNotFound(err) {
return errors.Join(err, ociregistry.ErrManifestUnknown)
}
return err
}
return nil
}

func (r containerdRegistry) DeleteTag(ctx context.Context, repo string, name string) error {
return r.client.ImageService().Delete(ctx, repo+":"+name)
if err := r.client.ImageService().Delete(ctx, repo+":"+name); err != nil {
if errdefs.IsNotFound(err) {
return errors.Join(err, ociregistry.ErrManifestUnknown)
}
return err
}
return nil
}

func (r containerdRegistry) PushBlob(ctx context.Context, repo string, desc ociregistry.Descriptor, reader io.Reader) (ociregistry.Descriptor, error) {
Expand All @@ -365,8 +444,11 @@ func (r containerdRegistry) PushBlob(ctx context.Context, repo string, desc ocir
}

if err := content.WriteBlob(ctx, cs, ingestRef, reader, desc); err != nil {
_ = cs.Abort(ctx, ingestRef)
_ = deleteLease(ctx)
_ = cs.Abort(context.WithoutCancel(ctx), ingestRef)
_ = deleteLease(context.WithoutCancel(ctx))
if errdefs.IsFailedPrecondition(err) {
err = errors.Join(err, ociregistry.ErrDigestInvalid)
}
return ociregistry.Descriptor{}, err
}

Expand Down Expand Up @@ -425,6 +507,9 @@ func (bw *containerdBlobWriter) Commit(digest ociregistry.Digest) (ociregistry.D
return ociregistry.Descriptor{}, err
}
if err := bw.Writer.Commit(bw.ctx, 0, digest); err != nil && !errdefs.IsAlreadyExists(err) {
if errdefs.IsFailedPrecondition(err) {
err = errors.Join(err, ociregistry.ErrDigestInvalid)
}
return ociregistry.Descriptor{}, err
}
return ociregistry.Descriptor{
Expand Down Expand Up @@ -473,7 +558,7 @@ func (r containerdRegistry) PushBlobChunkedResume(ctx context.Context, repo, id

writer, err := content.OpenWriter(ctx, cs, content.WithRef(id))
if err != nil {
_ = deleteLease(ctx)
_ = deleteLease(context.WithoutCancel(ctx))
return nil, err
}

Expand Down Expand Up @@ -529,7 +614,6 @@ func (r containerdRegistry) PushManifest(ctx context.Context, repo string, tag s
"l": manifestChildren.Layers,
} {
for i, d := range list {
d := d
labelMappings[prefix+"."+strconv.Itoa(i)] = &d
}
}
Expand All @@ -555,6 +639,9 @@ func (r containerdRegistry) PushManifest(ctx context.Context, repo string, tag s
return ociregistry.Descriptor{}, err
}
if err := content.WriteBlob(ctx, cs, ingestRef, bytes.NewReader(contents), desc, content.WithLabels(labels)); err != nil {
if errdefs.IsFailedPrecondition(err) {
err = errors.Join(err, ociregistry.ErrDigestInvalid)
}
return ociregistry.Descriptor{}, err
}

Expand Down
14 changes: 14 additions & 0 deletions oci-conformance.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
tls: disabled
apis:
blobs:
digestHeader: true
manifests:
digestHeader: true
data:
# TODO implement sha512 (requires containerd to support it first 😭)
# $ ctr content fetch docker.io/tianon/test:sha512-blobs
# docker.io/tianon/test:sha512-blobs: resolved |++++++++++++++++++++++++++++++++++++++|
# manifest-sha256:3aeaae15b975d380c89b18aa9694dbb690af663575ac746a8a6db90015550295: downloading |--------------------------------------| 0.0 B/1.3 KiB
# elapsed: 1.1 s total: 0.0 B (0.0 B/s)
# ctr: failed commit on ref "layer-sha512:bae9ebc21672534b4a4014540b89e9b851ba90e091f3adb929a99ef575fe86ddef8584327630501f65636bc587c8b999cd4ca5f9200166abe15dc82f05f2ffca": commit failed: unexpected commit digest sha256:1c51fc286aa95d9413226599576bafa38490b1e292375c90de095855b64caea6, expected sha512:bae9ebc21672534b4a4014540b89e9b851ba90e091f3adb929a99ef575fe86ddef8584327630501f65636bc587c8b999cd4ca5f9200166abe15dc82f05f2ffca: failed precondition
sha512: false