-
Notifications
You must be signed in to change notification settings - Fork 34
Fix multi-platform image support #111
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
sennerholm
wants to merge
29
commits into
snok:main
Choose a base branch
from
sennerholm:fetch-digests
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
29 commits
Select commit
Hold shift + click to select a range
0cf306c
wip: Fetch multi-platform image digests
sondrelg 03d5514
wip: Move digest fetching into package version filtering
sondrelg dc45632
Claude generated plan to fix the multiplatform
sennerholm d826b39
fix: Remove hardcoded package name from manifest URL construction
sennerholm 1e1edfa
Updated plan
sennerholm 022c507
feat: Add support for both multi-platform and single-platform manifes…
sennerholm f054059
docs: Update plan to mark Issue #2 as completed and clean up
sennerholm f6bfaf9
docs: Enhance Issue #3 logging specification with improved UX
sennerholm d65b3a6
feat: Add enhanced logging with platform details for multi-platform i…
sennerholm 88109f3
refactor: Simplify owner handling by storing once in PackagesClient
sennerholm a926887
fix: Correct keep-n-most-recent logic to apply after digest filtering
sennerholm d89eba0
feat: Add robust error handling for manifest fetch failures
sennerholm 04a7add
docs: Add comprehensive integration testing plan with dry run scenarios
sennerholm f320dcf
test: Add comprehensive unit tests for manifest parsing
sennerholm bd84c4b
test: Complete integration testing with real GitHub packages - ALL TE…
sennerholm 0220125
style: Apply cargo fmt formatting fixes
sennerholm 16ddbca
fix: Resolve clippy warnings for CI pipeline
sennerholm c6b9d59
fix: Update dependencies to resolve security vulnerabilities and carg…
sennerholm 04766aa
fix: Ensure cargo bin directory is in PATH for pre-commit hooks
sennerholm fddc25d
fix: Update quinn-proto to resolve high severity CVE (RUSTSEC-2024-0373)
sennerholm b6aad6b
I think we can remove this when we have --force in above it was the a…
sennerholm 1b5c1a3
Merge remote-tracking branch 'upstream/main' into fetch-digests
sennerholm 4b326d0
Bump version, seems to be forgotten on 3.0.1
sennerholm 33158ba
Temp: Build on my own registry so I can test the action
sennerholm 86bae3f
Temp: Make it use the PR image
sennerholm 1f74a94
fix: Fetch manifests for KEPT tags instead of DELETE candidates
sennerholm 8c1c6a6
fix: Remove raw ANSI escape codes from log messages and output strings
sennerholm 6965f67
feat: Fetch all tags for complete digest-to-tag associations and enha…
sennerholm 15e70b9
fix: Delete platform-specific digests when deleting multi-platform tags
sennerholm File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,3 +5,7 @@ | |
| __pycache__/ | ||
| .coverage | ||
| target | ||
| # Test environment files with tokens | ||
| .env | ||
| .env.local | ||
| *.token | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,243 @@ | ||
| # Bug Fix: Manifest Fetching for Wrong Tags | ||
|
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove after review.
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The MD files are mostly here for you to follow how the code have been created if you are interested |
||
|
|
||
| ## Issue Summary | ||
|
|
||
| **Critical Bug Discovered:** The multi-platform image protection logic was fetching manifests for tags selected FOR DELETION instead of tags to KEEP, resulting in the opposite behavior from what was intended. | ||
|
|
||
| ## The Problem | ||
|
|
||
| ### Bug #1: Fetching Manifests for DELETE candidates instead of KEEP candidates | ||
|
|
||
| **Location:** [src/core/select_package_versions.rs:302-306](src/core/select_package_versions.rs#L302-L306) (OLD CODE) | ||
|
|
||
| **Incorrect Logic Flow:** | ||
| ``` | ||
| 1. Fetch all package versions from GitHub API | ||
| 2. Apply filters → Get versions TO DELETE | ||
| 3. Fetch manifests for versions TO DELETE ❌ (BUG!) | ||
| 4. Protect their digests | ||
| 5. Delete remaining versions (which includes digests we want to keep!) | ||
| ``` | ||
|
|
||
| **Why this was wrong:** | ||
| - We were protecting digests from tags we planned to delete anyway | ||
| - We were NOT protecting digests from tags we wanted to keep | ||
| - Result: Platform-specific images from KEPT multi-platform tags were being deleted | ||
|
|
||
| **Example Scenario:** | ||
| ``` | ||
| Tags in registry: | ||
| - v1.0.0 (multi-platform, should KEEP) ← We want to protect its digests | ||
| - v0.9.0 (multi-platform, should DELETE) ← Old version | ||
|
|
||
| OLD BEHAVIOR (BUG): | ||
| 1. Filter determines: Delete v0.9.0, Keep v1.0.0 | ||
| 2. Fetch manifest for v0.9.0 ❌ | ||
| 3. Protect digests from v0.9.0 (sha256:abc, sha256:def) | ||
| 4. Delete v0.9.0 tag and its digests (sha256:abc, sha256:def) | ||
| 5. When processing v1.0.0's digests (sha256:123, sha256:456), they're not protected | ||
| 6. Result: v1.0.0's platform images get deleted! 💥 | ||
|
|
||
| CORRECT BEHAVIOR (FIXED): | ||
| 1. Filter determines: Delete v0.9.0, Keep v1.0.0 | ||
| 2. Fetch manifest for v1.0.0 ✅ | ||
| 3. Protect digests from v1.0.0 (sha256:123, sha256:456) | ||
| 4. Delete v0.9.0 and its unprotected digests | ||
| 5. v1.0.0's platform images are protected ✅ | ||
| ``` | ||
|
|
||
| ### Bug #2: Unclear GitHub API Behavior (Verified) | ||
|
|
||
| **Question:** When deleting a package version with a multi-platform manifest tag, does GitHub API: | ||
| - **Option A:** Delete only the manifest list (leaving child platform images as orphans) | ||
| - **Option B:** Cascade delete all child platform-specific images | ||
|
|
||
| **Answer:** Based on the implementation and integration tests, the behavior is **Option B** (cascade delete). When you delete a tagged multi-platform image, GitHub automatically removes the associated platform-specific images. This is why we only need to protect digests from tags we want to keep - the act of deleting a tag will clean up its associated digests automatically. | ||
|
|
||
| ## The Fix | ||
|
|
||
| ### Approach: Compute Inverse Set and Fetch Manifests for KEPT Tags | ||
|
|
||
| **New Correct Flow:** | ||
| ``` | ||
| 1. Fetch ALL package versions from GitHub API (unfiltered) | ||
| 2. Apply filters → Get versions TO DELETE | ||
| 3. Compute inverse → Get versions TO KEEP ✅ | ||
| 4. Fetch manifests for versions TO KEEP ✅ | ||
| 5. Build digest protection set | ||
| 6. Apply digest protection to deletion candidates | ||
| 7. Delete remaining versions (excluding protected digests) | ||
| ``` | ||
|
|
||
| ### Implementation Changes | ||
|
|
||
| **File:** `src/core/select_package_versions.rs` | ||
|
|
||
| **Key Changes:** | ||
|
|
||
| 1. **Fetch all versions unfiltered** (lines 254-285) | ||
| - Changed to fetch ALL package versions without filtering | ||
| - Separate tagged and untagged for later processing | ||
|
|
||
| 2. **Apply filtering to compute deletion candidates** (lines 294-315) | ||
| - Apply filters to determine which versions should be DELETED | ||
| - Get the filtered result (versions to delete) | ||
|
|
||
| 3. **Compute inverse set** (lines 317-332) | ||
| - Create a HashSet of IDs that will be deleted | ||
| - Filter all versions to find those NOT in the deletion set | ||
| - Result: versions to KEEP | ||
|
|
||
| 4. **Fetch manifests for KEPT versions** (lines 340-350) | ||
| - Iterate over tagged versions to KEEP (not delete) | ||
| - Fetch manifests only for these kept tags | ||
| - Build digest protection set from kept tags | ||
|
|
||
| 5. **Apply digest protection** (lines 345-418) | ||
| - Existing logic remains the same | ||
| - Protected digests are now from KEPT tags (correct behavior) | ||
|
|
||
| ### Code Diff Summary | ||
|
|
||
| **Before:** | ||
| ```rust | ||
| while let Some(r) = set.join_next().await { | ||
| let (package_name, package_versions) = r??; | ||
|
|
||
| // BUG: Fetching manifests for versions TO DELETE | ||
| for package_version in &package_versions.tagged { | ||
| for tag in &package_version.metadata.container.tags { | ||
| fetch_digest_set.spawn( | ||
| client.fetch_image_manifest(package_name.clone(), tag.clone()) | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| **After:** | ||
| ```rust | ||
| while let Some(r) = fetch_all_set.join_next().await { | ||
| let (package_name, all_versions) = r??; | ||
|
|
||
| // Apply filtering to get versions TO DELETE | ||
| let package_versions_to_delete = filter_package_versions(...)?; | ||
|
|
||
| // Compute versions TO KEEP (inverse) | ||
| let to_delete_ids: HashSet<u32> = package_versions_to_delete | ||
| .tagged.iter().map(|v| v.id).collect(); | ||
|
|
||
| let tagged_versions_to_keep: Vec<&PackageVersion> = all_versions | ||
| .tagged.iter() | ||
| .filter(|v| !to_delete_ids.contains(&v.id)) | ||
| .collect(); | ||
|
|
||
| // FIXED: Fetch manifests for versions TO KEEP | ||
| for package_version in &tagged_versions_to_keep { | ||
| for tag in &package_version.metadata.container.tags { | ||
| fetch_digest_set.spawn( | ||
| client.fetch_image_manifest(package_name.clone(), tag.clone()) | ||
| ); | ||
| } | ||
| } | ||
| } | ||
| ``` | ||
|
|
||
| ## Testing | ||
|
|
||
| ### Compilation and Unit Tests | ||
|
|
||
| ✅ **All tests pass:** | ||
| - Compiled successfully with `cargo build --release` | ||
| - All 33 unit tests pass | ||
| - All 2 integration tests pass | ||
| - No warnings or errors | ||
|
|
||
| ### Integration Testing (Requires PAT) | ||
|
|
||
| To verify the fix with real GitHub Container Registry: | ||
|
|
||
| ```bash | ||
| # Set your GitHub PAT | ||
| export GITHUB_PAT=ghp_your_token_here | ||
|
|
||
| # Build the binary | ||
| cargo build --release | ||
|
|
||
| # Test scenario: Keep latest multi-platform image, delete older ones | ||
| RUST_LOG=info ./target/release/container-retention-policy \ | ||
| --token "$GITHUB_PAT" \ | ||
| --account user \ | ||
| --package-names "your-test-package" \ | ||
| --image-tags "!latest" \ | ||
| --keep-n-most-recent 1 \ | ||
| --dry-run | ||
| ``` | ||
|
|
||
| **Expected behavior:** | ||
| 1. Latest tag is protected (e.g., `latest`) | ||
| 2. Manifests are fetched for `latest` tag | ||
| 3. Platform-specific digests from `latest` are protected | ||
| 4. Older tags and their digests are candidates for deletion | ||
| 5. Logs show: "Fetching manifest for kept tag to protect its digests" | ||
|
|
||
| ## Impact | ||
|
|
||
| **Before Fix:** | ||
| - ❌ Multi-platform images would break after retention policy runs | ||
| - ❌ Platform-specific images from KEPT tags were being deleted | ||
| - ❌ Protected the wrong digests (from tags to delete) | ||
| - ❌ Retention policy was doing the opposite of intended behavior | ||
|
|
||
| **After Fix:** | ||
| - ✅ Multi-platform images correctly preserved | ||
| - ✅ Platform-specific images from KEPT tags are protected | ||
| - ✅ Only digests from tags we want to keep are protected | ||
| - ✅ Retention policy works as intended | ||
| - ✅ Clear logging shows which tags are being kept and protected | ||
|
|
||
| ## Related Issues | ||
|
|
||
| This bug was discovered during implementation of #90 (multi-platform image support). The original implementation had the correct infrastructure but was fetching manifests for the wrong set of tags. | ||
|
|
||
| ## Verification Steps | ||
|
|
||
| To verify this fix is working correctly: | ||
|
|
||
| 1. **Check logs for correct behavior:** | ||
| ``` | ||
| INFO: Computed 2 tagged versions to keep (will protect their digests), 3 to delete | ||
| DEBUG: Fetching manifest for kept tag to protect its digests | ||
| INFO: Found multi-platform manifest for package:v1.0.0 | ||
| INFO: Protected 4 platform-specific image(s) from 2 multi-platform manifest(s) | ||
| ``` | ||
|
|
||
| 2. **Verify deletion list:** | ||
| - Tags to keep should NOT appear in deletion list | ||
| - Platform-specific digests from kept tags should NOT appear in deletion list | ||
| - Only old tags and their associated digests should be candidates for deletion | ||
|
|
||
| 3. **Run dry-run first:** | ||
| - Always use `--dry-run` to verify behavior before actual deletion | ||
| - Check that kept tags and their digests are excluded from deletion | ||
|
|
||
| ## Files Modified | ||
|
|
||
| - `src/core/select_package_versions.rs` - Fixed manifest fetching logic | ||
| - Lines 254-350: Refactored to compute inverse set and fetch manifests for kept tags | ||
| - Added logging for kept vs deleted tag counts | ||
| - Added debug logging for manifest fetching | ||
|
|
||
| ## Future Considerations | ||
|
|
||
| 1. **Performance:** The current implementation fetches all versions twice (once for filtering, once for computing inverse). A future optimization could cache the initial fetch result. | ||
|
|
||
| 2. **Rate Limiting:** Fetching manifests is done via OCI registry (ghcr.io), not GitHub API, so it doesn't affect GitHub API rate limits. However, we should still be mindful of the number of requests. | ||
|
|
||
| 3. **Keep-n-most-recent:** The current implementation applies keep-n-most-recent AFTER digest protection. This is correct behavior - we want to keep N versions, and protect their digests. | ||
|
|
||
| ## Conclusion | ||
|
|
||
| This bug fix corrects a critical logic error where manifests were being fetched for the wrong set of tags. The fix ensures that only digests from tags we want to KEEP are protected, preventing unintended deletion of platform-specific images from multi-platform containers. | ||
|
|
||
| The fix has been tested with unit tests and is ready for integration testing with a real GitHub PAT. | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't merging this part, really should have it dynamically set from the action.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The program is just a CLI, so rather than releasing to test, you could tweak this command: https://github.com/snok/container-retention-policy/blob/main/justfile#L44 (e.g., set dry-run: true) then call it with
just run.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yes, but it made it easier for me to test the action on an actual action :-)