cuelabs.dev/go/oci/ociregistry@v0.0.0-20240906074133-82eb438dd565/ocitest/ocitest.go (about) 1 // Copyright 2023 CUE Labs AG 2 // 3 // Licensed under the Apache License, Version 2.0 (the "License"); 4 // you may not use this file except in compliance with the License. 5 // You may obtain a copy of the License at 6 // 7 // http://www.apache.org/licenses/LICENSE-2.0 8 // 9 // Unless required by applicable law or agreed to in writing, software 10 // distributed under the License is distributed on an "AS IS" BASIS, 11 // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 // See the License for the specific language governing permissions and 13 // limitations under the License. 14 15 // Package ocitest provides some helper types for writing ociregistry-related 16 // tests. It's designed to be used alongside the [qt package]. 17 // 18 // [qt package]: https://pkg.go.dev/github.com/go-quicktest/qt 19 package ocitest 20 21 import ( 22 "bytes" 23 "context" 24 "encoding/json" 25 "fmt" 26 "io" 27 "slices" 28 "strings" 29 "testing" 30 31 "github.com/go-quicktest/qt" 32 "github.com/opencontainers/go-digest" 33 34 "cuelabs.dev/go/oci/ociregistry" 35 ) 36 37 type Registry struct { 38 T *testing.T 39 R ociregistry.Interface 40 } 41 42 // NewRegistry returns a Registry instance that wraps r, providing 43 // convenience methods for pushing and checking content 44 // inside the given test instance. 45 // 46 // When a Must* method fails, it will fail using t. 47 func NewRegistry(t *testing.T, r ociregistry.Interface) Registry { 48 return Registry{t, r} 49 } 50 51 // RegistryContent specifies the contents of a registry: a map from 52 // repository name to the contents of that repository. 53 type RegistryContent map[string]RepoContent 54 55 // RepoContent specifies the content of a repository. 56 // manifests and blobs are keyed by symbolic identifiers, 57 // not used inside the registry itself, but instead 58 // placeholders for the digest of the associated content. 59 // 60 // Digest strings inside manifests that are not valid digests 61 // will be replaced by the calculated digest of the manifest or 62 // blob with that identifier; the size and media type fields will also be 63 // filled in. 64 type RepoContent struct { 65 // Manifests maps from manifest identifier to the contents of the manifest. 66 // TODO support manifest indexes too. 67 Manifests map[string]ociregistry.Manifest 68 69 // Blobs maps from blob identifer to the contents of the blob. 70 Blobs map[string]string 71 72 // Tags maps from tag name to manifest identifier. 73 Tags map[string]string 74 } 75 76 // PushedRepoContent mirrors RepoContent but, instead 77 // of describing content that is to be pushed, describes the 78 // content that has been pushed. 79 type PushedRepoContent struct { 80 // Manifests holds an entry for each manifest identifier 81 // with the descriptor for that manifest. 82 Manifests map[string]ociregistry.Descriptor 83 84 // ManifestData holds the actually pushed data for each manifest. 85 ManifestData map[string][]byte 86 87 // Blobs holds an entry for each blob identifier 88 // with the descriptor for that manifest. 89 Blobs map[string]ociregistry.Descriptor 90 } 91 92 // PushContent pushes all the content in rc to r. 93 // 94 // It returns a map mapping repository name to the descriptors 95 // describing the content that has actually been pushed. 96 func PushContent(r ociregistry.Interface, rc RegistryContent) (map[string]PushedRepoContent, error) { 97 regContent := make(map[string]PushedRepoContent) 98 for repo, repoc := range rc { 99 prc, err := PushRepoContent(r, repo, repoc) 100 if err != nil { 101 return nil, fmt.Errorf("cannot push content for repository %q: %v", repo, err) 102 } 103 regContent[repo] = prc 104 } 105 return regContent, nil 106 } 107 108 // PushRepoContent pushes the content for a single repository. 109 func PushRepoContent(r ociregistry.Interface, repo string, repoc RepoContent) (PushedRepoContent, error) { 110 ctx := context.Background() 111 prc := PushedRepoContent{ 112 Manifests: make(map[string]ociregistry.Descriptor), 113 ManifestData: make(map[string][]byte), 114 Blobs: make(map[string]ociregistry.Descriptor), 115 } 116 117 for id, blob := range repoc.Blobs { 118 prc.Blobs[id] = ociregistry.Descriptor{ 119 Digest: digest.FromString(blob), 120 Size: int64(len(blob)), 121 MediaType: "application/binary", 122 } 123 } 124 manifests, manifestSeq, err := completedManifests(repoc, prc.Blobs) 125 if err != nil { 126 return PushedRepoContent{}, err 127 } 128 for id, content := range manifests { 129 prc.Manifests[id] = content.desc 130 prc.ManifestData[id] = content.data 131 } 132 // First push all the blobs: 133 for id, content := range repoc.Blobs { 134 _, err := r.PushBlob(ctx, repo, prc.Blobs[id], strings.NewReader(content)) 135 if err != nil { 136 return PushedRepoContent{}, fmt.Errorf("cannot push blob %q in repo %q: %v", id, repo, err) 137 } 138 } 139 // Then push the manifests that refer to the blobs. 140 for _, mc := range manifestSeq { 141 _, err := r.PushManifest(ctx, repo, "", mc.data, mc.desc.MediaType) 142 if err != nil { 143 return PushedRepoContent{}, fmt.Errorf("cannot push manifest %q in repo %q: %v", mc.id, repo, err) 144 } 145 } 146 // Then push any tags. 147 for tag, id := range repoc.Tags { 148 mc, ok := manifests[id] 149 if !ok { 150 return PushedRepoContent{}, fmt.Errorf("tag %q refers to unknown manifest id %q", tag, id) 151 } 152 _, err := r.PushManifest(ctx, repo, tag, mc.data, mc.desc.MediaType) 153 if err != nil { 154 return PushedRepoContent{}, fmt.Errorf("cannot push tag %q in repo %q: %v", id, repo, err) 155 } 156 } 157 return prc, nil 158 } 159 160 // PushContent pushes all the content in rc to r. 161 // 162 // It returns a map mapping repository name to the descriptors 163 // describing the content that has actually been pushed. 164 func (r Registry) MustPushContent(rc RegistryContent) map[string]PushedRepoContent { 165 prc, err := PushContent(r.R, rc) 166 qt.Assert(r.T, qt.IsNil(err)) 167 return prc 168 } 169 170 type manifestContent struct { 171 id string 172 data []byte 173 desc ociregistry.Descriptor 174 } 175 176 // completedManifests calculates the content of all the manifests and returns 177 // them all, keyed by id, and a partially ordered sequence suitable 178 // for pushing to a registry in bottom-up order. 179 func completedManifests(repoc RepoContent, blobs map[string]ociregistry.Descriptor) (map[string]manifestContent, []manifestContent, error) { 180 manifests := make(map[string]manifestContent) 181 manifestSeq := make([]manifestContent, 0, len(repoc.Manifests)) 182 // subject relationships can be arbitrarily deep, so continue iterating until 183 // all the levels are completed. If at any point we can't make progress, we 184 // know there's a problem and panic. 185 required := make(map[string]bool) 186 for { 187 madeProgress := false 188 needMore := false 189 need := func(digest ociregistry.Digest) { 190 needMore = true 191 if !required[string(digest)] { 192 required[string(digest)] = true 193 madeProgress = true 194 } 195 } 196 for id, m := range repoc.Manifests { 197 if _, ok := manifests[id]; ok { 198 continue 199 } 200 m1 := m 201 if m1.Subject != nil { 202 mc, ok := manifests[string(m1.Subject.Digest)] 203 if !ok { 204 need(m1.Subject.Digest) 205 continue 206 } 207 m1.Subject = ref(*m1.Subject) 208 *m1.Subject = mc.desc 209 madeProgress = true 210 } 211 m1 = fillManifestDescriptors(m1, blobs) 212 data, err := json.Marshal(m1) 213 if err != nil { 214 panic(err) 215 } 216 mc := manifestContent{ 217 id: id, 218 data: data, 219 desc: ociregistry.Descriptor{ 220 Digest: digest.FromBytes(data), 221 Size: int64(len(data)), 222 MediaType: m.MediaType, 223 }, 224 } 225 manifests[id] = mc 226 madeProgress = true 227 manifestSeq = append(manifestSeq, mc) 228 } 229 if !needMore { 230 return manifests, manifestSeq, nil 231 } 232 if !madeProgress { 233 for m := range required { 234 if _, ok := manifests[m]; ok { 235 delete(required, m) 236 } 237 } 238 return nil, nil, fmt.Errorf("no manifest found for ids %s", strings.Join(mapKeys(required), ", ")) 239 } 240 } 241 } 242 243 func fillManifestDescriptors(m ociregistry.Manifest, blobs map[string]ociregistry.Descriptor) ociregistry.Manifest { 244 m.Config = fillBlobDescriptor(m.Config, blobs) 245 m.Layers = append([]ociregistry.Descriptor(nil), m.Layers...) 246 for i, desc := range m.Layers { 247 m.Layers[i] = fillBlobDescriptor(desc, blobs) 248 } 249 return m 250 } 251 252 func fillBlobDescriptor(d ociregistry.Descriptor, blobs map[string]ociregistry.Descriptor) ociregistry.Descriptor { 253 blobDesc, ok := blobs[string(d.Digest)] 254 if !ok { 255 panic(fmt.Errorf("no blob found with id %q", d.Digest)) 256 } 257 d.Digest = blobDesc.Digest 258 d.Size = blobDesc.Size 259 if d.MediaType == "" { 260 d.MediaType = blobDesc.MediaType 261 } 262 return d 263 } 264 265 func (r Registry) MustPushBlob(repo string, data []byte) ociregistry.Descriptor { 266 desc := ociregistry.Descriptor{ 267 Digest: digest.FromBytes(data), 268 Size: int64(len(data)), 269 MediaType: "application/octet-stream", 270 } 271 desc1, err := r.R.PushBlob(context.Background(), repo, desc, bytes.NewReader(data)) 272 qt.Assert(r.T, qt.IsNil(err)) 273 return desc1 274 } 275 276 func (r Registry) MustPushManifest(repo string, jsonObject any, tag string) ([]byte, ociregistry.Descriptor) { 277 data, err := json.Marshal(jsonObject) 278 qt.Assert(r.T, qt.IsNil(err)) 279 var mt struct { 280 MediaType string `json:"mediaType,omitempty"` 281 } 282 err = json.Unmarshal(data, &mt) 283 qt.Assert(r.T, qt.IsNil(err)) 284 qt.Assert(r.T, qt.Not(qt.Equals(mt.MediaType, ""))) 285 desc := ociregistry.Descriptor{ 286 Digest: digest.FromBytes(data), 287 Size: int64(len(data)), 288 MediaType: mt.MediaType, 289 } 290 desc1, err := r.R.PushManifest(context.Background(), repo, tag, data, mt.MediaType) 291 qt.Assert(r.T, qt.IsNil(err)) 292 qt.Check(r.T, qt.Equals(desc1.Digest, desc.Digest)) 293 qt.Check(r.T, qt.Equals(desc1.Size, desc.Size)) 294 qt.Check(r.T, qt.Equals(desc1.MediaType, desc.MediaType)) 295 return data, desc1 296 } 297 298 type Repo struct { 299 T *testing.T 300 Name string 301 R ociregistry.Interface 302 } 303 304 // HasContent returns a checker that checks r matches the expected 305 // data and has the expected content type. If wantMediaType is 306 // empty, application/octet-stream will be expected. 307 func HasContent(r ociregistry.BlobReader, wantData []byte, wantMediaType string) qt.Checker { 308 if wantMediaType == "" { 309 wantMediaType = "application/octet-stream" 310 } 311 return contentChecker{ 312 r: r, 313 wantData: wantData, 314 wantMediaType: wantMediaType, 315 } 316 } 317 318 type contentChecker struct { 319 r ociregistry.BlobReader 320 wantData []byte 321 wantMediaType string 322 } 323 324 func (c contentChecker) Args() []qt.Arg { 325 return []qt.Arg{{ 326 Name: "reader", 327 Value: c.r, 328 }, { 329 Name: "data", 330 Value: c.wantData, 331 }, { 332 Name: "mediaType", 333 Value: c.wantMediaType, 334 }} 335 } 336 337 func (c contentChecker) Check(note func(key string, value any)) error { 338 desc := c.r.Descriptor() 339 gotData, err := io.ReadAll(c.r) 340 if err != nil { 341 return qt.BadCheckf("error reading data: %v", err) 342 } 343 if got, want := desc.Size, int64(len(c.wantData)); got != want { 344 note("actual data", gotData) 345 return fmt.Errorf("mismatched content length (got %d want %d)", got, want) 346 } 347 if got, want := desc.Digest, digest.FromBytes(c.wantData); got != want { 348 note("actual data", gotData) 349 return fmt.Errorf("mismatched digest (got %v want %v)", got, want) 350 } 351 if !bytes.Equal(gotData, c.wantData) { 352 note("actual data", gotData) 353 return fmt.Errorf("mismatched content") 354 } 355 if got, want := desc.MediaType, c.wantMediaType; got != want { 356 note("actual media type", desc.MediaType) 357 return fmt.Errorf("media type mismatch") 358 } 359 return nil 360 } 361 362 func ref[T any](x T) *T { 363 return &x 364 } 365 366 func mapKeys[V any](m map[string]V) []string { 367 keys := make([]string, 0, len(m)) 368 for k := range m { 369 keys = append(keys, k) 370 } 371 slices.Sort(keys) 372 return keys 373 }