diff --git a/pkg/liboras/dummy_manifest.go b/pkg/liboras/dummy_manifest.go new file mode 100644 index 000000000..53576fac1 --- /dev/null +++ b/pkg/liboras/dummy_manifest.go @@ -0,0 +1,151 @@ +package liboras + +import ( + "bytes" + "context" + "fmt" + "time" + + "github.com/opencontainers/go-digest" + "github.com/opencontainers/image-spec/specs-go" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "github.com/rs/zerolog/log" + "github.com/segmentio/encoding/json" + "oras.land/oras-go/v2/registry/remote" +) + +// generateMinimalManifest creates a minimal OCI manifest with empty config and no layers +func generateMinimalManifest() (*ocispec.Manifest, []byte, error) { + // Create empty config blob + emptyConfig := []byte("{}") + configDescriptor := ocispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: digest.FromBytes(emptyConfig), // sha256 of empty JSON object "{}" + Size: int64(len(emptyConfig)), + } + + // Create minimal manifest with no layers + manifest := &ocispec.Manifest{ + Versioned: specs.Versioned{ + SchemaVersion: 2, + }, + MediaType: "application/vnd.oci.image.manifest.v1+json", + Config: configDescriptor, + Layers: []ocispec.Descriptor{}, // Empty layers array + } + + // Marshal manifest to JSON + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal dummy manifest: %w", err) + } + + return manifest, manifestBytes, nil +} + +// CreateDummyManifest creates a minimal dummy manifest in the repository and returns its digest +func CreateDummyManifest(registryClient *remote.Registry, repository string) (string, error) { + ctx := context.Background() + + // Get repository handle + repo, err := registryClient.Repository(ctx, repository) + if err != nil { + return "", fmt.Errorf("failed to get repository handle: %w", err) + } + + // Generate minimal manifest + manifest, manifestBytes, err := generateMinimalManifest() + if err != nil { + return "", fmt.Errorf("failed to generate minimal manifest: %w", err) + } + + // First, push the empty config blob + emptyConfig := []byte("{}") + configReader := bytes.NewReader(emptyConfig) + configDescriptor := ocispec.Descriptor{ + MediaType: "application/vnd.oci.image.config.v1+json", + Digest: digest.FromBytes(emptyConfig), + Size: int64(len(emptyConfig)), + } + + err = repo.Blobs().Push(ctx, configDescriptor, configReader) + if err != nil { + return "", fmt.Errorf("failed to push config blob: %w", err) + } + + // Then push the manifest with a temporary tag + manifestReader := bytes.NewReader(manifestBytes) + manifestDescriptor := ocispec.Descriptor{ + MediaType: manifest.MediaType, + Size: int64(len(manifestBytes)), + Digest: digest.FromBytes(manifestBytes), + } + + // Use a unique temporary tag name for the dummy manifest + dummyTag := fmt.Sprintf("__portainer_dummy_%d", time.Now().UnixNano()) + + err = repo.Manifests().PushReference(ctx, manifestDescriptor, manifestReader, dummyTag) + if err != nil { + return "", fmt.Errorf("failed to push dummy manifest: %w", err) + } + + // Return the manifest digest directly from what we calculated + return manifestDescriptor.Digest.String(), nil +} + +// PointTagToDummy updates a tag to point to the dummy manifest +func PointTagToDummy(registryClient *remote.Registry, repository, tagName, dummyDigest string) error { + // Generate the same minimal manifest content + _, manifestBytes, err := generateMinimalManifest() + if err != nil { + return fmt.Errorf("failed to generate minimal manifest: %w", err) + } + + return AddTagToManifest(registryClient, repository, tagName, dummyDigest, manifestBytes) +} + +// SafeDeleteTags safely deletes multiple tags without affecting others pointing to the same manifest +func SafeDeleteTags(registryClient *remote.Registry, repository string, tagsToDelete []string) error { + if len(tagsToDelete) == 0 { + return nil + } + + // Create a dummy manifest for all tags to delete + dummyDigest, err := CreateDummyManifest(registryClient, repository) + if err != nil { + return fmt.Errorf("failed to create dummy manifest: %w", err) + } + + // Point all tags to the same dummy manifest + for _, tagToDelete := range tagsToDelete { + err = PointTagToDummy(registryClient, repository, tagToDelete, dummyDigest) + if err != nil { + // Cleanup: delete dummy manifest on failure + cleanupErr := DeleteManifestByDigest(registryClient, repository, dummyDigest) + if cleanupErr != nil { + log.Warn(). + Err(cleanupErr). + Str("repository", repository). + Str("tag", tagToDelete). + Str("digest", dummyDigest). + Msg("Failed to cleanup dummy manifest after tag pointing error") + return fmt.Errorf("failed to point tag %s to dummy: %w (cleanup also failed: %w)", tagToDelete, err, cleanupErr) + } + return fmt.Errorf("failed to point tag %s to dummy: %w", tagToDelete, err) + } + } + + // Delete the dummy manifest (removes ALL pointed tags safely) + err = DeleteManifestByDigest(registryClient, repository, dummyDigest) + if err != nil { + log.Error(). + Err(err). + Str("repository", repository). + Str("digest", dummyDigest). + Int("tag_count", len(tagsToDelete)). + Msg("Failed to delete dummy manifest containing tags") + return fmt.Errorf("failed to delete dummy manifest: %w", err) + } + + return nil +} diff --git a/pkg/liboras/dummy_manifest_test.go b/pkg/liboras/dummy_manifest_test.go new file mode 100644 index 000000000..18ed69f78 --- /dev/null +++ b/pkg/liboras/dummy_manifest_test.go @@ -0,0 +1,170 @@ +package liboras + +import ( + "crypto/sha256" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "oras.land/oras-go/v2/registry/remote" +) + +func TestGenerateMinimalManifest(t *testing.T) { + t.Run("creates consistent manifest", func(t *testing.T) { + manifest1, bytes1, err1 := generateMinimalManifest() + require.NoError(t, err1) + require.NotNil(t, manifest1) + require.NotNil(t, bytes1) + + manifest2, bytes2, err2 := generateMinimalManifest() + require.NoError(t, err2) + require.NotNil(t, manifest2) + require.NotNil(t, bytes2) + + // Manifests should be identical + assert.Equal(t, manifest1, manifest2) + assert.Equal(t, bytes1, bytes2) + }) + + t.Run("has correct media type", func(t *testing.T) { + manifest, _, err := generateMinimalManifest() + require.NoError(t, err) + + expectedMediaType := "application/vnd.oci.image.manifest.v1+json" + assert.Equal(t, expectedMediaType, manifest.MediaType) + }) +} + +func TestSafeDeleteTags(t *testing.T) { + t.Run("handles empty tag list", func(t *testing.T) { + // Test the early return path - this should not call any registry operations + err := SafeDeleteTags(nil, "test-repo", []string{}) + require.NoError(t, err, "Empty tag list should return without error") + }) + + t.Run("handles nil tag list", func(t *testing.T) { + // Test the early return path - this should not call any registry operations + err := SafeDeleteTags(nil, "test-repo", nil) + require.NoError(t, err, "Nil tag list should return without error") + }) + + t.Run("single tag deletion", func(t *testing.T) { + // Track the actual HTTP requests made to verify correct method calls and arguments + var requests []string + var configBlobPushed bool + var manifestPushed bool + var tagUpdated bool + var manifestDeleted bool + var dummyManifestDigest string // Store the digest of the dummy manifest created + + // Create a mock registry server that handles the OCI registry API endpoints + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests = append(requests, fmt.Sprintf("%s %s", r.Method, r.URL.Path)) + + switch { + // Handle blob uploads (for dummy manifest config) + case strings.HasPrefix(r.URL.Path, "/v2/test-repo/blobs/uploads/") && r.Method == "POST": + w.Header().Set("Location", "/v2/test-repo/blobs/uploads/uuid") + w.WriteHeader(http.StatusAccepted) + + case strings.HasPrefix(r.URL.Path, "/v2/test-repo/blobs/uploads/") && r.Method == "PUT": + configBlobPushed = true + w.Header().Set("Docker-Content-Digest", "sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a") + w.WriteHeader(http.StatusCreated) + + // Handle blob existence check + case strings.HasPrefix(r.URL.Path, "/v2/test-repo/blobs/sha256:") && r.Method == "HEAD": + w.Header().Set("Content-Length", "2") + w.Header().Set("Docker-Content-Digest", r.URL.Path[len("/v2/test-repo/blobs/"):]) + w.WriteHeader(http.StatusOK) + + // Handle manifest operations + case strings.HasPrefix(r.URL.Path, "/v2/test-repo/manifests/"): + if r.Method == "PUT" { + // Read the manifest content to calculate the correct digest + body, err := io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + + // Calculate the SHA256 digest of the manifest content + hash := sha256.Sum256(body) + manifestDigest := fmt.Sprintf("sha256:%x", hash) + + if strings.Contains(r.URL.Path, "__portainer_dummy_") { + manifestPushed = true + dummyManifestDigest = manifestDigest // Store the dummy manifest digest + } else if strings.Contains(r.URL.Path, "v1.0.0") { + tagUpdated = true + } + w.Header().Set("Docker-Content-Digest", manifestDigest) + w.WriteHeader(http.StatusCreated) + + } else if r.Method == "DELETE" { + manifestDeleted = true + + // Extract and validate the digest being deleted + digestPath := strings.TrimPrefix(r.URL.Path, "/v2/test-repo/manifests/") + + // Verify that the digest being deleted matches the dummy manifest digest + if dummyManifestDigest != "" && digestPath != dummyManifestDigest { + t.Errorf("DELETE digest mismatch: expected %s, got %s", dummyManifestDigest, digestPath) + } + + w.WriteHeader(http.StatusAccepted) + + } else if r.Method == "GET" { + // Return a minimal manifest for GET requests + w.Header().Set("Content-Type", "application/vnd.oci.image.manifest.v1+json") + w.Write([]byte(`{"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.oci.image.config.v1+json","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[]}`)) + } + + // Handle repository operations + case r.URL.Path == "/v2/" && r.Method == "GET": + w.WriteHeader(http.StatusOK) + + default: + t.Logf("Unexpected request: %s %s", r.Method, r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer ts.Close() + + // Create a registry client pointing to our test server + registry, err := remote.NewRegistry(strings.TrimPrefix(ts.URL, "http://")) + require.NoError(t, err) + registry.PlainHTTP = true // Use HTTP for testing + + // Test SafeDeleteTags with a single tag + repository := "test-repo" + tagsToDelete := []string{"v1.0.0"} + + err = SafeDeleteTags(registry, repository, tagsToDelete) + require.NoError(t, err) + + // Verify the expected sequence of HTTP calls was made + assert.True(t, configBlobPushed, "Config blob should be pushed for dummy manifest") + assert.True(t, manifestPushed, "Dummy manifest should be pushed with temporary tag") + assert.True(t, tagUpdated, "Tag v1.0.0 should be updated to point to dummy manifest") + assert.True(t, manifestDeleted, "Dummy manifest should be deleted to remove the tag") + + // Verify that we captured the dummy manifest digest + assert.NotEmpty(t, dummyManifestDigest, "Dummy manifest digest should be captured") + assert.True(t, strings.HasPrefix(dummyManifestDigest, "sha256:"), "Dummy manifest digest should be a SHA256 digest") + + // Verify the correct repository was used in all calls + for _, req := range requests { + if strings.Contains(req, "/v2/") && !strings.Contains(req, "/v2/test-repo") && !strings.Contains(req, "/v2/") { + assert.Contains(t, req, "test-repo", "All repository operations should use correct repository name") + } + } + + t.Logf("HTTP requests made: %v", requests) + }) +} diff --git a/pkg/liboras/manifest.go b/pkg/liboras/manifest.go new file mode 100644 index 000000000..2d6938c37 --- /dev/null +++ b/pkg/liboras/manifest.go @@ -0,0 +1,68 @@ +package liboras + +import ( + "bytes" + "context" + "fmt" + + "github.com/opencontainers/go-digest" + ocispec "github.com/opencontainers/image-spec/specs-go/v1" + "oras.land/oras-go/v2/registry/remote" +) + +// DeleteManifestByDigest deletes a manifest by its digest +func DeleteManifestByDigest(registryClient *remote.Registry, repository, digestStr string) error { + ctx := context.Background() + + // Get repository handle + repo, err := registryClient.Repository(ctx, repository) + if err != nil { + return fmt.Errorf("failed to get repository handle: %w", err) + } + + // Delete the manifest by digest + manifestDigest, err := digest.Parse(digestStr) + if err != nil { + return fmt.Errorf("failed to parse digest: %w", err) + } + + err = repo.Manifests().Delete(ctx, ocispec.Descriptor{ + Digest: manifestDigest, + }) + if err != nil { + return fmt.Errorf("failed to delete manifest: %w", err) + } + + return nil +} + +// AddTagToManifest creates a new tag pointing to an existing manifest +func AddTagToManifest(registryClient *remote.Registry, repository, tagName, targetDigest string, manifestBytes []byte) error { + ctx := context.Background() + + // Get repository handle + repo, err := registryClient.Repository(ctx, repository) + if err != nil { + return fmt.Errorf("failed to get repository handle: %w", err) + } + + // Parse the target digest + parsedDigest, err := digest.Parse(targetDigest) + if err != nil { + return fmt.Errorf("failed to parse digest: %w", err) + } + + // Create descriptor for the manifest + manifestDescriptor := ocispec.Descriptor{ + MediaType: "application/vnd.oci.image.manifest.v1+json", + Size: int64(len(manifestBytes)), + Digest: parsedDigest, + } + + err = repo.Manifests().PushReference(ctx, manifestDescriptor, bytes.NewReader(manifestBytes), tagName) + if err != nil { + return fmt.Errorf("failed to tag manifest: %w", err) + } + + return nil +} \ No newline at end of file