mirror of https://github.com/portainer/portainer
refactor(app/repository): migrate edit repository view to React [R8S-332] (#768)
parent
f4335e1e72
commit
c90a15dd0f
|
@ -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
|
||||
}
|
|
@ -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)
|
||||
})
|
||||
}
|
|
@ -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
|
||||
}
|
Loading…
Reference in New Issue