diff --git a/oci/layout/oci_transport.go b/oci/layout/oci_transport.go index 6586b84402..1a7c791262 100644 --- a/oci/layout/oci_transport.go +++ b/oci/layout/oci_transport.go @@ -204,6 +204,25 @@ func (ref ociReference) getManifestDescriptor() (imgspecv1.Descriptor, error) { return imgspecv1.Descriptor{}, ImageNotFoundError{ref} } +func (ref ociReference) getManifest(descriptor imgspecv1.Descriptor) (imgspecv1.Manifest, error) { + manifestPath, err := ref.blobPath(descriptor.Digest, "") + if err != nil { + return imgspecv1.Manifest{}, err + } + + manifestJSON, err := os.Open(manifestPath) + if err != nil { + return imgspecv1.Manifest{}, err + } + defer manifestJSON.Close() + + manifest := &imgspecv1.Manifest{} + if err := json.NewDecoder(manifestJSON).Decode(manifest); err != nil { + return imgspecv1.Manifest{}, err + } + return *manifest, nil +} + // LoadManifestDescriptor loads the manifest descriptor to be used to retrieve the image name // when pulling an image func LoadManifestDescriptor(imgRef types.ImageReference) (imgspecv1.Descriptor, error) { @@ -228,7 +247,75 @@ func (ref ociReference) NewImageDestination(ctx context.Context, sys *types.Syst // DeleteImage deletes the named image from the registry, if supported. func (ref ociReference) DeleteImage(ctx context.Context, sys *types.SystemContext) error { - return errors.New("Deleting images not implemented for oci: images") + + // Get the manifest for the image + manifestDescriptor, err := ref.getManifestDescriptor() + if err != nil { + return err + } + + manifest, err := ref.getManifest(manifestDescriptor) + if err != nil { + return err + } + + // Get all the layers used by all other images + index, err := ref.getIndex() + if err != nil { + return err + } + layersUsedByOtherImages := make(map[digest.Digest]bool, len(index.Manifests)) + for _, v := range index.Manifests { + if v.Digest != manifestDescriptor.Digest { + otherImageManifest, err := ref.getManifest(v) + if err != nil { + return err + } + for _, layer := range otherImageManifest.Layers { + layersUsedByOtherImages[layer.Digest] = true + } + } + } + + // Delete all blobs + blobsToDelete := make([]digest.Digest, 0, len(manifest.Layers)) + for _, layer := range manifest.Layers { + if !layersUsedByOtherImages[layer.Digest] { + blobsToDelete = append(blobsToDelete, layer.Digest) + } + } + for _, digest := range append(blobsToDelete, manifest.Config.Digest, manifestDescriptor.Digest) { + blobPath, err := ref.blobPath(digest, "") + if err != nil { + return err + } + err = os.Remove(blobPath) + if err != nil { + return err + } + } + + // Update the index + newManifests := make([]imgspecv1.Descriptor, 0, len(index.Manifests)-1) + for _, v := range index.Manifests { + if v.Digest != manifestDescriptor.Digest { + newManifests = append(newManifests, v) + } + } + index.Manifests = newManifests + + indexInfo, err := os.Stat(ref.indexPath()) + if err != nil { + return err + } + + indexJSON, err := os.OpenFile(ref.indexPath(), os.O_WRONLY|os.O_TRUNC, indexInfo.Mode()) + if err != nil { + return err + } + defer indexJSON.Close() + + return json.NewEncoder(indexJSON).Encode(index) } // ociLayoutPath returns a path for the oci-layout within a directory using OCI conventions. diff --git a/oci/layout/oci_transport_test.go b/oci/layout/oci_transport_test.go index 594e6dc3aa..eb699dd8fa 100644 --- a/oci/layout/oci_transport_test.go +++ b/oci/layout/oci_transport_test.go @@ -2,12 +2,15 @@ package layout import ( "context" + "encoding/json" "os" "path/filepath" "testing" _ "github.com/containers/image/v5/internal/testing/explicitfilepath-tmpdir" "github.com/containers/image/v5/types" + "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" imgspecv1 "github.com/opencontainers/image-spec/specs-go/v1" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -318,12 +321,279 @@ func TestReferenceNewImageDestination(t *testing.T) { defer dest.Close() } +type fakeImageSpec struct { + name string + manifest string + config string + layers []string +} + +func generateOciIndexAndContent(t *testing.T, images ...fakeImageSpec) string { + tmpDir := t.TempDir() + + // Create blobs dir - assumption: all the content is sha256 digest-ed + blobsDir := filepath.Join(tmpDir, "blobs", string(digest.SHA256)) + err := os.MkdirAll(blobsDir, 0777) + require.NoError(t, err) + + saveJson := func(path string, content any) { + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY, 0644) + require.NoError(t, err) + defer file.Close() + + err = json.NewEncoder(file).Encode(content) + require.NoError(t, err) + } + + indexManifests := make([]imgspecv1.Descriptor, 0, len(images)) + + for _, image := range images { + // Create the layers blobs + layers := make([]imgspecv1.Descriptor, 0, len(image.layers)) + + for _, layer := range image.layers { + layerDigest, err := digest.Parse(layer) + require.NoError(t, err) + path := filepath.Join(blobsDir, layerDigest.Hex()) + content := []byte("ABCDEF") + err = os.WriteFile(path, content, 0644) + require.NoError(t, err) + layers = append(layers, imgspecv1.Descriptor{ + MediaType: imgspecv1.MediaTypeImageLayerGzip, + Digest: layerDigest, + }) + } + + // Create the config blob + configDigest, err := digest.Parse(image.config) + require.NoError(t, err) + + config := imgspecv1.Descriptor{} + saveJson(filepath.Join(blobsDir, configDigest.Hex()), config) + + // Create the manifest blob + manifestDigest, err := digest.Parse(image.manifest) + require.NoError(t, err) + + manifest := imgspecv1.Manifest{ + Versioned: specs.Versioned{SchemaVersion: 2}, + MediaType: imgspecv1.MediaTypeImageManifest, + Config: imgspecv1.Descriptor{ + MediaType: imgspecv1.MediaTypeImageConfig, + Digest: configDigest, + Size: 10, + }, + Layers: layers, + } + + saveJson(filepath.Join(blobsDir, manifestDigest.Hex()), manifest) + + // Populate the index + indexManifests = append(indexManifests, imgspecv1.Descriptor{ + MediaType: imgspecv1.MediaTypeImageManifest, + Digest: manifestDigest, + Annotations: map[string]string{ + imgspecv1.AnnotationRefName: image.name, + }, + }) + } + + // Create the index + index := imgspecv1.Index{ + Versioned: specs.Versioned{SchemaVersion: 2}, + Manifests: indexManifests, + } + + saveJson(filepath.Join(tmpDir, "index.json"), index) + + return tmpDir +} + func TestReferenceDeleteImage(t *testing.T) { - ref, _ := refToTempOCI(t) - err := ref.DeleteImage(context.Background(), nil) + image := fakeImageSpec{ + name: "image-1:latest", + manifest: "sha256:7df521835a17f9308c7d89484c6f6c630f6d5ed7126df8485f0e6ec0ec1cc9bc", + config: "sha256:d0cc41b6cef5cc972a521ce9b81995c39533f2430df03cb62f44799b15d21217", + layers: []string{ + "sha256:ebfb402c523af279c1b58751b9c3c48d250906f4e57ef8af4fc0540e290281dc", + "sha256:f37ee95567a8ca93744f71634155b45ac405f509fa1e5d1a497b36a54619be56", + }, + } + + tmpDir := generateOciIndexAndContent(t, []fakeImageSpec{image}...) + + ref, err := NewReference(tmpDir, image.name) + require.NoError(t, err) + + err = ref.DeleteImage(context.Background(), nil) + require.NoError(t, err) + + // Check that all blobs were deleted + blobsDir := filepath.Join(tmpDir, "blobs") + blobDoesNotExist(t, blobsDir, image.manifest) + blobDoesNotExist(t, blobsDir, image.config) + for _, layer := range image.layers { + blobDoesNotExist(t, blobsDir, layer) + } + + // Check that the index doesn't contain the reference anymore + ociRef, ok := ref.(ociReference) + require.True(t, ok) + index, err := ociRef.getIndex() + require.NoError(t, err) + for _, v := range index.Manifests { + if v.Annotations[imgspecv1.AnnotationRefName] == ociRef.image { + assert.Fail(t, "image still present in the index after deletion") + } + } +} + +func TestReferenceDeleteImage_emptyImageName(t *testing.T) { + image := fakeImageSpec{ + name: "image-1:latest", + manifest: "sha256:7df521835a17f9308c7d89484c6f6c630f6d5ed7126df8485f0e6ec0ec1cc9bc", + config: "sha256:d0cc41b6cef5cc972a521ce9b81995c39533f2430df03cb62f44799b15d21217", + layers: []string{ + "sha256:ebfb402c523af279c1b58751b9c3c48d250906f4e57ef8af4fc0540e290281dc", + }, + } + + tmpDir := generateOciIndexAndContent(t, []fakeImageSpec{image}...) + + ref, err := NewReference(tmpDir, "") + require.NoError(t, err) + + err = ref.DeleteImage(context.Background(), nil) + require.NoError(t, err) + + // Check that all blobs were deleted + blobsDir := filepath.Join(tmpDir, "blobs") + blobDoesNotExist(t, blobsDir, image.manifest) + blobDoesNotExist(t, blobsDir, image.config) + for _, layer := range image.layers { + blobDoesNotExist(t, blobsDir, layer) + } + + // Check that the index doesn't contain the reference anymore + ociRef, ok := ref.(ociReference) + require.True(t, ok) + index, err := ociRef.getIndex() + require.NoError(t, err) + for _, v := range index.Manifests { + if v.Annotations[imgspecv1.AnnotationRefName] == image.name { + assert.Fail(t, "image still present in the index after deletion") + } + } +} + +func TestReferenceDeleteImage_emptyImageNameButMoreThanOneImageInIndex(t *testing.T) { + images := []fakeImageSpec{ + { + name: "image-1:latest", + manifest: "sha256:7df521835a17f9308c7d89484c6f6c630f6d5ed7126df8485f0e6ec0ec1cc9bc", + config: "sha256:d0cc41b6cef5cc972a521ce9b81995c39533f2430df03cb62f44799b15d21217", + layers: []string{ + "sha256:ebfb402c523af279c1b58751b9c3c48d250906f4e57ef8af4fc0540e290281dc", + }, + }, + { + name: "image-2:latest", + manifest: "sha256:b4679e9e04b749cab43f1534ae5c82a521b745c6346b90a7034ca72d3ed38beb", + config: "sha256:eb6bea08ad372676ed419b424557f517e9b5190b0af38614cb30548908fcf794", + layers: []string{ + "sha256:623802888f95381343c8511943d774b6ac96ff8710fc40b7b47845a50d038c2c", + }, + }, + } + + tmpDir := generateOciIndexAndContent(t, images...) + + ref, err := NewReference(tmpDir, "") + require.NoError(t, err) + + err = ref.DeleteImage(context.Background(), nil) + require.Error(t, err) +} + +func TestReferenceDeleteImage_imageDoesNotExist(t *testing.T) { + image := fakeImageSpec{ + name: "image-1:latest", + manifest: "sha256:7df521835a17f9308c7d89484c6f6c630f6d5ed7126df8485f0e6ec0ec1cc9bc", + config: "sha256:d0cc41b6cef5cc972a521ce9b81995c39533f2430df03cb62f44799b15d21217", + layers: []string{ + "sha256:ebfb402c523af279c1b58751b9c3c48d250906f4e57ef8af4fc0540e290281dc", + "sha256:f37ee95567a8ca93744f71634155b45ac405f509fa1e5d1a497b36a54619be56", + }, + } + + tmpDir := generateOciIndexAndContent(t, []fakeImageSpec{image}...) + + ref, err := NewReference(tmpDir, "does-not:exist") + assert.NoError(t, err) + + err = ref.DeleteImage(context.Background(), nil) assert.Error(t, err) } +func TestReferenceDeleteImage_someLayersAreReferencedByOtherImages(t *testing.T) { + const commonLayer = "sha256:bff18d814a6d85fb3ea9b1ee7271b831e204ff0bd88a17c4bfcf9a83ed07e8f8" + images := []fakeImageSpec{ + { + name: "image-1:latest", + manifest: "sha256:7df521835a17f9308c7d89484c6f6c630f6d5ed7126df8485f0e6ec0ec1cc9bc", + config: "sha256:d0cc41b6cef5cc972a521ce9b81995c39533f2430df03cb62f44799b15d21217", + layers: []string{ + "sha256:ebfb402c523af279c1b58751b9c3c48d250906f4e57ef8af4fc0540e290281dc", + commonLayer, + "sha256:f37ee95567a8ca93744f71634155b45ac405f509fa1e5d1a497b36a54619be56", + }, + }, + { + name: "image-2:latest", + manifest: "sha256:b4679e9e04b749cab43f1534ae5c82a521b745c6346b90a7034ca72d3ed38beb", + config: "sha256:eb6bea08ad372676ed419b424557f517e9b5190b0af38614cb30548908fcf794", + layers: []string{ + "sha256:623802888f95381343c8511943d774b6ac96ff8710fc40b7b47845a50d038c2c", + commonLayer, + "sha256:66a79b735a97ede9e52267492df7c4a7ee6e287113efe3e7def0c6f73f158589", + }, + }, + } + + tmpDir := generateOciIndexAndContent(t, images...) + + image := images[0] + ref, err := NewReference(tmpDir, image.name) + require.NoError(t, err) + + err = ref.DeleteImage(context.Background(), nil) + require.NoError(t, err) + + // Check that all relevant blobs were deleted + blobsDir := filepath.Join(tmpDir, "blobs") + blobDoesNotExist(t, blobsDir, image.manifest) + blobDoesNotExist(t, blobsDir, image.config) + blobDoesNotExist(t, blobsDir, image.layers[0]) + blobDoesNotExist(t, blobsDir, image.layers[2]) + + // Check that the blob used by another image was not deleted + commonBlobDigest, err := digest.Parse(commonLayer) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(blobsDir, commonBlobDigest.Algorithm().String(), commonBlobDigest.Hex())) + require.NoError(t, err) + + // Check that the index doesn't contain the reference anymore + ociRef, ok := ref.(ociReference) + require.True(t, ok) + index, err := ociRef.getIndex() + require.NoError(t, err) + for _, v := range index.Manifests { + if v.Annotations[imgspecv1.AnnotationRefName] == image.name { + assert.Fail(t, "image still present in the index after deletion") + } + } +} + func TestReferenceOCILayoutPath(t *testing.T) { ref, tmpDir := refToTempOCI(t) ociRef, ok := ref.(ociReference) @@ -369,3 +639,11 @@ func TestReferenceBlobPathInvalid(t *testing.T) { _, err := ociRef.blobPath(hex, "") assert.ErrorContains(t, err, "unexpected digest reference "+hex) } + +func blobDoesNotExist(t *testing.T, blobsDir string, blobDigest string) { + digest, err := digest.Parse(blobDigest) + require.NoError(t, err) + blobPath := filepath.Join(blobsDir, digest.Algorithm().String(), digest.Hex()) + _, err = os.Stat(blobPath) + require.True(t, os.IsNotExist(err)) +}