Skip to content

Commit 6c0618a

Browse files
authored
fix: update OCI conformance workflow and improve blob upload handling (#4170)
Signed-off-by: Ramkumar Chinchani <rchincha.dev@gmail.com>
1 parent 3fa6104 commit 6c0618a

9 files changed

Lines changed: 367 additions & 72 deletions

File tree

.github/workflows/oci-conformance-action.yaml

Lines changed: 18 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ jobs:
2020
runs-on: ubuntu-latest
2121
# Steps represent a sequence of tasks that will be executed as part of the job
2222
steps:
23-
- name: Install go 1.23
23+
- name: Install go 1.26
2424
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
2525
with:
2626
cache: false
@@ -36,35 +36,24 @@ jobs:
3636
make binary
3737
RUNNER_TRACKING_ID="" && ./bin/zot-linux-amd64 serve examples/config-conformance.json &
3838
IP=`hostname -I | awk '{print $1}'`
39-
echo "SERVER_URL=http://${IP}:8080" >> $GITHUB_ENV
40-
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
41-
with:
42-
repository: opencontainers/distribution-spec
43-
ref: af32c8c0de81c7e2e7c74709fd98b1c11ac55659
44-
path: distribution-spec
45-
persist-credentials: false
46-
- name: build conformance binary from main
47-
run: |
48-
(cd distribution-spec/ && make conformance-binary)
49-
mv distribution-spec/output/conformance.test .
50-
rm -rf distribution-spec/
39+
echo "OCI_REGISTRY=${IP}:8080" >> $GITHUB_ENV
5140
- name: run conformance
41+
# pinned to opencontainers/distribution-spec main@fcfba1ec55526073f48b2f6d4e3d7eef410ddcbc
42+
uses: opencontainers/distribution-spec@fcfba1ec55526073f48b2f6d4e3d7eef410ddcbc
5243
env:
53-
OCI_ROOT_URL: ${{ env.SERVER_URL }}
54-
OCI_NAMESPACE: oci-conformance/distribution-test
55-
OCI_TEST_PULL: 1
56-
OCI_TEST_PUSH: 1
57-
OCI_TEST_CONTENT_DISCOVERY: 1
58-
OCI_TEST_CONTENT_MANAGEMENT: 1
59-
OCI_REFERRERS: 1
60-
OCI_CROSSMOUNT_NAMESPACE: oci-conformance/crossmount-test
61-
run: |
62-
./conformance.test
63-
- run: mkdir -p .out/ && mv {report.html,junit.xml} .out/
64-
if: always()
65-
- name: Upload test results zip as build artifact
44+
OCI_REGISTRY: ${{ env.OCI_REGISTRY }}
45+
OCI_TLS: "disabled"
46+
OCI_REPO1: oci-conformance/distribution-test
47+
OCI_REPO2: oci-conformance/crossmount-test
48+
OCI_VERSION: "1.1"
49+
OCI_RESULTS_DIR: "."
50+
- name: upload conformance artifacts
6651
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
52+
if: always()
6753
with:
68-
name: oci-test-results-${{ github.sha }}
69-
path: .out/
70-
if: github.event_name == 'push'
54+
name: oci-conformance-results
55+
# Conformance output has used result.yaml/results.yaml across upstream versions.
56+
path: |
57+
junit.xml
58+
report.html
59+
result*.yaml

examples/config-conformance.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"storage": {
44
"rootDirectory": "/tmp/zot",
55
"gc": true,
6-
"dedupe": true
6+
"dedupe": false
77
},
88
"http": {
99
"address": "0.0.0.0",

pkg/api/routes.go

Lines changed: 39 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1844,16 +1844,16 @@ func (rh *RouteHandler) CreateBlobUpload(response http.ResponseWriter, request *
18441844

18451845
var contentLength int64
18461846

1847-
contentLength, err = strconv.ParseInt(request.Header.Get("Content-Length"), 10, 64)
1848-
if err != nil || contentLength <= 0 {
1849-
rh.c.Log.Warn().Str("actual", request.Header.Get("Content-Length")).Msg("invalid content length")
1850-
1851-
details := map[string]string{"digest": digest.String()}
1852-
1853-
if err != nil {
1854-
details["conversion error"] = err.Error()
1855-
} else {
1856-
details["Content-Length"] = request.Header.Get("Content-Length")
1847+
// request.ContentLength is pre-parsed by net/http: 0 for an explicit empty
1848+
// body, -1 when unknown/chunked. Reject only the unknown case (< 0); a
1849+
// Content-Length of 0 is a valid empty-blob upload.
1850+
contentLength = request.ContentLength
1851+
if contentLength < 0 {
1852+
rh.c.Log.Warn().Int64("actual", contentLength).Msg("invalid content length")
1853+
1854+
details := map[string]string{
1855+
"digest": digest.String(),
1856+
"Content-Length": strconv.FormatInt(contentLength, 10),
18571857
}
18581858

18591859
e := apiErr.NewError(apiErr.BLOB_UPLOAD_INVALID).AddDetail(details)
@@ -2134,39 +2134,44 @@ func (rh *RouteHandler) UpdateBlobUpload(response http.ResponseWriter, request *
21342134
return
21352135
}
21362136

2137-
contentPresent := true
2137+
contentLenHeader := request.Header.Get("Content-Length")
2138+
contentRange := request.Header.Get("Content-Range")
2139+
// Header.Get cannot distinguish missing headers from present-but-empty headers.
2140+
contentLenHeaderPresent := len(request.Header.Values("Content-Length")) > 0
2141+
contentRangePresent := len(request.Header.Values("Content-Range")) > 0
21382142

2139-
contentLen, err := strconv.ParseInt(request.Header.Get("Content-Length"), 10, 64)
2140-
if err != nil {
2141-
contentPresent = false
2142-
}
2143+
shouldPutBlobChunk := contentLenHeaderPresent || contentRangePresent
21432144

2144-
contentRangePresent := true
2145+
var from, to int64
21452146

2146-
if request.Header.Get("Content-Range") == "" {
2147-
contentRangePresent = false
2148-
}
2147+
if shouldPutBlobChunk {
2148+
contentLen, err := strconv.ParseInt(contentLenHeader, 10, 64)
2149+
if err != nil || contentLen < 0 {
2150+
rh.c.Log.Warn().Str("actual", contentLenHeader).Msg("invalid content length")
21492151

2150-
// we expect at least one of "Content-Length" or "Content-Range" to be
2151-
// present
2152-
if !contentPresent && !contentRangePresent {
2153-
response.WriteHeader(http.StatusBadRequest)
2152+
details := map[string]string{"digest": digest.String()}
2153+
if err != nil {
2154+
details["conversion error"] = err.Error()
2155+
} else {
2156+
details["Content-Length"] = contentLenHeader
2157+
}
21542158

2155-
return
2156-
}
2159+
e := apiErr.NewError(apiErr.BLOB_UPLOAD_INVALID).AddDetail(details)
2160+
zcommon.WriteJSON(response, http.StatusBadRequest, apiErr.NewErrorList(e))
21572161

2158-
var from, to int64
2162+
return
2163+
}
21592164

2160-
if contentPresent {
2161-
contentRange := request.Header.Get("Content-Range")
21622165
if contentRange == "" { // monolithic upload
21632166
from = 0
21642167

2165-
if contentLen == 0 {
2166-
goto finish
2168+
if contentLen > 0 {
2169+
to = contentLen
2170+
} else {
2171+
// Zero-length monolithic upload (e.g. empty blob): skip
2172+
// PutBlobChunk and go straight to FinishBlobUpload below.
2173+
shouldPutBlobChunk = false
21672174
}
2168-
2169-
to = contentLen
21702175
} else if from, to, err = getContentRange(request); err != nil { // finish chunked upload
21712176
details := zerr.GetDetails(err)
21722177
details["session_id"] = sessionID
@@ -2175,7 +2180,9 @@ func (rh *RouteHandler) UpdateBlobUpload(response http.ResponseWriter, request *
21752180

21762181
return
21772182
}
2183+
}
21782184

2185+
if shouldPutBlobChunk {
21792186
_, err = imgStore.PutBlobChunk(ctx, name, sessionID, from, to, request.Body)
21802187
if err != nil { //nolint:dupl
21812188
details := zerr.GetDetails(err)
@@ -2207,7 +2214,6 @@ func (rh *RouteHandler) UpdateBlobUpload(response http.ResponseWriter, request *
22072214
}
22082215
}
22092216

2210-
finish:
22112217
// blob chunks already transferred, just finish
22122218
if err := imgStore.FinishBlobUpload(name, sessionID, request.Body, digest); err != nil {
22132219
details := zerr.GetDetails(err)

pkg/api/routes_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1353,6 +1353,23 @@ func TestRoutes(t *testing.T) {
13531353
})
13541354
So(statusCode, ShouldEqual, http.StatusInternalServerError)
13551355

1356+
// full blob upload with empty body and no Content-Length header
1357+
statusCode = testCreateBlobUpload(
1358+
[]struct{ k, v string }{
1359+
{"digest", "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"},
1360+
},
1361+
map[string]string{
1362+
"Content-Type": constants.BinaryMediaType,
1363+
},
1364+
&mocks.MockedImageStore{
1365+
FullBlobUploadFn: func(ctx context.Context, repo string, body io.Reader,
1366+
digest godigest.Digest,
1367+
) (string, int64, error) {
1368+
return sessionStr, 0, nil
1369+
},
1370+
})
1371+
So(statusCode, ShouldEqual, http.StatusCreated)
1372+
13561373
// newBlobUpload not found
13571374
statusCode = testCreateBlobUpload(
13581375
[]struct{ k, v string }{},

pkg/storage/common/common.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -186,7 +186,6 @@ func GetAndValidateRequestDigest(body []byte, reference string, log zlog.Logger)
186186
) {
187187
expectedDigest, err := godigest.Parse(reference)
188188
if err != nil {
189-
// This is a non-digest reference
190189
return godigest.Canonical.FromBytes(body), err
191190
}
192191

pkg/storage/imagestore/imagestore.go

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1444,7 +1444,34 @@ func (is *ImageStore) CheckBlob(ctx context.Context, repo string, digest godiges
14441444
}
14451445

14461446
binfo, err := is.storeDriver.Stat(blobPath)
1447-
if err == nil && binfo.Size() > 0 {
1447+
if err != nil {
1448+
dstRecord, err := is.checkCacheBlob(digest)
1449+
if err != nil {
1450+
if errors.Is(err, zerr.ErrCacheMiss) || errors.Is(err, zerr.ErrBlobNotFound) {
1451+
is.log.Debug().Err(err).Str("digest", digest.String()).Msg("cache miss for blob")
1452+
} else {
1453+
is.log.Warn().Err(err).Str("digest", digest.String()).Msg("failed to lookup blob in cache")
1454+
}
1455+
1456+
return false, -1, zerr.ErrBlobNotFound
1457+
}
1458+
1459+
blobSize, err := is.copyBlob(ctx, repo, blobPath, dstRecord)
1460+
if err != nil {
1461+
return false, -1, zerr.ErrBlobNotFound
1462+
}
1463+
1464+
// put deduped blob in cache
1465+
if err := is.cache.PutBlob(digest, blobPath); err != nil {
1466+
is.log.Error().Err(err).Str("blobPath", blobPath).Str("component", "dedupe").Msg("failed to insert blob record")
1467+
1468+
return false, -1, err
1469+
}
1470+
1471+
return true, blobSize, nil
1472+
}
1473+
1474+
if binfo.Size() > 0 {
14481475
// try to find blob size in blob descriptors, if blob can not be found
14491476
desc, err := common.GetBlobDescriptorFromRepo(is, repo, digest, is.log)
14501477
if err != nil || desc.Size == binfo.Size() {
@@ -1460,9 +1487,19 @@ func (is *ImageStore) CheckBlob(ctx context.Context, repo string, digest godiges
14601487
return false, -1, zerr.ErrBlobNotFound
14611488
}
14621489
}
1463-
// otherwise is a 'deduped' blob (empty file)
14641490

1465-
// Check blobs in cache
1491+
// Size == 0: either a genuine empty blob, or an S3-style deduped placeholder.
1492+
// Distinguish by comparing the digest against the hash of empty content for
1493+
// the same algorithm (cheap single-pass hash over 0 bytes).
1494+
emptyDigest := digest.Algorithm().FromBytes(nil)
1495+
if emptyDigest == digest {
1496+
// Genuine empty blob (e.g. sha256:e3b0c44... or sha512:cf83e13...).
1497+
is.log.Debug().Str("blob path", blobPath).Msg("empty blob found")
1498+
1499+
return true, 0, nil
1500+
}
1501+
1502+
// S3-style deduped placeholder: the real content lives elsewhere in the cache.
14661503
dstRecord, err := is.checkCacheBlob(digest)
14671504
if err != nil {
14681505
// Cache miss / not-found is a normal condition when the blob truly doesn't exist.
@@ -1633,6 +1670,16 @@ func (is *ImageStore) originalBlobInfo(repo string, digest godigest.Digest) (dri
16331670
}
16341671

16351672
if binfo.Size() == 0 {
1673+
// A zero-size file is either a genuine empty blob or an S3-style
1674+
// deduplication placeholder pointing into the cache. Distinguish the
1675+
// two by checking whether the digest matches the hash of zero bytes for
1676+
// the same algorithm (cheap single-pass hash over 0 bytes).
1677+
emptyDigest := digest.Algorithm().FromBytes(nil)
1678+
if emptyDigest == digest {
1679+
// Genuine empty blob – return its FileInfo as-is.
1680+
return binfo, nil
1681+
}
1682+
16361683
dstRecord, err := is.checkCacheBlob(digest)
16371684
if err != nil {
16381685
is.log.Debug().Err(err).Str("digest", digest.String()).Msg("not found in cache")

pkg/storage/local/local_test.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1677,6 +1677,28 @@ func TestDedupeRestoreCompleteMarker(t *testing.T) {
16771677
})
16781678
}
16791679

1680+
func TestCheckBlobTreatsRealEmptyBlobAsPresent(t *testing.T) {
1681+
Convey("CheckBlob returns present for genuine empty blob with dedupe/cache disabled", t, func() {
1682+
dir := t.TempDir()
1683+
1684+
logger := zlog.NewTestLogger()
1685+
metrics := monitoring.NewMetricsServer(false, logger)
1686+
1687+
imgStore := local.NewImageStore(dir, false, true, logger, metrics, nil, nil, nil, nil)
1688+
1689+
emptyDigest := godigest.Canonical.FromBytes(nil)
1690+
1691+
_, size, err := imgStore.FullBlobUpload(context.Background(), repoName, bytes.NewReader(nil), emptyDigest)
1692+
So(err, ShouldBeNil)
1693+
So(size, ShouldEqual, 0)
1694+
1695+
found, blobSize, err := imgStore.CheckBlob(context.Background(), repoName, emptyDigest)
1696+
So(err, ShouldBeNil)
1697+
So(found, ShouldBeTrue)
1698+
So(blobSize, ShouldEqual, int64(0))
1699+
})
1700+
}
1701+
16801702
// recheckDriver wraps local.Driver and, for a single target path, returns a
16811703
// custom result on the second Stat call to simulate the state observed by
16821704
// restoreDedupedBlobs' re-check under the write lock.

0 commit comments

Comments
 (0)