Skip to content
Open
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
12 changes: 12 additions & 0 deletions Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,15 @@ tasks:
cmds:
- task: lint
- task: test

conformance:
desc: Run OCI distribution conformance tests against all supported backends
cmd: go test -tags=integration ./conformance -run TestOCIConformance -count=1 -v

conformance:ocimem:
desc: Run OCI distribution conformance tests against ocimem
cmd: go test -tags=integration ./conformance -run TestOCIConformance/ocimem -count=1 -v

conformance:ocilayout:
desc: Run OCI distribution conformance tests against ocilayout
cmd: go test -tags=integration ./conformance -run TestOCIConformance/ocilayout -count=1 -v
4 changes: 1 addition & 3 deletions cmd/ocisrv/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,8 @@ go 1.25.0

require (
github.com/cue-exp/cueconfig v0.0.1
github.com/go-json-experiment/json v0.0.0-20240524174822-2d9f40f7385b
github.com/docker/oci v0.0.0
github.com/opencontainers/go-digest v1.0.0
github.com/go-json-experiment/json v0.0.0-20240524174822-2d9f40f7385b
github.com/rogpeppe/go-internal v1.14.1
github.com/rogpeppe/retry v0.1.0
)
Expand All @@ -21,7 +20,6 @@ require (
github.com/google/uuid v1.3.0 // indirect
github.com/mitchellh/go-wordwrap v1.0.1 // indirect
github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 // indirect
golang.org/x/mod v0.31.0 // indirect
golang.org/x/net v0.48.0 // indirect
Expand Down
4 changes: 0 additions & 4 deletions cmd/ocisrv/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,6 @@ github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQ
github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0=
github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de h1:D5x39vF5KCwKQaw+OC9ZPiLVHXz3UFw2+psEX+gYcto=
github.com/mpvl/unique v0.0.0-20150818121801-cbe035fff7de/go.mod h1:kJun4WP5gFuHZgRjZUWWuH1DTxCtxbHDOIJsudS8jzY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/protocolbuffers/txtpbfmt v0.0.0-20230328191034-3462fbc510c0 h1:sadMIsgmHpEOGbUs6VtHBXRR1OHevnj7hLx9ZcdNGW4=
Expand Down
6 changes: 5 additions & 1 deletion cmd/ocisrv/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,11 @@ func main1() error {
writeNetAddr(l)
}
fmt.Printf("listening on %v\n", l.Addr())
err = http.Serve(l, ociserver.New(r, nil))
srv, err := ociserver.New(r, nil)
if err != nil {
return fmt.Errorf("cannot construct server: %v", err)
}
err = http.Serve(l, srv)
return fmt.Errorf("http server error: %v", err)
}

Expand Down
2 changes: 2 additions & 0 deletions cmd/ocisrv/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ import (
"testing"
"time"

"github.com/docker/oci"
"github.com/docker/oci/ociclient"
digest "github.com/docker/oci/ocidigest"
"github.com/rogpeppe/go-internal/testscript"
"github.com/rogpeppe/retry"
)
Expand Down
21 changes: 21 additions & 0 deletions conformance/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
FROM golang:1.25-alpine AS build

ARG DISTRIBUTION_SPEC_REF=967efdc079b91785ad18c77cc4f8991a47feefbf

RUN apk add --no-cache ca-certificates git
RUN mkdir /src \
&& cd /src \
&& git init \
&& git remote add origin https://github.com/opencontainers/distribution-spec.git \
&& git fetch --depth 1 origin "${DISTRIBUTION_SPEC_REF}" \
&& git checkout --detach FETCH_HEAD

WORKDIR /src/conformance
RUN CGO_ENABLED=0 go build -o /conformance .

FROM alpine:3.21

RUN apk add --no-cache ca-certificates
COPY --from=build /conformance /conformance
WORKDIR /work
ENTRYPOINT ["/conformance"]
18 changes: 18 additions & 0 deletions conformance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# OCI distribution conformance

This directory runs the official OCI distribution-spec conformance suite
against `ociserver` backed by the registry implementations in this repository.

Docker must be installed and running. Run every backend with:

```console
task conformance
```

Individual backends can be selected with `task conformance:ocimem` or
`task conformance:ocilayout`.

The harness builds a pinned version of the upstream conformance runner, starts
each registry on a temporary local port, and fails when the upstream runner
reports a conformance failure. HTML, YAML, and JUnit reports are written under
`conformance/results/<backend>/`.
203 changes: 203 additions & 0 deletions conformance/conformance_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
//go:build integration

package conformance

import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"os/exec"
"os/user"
"path/filepath"
"strconv"
"strings"
"testing"
"time"

"github.com/docker/oci"
"github.com/docker/oci/ocilayout"
"github.com/docker/oci/ocimem"
"github.com/docker/oci/ociserver"
)

const conformanceImage = "docker-oci-conformance:integration"

type backendCase struct {
name string
new func(*testing.T) oci.Interface
}

func TestOCIConformance(t *testing.T) {
if testing.Short() {
t.Skip("skipping OCI conformance tests in short mode")
}

root := repositoryRoot(t)
requireDocker(t, root)
buildConformanceImage(t, root)

backends := []backendCase{
{
name: "ocimem",
new: func(*testing.T) oci.Interface {
return ocimem.New()
},
},
{
name: "ocilayout",
new: func(t *testing.T) oci.Interface {
r, err := ocilayout.New(t.TempDir(), nil)
if err != nil {
t.Fatalf("creating ocilayout backend: %v", err)
}
return r
},
},
}

for _, backend := range backends {
t.Run(backend.name, func(t *testing.T) {
runConformance(t, root, backend.name, backend.new(t))
})
}
}

func runConformance(t *testing.T, root, backendName string, backend oci.Interface) {
t.Helper()

handler, err := ociserver.New(backend, nil)
if err != nil {
t.Fatalf("creating OCI server: %v", err)
}
listener, err := net.Listen("tcp4", "0.0.0.0:0")
if err != nil {
t.Fatalf("listening for OCI server: %v", err)
}
httpServer := &http.Server{
Handler: handler,
ReadHeaderTimeout: 30 * time.Second,
IdleTimeout: 120 * time.Second,
}
serveErr := make(chan error, 1)
go func() {
serveErr <- httpServer.Serve(listener)
}()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := httpServer.Shutdown(ctx); err != nil {
t.Errorf("shutting down OCI server: %v", err)
_ = httpServer.Close()
}
select {
case err := <-serveErr:
if err != nil && !errors.Is(err, http.ErrServerClosed) {
t.Errorf("serving OCI registry: %v", err)
}
case <-time.After(time.Second):
t.Error("timed out waiting for OCI server to stop")
}
})

port := listener.Addr().(*net.TCPAddr).Port
waitForRegistry(t, fmt.Sprintf("http://127.0.0.1:%d/v2/", port))

resultsDir := filepath.Join(root, "conformance", "results", backendName)
if err := os.RemoveAll(resultsDir); err != nil {
t.Fatalf("removing old conformance results: %v", err)
}
if err := os.MkdirAll(resultsDir, 0o755); err != nil {
t.Fatalf("creating conformance results directory: %v", err)
}

runID := strconv.FormatInt(time.Now().UnixNano(), 36)
args := []string{
"run", "--rm",
"--add-host=host.docker.internal:host-gateway",
"-v", filepath.Join(root, "conformance", "oci-conformance.yaml") + ":/work/oci-conformance.yaml:ro",
"-v", resultsDir + ":/results",
"-e", fmt.Sprintf("OCI_REGISTRY=host.docker.internal:%d", port),
"-e", "OCI_REPO1=conformance/" + backendName + "/" + runID + "/repo1",
"-e", "OCI_REPO2=conformance/" + backendName + "/" + runID + "/repo2",
}
if currentUser, err := user.Current(); err == nil && currentUser.Uid != "" && currentUser.Gid != "" {
args = append(args, "--user", currentUser.Uid+":"+currentUser.Gid)
}
args = append(args, conformanceImage)

runCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
output, err := runCommand(runCtx, root, "docker", args...)
t.Logf("OCI conformance output for %s:\n%s", backendName, output)
if err != nil {
t.Fatalf("OCI conformance failed for %s: %v; reports: %s", backendName, err, resultsDir)
}
}

func repositoryRoot(t *testing.T) string {
t.Helper()
wd, err := os.Getwd()
if err != nil {
t.Fatalf("getting working directory: %v", err)
}
root, err := filepath.Abs(filepath.Join(wd, ".."))
if err != nil {
t.Fatalf("resolving repository root: %v", err)
}
return root
}

func requireDocker(t *testing.T, root string) {
t.Helper()
if output, err := runCommand(context.Background(), root, "docker", "version"); err != nil {
t.Fatalf("Docker is required for conformance tests: %v\n%s", err, output)
}
}

func buildConformanceImage(t *testing.T, root string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
output, err := runCommand(ctx, root, "docker", "build",
"-f", "conformance/Dockerfile",
"-t", conformanceImage,
"conformance/",
)
if err != nil {
t.Fatalf("building OCI conformance image: %v\n%s", err, output)
}
}

func waitForRegistry(t *testing.T, registryURL string) {
t.Helper()
client := &http.Client{Timeout: time.Second}
deadline := time.Now().Add(30 * time.Second)
var lastErr error
for time.Now().Before(deadline) {
resp, err := client.Get(registryURL)
if err == nil {
_ = resp.Body.Close()
if resp.StatusCode == http.StatusOK {
return
}
lastErr = fmt.Errorf("status %s", resp.Status)
} else {
lastErr = err
}
time.Sleep(100 * time.Millisecond)
}
t.Fatalf("timed out waiting for registry %s: %v", registryURL, lastErr)
}

func runCommand(ctx context.Context, dir, name string, args ...string) (string, error) {
cmd := exec.CommandContext(ctx, name, args...)
cmd.Dir = dir
output, err := cmd.CombinedOutput()
if err != nil {
return string(output), fmt.Errorf("%s %s: %w", name, strings.Join(args, " "), err)
}
return string(output), nil
}
50 changes: 50 additions & 0 deletions conformance/oci-conformance.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
tls: disabled
resultsDir: /results
version: "1.1"
logging: warn

# The integration harness overrides these for every backend and run.
registry: host.docker.internal:5000
repo1: conformance/repo1
repo2: conformance/repo2

username: ""
password: ""

apis:
ping: true
pull: true
push: true
blobs:
atomic: true
delete: true
digestHeader: true
mountAnonymous: true
uploadCancel: true
manifests:
atomic: true
delete: true
digestHeader: true
tagParam: true
tags:
atomic: true
delete: true
list: true
referrer: true

data:
image: true
index: true
indexList: true
sparse: false
artifact: true
subject: true
subjectMissing: true
artifactList: true
subjectList: true
dataField: true
nondistributable: true
customFields: true
noLayers: true
emptyBlob: true
sha512: true
Loading
Loading