The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .dockerignore
├── .editorconfig
├── .github
    ├── FUNDING.yml
    ├── ISSUE_TEMPLATE
    │   └── bug_report.md
    ├── images
    │   ├── logo-dark.svg
    │   └── logo-light.svg
    └── workflows
    │   ├── test-build.yaml
    │   └── test-shell.yaml
├── .gitignore
├── .goreleaser.yaml
├── .mise.toml
├── Dockerfile
├── Dockerfile.test
├── LICENSE
├── Makefile
├── README.md
├── cmd
    └── unregistry
    │   └── main.go
├── docker-pussh
├── go.mod
├── go.sum
├── internal
    ├── registry
    │   ├── config.go
    │   └── registry.go
    └── storage
    │   └── containerd
    │       ├── blob.go
    │       ├── blobwriter.go
    │       ├── manifest.go
    │       ├── middleware.go
    │       ├── registry.go
    │       ├── repository.go
    │       └── tags.go
├── misc
    └── dummy.go
├── scripts
    ├── dind-entrypoint.sh
    └── release-version.sh
└── test
    ├── conformance
        ├── .gitignore
        ├── 00_conformance_suite_test.go
        ├── 01_pull_test.go
        ├── 02_push_test.go
        ├── 03_discovery_test.go
        ├── 04_management_test.go
        ├── README.md
        ├── image.go
        ├── reporter.go
        ├── setup.go
        └── unregistry.go
    ├── e2e
        ├── images
        │   ├── busybox:1.36.0-musl_multi.tar
        │   ├── busybox:1.36.0-uclibc-arm64.tar
        │   ├── busybox:1.36.1-musl-amd64_oci.tar
        │   └── busybox:1.37.0-uclibc_multi_oci.tar
        └── registry_test.go
    ├── go.mod
    └── go.sum


/.dockerignore:
--------------------------------------------------------------------------------
 1 | # Ignore everything and use an allowlist of what is allowed to not package any secrets by accident.
 2 | *
 3 | 
 4 | # Allow files and directories.
 5 | !cmd/
 6 | !internal/
 7 | !scripts/
 8 | !go.*
 9 | 
10 | # Ignore unnecessary files inside allowed directories.
11 | **/.DS_Store
12 | 


--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
 1 | # EditorConfig is awesome: https://editorconfig.org
 2 | 
 3 | root = true
 4 | 
 5 | # Default settings for all files
 6 | [*]
 7 | charset = utf-8
 8 | end_of_line = lf
 9 | insert_final_newline = true
10 | trim_trailing_whitespace = true
11 | 
12 | # Settings for Go files
13 | [*.go]
14 | indent_style = tab
15 | indent_size = 4
16 | 
17 | # Settings for Bash scripts
18 | [{*.sh,docker-pussh}]
19 | indent_style = space
20 | indent_size = 4
21 | 


--------------------------------------------------------------------------------
/.github/FUNDING.yml:
--------------------------------------------------------------------------------
1 | github: [psviderski]
2 | 


--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
 1 | ---
 2 | name: Bug Report
 3 | about: Create a report to help us improve
 4 | title: "[BUG] "
 5 | labels: bug
 6 | assignees: ""
 7 | ---
 8 | 
 9 | **Describe the bug**
10 | 
11 | <!-- A clear and concise description of what the bug is. -->
12 | 
13 | **How to reproduce**
14 | 
15 | <!-- Steps to reproduce the behavior:
16 | 
17 | 1. Run ...
18 | 2. Do ...
19 | -->
20 | 
21 | **Expected behavior**
22 | 
23 | <!-- A clear and concise description of what you expected to happen. -->
24 | 
25 | **Environment:**
26 | 
27 | - Unregistry versions
28 |   - pussh version (output of `docker pussh --version`):
29 |   - unregistry (Docker image) version:
30 | - OS version: <!-- e.g. macOS 14.5 -->
31 | - Bash version (output of `bash --version`):
32 | - Output of `docker info`:
33 | - For macOS clients, how do you run docker: <!-- e.g. Docker Desktop or Colima, version 1.2.3 -->
34 | 
35 | **Additional context**
36 | 
37 | <!-- Add any other context about the problem here. -->
38 | 


--------------------------------------------------------------------------------
/.github/images/logo-dark.svg:
--------------------------------------------------------------------------------
1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="320" zoomAndPan="magnify" viewBox="0 0 240 66" height="88" preserveAspectRatio="xMidYMid meet" version="1.2"><defs><clipPath id="36585093fa"><path d="M 0 17.757812 L 39 17.757812 L 39 56.757812 L 0 56.757812 Z M 0 17.757812 "/></clipPath></defs><g id="a292c08d68"><g clip-rule="nonzero" clip-path="url(#36585093fa)"><path style=" stroke:none;fill-rule:nonzero;fill:#823bf8;fill-opacity:1;" d="M 4.84375 17.757812 L 33.910156 17.757812 C 34.226562 17.757812 34.542969 17.785156 34.855469 17.847656 C 35.167969 17.910156 35.46875 18.003906 35.761719 18.125 C 36.058594 18.246094 36.335938 18.394531 36.601562 18.574219 C 36.867188 18.75 37.109375 18.949219 37.335938 19.175781 C 37.558594 19.398438 37.761719 19.644531 37.9375 19.910156 C 38.113281 20.171875 38.261719 20.453125 38.386719 20.746094 C 38.507812 21.039062 38.597656 21.34375 38.660156 21.65625 C 38.722656 21.96875 38.753906 22.28125 38.753906 22.601562 L 38.753906 51.664062 C 38.753906 51.984375 38.722656 52.296875 38.660156 52.609375 C 38.597656 52.921875 38.507812 53.226562 38.386719 53.519531 C 38.261719 53.8125 38.113281 54.09375 37.9375 54.355469 C 37.761719 54.621094 37.558594 54.867188 37.335938 55.089844 C 37.109375 55.316406 36.867188 55.515625 36.601562 55.695312 C 36.335938 55.871094 36.058594 56.019531 35.761719 56.140625 C 35.46875 56.261719 35.167969 56.355469 34.855469 56.417969 C 34.542969 56.480469 34.226562 56.511719 33.910156 56.511719 L 4.84375 56.511719 C 4.527344 56.511719 4.210938 56.480469 3.898438 56.417969 C 3.585938 56.355469 3.285156 56.261719 2.992188 56.140625 C 2.695312 56.019531 2.417969 55.871094 2.152344 55.695312 C 1.886719 55.515625 1.644531 55.316406 1.417969 55.089844 C 1.195312 54.867188 0.992188 54.621094 0.816406 54.355469 C 0.640625 54.09375 0.492188 53.8125 0.367188 53.519531 C 0.246094 53.226562 0.15625 52.921875 0.09375 52.609375 C 0.03125 52.296875 0 51.984375 0 51.664062 L 0 22.601562 C 0 22.28125 0.03125 21.96875 0.09375 21.65625 C 0.15625 21.34375 0.246094 21.039062 0.367188 20.746094 C 0.492188 20.453125 0.640625 20.171875 0.816406 19.910156 C 0.992188 19.644531 1.195312 19.398438 1.417969 19.175781 C 1.644531 18.949219 1.886719 18.75 2.152344 18.574219 C 2.417969 18.394531 2.695312 18.246094 2.992188 18.125 C 3.285156 18.003906 3.585938 17.910156 3.898438 17.847656 C 4.210938 17.785156 4.527344 17.757812 4.84375 17.757812 Z M 4.84375 17.757812 "/></g><path style="fill:none;stroke-width:6;stroke-linecap:square;stroke-linejoin:miter;stroke:#ffffff;stroke-opacity:1;stroke-miterlimit:4;" d="M 19.997985 32.000607 L 19.997985 47.998995 L 35.996373 47.998995 L 35.996373 32.000607 M 60.002019 32.000607 L 45.00353 32.000607 L 45.00353 47.998995 " transform="matrix(0.484424,0,0,0.484424,-0.000000000000010658,17.755957)"/><g style="fill:#ffffff;fill-opacity:1;"><g transform="translate(46.125507, 48.519997)"><path style="stroke:none" d="M 14.078125 -19.875 C 14.078125 -20.332031 14.304688 -20.5625 14.765625 -20.5625 L 19.046875 -20.5625 C 19.492188 -20.5625 19.71875 -20.332031 19.71875 -19.875 L 19.71875 -0.6875 C 19.71875 -0.226562 19.492188 0 19.046875 0 L 14.765625 0 C 14.304688 0 14.078125 -0.226562 14.078125 -0.6875 L 14.078125 -1.765625 C 14.078125 -1.867188 14.039062 -1.925781 13.96875 -1.9375 C 13.90625 -1.945312 13.847656 -1.914062 13.796875 -1.84375 C 12.753906 -0.425781 11.113281 0.28125 8.875 0.28125 C 6.851562 0.28125 5.1875 -0.332031 3.875 -1.5625 C 2.570312 -2.789062 1.921875 -4.507812 1.921875 -6.71875 L 1.921875 -19.875 C 1.921875 -20.332031 2.144531 -20.5625 2.59375 -20.5625 L 6.84375 -20.5625 C 7.289062 -20.5625 7.515625 -20.332031 7.515625 -19.875 L 7.515625 -8.125 C 7.515625 -7.050781 7.800781 -6.179688 8.375 -5.515625 C 8.945312 -4.847656 9.742188 -4.515625 10.765625 -4.515625 C 11.671875 -4.515625 12.414062 -4.785156 13 -5.328125 C 13.582031 -5.878906 13.941406 -6.597656 14.078125 -7.484375 Z M 14.078125 -19.875 "/></g></g><g style="fill:#ffffff;fill-opacity:1;"><g transform="translate(67.923342, 48.519997)"><path style="stroke:none" d="M 12.796875 -20.875 C 14.929688 -20.875 16.648438 -20.21875 17.953125 -18.90625 C 19.265625 -17.601562 19.921875 -15.832031 19.921875 -13.59375 L 19.921875 -0.6875 C 19.921875 -0.226562 19.691406 0 19.234375 0 L 14.953125 0 C 14.503906 0 14.28125 -0.226562 14.28125 -0.6875 L 14.28125 -12.4375 C 14.28125 -13.507812 13.984375 -14.378906 13.390625 -15.046875 C 12.804688 -15.710938 12.023438 -16.046875 11.046875 -16.046875 C 10.054688 -16.046875 9.253906 -15.710938 8.640625 -15.046875 C 8.023438 -14.378906 7.71875 -13.507812 7.71875 -12.4375 L 7.71875 -0.6875 C 7.71875 -0.226562 7.492188 0 7.046875 0 L 2.765625 0 C 2.304688 0 2.078125 -0.226562 2.078125 -0.6875 L 2.078125 -19.875 C 2.078125 -20.332031 2.304688 -20.5625 2.765625 -20.5625 L 7.046875 -20.5625 C 7.492188 -20.5625 7.71875 -20.332031 7.71875 -19.875 L 7.71875 -18.765625 C 7.71875 -18.648438 7.742188 -18.578125 7.796875 -18.546875 C 7.847656 -18.523438 7.898438 -18.554688 7.953125 -18.640625 C 9.046875 -20.128906 10.660156 -20.875 12.796875 -20.875 Z M 12.796875 -20.875 "/></g></g><g style="fill:#823bf8;fill-opacity:1;"><g transform="translate(89.801286, 48.519997)"><path style="stroke:none" d="M 12.3125 -20.84375 C 13.164062 -20.84375 13.875 -20.679688 14.4375 -20.359375 C 14.757812 -20.203125 14.878906 -19.921875 14.796875 -19.515625 L 14.046875 -15.3125 C 14.015625 -15.070312 13.9375 -14.925781 13.8125 -14.875 C 13.695312 -14.820312 13.503906 -14.820312 13.234375 -14.875 C 12.804688 -14.988281 12.421875 -15.046875 12.078125 -15.046875 C 11.890625 -15.046875 11.582031 -15.015625 11.15625 -14.953125 C 10.195312 -14.878906 9.382812 -14.523438 8.71875 -13.890625 C 8.050781 -13.265625 7.71875 -12.4375 7.71875 -11.40625 L 7.71875 -0.6875 C 7.71875 -0.226562 7.492188 0 7.046875 0 L 2.765625 0 C 2.304688 0 2.078125 -0.226562 2.078125 -0.6875 L 2.078125 -19.875 C 2.078125 -20.332031 2.304688 -20.5625 2.765625 -20.5625 L 7.046875 -20.5625 C 7.492188 -20.5625 7.71875 -20.332031 7.71875 -19.875 L 7.71875 -18.640625 C 7.71875 -18.535156 7.742188 -18.46875 7.796875 -18.4375 C 7.847656 -18.414062 7.914062 -18.441406 8 -18.515625 C 9.0625 -20.066406 10.5 -20.84375 12.3125 -20.84375 Z M 12.3125 -20.84375 "/></g></g><g style="fill:#823bf8;fill-opacity:1;"><g transform="translate(104.599801, 48.519997)"><path style="stroke:none" d="M 12 -4.4375 C 13.757812 -4.519531 15.109375 -5.132812 16.046875 -6.28125 C 16.203125 -6.46875 16.375 -6.5625 16.5625 -6.5625 C 16.71875 -6.5625 16.863281 -6.492188 17 -6.359375 L 19.28125 -4.15625 C 19.4375 -4 19.515625 -3.84375 19.515625 -3.6875 C 19.515625 -3.5 19.460938 -3.335938 19.359375 -3.203125 C 18.484375 -2.109375 17.359375 -1.25 15.984375 -0.625 C 14.609375 0 13.109375 0.3125 11.484375 0.3125 C 9.023438 0.3125 6.988281 -0.3125 5.375 -1.5625 C 3.757812 -2.8125 2.675781 -4.53125 2.125 -6.71875 C 1.800781 -7.894531 1.640625 -9.132812 1.640625 -10.4375 C 1.640625 -11.988281 1.785156 -13.296875 2.078125 -14.359375 C 2.609375 -16.328125 3.679688 -17.910156 5.296875 -19.109375 C 6.910156 -20.316406 8.828125 -20.921875 11.046875 -20.921875 C 13.648438 -20.921875 15.71875 -20.144531 17.25 -18.59375 C 18.789062 -17.050781 19.785156 -14.878906 20.234375 -12.078125 C 20.347656 -11.203125 20.429688 -10.226562 20.484375 -9.15625 C 20.484375 -8.707031 20.253906 -8.484375 19.796875 -8.484375 L 7.515625 -8.484375 C 7.359375 -8.484375 7.28125 -8.398438 7.28125 -8.234375 C 7.363281 -7.679688 7.457031 -7.269531 7.5625 -7 C 7.820312 -6.175781 8.34375 -5.539062 9.125 -5.09375 C 9.914062 -4.65625 10.875 -4.4375 12 -4.4375 Z M 10.953125 -16.046875 C 10.109375 -16.046875 9.394531 -15.835938 8.8125 -15.421875 C 8.238281 -15.003906 7.847656 -14.425781 7.640625 -13.6875 C 7.503906 -13.175781 7.425781 -12.84375 7.40625 -12.6875 C 7.375 -12.519531 7.4375 -12.4375 7.59375 -12.4375 L 14.40625 -12.4375 C 14.53125 -12.4375 14.59375 -12.488281 14.59375 -12.59375 C 14.59375 -12.894531 14.539062 -13.203125 14.4375 -13.515625 C 14.195312 -14.296875 13.78125 -14.910156 13.1875 -15.359375 C 12.601562 -15.816406 11.859375 -16.046875 10.953125 -16.046875 Z M 10.953125 -16.046875 "/></g></g><g style="fill:#823bf8;fill-opacity:1;"><g transform="translate(126.477631, 48.519997)"><path style="stroke:none" d="M 14.640625 -19.875 C 14.640625 -20.332031 14.863281 -20.5625 15.3125 -20.5625 L 19.59375 -20.5625 C 20.050781 -20.5625 20.28125 -20.332031 20.28125 -19.875 L 20.28125 -1.84375 C 20.28125 1.65625 19.28125 4.148438 17.28125 5.640625 C 15.28125 7.128906 12.707031 7.875 9.5625 7.875 C 8.675781 7.875 7.675781 7.8125 6.5625 7.6875 C 6.15625 7.65625 5.953125 7.410156 5.953125 6.953125 L 6.125 3.234375 C 6.125 3.023438 6.195312 2.859375 6.34375 2.734375 C 6.488281 2.617188 6.664062 2.585938 6.875 2.640625 C 7.863281 2.773438 8.675781 2.84375 9.3125 2.84375 C 11.019531 2.84375 12.332031 2.46875 13.25 1.71875 C 14.175781 0.96875 14.640625 -0.226562 14.640625 -1.875 C 14.640625 -1.957031 14.613281 -2.003906 14.5625 -2.015625 C 14.507812 -2.035156 14.441406 -2.003906 14.359375 -1.921875 C 13.367188 -0.847656 11.898438 -0.3125 9.953125 -0.3125 C 8.222656 -0.3125 6.628906 -0.734375 5.171875 -1.578125 C 3.722656 -2.421875 2.691406 -3.773438 2.078125 -5.640625 C 1.679688 -6.867188 1.484375 -8.457031 1.484375 -10.40625 C 1.484375 -12.476562 1.722656 -14.171875 2.203125 -15.484375 C 2.765625 -17.109375 3.710938 -18.410156 5.046875 -19.390625 C 6.378906 -20.378906 7.925781 -20.875 9.6875 -20.875 C 11.6875 -20.875 13.242188 -20.273438 14.359375 -19.078125 C 14.441406 -18.992188 14.507812 -18.96875 14.5625 -19 C 14.613281 -19.03125 14.640625 -19.097656 14.640625 -19.203125 Z M 14.3125 -7.15625 C 14.53125 -7.957031 14.640625 -9.050781 14.640625 -10.4375 C 14.640625 -11.269531 14.613281 -11.910156 14.5625 -12.359375 C 14.507812 -12.816406 14.414062 -13.242188 14.28125 -13.640625 C 14.039062 -14.359375 13.640625 -14.9375 13.078125 -15.375 C 12.515625 -15.820312 11.820312 -16.046875 11 -16.046875 C 10.195312 -16.046875 9.515625 -15.820312 8.953125 -15.375 C 8.398438 -14.9375 7.988281 -14.359375 7.71875 -13.640625 C 7.34375 -12.835938 7.15625 -11.757812 7.15625 -10.40625 C 7.15625 -8.90625 7.316406 -7.835938 7.640625 -7.203125 C 7.878906 -6.484375 8.296875 -5.898438 8.890625 -5.453125 C 9.492188 -5.015625 10.210938 -4.796875 11.046875 -4.796875 C 11.890625 -4.796875 12.59375 -5.015625 13.15625 -5.453125 C 13.71875 -5.898438 14.101562 -6.46875 14.3125 -7.15625 Z M 14.3125 -7.15625 "/></g></g><g style="fill:#823bf8;fill-opacity:1;"><g transform="translate(148.715423, 48.519997)"><path style="stroke:none" d="M 5.203125 -22.515625 C 4.191406 -22.515625 3.363281 -22.828125 2.71875 -23.453125 C 2.082031 -24.085938 1.765625 -24.894531 1.765625 -25.875 C 1.765625 -26.863281 2.082031 -27.671875 2.71875 -28.296875 C 3.363281 -28.921875 4.191406 -29.234375 5.203125 -29.234375 C 6.242188 -29.234375 7.078125 -28.925781 7.703125 -28.3125 C 8.328125 -27.707031 8.640625 -26.894531 8.640625 -25.875 C 8.640625 -24.863281 8.328125 -24.050781 7.703125 -23.4375 C 7.078125 -22.820312 6.242188 -22.515625 5.203125 -22.515625 Z M 3.078125 0 C 2.628906 0 2.40625 -0.226562 2.40625 -0.6875 L 2.40625 -19.875 C 2.40625 -20.332031 2.628906 -20.5625 3.078125 -20.5625 L 7.359375 -20.5625 C 7.816406 -20.5625 8.046875 -20.332031 8.046875 -19.875 L 8.046875 -0.6875 C 8.046875 -0.226562 7.816406 0 7.359375 0 Z M 3.078125 0 "/></g></g><g style="fill:#823bf8;fill-opacity:1;"><g transform="translate(159.114394, 48.519997)"><path style="stroke:none" d="M 10.28125 0.28125 C 8.488281 0.28125 6.925781 0.03125 5.59375 -0.46875 C 4.257812 -0.976562 3.234375 -1.6875 2.515625 -2.59375 C 1.796875 -3.5 1.4375 -4.53125 1.4375 -5.6875 L 1.4375 -5.875 C 1.4375 -6.332031 1.664062 -6.5625 2.125 -6.5625 L 6.15625 -6.5625 C 6.613281 -6.5625 6.84375 -6.476562 6.84375 -6.3125 L 6.84375 -6.046875 C 6.84375 -5.453125 7.160156 -4.945312 7.796875 -4.53125 C 8.441406 -4.125 9.253906 -3.921875 10.234375 -3.921875 C 11.140625 -3.921875 11.875 -4.09375 12.4375 -4.4375 C 13 -4.78125 13.28125 -5.234375 13.28125 -5.796875 C 13.28125 -6.273438 13.039062 -6.632812 12.5625 -6.875 C 12.082031 -7.113281 11.296875 -7.367188 10.203125 -7.640625 C 8.890625 -7.984375 7.914062 -8.289062 7.28125 -8.5625 C 5.550781 -9.144531 4.175781 -9.863281 3.15625 -10.71875 C 2.144531 -11.570312 1.640625 -12.800781 1.640625 -14.40625 C 1.640625 -16.375 2.410156 -17.9375 3.953125 -19.09375 C 5.503906 -20.257812 7.535156 -20.84375 10.046875 -20.84375 C 11.742188 -20.84375 13.238281 -20.566406 14.53125 -20.015625 C 15.832031 -19.472656 16.832031 -18.707031 17.53125 -17.71875 C 18.238281 -16.726562 18.59375 -15.609375 18.59375 -14.359375 C 18.59375 -14.222656 18.53125 -14.109375 18.40625 -14.015625 C 18.289062 -13.921875 18.128906 -13.875 17.921875 -13.875 L 14 -13.875 C 13.539062 -13.875 13.3125 -13.957031 13.3125 -14.125 L 13.3125 -14.359375 C 13.3125 -14.941406 13.019531 -15.429688 12.4375 -15.828125 C 11.851562 -16.234375 11.066406 -16.4375 10.078125 -16.4375 C 9.203125 -16.4375 8.476562 -16.273438 7.90625 -15.953125 C 7.332031 -15.640625 7.046875 -15.203125 7.046875 -14.640625 C 7.046875 -14.109375 7.316406 -13.707031 7.859375 -13.4375 C 8.410156 -13.175781 9.3125 -12.894531 10.5625 -12.59375 C 11.332031 -12.4375 12.078125 -12.238281 12.796875 -12 C 14.722656 -11.4375 16.226562 -10.710938 17.3125 -9.828125 C 18.40625 -8.953125 18.953125 -7.675781 18.953125 -6 C 18.953125 -4.03125 18.164062 -2.488281 16.59375 -1.375 C 15.019531 -0.269531 12.914062 0.28125 10.28125 0.28125 Z M 10.28125 0.28125 "/></g></g><g style="fill:#823bf8;fill-opacity:1;"><g transform="translate(179.192401, 48.519997)"><path style="stroke:none" d="M 13.875 -16.71875 C 13.875 -16.269531 13.648438 -16.046875 13.203125 -16.046875 L 9.59375 -16.046875 C 9.4375 -16.046875 9.359375 -15.960938 9.359375 -15.796875 L 9.359375 -7.515625 C 9.359375 -6.640625 9.535156 -5.992188 9.890625 -5.578125 C 10.253906 -5.160156 10.835938 -4.953125 11.640625 -4.953125 L 12.765625 -4.953125 C 13.210938 -4.953125 13.4375 -4.726562 13.4375 -4.28125 L 13.4375 -0.84375 C 13.4375 -0.414062 13.210938 -0.175781 12.765625 -0.125 C 11.742188 -0.0703125 11.007812 -0.046875 10.5625 -0.046875 C 8.34375 -0.046875 6.6875 -0.414062 5.59375 -1.15625 C 4.5 -1.90625 3.941406 -3.289062 3.921875 -5.3125 L 3.921875 -15.796875 C 3.921875 -15.960938 3.84375 -16.046875 3.6875 -16.046875 L 1.640625 -16.046875 C 1.179688 -16.046875 0.953125 -16.269531 0.953125 -16.71875 L 0.953125 -19.875 C 0.953125 -20.332031 1.179688 -20.5625 1.640625 -20.5625 L 3.6875 -20.5625 C 3.84375 -20.5625 3.921875 -20.640625 3.921875 -20.796875 L 3.921875 -25.3125 C 3.921875 -25.769531 4.144531 -26 4.59375 -26 L 8.6875 -26 C 9.132812 -26 9.359375 -25.769531 9.359375 -25.3125 L 9.359375 -20.796875 C 9.359375 -20.640625 9.4375 -20.5625 9.59375 -20.5625 L 13.203125 -20.5625 C 13.648438 -20.5625 13.875 -20.332031 13.875 -19.875 Z M 13.875 -16.71875 "/></g></g><g style="fill:#823bf8;fill-opacity:1;"><g transform="translate(194.390886, 48.519997)"><path style="stroke:none" d="M 12.3125 -20.84375 C 13.164062 -20.84375 13.875 -20.679688 14.4375 -20.359375 C 14.757812 -20.203125 14.878906 -19.921875 14.796875 -19.515625 L 14.046875 -15.3125 C 14.015625 -15.070312 13.9375 -14.925781 13.8125 -14.875 C 13.695312 -14.820312 13.503906 -14.820312 13.234375 -14.875 C 12.804688 -14.988281 12.421875 -15.046875 12.078125 -15.046875 C 11.890625 -15.046875 11.582031 -15.015625 11.15625 -14.953125 C 10.195312 -14.878906 9.382812 -14.523438 8.71875 -13.890625 C 8.050781 -13.265625 7.71875 -12.4375 7.71875 -11.40625 L 7.71875 -0.6875 C 7.71875 -0.226562 7.492188 0 7.046875 0 L 2.765625 0 C 2.304688 0 2.078125 -0.226562 2.078125 -0.6875 L 2.078125 -19.875 C 2.078125 -20.332031 2.304688 -20.5625 2.765625 -20.5625 L 7.046875 -20.5625 C 7.492188 -20.5625 7.71875 -20.332031 7.71875 -19.875 L 7.71875 -18.640625 C 7.71875 -18.535156 7.742188 -18.46875 7.796875 -18.4375 C 7.847656 -18.414062 7.914062 -18.441406 8 -18.515625 C 9.0625 -20.066406 10.5 -20.84375 12.3125 -20.84375 Z M 12.3125 -20.84375 "/></g></g><g style="fill:#823bf8;fill-opacity:1;"><g transform="translate(210.149331, 48.519997)"><path style="stroke:none" d="M 1.953125 7.953125 C 1.773438 7.953125 1.6875 7.726562 1.6875 7.28125 L 1.6875 4 C 1.6875 3.539062 1.765625 3.3125 1.921875 3.3125 L 2.15625 3.3125 C 3.8125 3.3125 4.992188 3.101562 5.703125 2.6875 C 6.410156 2.28125 6.921875 1.4375 7.234375 0.15625 C 7.285156 0.0195312 7.285156 -0.0820312 7.234375 -0.15625 L 0.796875 -19.796875 C 0.773438 -19.847656 0.765625 -19.929688 0.765625 -20.046875 C 0.765625 -20.203125 0.816406 -20.328125 0.921875 -20.421875 C 1.023438 -20.515625 1.171875 -20.5625 1.359375 -20.5625 L 5.953125 -20.5625 C 6.328125 -20.5625 6.582031 -20.375 6.71875 -20 L 10 -8.078125 C 10.03125 -7.972656 10.082031 -7.921875 10.15625 -7.921875 C 10.238281 -7.921875 10.289062 -7.972656 10.3125 -8.078125 L 13.4375 -19.953125 C 13.519531 -20.359375 13.773438 -20.5625 14.203125 -20.5625 L 18.796875 -20.5625 C 19.035156 -20.5625 19.207031 -20.488281 19.3125 -20.34375 C 19.425781 -20.195312 19.441406 -20.015625 19.359375 -19.796875 L 12.515625 0.765625 C 11.847656 2.710938 11.164062 4.171875 10.46875 5.140625 C 9.78125 6.109375 8.8125 6.816406 7.5625 7.265625 C 6.3125 7.722656 4.535156 7.953125 2.234375 7.953125 Z M 1.953125 7.953125 "/></g></g></g></svg>


--------------------------------------------------------------------------------
/.github/images/logo-light.svg:
--------------------------------------------------------------------------------
1 | <svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="320" zoomAndPan="magnify" viewBox="0 0 240 66" height="88" preserveAspectRatio="xMidYMid meet" version="1.2"><defs><clipPath id="5012491012"><path d="M 0 17.757812 L 39 17.757812 L 39 56.757812 L 0 56.757812 Z M 0 17.757812 "/></clipPath></defs><g id="430e20ca6e"><g clip-rule="nonzero" clip-path="url(#5012491012)"><path style=" stroke:none;fill-rule:nonzero;fill:#521fa5;fill-opacity:1;" d="M 4.875 17.757812 L 34.125 17.757812 C 34.445312 17.757812 34.761719 17.789062 35.074219 17.851562 C 35.390625 17.910156 35.695312 18.003906 35.992188 18.128906 C 36.285156 18.25 36.566406 18.398438 36.832031 18.578125 C 37.097656 18.753906 37.347656 18.957031 37.570312 19.183594 C 37.796875 19.410156 38 19.65625 38.179688 19.921875 C 38.355469 20.1875 38.507812 20.46875 38.628906 20.765625 C 38.75 21.0625 38.84375 21.367188 38.90625 21.679688 C 38.96875 21.992188 39 22.3125 39 22.632812 L 39 51.882812 C 39 52.199219 38.96875 52.519531 38.90625 52.832031 C 38.84375 53.144531 38.75 53.449219 38.628906 53.746094 C 38.507812 54.042969 38.355469 54.324219 38.179688 54.589844 C 38 54.855469 37.796875 55.101562 37.570312 55.328125 C 37.347656 55.554688 37.097656 55.757812 36.832031 55.933594 C 36.566406 56.113281 36.285156 56.261719 35.992188 56.386719 C 35.695312 56.507812 35.390625 56.601562 35.074219 56.664062 C 34.761719 56.726562 34.445312 56.757812 34.125 56.757812 L 4.875 56.757812 C 4.554688 56.757812 4.238281 56.726562 3.925781 56.664062 C 3.609375 56.601562 3.304688 56.507812 3.007812 56.386719 C 2.714844 56.261719 2.433594 56.113281 2.167969 55.933594 C 1.902344 55.757812 1.652344 55.554688 1.429688 55.328125 C 1.203125 55.101562 1 54.855469 0.820312 54.589844 C 0.644531 54.324219 0.492188 54.042969 0.371094 53.746094 C 0.25 53.449219 0.15625 53.144531 0.09375 52.832031 C 0.03125 52.519531 0 52.199219 0 51.882812 L 0 22.632812 C 0 22.3125 0.03125 21.992188 0.09375 21.679688 C 0.15625 21.367188 0.25 21.0625 0.371094 20.765625 C 0.492188 20.46875 0.644531 20.1875 0.820312 19.921875 C 1 19.65625 1.203125 19.410156 1.429688 19.183594 C 1.652344 18.957031 1.902344 18.753906 2.167969 18.578125 C 2.433594 18.398438 2.714844 18.25 3.007812 18.128906 C 3.304688 18.003906 3.609375 17.910156 3.925781 17.851562 C 4.238281 17.789062 4.554688 17.757812 4.875 17.757812 Z M 4.875 17.757812 "/></g><path style="fill:none;stroke-width:6;stroke-linecap:square;stroke-linejoin:miter;stroke:#ffffff;stroke-opacity:1;stroke-miterlimit:4;" d="M 20.000001 31.999001 L 20.000001 48.000604 L 36.001604 48.000604 L 36.001604 31.999001 M 60.000003 31.999001 L 45.000002 31.999001 L 45.000002 48.000604 " transform="matrix(0.4875,0,0,0.4875,-0.000000000000010658,17.755957)"/><g style="fill:#18181b;fill-opacity:1;"><g transform="translate(46.125507, 48.519997)"><path style="stroke:none" d="M 14.078125 -19.875 C 14.078125 -20.332031 14.304688 -20.5625 14.765625 -20.5625 L 19.046875 -20.5625 C 19.492188 -20.5625 19.71875 -20.332031 19.71875 -19.875 L 19.71875 -0.6875 C 19.71875 -0.226562 19.492188 0 19.046875 0 L 14.765625 0 C 14.304688 0 14.078125 -0.226562 14.078125 -0.6875 L 14.078125 -1.765625 C 14.078125 -1.867188 14.039062 -1.925781 13.96875 -1.9375 C 13.90625 -1.945312 13.847656 -1.914062 13.796875 -1.84375 C 12.753906 -0.425781 11.113281 0.28125 8.875 0.28125 C 6.851562 0.28125 5.1875 -0.332031 3.875 -1.5625 C 2.570312 -2.789062 1.921875 -4.507812 1.921875 -6.71875 L 1.921875 -19.875 C 1.921875 -20.332031 2.144531 -20.5625 2.59375 -20.5625 L 6.84375 -20.5625 C 7.289062 -20.5625 7.515625 -20.332031 7.515625 -19.875 L 7.515625 -8.125 C 7.515625 -7.050781 7.800781 -6.179688 8.375 -5.515625 C 8.945312 -4.847656 9.742188 -4.515625 10.765625 -4.515625 C 11.671875 -4.515625 12.414062 -4.785156 13 -5.328125 C 13.582031 -5.878906 13.941406 -6.597656 14.078125 -7.484375 Z M 14.078125 -19.875 "/></g></g><g style="fill:#18181b;fill-opacity:1;"><g transform="translate(67.923342, 48.519997)"><path style="stroke:none" d="M 12.796875 -20.875 C 14.929688 -20.875 16.648438 -20.21875 17.953125 -18.90625 C 19.265625 -17.601562 19.921875 -15.832031 19.921875 -13.59375 L 19.921875 -0.6875 C 19.921875 -0.226562 19.691406 0 19.234375 0 L 14.953125 0 C 14.503906 0 14.28125 -0.226562 14.28125 -0.6875 L 14.28125 -12.4375 C 14.28125 -13.507812 13.984375 -14.378906 13.390625 -15.046875 C 12.804688 -15.710938 12.023438 -16.046875 11.046875 -16.046875 C 10.054688 -16.046875 9.253906 -15.710938 8.640625 -15.046875 C 8.023438 -14.378906 7.71875 -13.507812 7.71875 -12.4375 L 7.71875 -0.6875 C 7.71875 -0.226562 7.492188 0 7.046875 0 L 2.765625 0 C 2.304688 0 2.078125 -0.226562 2.078125 -0.6875 L 2.078125 -19.875 C 2.078125 -20.332031 2.304688 -20.5625 2.765625 -20.5625 L 7.046875 -20.5625 C 7.492188 -20.5625 7.71875 -20.332031 7.71875 -19.875 L 7.71875 -18.765625 C 7.71875 -18.648438 7.742188 -18.578125 7.796875 -18.546875 C 7.847656 -18.523438 7.898438 -18.554688 7.953125 -18.640625 C 9.046875 -20.128906 10.660156 -20.875 12.796875 -20.875 Z M 12.796875 -20.875 "/></g></g><g style="fill:#521fa5;fill-opacity:1;"><g transform="translate(89.801286, 48.519997)"><path style="stroke:none" d="M 12.3125 -20.84375 C 13.164062 -20.84375 13.875 -20.679688 14.4375 -20.359375 C 14.757812 -20.203125 14.878906 -19.921875 14.796875 -19.515625 L 14.046875 -15.3125 C 14.015625 -15.070312 13.9375 -14.925781 13.8125 -14.875 C 13.695312 -14.820312 13.503906 -14.820312 13.234375 -14.875 C 12.804688 -14.988281 12.421875 -15.046875 12.078125 -15.046875 C 11.890625 -15.046875 11.582031 -15.015625 11.15625 -14.953125 C 10.195312 -14.878906 9.382812 -14.523438 8.71875 -13.890625 C 8.050781 -13.265625 7.71875 -12.4375 7.71875 -11.40625 L 7.71875 -0.6875 C 7.71875 -0.226562 7.492188 0 7.046875 0 L 2.765625 0 C 2.304688 0 2.078125 -0.226562 2.078125 -0.6875 L 2.078125 -19.875 C 2.078125 -20.332031 2.304688 -20.5625 2.765625 -20.5625 L 7.046875 -20.5625 C 7.492188 -20.5625 7.71875 -20.332031 7.71875 -19.875 L 7.71875 -18.640625 C 7.71875 -18.535156 7.742188 -18.46875 7.796875 -18.4375 C 7.847656 -18.414062 7.914062 -18.441406 8 -18.515625 C 9.0625 -20.066406 10.5 -20.84375 12.3125 -20.84375 Z M 12.3125 -20.84375 "/></g></g><g style="fill:#521fa5;fill-opacity:1;"><g transform="translate(104.599801, 48.519997)"><path style="stroke:none" d="M 12 -4.4375 C 13.757812 -4.519531 15.109375 -5.132812 16.046875 -6.28125 C 16.203125 -6.46875 16.375 -6.5625 16.5625 -6.5625 C 16.71875 -6.5625 16.863281 -6.492188 17 -6.359375 L 19.28125 -4.15625 C 19.4375 -4 19.515625 -3.84375 19.515625 -3.6875 C 19.515625 -3.5 19.460938 -3.335938 19.359375 -3.203125 C 18.484375 -2.109375 17.359375 -1.25 15.984375 -0.625 C 14.609375 0 13.109375 0.3125 11.484375 0.3125 C 9.023438 0.3125 6.988281 -0.3125 5.375 -1.5625 C 3.757812 -2.8125 2.675781 -4.53125 2.125 -6.71875 C 1.800781 -7.894531 1.640625 -9.132812 1.640625 -10.4375 C 1.640625 -11.988281 1.785156 -13.296875 2.078125 -14.359375 C 2.609375 -16.328125 3.679688 -17.910156 5.296875 -19.109375 C 6.910156 -20.316406 8.828125 -20.921875 11.046875 -20.921875 C 13.648438 -20.921875 15.71875 -20.144531 17.25 -18.59375 C 18.789062 -17.050781 19.785156 -14.878906 20.234375 -12.078125 C 20.347656 -11.203125 20.429688 -10.226562 20.484375 -9.15625 C 20.484375 -8.707031 20.253906 -8.484375 19.796875 -8.484375 L 7.515625 -8.484375 C 7.359375 -8.484375 7.28125 -8.398438 7.28125 -8.234375 C 7.363281 -7.679688 7.457031 -7.269531 7.5625 -7 C 7.820312 -6.175781 8.34375 -5.539062 9.125 -5.09375 C 9.914062 -4.65625 10.875 -4.4375 12 -4.4375 Z M 10.953125 -16.046875 C 10.109375 -16.046875 9.394531 -15.835938 8.8125 -15.421875 C 8.238281 -15.003906 7.847656 -14.425781 7.640625 -13.6875 C 7.503906 -13.175781 7.425781 -12.84375 7.40625 -12.6875 C 7.375 -12.519531 7.4375 -12.4375 7.59375 -12.4375 L 14.40625 -12.4375 C 14.53125 -12.4375 14.59375 -12.488281 14.59375 -12.59375 C 14.59375 -12.894531 14.539062 -13.203125 14.4375 -13.515625 C 14.195312 -14.296875 13.78125 -14.910156 13.1875 -15.359375 C 12.601562 -15.816406 11.859375 -16.046875 10.953125 -16.046875 Z M 10.953125 -16.046875 "/></g></g><g style="fill:#521fa5;fill-opacity:1;"><g transform="translate(126.477631, 48.519997)"><path style="stroke:none" d="M 14.640625 -19.875 C 14.640625 -20.332031 14.863281 -20.5625 15.3125 -20.5625 L 19.59375 -20.5625 C 20.050781 -20.5625 20.28125 -20.332031 20.28125 -19.875 L 20.28125 -1.84375 C 20.28125 1.65625 19.28125 4.148438 17.28125 5.640625 C 15.28125 7.128906 12.707031 7.875 9.5625 7.875 C 8.675781 7.875 7.675781 7.8125 6.5625 7.6875 C 6.15625 7.65625 5.953125 7.410156 5.953125 6.953125 L 6.125 3.234375 C 6.125 3.023438 6.195312 2.859375 6.34375 2.734375 C 6.488281 2.617188 6.664062 2.585938 6.875 2.640625 C 7.863281 2.773438 8.675781 2.84375 9.3125 2.84375 C 11.019531 2.84375 12.332031 2.46875 13.25 1.71875 C 14.175781 0.96875 14.640625 -0.226562 14.640625 -1.875 C 14.640625 -1.957031 14.613281 -2.003906 14.5625 -2.015625 C 14.507812 -2.035156 14.441406 -2.003906 14.359375 -1.921875 C 13.367188 -0.847656 11.898438 -0.3125 9.953125 -0.3125 C 8.222656 -0.3125 6.628906 -0.734375 5.171875 -1.578125 C 3.722656 -2.421875 2.691406 -3.773438 2.078125 -5.640625 C 1.679688 -6.867188 1.484375 -8.457031 1.484375 -10.40625 C 1.484375 -12.476562 1.722656 -14.171875 2.203125 -15.484375 C 2.765625 -17.109375 3.710938 -18.410156 5.046875 -19.390625 C 6.378906 -20.378906 7.925781 -20.875 9.6875 -20.875 C 11.6875 -20.875 13.242188 -20.273438 14.359375 -19.078125 C 14.441406 -18.992188 14.507812 -18.96875 14.5625 -19 C 14.613281 -19.03125 14.640625 -19.097656 14.640625 -19.203125 Z M 14.3125 -7.15625 C 14.53125 -7.957031 14.640625 -9.050781 14.640625 -10.4375 C 14.640625 -11.269531 14.613281 -11.910156 14.5625 -12.359375 C 14.507812 -12.816406 14.414062 -13.242188 14.28125 -13.640625 C 14.039062 -14.359375 13.640625 -14.9375 13.078125 -15.375 C 12.515625 -15.820312 11.820312 -16.046875 11 -16.046875 C 10.195312 -16.046875 9.515625 -15.820312 8.953125 -15.375 C 8.398438 -14.9375 7.988281 -14.359375 7.71875 -13.640625 C 7.34375 -12.835938 7.15625 -11.757812 7.15625 -10.40625 C 7.15625 -8.90625 7.316406 -7.835938 7.640625 -7.203125 C 7.878906 -6.484375 8.296875 -5.898438 8.890625 -5.453125 C 9.492188 -5.015625 10.210938 -4.796875 11.046875 -4.796875 C 11.890625 -4.796875 12.59375 -5.015625 13.15625 -5.453125 C 13.71875 -5.898438 14.101562 -6.46875 14.3125 -7.15625 Z M 14.3125 -7.15625 "/></g></g><g style="fill:#521fa5;fill-opacity:1;"><g transform="translate(148.715423, 48.519997)"><path style="stroke:none" d="M 5.203125 -22.515625 C 4.191406 -22.515625 3.363281 -22.828125 2.71875 -23.453125 C 2.082031 -24.085938 1.765625 -24.894531 1.765625 -25.875 C 1.765625 -26.863281 2.082031 -27.671875 2.71875 -28.296875 C 3.363281 -28.921875 4.191406 -29.234375 5.203125 -29.234375 C 6.242188 -29.234375 7.078125 -28.925781 7.703125 -28.3125 C 8.328125 -27.707031 8.640625 -26.894531 8.640625 -25.875 C 8.640625 -24.863281 8.328125 -24.050781 7.703125 -23.4375 C 7.078125 -22.820312 6.242188 -22.515625 5.203125 -22.515625 Z M 3.078125 0 C 2.628906 0 2.40625 -0.226562 2.40625 -0.6875 L 2.40625 -19.875 C 2.40625 -20.332031 2.628906 -20.5625 3.078125 -20.5625 L 7.359375 -20.5625 C 7.816406 -20.5625 8.046875 -20.332031 8.046875 -19.875 L 8.046875 -0.6875 C 8.046875 -0.226562 7.816406 0 7.359375 0 Z M 3.078125 0 "/></g></g><g style="fill:#521fa5;fill-opacity:1;"><g transform="translate(159.114394, 48.519997)"><path style="stroke:none" d="M 10.28125 0.28125 C 8.488281 0.28125 6.925781 0.03125 5.59375 -0.46875 C 4.257812 -0.976562 3.234375 -1.6875 2.515625 -2.59375 C 1.796875 -3.5 1.4375 -4.53125 1.4375 -5.6875 L 1.4375 -5.875 C 1.4375 -6.332031 1.664062 -6.5625 2.125 -6.5625 L 6.15625 -6.5625 C 6.613281 -6.5625 6.84375 -6.476562 6.84375 -6.3125 L 6.84375 -6.046875 C 6.84375 -5.453125 7.160156 -4.945312 7.796875 -4.53125 C 8.441406 -4.125 9.253906 -3.921875 10.234375 -3.921875 C 11.140625 -3.921875 11.875 -4.09375 12.4375 -4.4375 C 13 -4.78125 13.28125 -5.234375 13.28125 -5.796875 C 13.28125 -6.273438 13.039062 -6.632812 12.5625 -6.875 C 12.082031 -7.113281 11.296875 -7.367188 10.203125 -7.640625 C 8.890625 -7.984375 7.914062 -8.289062 7.28125 -8.5625 C 5.550781 -9.144531 4.175781 -9.863281 3.15625 -10.71875 C 2.144531 -11.570312 1.640625 -12.800781 1.640625 -14.40625 C 1.640625 -16.375 2.410156 -17.9375 3.953125 -19.09375 C 5.503906 -20.257812 7.535156 -20.84375 10.046875 -20.84375 C 11.742188 -20.84375 13.238281 -20.566406 14.53125 -20.015625 C 15.832031 -19.472656 16.832031 -18.707031 17.53125 -17.71875 C 18.238281 -16.726562 18.59375 -15.609375 18.59375 -14.359375 C 18.59375 -14.222656 18.53125 -14.109375 18.40625 -14.015625 C 18.289062 -13.921875 18.128906 -13.875 17.921875 -13.875 L 14 -13.875 C 13.539062 -13.875 13.3125 -13.957031 13.3125 -14.125 L 13.3125 -14.359375 C 13.3125 -14.941406 13.019531 -15.429688 12.4375 -15.828125 C 11.851562 -16.234375 11.066406 -16.4375 10.078125 -16.4375 C 9.203125 -16.4375 8.476562 -16.273438 7.90625 -15.953125 C 7.332031 -15.640625 7.046875 -15.203125 7.046875 -14.640625 C 7.046875 -14.109375 7.316406 -13.707031 7.859375 -13.4375 C 8.410156 -13.175781 9.3125 -12.894531 10.5625 -12.59375 C 11.332031 -12.4375 12.078125 -12.238281 12.796875 -12 C 14.722656 -11.4375 16.226562 -10.710938 17.3125 -9.828125 C 18.40625 -8.953125 18.953125 -7.675781 18.953125 -6 C 18.953125 -4.03125 18.164062 -2.488281 16.59375 -1.375 C 15.019531 -0.269531 12.914062 0.28125 10.28125 0.28125 Z M 10.28125 0.28125 "/></g></g><g style="fill:#521fa5;fill-opacity:1;"><g transform="translate(179.192401, 48.519997)"><path style="stroke:none" d="M 13.875 -16.71875 C 13.875 -16.269531 13.648438 -16.046875 13.203125 -16.046875 L 9.59375 -16.046875 C 9.4375 -16.046875 9.359375 -15.960938 9.359375 -15.796875 L 9.359375 -7.515625 C 9.359375 -6.640625 9.535156 -5.992188 9.890625 -5.578125 C 10.253906 -5.160156 10.835938 -4.953125 11.640625 -4.953125 L 12.765625 -4.953125 C 13.210938 -4.953125 13.4375 -4.726562 13.4375 -4.28125 L 13.4375 -0.84375 C 13.4375 -0.414062 13.210938 -0.175781 12.765625 -0.125 C 11.742188 -0.0703125 11.007812 -0.046875 10.5625 -0.046875 C 8.34375 -0.046875 6.6875 -0.414062 5.59375 -1.15625 C 4.5 -1.90625 3.941406 -3.289062 3.921875 -5.3125 L 3.921875 -15.796875 C 3.921875 -15.960938 3.84375 -16.046875 3.6875 -16.046875 L 1.640625 -16.046875 C 1.179688 -16.046875 0.953125 -16.269531 0.953125 -16.71875 L 0.953125 -19.875 C 0.953125 -20.332031 1.179688 -20.5625 1.640625 -20.5625 L 3.6875 -20.5625 C 3.84375 -20.5625 3.921875 -20.640625 3.921875 -20.796875 L 3.921875 -25.3125 C 3.921875 -25.769531 4.144531 -26 4.59375 -26 L 8.6875 -26 C 9.132812 -26 9.359375 -25.769531 9.359375 -25.3125 L 9.359375 -20.796875 C 9.359375 -20.640625 9.4375 -20.5625 9.59375 -20.5625 L 13.203125 -20.5625 C 13.648438 -20.5625 13.875 -20.332031 13.875 -19.875 Z M 13.875 -16.71875 "/></g></g><g style="fill:#521fa5;fill-opacity:1;"><g transform="translate(194.390886, 48.519997)"><path style="stroke:none" d="M 12.3125 -20.84375 C 13.164062 -20.84375 13.875 -20.679688 14.4375 -20.359375 C 14.757812 -20.203125 14.878906 -19.921875 14.796875 -19.515625 L 14.046875 -15.3125 C 14.015625 -15.070312 13.9375 -14.925781 13.8125 -14.875 C 13.695312 -14.820312 13.503906 -14.820312 13.234375 -14.875 C 12.804688 -14.988281 12.421875 -15.046875 12.078125 -15.046875 C 11.890625 -15.046875 11.582031 -15.015625 11.15625 -14.953125 C 10.195312 -14.878906 9.382812 -14.523438 8.71875 -13.890625 C 8.050781 -13.265625 7.71875 -12.4375 7.71875 -11.40625 L 7.71875 -0.6875 C 7.71875 -0.226562 7.492188 0 7.046875 0 L 2.765625 0 C 2.304688 0 2.078125 -0.226562 2.078125 -0.6875 L 2.078125 -19.875 C 2.078125 -20.332031 2.304688 -20.5625 2.765625 -20.5625 L 7.046875 -20.5625 C 7.492188 -20.5625 7.71875 -20.332031 7.71875 -19.875 L 7.71875 -18.640625 C 7.71875 -18.535156 7.742188 -18.46875 7.796875 -18.4375 C 7.847656 -18.414062 7.914062 -18.441406 8 -18.515625 C 9.0625 -20.066406 10.5 -20.84375 12.3125 -20.84375 Z M 12.3125 -20.84375 "/></g></g><g style="fill:#521fa5;fill-opacity:1;"><g transform="translate(210.149331, 48.519997)"><path style="stroke:none" d="M 1.953125 7.953125 C 1.773438 7.953125 1.6875 7.726562 1.6875 7.28125 L 1.6875 4 C 1.6875 3.539062 1.765625 3.3125 1.921875 3.3125 L 2.15625 3.3125 C 3.8125 3.3125 4.992188 3.101562 5.703125 2.6875 C 6.410156 2.28125 6.921875 1.4375 7.234375 0.15625 C 7.285156 0.0195312 7.285156 -0.0820312 7.234375 -0.15625 L 0.796875 -19.796875 C 0.773438 -19.847656 0.765625 -19.929688 0.765625 -20.046875 C 0.765625 -20.203125 0.816406 -20.328125 0.921875 -20.421875 C 1.023438 -20.515625 1.171875 -20.5625 1.359375 -20.5625 L 5.953125 -20.5625 C 6.328125 -20.5625 6.582031 -20.375 6.71875 -20 L 10 -8.078125 C 10.03125 -7.972656 10.082031 -7.921875 10.15625 -7.921875 C 10.238281 -7.921875 10.289062 -7.972656 10.3125 -8.078125 L 13.4375 -19.953125 C 13.519531 -20.359375 13.773438 -20.5625 14.203125 -20.5625 L 18.796875 -20.5625 C 19.035156 -20.5625 19.207031 -20.488281 19.3125 -20.34375 C 19.425781 -20.195312 19.441406 -20.015625 19.359375 -19.796875 L 12.515625 0.765625 C 11.847656 2.710938 11.164062 4.171875 10.46875 5.140625 C 9.78125 6.109375 8.8125 6.816406 7.5625 7.265625 C 6.3125 7.722656 4.535156 7.953125 2.234375 7.953125 Z M 1.953125 7.953125 "/></g></g></g></svg>


--------------------------------------------------------------------------------
/.github/workflows/test-build.yaml:
--------------------------------------------------------------------------------
 1 | name: Test and build
 2 | 
 3 | on:
 4 |   push:
 5 |     branches:
 6 |       - main
 7 |     tags:
 8 |       - v*
 9 |   pull_request:
10 |     branches:
11 |       - main
12 |     paths:
13 |       - "!docker-pussh"
14 |       - "!**.md"
15 | 
16 | jobs:
17 |   test_build:
18 |     runs-on: ubuntu-latest
19 |     timeout-minutes: 20
20 |     permissions:
21 |       contents: read
22 |       packages: write
23 |     steps:
24 |       - name: Checkout code
25 |         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
26 | 
27 |       - name: Set up Go
28 |         uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0
29 |         with:
30 |           go-version: "1.24"
31 | 
32 |       - name: Install Go test dependencies
33 |         run: go mod tidy
34 |         working-directory: test
35 | 
36 |       - name: Conformance and e2e tests
37 |         run: go test -v ./...
38 |         working-directory: test
39 | 
40 |       # Build and push Docker image for tagged releases.
41 |       - name: Set up QEMU
42 |         if: ${{ startsWith(github.ref, 'refs/tags/') }}
43 |         uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392 # v3.6.0
44 | 
45 |       - name: set up Docker Buildx
46 |         if: ${{ startsWith(github.ref, 'refs/tags/') }}
47 |         uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 # v3.10.0
48 | 
49 |       - name: Login to GitHub Container Registry
50 |         if: ${{ startsWith(github.ref, 'refs/tags/') }}
51 |         uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3.4.0
52 |         with:
53 |           registry: ghcr.io
54 |           username: ${{ github.actor }}
55 |           password: ${{ secrets.GITHUB_TOKEN }}
56 | 
57 |       - name: Docker meta
58 |         if: ${{ startsWith(github.ref, 'refs/tags/') }}
59 |         id: meta
60 |         uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 # v5.7.0
61 |         with:
62 |           images: ghcr.io/psviderski/unregistry
63 |           # latest tag is set automatically by the default flavor: latest=auto behaviour.
64 |           tags: |
65 |             type=semver,pattern={{version}}
66 | 
67 |       - name: Build and push Docker image (tagged and latest) to GitHub Container Registry
68 |         if: ${{ startsWith(github.ref, 'refs/tags/') }}
69 |         uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
70 |         with:
71 |           platforms: linux/amd64,linux/arm/v7,linux/arm64
72 |           push: true
73 |           tags: ${{ steps.meta.outputs.tags }}
74 | 


--------------------------------------------------------------------------------
/.github/workflows/test-shell.yaml:
--------------------------------------------------------------------------------
 1 | name: Check shell scripts
 2 | 
 3 | on:
 4 |   push:
 5 |     branches:
 6 |       - main
 7 |       - test/**
 8 |       - release/**
 9 |     tags:
10 |       - v*
11 |   pull_request:
12 |     branches:
13 |       - main
14 |     paths:
15 |       - "docker-pussh"
16 |       - "**.sh"
17 | 
18 | jobs:
19 |   shellcheck:
20 |     name: Run Shellcheck
21 |     runs-on: ubuntu-latest
22 |     timeout-minutes: 10
23 |     steps:
24 |       - name: Checkout code
25 |         uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
26 | 
27 |       - name: Install Shellcheck via mise
28 |         uses: jdx/mise-action@13abe502c30c1559a5c37dff303831bab82c9402 # v2.2.3
29 |         with:
30 |           version: "2025.6.5"
31 |           install_args: "shellcheck"
32 |         env:
33 |           GITHUB_TOKEN: ${{ github.token }}
34 | 
35 |       - name: Find and lint bash scripts
36 |         run: |
37 |           make shellcheck
38 | 


--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
 1 | # If you prefer the allow list template instead of the deny list, see community template:
 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
 3 | #
 4 | # Binaries for programs and plugins
 5 | *.exe
 6 | *.exe~
 7 | *.dll
 8 | *.so
 9 | *.dylib
10 | 
11 | # Test binary, built with `go test -c`
12 | *.test
13 | 
14 | # Code coverage profiles and other test artifacts
15 | *.out
16 | coverage.*
17 | *.coverprofile
18 | profile.cov
19 | 
20 | # Dependency directories (remove the comment below to include it)
21 | # vendor/
22 | 
23 | # Go workspace file
24 | go.work
25 | go.work.sum
26 | 
27 | # env file
28 | .env
29 | 
30 | # Editor/IDE
31 | .idea/
32 | # .vscode/
33 | 
34 | # GoReleaser
35 | dist/
36 | 
37 | # Misc
38 | tmp/
39 | 


--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json
 2 | # vim: set ts=2 sw=2 tw=0 fo=cnqoj
 3 | version: 2
 4 | 
 5 | builds:
 6 |   # Build a dummy binary for every platform/os combination, so that
 7 |   # GoReleaser does not complain about missing binaries when releasing homebrew casks.
 8 |   - id: dummy
 9 |     main: ./misc/dummy.go
10 |     env:
11 |       - CGO_ENABLED=0
12 |     binary: _empty
13 |     goos:
14 |       - linux
15 |       - darwin
16 |     goarch:
17 |       - amd64
18 |       - arm64
19 |     hooks:
20 |       post:
21 |         # Clear the file
22 |         - bash -c "> {{ .Path }}"
23 | 
24 | archives:
25 |   - id: script
26 |     files:
27 |       - ./docker-pussh
28 | 
29 | changelog:
30 |   sort: asc
31 |   filters:
32 |     exclude:
33 |       - "^docs:"
34 |       - "^test:"
35 |       - "^release:"
36 | 
37 | homebrew_casks:
38 |   - name: docker-pussh
39 |     ids: [script]
40 |     binary: docker-pussh
41 |     repository:
42 |       owner: psviderski
43 |       name: homebrew-tap
44 |     homepage: https://github.com/psviderski/unregistry
45 |     description: "Upload Docker images to remote servers via SSH without an external registry."
46 |     custom_block: |
47 |       name "docker-pussh"
48 | 
49 |       caveats do
50 |         <<~EOS
51 |           To use docker-pussh as a Docker CLI plugin ('docker pussh' command) you need to create a symlink:
52 | 
53 |             mkdir -p ~/.docker/cli-plugins
54 |             ln -sf #{HOMEBREW_PREFIX}/bin/docker-pussh ~/.docker/cli-plugins/docker-pussh
55 | 
56 |           After installation, you can use it with:
57 |             docker pussh [OPTIONS] IMAGE[:TAG] [USER@]HOST[:PORT]
58 | 
59 |           To uninstall the plugin:
60 |             rm ~/.docker/cli-plugins/docker-pussh
61 |         EOS
62 |       end
63 | 


--------------------------------------------------------------------------------
/.mise.toml:
--------------------------------------------------------------------------------
1 | [tools]
2 | go = "1.24"
3 | goreleaser = "2.10.2"
4 | perl = "5.40.2"
5 | shellcheck = "0.10.0"
6 | 
7 | [env]
8 | _.file = ".env"
9 | 


--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
 1 | ARG ALPINE_VERSION=3.22.0
 2 | 
 3 | # Cross-compile unregistry for multiple architectures to speed up the build process on GitHub Actions.
 4 | FROM --platform=$BUILDPLATFORM golang:1.24-alpine AS builder
 5 | ARG TARGETOS
 6 | ARG TARGETARCH
 7 | 
 8 | WORKDIR /build
 9 | 
10 | # Download and cache dependencies and only redownload them in subsequent builds if they change.
11 | COPY go.mod go.sum ./
12 | RUN go mod download && go mod verify
13 | 
14 | COPY . .
15 | RUN GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o unregistry ./cmd/unregistry
16 | 
17 | 
18 | # Create a minimal image with the static binary built in the builder stage.
19 | FROM alpine:${ALPINE_VERSION}
20 | 
21 | COPY --from=builder /build/unregistry /usr/local/bin/
22 | 
23 | EXPOSE 5000
24 | # Run as root user by default to allow access to the containerd socket. This in unfortunate as running as non-root user
25 | # requires changing the containerd socket permissions which still can be manually done by advanced users.
26 | ENTRYPOINT ["unregistry"]
27 | 
28 | 


--------------------------------------------------------------------------------
/Dockerfile.test:
--------------------------------------------------------------------------------
 1 | # This is a separate Dockerfile for building the unregistry image for e2e tests using Docker-in-Docker (DinD).
 2 | # testcontainers uses legacy build process without BuildKit, so it doesn't pass BUILDPLATFORM as build argument.
 3 | # As a result, it fails if a Dockerfile contains a stage with `FROM --platform=$BUILDPLATFORM ...` directive.
 4 | 
 5 | # Native build of unregistry for the Docker-in-Docker image. I couldn't make testcontainers to pass BUILDPLATFORM
 6 | # to builder-cross, so this is a workaround for e2e tests.
 7 | FROM golang:1.24-alpine AS builder
 8 | 
 9 | WORKDIR /build
10 | 
11 | # Download and cache dependencies and only redownload them in subsequent builds if they change.
12 | COPY go.mod go.sum ./
13 | RUN go mod download && go mod verify
14 | 
15 | COPY . .
16 | RUN go build -o unregistry ./cmd/unregistry
17 | 
18 | 
19 | # Unregistry in Docker-in-Docker image for e2e tests.
20 | FROM docker:28.2.2-dind AS unregistry-dind
21 | 
22 | ENV UNREGISTRY_CONTAINERD_SOCK="/run/docker/containerd/containerd.sock"
23 | 
24 | COPY scripts/dind-entrypoint.sh /usr/local/bin/entrypoint.sh
25 | COPY --from=builder /build/unregistry /usr/local/bin/
26 | 
27 | EXPOSE 5000
28 | ENTRYPOINT ["entrypoint.sh"]
29 | CMD ["unregistry"]
30 | 


--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
  1 |                                  Apache License
  2 |                            Version 2.0, January 2004
  3 |                         http://www.apache.org/licenses/
  4 | 
  5 |    TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
  6 | 
  7 |    1. Definitions.
  8 | 
  9 |       "License" shall mean the terms and conditions for use, reproduction,
 10 |       and distribution as defined by Sections 1 through 9 of this document.
 11 | 
 12 |       "Licensor" shall mean the copyright owner or entity authorized by
 13 |       the copyright owner that is granting the License.
 14 | 
 15 |       "Legal Entity" shall mean the union of the acting entity and all
 16 |       other entities that control, are controlled by, or are under common
 17 |       control with that entity. For the purposes of this definition,
 18 |       "control" means (i) the power, direct or indirect, to cause the
 19 |       direction or management of such entity, whether by contract or
 20 |       otherwise, or (ii) ownership of fifty percent (50%) or more of the
 21 |       outstanding shares, or (iii) beneficial ownership of such entity.
 22 | 
 23 |       "You" (or "Your") shall mean an individual or Legal Entity
 24 |       exercising permissions granted by this License.
 25 | 
 26 |       "Source" form shall mean the preferred form for making modifications,
 27 |       including but not limited to software source code, documentation
 28 |       source, and configuration files.
 29 | 
 30 |       "Object" form shall mean any form resulting from mechanical
 31 |       transformation or translation of a Source form, including but
 32 |       not limited to compiled object code, generated documentation,
 33 |       and conversions to other media types.
 34 | 
 35 |       "Work" shall mean the work of authorship, whether in Source or
 36 |       Object form, made available under the License, as indicated by a
 37 |       copyright notice that is included in or attached to the work
 38 |       (an example is provided in the Appendix below).
 39 | 
 40 |       "Derivative Works" shall mean any work, whether in Source or Object
 41 |       form, that is based on (or derived from) the Work and for which the
 42 |       editorial revisions, annotations, elaborations, or other modifications
 43 |       represent, as a whole, an original work of authorship. For the purposes
 44 |       of this License, Derivative Works shall not include works that remain
 45 |       separable from, or merely link (or bind by name) to the interfaces of,
 46 |       the Work and Derivative Works thereof.
 47 | 
 48 |       "Contribution" shall mean any work of authorship, including
 49 |       the original version of the Work and any modifications or additions
 50 |       to that Work or Derivative Works thereof, that is intentionally
 51 |       submitted to Licensor for inclusion in the Work by the copyright owner
 52 |       or by an individual or Legal Entity authorized to submit on behalf of
 53 |       the copyright owner. For the purposes of this definition, "submitted"
 54 |       means any form of electronic, verbal, or written communication sent
 55 |       to the Licensor or its representatives, including but not limited to
 56 |       communication on electronic mailing lists, source code control systems,
 57 |       and issue tracking systems that are managed by, or on behalf of, the
 58 |       Licensor for the purpose of discussing and improving the Work, but
 59 |       excluding communication that is conspicuously marked or otherwise
 60 |       designated in writing by the copyright owner as "Not a Contribution."
 61 | 
 62 |       "Contributor" shall mean Licensor and any individual or Legal Entity
 63 |       on behalf of whom a Contribution has been received by Licensor and
 64 |       subsequently incorporated within the Work.
 65 | 
 66 |    2. Grant of Copyright License. Subject to the terms and conditions of
 67 |       this License, each Contributor hereby grants to You a perpetual,
 68 |       worldwide, non-exclusive, no-charge, royalty-free, irrevocable
 69 |       copyright license to reproduce, prepare Derivative Works of,
 70 |       publicly display, publicly perform, sublicense, and distribute the
 71 |       Work and such Derivative Works in Source or Object form.
 72 | 
 73 |    3. Grant of Patent License. Subject to the terms and conditions of
 74 |       this License, each Contributor hereby grants to You a perpetual,
 75 |       worldwide, non-exclusive, no-charge, royalty-free, irrevocable
 76 |       (except as stated in this section) patent license to make, have made,
 77 |       use, offer to sell, sell, import, and otherwise transfer the Work,
 78 |       where such license applies only to those patent claims licensable
 79 |       by such Contributor that are necessarily infringed by their
 80 |       Contribution(s) alone or by combination of their Contribution(s)
 81 |       with the Work to which such Contribution(s) was submitted. If You
 82 |       institute patent litigation against any entity (including a
 83 |       cross-claim or counterclaim in a lawsuit) alleging that the Work
 84 |       or a Contribution incorporated within the Work constitutes direct
 85 |       or contributory patent infringement, then any patent licenses
 86 |       granted to You under this License for that Work shall terminate
 87 |       as of the date such litigation is filed.
 88 | 
 89 |    4. Redistribution. You may reproduce and distribute copies of the
 90 |       Work or Derivative Works thereof in any medium, with or without
 91 |       modifications, and in Source or Object form, provided that You
 92 |       meet the following conditions:
 93 | 
 94 |       (a) You must give any other recipients of the Work or
 95 |           Derivative Works a copy of this License; and
 96 | 
 97 |       (b) You must cause any modified files to carry prominent notices
 98 |           stating that You changed the files; and
 99 | 
100 |       (c) You must retain, in the Source form of any Derivative Works
101 |           that You distribute, all copyright, patent, trademark, and
102 |           attribution notices from the Source form of the Work,
103 |           excluding those notices that do not pertain to any part of
104 |           the Derivative Works; and
105 | 
106 |       (d) If the Work includes a "NOTICE" text file as part of its
107 |           distribution, then any Derivative Works that You distribute must
108 |           include a readable copy of the attribution notices contained
109 |           within such NOTICE file, excluding those notices that do not
110 |           pertain to any part of the Derivative Works, in at least one
111 |           of the following places: within a NOTICE text file distributed
112 |           as part of the Derivative Works; within the Source form or
113 |           documentation, if provided along with the Derivative Works; or,
114 |           within a display generated by the Derivative Works, if and
115 |           wherever such third-party notices normally appear. The contents
116 |           of the NOTICE file are for informational purposes only and
117 |           do not modify the License. You may add Your own attribution
118 |           notices within Derivative Works that You distribute, alongside
119 |           or as an addendum to the NOTICE text from the Work, provided
120 |           that such additional attribution notices cannot be construed
121 |           as modifying the License.
122 | 
123 |       You may add Your own copyright statement to Your modifications and
124 |       may provide additional or different license terms and conditions
125 |       for use, reproduction, or distribution of Your modifications, or
126 |       for any such Derivative Works as a whole, provided Your use,
127 |       reproduction, and distribution of the Work otherwise complies with
128 |       the conditions stated in this License.
129 | 
130 |    5. Submission of Contributions. Unless You explicitly state otherwise,
131 |       any Contribution intentionally submitted for inclusion in the Work
132 |       by You to the Licensor shall be under the terms and conditions of
133 |       this License, without any additional terms or conditions.
134 |       Notwithstanding the above, nothing herein shall supersede or modify
135 |       the terms of any separate license agreement you may have executed
136 |       with Licensor regarding such Contributions.
137 | 
138 |    6. Trademarks. This License does not grant permission to use the trade
139 |       names, trademarks, service marks, or product names of the Licensor,
140 |       except as required for reasonable and customary use in describing the
141 |       origin of the Work and reproducing the content of the NOTICE file.
142 | 
143 |    7. Disclaimer of Warranty. Unless required by applicable law or
144 |       agreed to in writing, Licensor provides the Work (and each
145 |       Contributor provides its Contributions) on an "AS IS" BASIS,
146 |       WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 |       implied, including, without limitation, any warranties or conditions
148 |       of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 |       PARTICULAR PURPOSE. You are solely responsible for determining the
150 |       appropriateness of using or redistributing the Work and assume any
151 |       risks associated with Your exercise of permissions under this License.
152 | 
153 |    8. Limitation of Liability. In no event and under no legal theory,
154 |       whether in tort (including negligence), contract, or otherwise,
155 |       unless required by applicable law (such as deliberate and grossly
156 |       negligent acts) or agreed to in writing, shall any Contributor be
157 |       liable to You for damages, including any direct, indirect, special,
158 |       incidental, or consequential damages of any character arising as a
159 |       result of this License or out of the use or inability to use the
160 |       Work (including but not limited to damages for loss of goodwill,
161 |       work stoppage, computer failure or malfunction, or any and all
162 |       other commercial damages or losses), even if such Contributor
163 |       has been advised of the possibility of such damages.
164 | 
165 |    9. Accepting Warranty or Additional Liability. While redistributing
166 |       the Work or Derivative Works thereof, You may choose to offer,
167 |       and charge a fee for, acceptance of support, warranty, indemnity,
168 |       or other liability obligations and/or rights consistent with this
169 |       License. However, in accepting such obligations, You may act only
170 |       on Your own behalf and on Your sole responsibility, not on behalf
171 |       of any other Contributor, and only if You agree to indemnify,
172 |       defend, and hold each Contributor harmless for any liability
173 |       incurred by, or claims asserted against, such Contributor by reason
174 |       of your accepting any such warranty or additional liability.
175 | 
176 |    END OF TERMS AND CONDITIONS
177 | 
178 |    APPENDIX: How to apply the Apache License to your work.
179 | 
180 |       To apply the Apache License to your work, attach the following
181 |       boilerplate notice, with the fields enclosed by brackets "[]"
182 |       replaced with your own identifying information. (Don't include
183 |       the brackets!)  The text should be enclosed in the appropriate
184 |       comment syntax for the file format. We also recommend that a
185 |       file or class name and description of purpose be included on the
186 |       same "printed page" as the copyright notice for easier
187 |       identification within third-party archives.
188 | 
189 |    Copyright [yyyy] [name of copyright owner]
190 | 
191 |    Licensed under the Apache License, Version 2.0 (the "License");
192 |    you may not use this file except in compliance with the License.
193 |    You may obtain a copy of the License at
194 | 
195 |        http://www.apache.org/licenses/LICENSE-2.0
196 | 
197 |    Unless required by applicable law or agreed to in writing, software
198 |    distributed under the License is distributed on an "AS IS" BASIS,
199 |    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 |    See the License for the specific language governing permissions and
201 |    limitations under the License.
202 | 


--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: install-docker-plugin
2 | install-docker-plugin:
3 | 	cp docker-pussh ~/.docker/cli-plugins/docker-pussh
4 | 
5 | .PHONY: shellcheck
6 | shellcheck:
7 | 	find . -path "./tmp" -prune -o -type f \( -name "docker-pussh" -o -name "*.sh" \) -print0 \
8 | 		| xargs -0 shellcheck -s bash;
9 | 


--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
  1 | <div align="center">
  2 |   <img src=".github/images/logo-light.svg#gh-light-mode-only" alt="Unregistry logo"/>
  3 |   <img src=".github/images/logo-dark.svg#gh-dark-mode-only" alt="Unregistry logo"/>
  4 |   <p><strong>▸ Push docker images directly to remote servers without an external registry ◂</strong></p>
  5 | 
  6 |   <p>
  7 |     <a href="https://discord.gg/eR35KQJhPu"><img src="https://img.shields.io/badge/discord-5865F2.svg?style=for-the-badge&logo=discord&logoColor=white" alt="Join Discord"></a>
  8 |     <a href="https://x.com/psviderski"><img src="https://img.shields.io/badge/follow-black?style=for-the-badge&logo=X&logoColor=while" alt="Follow on X"></a>
  9 |     <a href="https://github.com/sponsors/psviderski"><img src="https://img.shields.io/badge/Donate-EA4AAA.svg?style=for-the-badge&logo=githubsponsors&logoColor=white" alt="Donate"></a>
 10 |   </p>
 11 | </div>
 12 | 
 13 | Unregistry is a lightweight container image registry that stores and serves images directly from your Docker daemon's
 14 | storage.
 15 | 
 16 | The included `docker pussh` command (extra 's' for SSH) lets you push images straight to remote Docker servers over SSH.
 17 | It transfers only the missing layers, making it fast and efficient.
 18 | 
 19 | https://github.com/user-attachments/assets/9d704b87-8e0d-4c8a-9544-17d4c63bd050
 20 | 
 21 | ## The problem
 22 | 
 23 | You've built a Docker image locally. Now you need it on your server. Your options suck:
 24 | 
 25 | - **Docker Hub / GitHub Container Registry** - Your code is now public, or you're paying for private repos
 26 | - **Self-hosted registry** - Another service to maintain, secure, and pay for storage
 27 | - **Save/Load** - `docker save | ssh <remote server> docker load` transfers the entire image, even if 90% already exists on the server
 28 | - **Rebuild remotely** - Wastes time and server resources. Plus now you're debugging why the build fails in production
 29 | 
 30 | You just want to move an image from A to B. Why is this so hard?
 31 | 
 32 | ## The solution
 33 | 
 34 | ```shell
 35 | docker pussh myapp:latest user@server
 36 | ```
 37 | 
 38 | That's it. Your image is on the remote server. No registry setup, no subscription, no intermediate storage, no exposed
 39 | ports. Just a **direct transfer** of the **missing layers** over SSH.
 40 | 
 41 | Here's what happens under the hood:
 42 | 
 43 | 1. Establishes SSH tunnel to the remote server
 44 | 2. Starts a temporary unregistry container on the server
 45 | 3. Forwards a random localhost port to the unregistry port over the tunnel
 46 | 4. `docker push` to unregistry through the forwarded port, transferring only the layers that don't already exist
 47 |    remotely. The transferred image is instantly available on the remote Docker daemon
 48 | 5. Stops the unregistry container and closes the SSH tunnel
 49 | 
 50 | It's like `rsync` for Docker images — simple and efficient.
 51 | 
 52 | > [!NOTE]
 53 | > Unregistry was created for [Uncloud](https://github.com/psviderski/uncloud), a lightweight tool for deploying
 54 | > containers across multiple Docker hosts. We needed something simpler than a full registry but more efficient than
 55 | > save/load.
 56 | 
 57 | ## Requirements
 58 | 
 59 | ### On local machine
 60 | 
 61 | - Docker CLI with plugin support (Docker 19.03+)
 62 | - OpenSSH client
 63 | 
 64 | ### On remote server
 65 | 
 66 | - Docker is installed and running
 67 | - SSH user has permissions to run `docker` commands (user is `root` or non-root user is in `docker` group — see
 68 |   [Manage Docker as a non-root user](https://docs.docker.com/engine/install/linux-postinstall/#manage-docker-as-a-non-root-user)
 69 |   for details)
 70 | - If `sudo` is required, ensure the user can run `sudo docker` without a password prompt
 71 | - Your server has internet access to [ghcr.io](https://ghcr.io) to pull the unregistry image
 72 |   `ghcr.io/psviderski/unregistry:latest` on first `docker pussh` use.
 73 |     - If your server requires a proxy to access the internet, configure Docker to use it by following the
 74 |       [Daemon proxy configuration](https://docs.docker.com/engine/daemon/proxy/) guide.
 75 |     - For air-gapped environments or where the access to [ghcr.io](https://ghcr.io) is restricted, you can preload the
 76 |       image manually:
 77 |       ```shell
 78 |       # On a machine with internet access
 79 |       docker pull ghcr.io/psviderski/unregistry:latest
 80 |       docker save ghcr.io/psviderski/unregistry:latest | ssh user@server docker load
 81 |       ```
 82 | - Unregistry container requires access to the containerd socket at `/run/containerd/containerd.sock`, so the container
 83 |   runs as `root` to have the necessary permissions
 84 | 
 85 | ## Installation
 86 | 
 87 | ### macOS/Linux via Homebrew
 88 | 
 89 | ```shell
 90 | brew install psviderski/tap/docker-pussh
 91 | ```
 92 | 
 93 | After installation, to use `docker-pussh` as a Docker CLI plugin (`docker pussh` command) you need to create a symlink:
 94 | 
 95 | ```shell
 96 | mkdir -p ~/.docker/cli-plugins
 97 | ln -sf $(brew --prefix)/bin/docker-pussh ~/.docker/cli-plugins/docker-pussh
 98 | ```
 99 | 
100 | ### macOS/Linux via direct download
101 | 
102 | Download the current version:
103 | 
104 | ```shell
105 | mkdir -p ~/.docker/cli-plugins
106 | 
107 | # Download the script to the docker plugins directory
108 | curl -sSL https://raw.githubusercontent.com/psviderski/unregistry/v0.1.3/docker-pussh \
109 |   -o ~/.docker/cli-plugins/docker-pussh
110 | 
111 | # Make it executable
112 | chmod +x ~/.docker/cli-plugins/docker-pussh
113 | ```
114 | 
115 | If you want to download and use the latest version from the main branch:
116 | 
117 | ```shell
118 | curl -sSL https://raw.githubusercontent.com/psviderski/unregistry/main/docker-pussh \
119 |   -o ~/.docker/cli-plugins/docker-pussh
120 | chmod +x ~/.docker/cli-plugins/docker-pussh
121 | ```
122 | 
123 | ### Debian 
124 | 
125 | Via unofficial repository packages created and maintained at [unregistry-debian](https://github.com/dariogriffo/unregistry-debian/) by @dariogriffo
126 | 
127 | You can install unregistry the debian way by running:
128 | 
129 | ```sh
130 | curl -sS https://debian.griffo.io/EA0F721D231FDD3A0A17B9AC7808B4DD62C41256.asc | sudo gpg --dearmor --yes -o /etc/apt/trusted.gpg.d/debian.griffo.io.gpg
131 | echo "deb https://debian.griffo.io/apt $(lsb_release -sc 2>/dev/null) main" | sudo tee /etc/apt/sources.list.d/debian.griffo.io.list
132 | apt install -y unregistry
133 | apt install docker-pussh
134 | ```
135 | 
136 | or in the releases page of the repository [here](https://github.com/dariogriffo/unregistry-debian/releases)
137 | 
138 | ### Windows
139 | 
140 | Windows is not currently supported, but you can try using [WSL 2](https://docs.docker.com/desktop/features/wsl/)
141 | with the above Linux instructions.
142 | 
143 | ### Verify installation
144 | 
145 | ```shell
146 | docker pussh --help
147 | ```
148 | 
149 | ## ⚠️ Containerd image store configuration
150 | 
151 | Unregistry stores images directly in containerd's image store, which is the underlying container runtime used by Docker.
152 | However, by default, Docker maintains its own separate storage layer and doesn't directly use images from containerd.
153 | 
154 | When you enable [containerd image store](https://docs.docker.com/engine/storage/containerd/) in Docker, it allows Docker
155 | to directly use the same images that unregistry stores in containerd, eliminating duplication.
156 | 
157 | ### With containerd image store enabled (recommended)
158 | 
159 | - Images pushed through unregistry are immediately available to Docker
160 | - No additional storage space is used (images are stored once in containerd)
161 | - Faster `pussh` operations without the additional pull step from unregistry to the classic Docker image store
162 | 
163 | ### Without containerd image store (default Docker behaviour)
164 | 
165 | - After pushing, `pussh` runs an additional `docker pull` on the remote host to pull the image from unregistry to make
166 |   it available to Docker
167 | - Images are stored twice: once in containerd (by unregistry) and once in the classic Docker image store
168 | - These unmanaged images in containerd can fill up disk space over time. To manage them manually, use:
169 |   ```shell
170 |   sudo ctr -n moby images ls
171 |   sudo ctr -n moby images rm <image>
172 |   ```
173 | 
174 | ### How to enable containerd image store
175 | 
176 | Please refer to the official Docker documentation:
177 | [Enable containerd image store on Docker Engine](https://docs.docker.com/engine/storage/containerd/#enable-containerd-image-store-on-docker-engine).
178 | 
179 | > [!WARNING]
180 | > Switching to containerd image store causes you to temporarily lose images and containers created using the classic
181 | > storage driver. Those resources still exist on your filesystem, and you can retrieve them by turning off the
182 | > containerd image store feature.
183 | 
184 | ## Usage
185 | 
186 | Push an image to a remote server. Please make sure the SSH user has permissions to run `docker` commands (user is
187 | `root` or non-root user is in `docker` group). If `sudo` is required, ensure the user can run `sudo docker` without a
188 | password prompt.
189 | 
190 | ```shell
191 | docker pussh myapp:latest user@server.example.com
192 | ```
193 | 
194 | With SSH key authentication if the private key is not added to your SSH agent:
195 | 
196 | ```shell
197 | docker pussh myapp:latest ubuntu@192.168.1.100 -i ~/.ssh/id_rsa
198 | ```
199 | 
200 | Using a custom SSH port:
201 | 
202 | ```shell
203 | docker pussh myapp:latest user@server:2222
204 | ```
205 | 
206 | Push a specific platform for a multi-platform image. The local Docker has to use
207 | [containerd image store](https://docs.docker.com/desktop/features/containerd/) to support multi-platform images.
208 | 
209 | ```shell
210 | docker pussh myapp:latest user@server --platform linux/amd64
211 | ```
212 | 
213 | ## Use cases
214 | 
215 | ### Deploy to production servers
216 | 
217 | Build locally and push directly to your production servers. No middleman.
218 | 
219 | ```shell
220 | docker build --platform linux/amd64 -t myapp:1.2.3 .
221 | docker pussh myapp:1.2.3 deploy@prod-server
222 | ssh deploy@prod-server docker run -d myapp:1.2.3
223 | ```
224 | 
225 | ### CI/CD pipelines
226 | 
227 | Skip the registry complexity in your pipelines. Build and push directly to deployment targets.
228 | 
229 | ```yaml
230 | - name: Build and deploy
231 |   run: |
232 |     docker build -t myapp:${{ github.sha }} .
233 |     docker pussh myapp:${{ github.sha }} deploy@staging-server
234 | ```
235 | 
236 | ### Homelab and air-gapped environments
237 | 
238 | Distribute images in isolated networks that can't access public registries over the internet.
239 | 
240 | ## Advanced usage
241 | 
242 | ### Running unregistry standalone
243 | 
244 | Sometimes you want a local registry without the overhead. Unregistry works great for this:
245 | 
246 | ```shell
247 | # Run unregistry locally and expose it on port 5000
248 | docker run -d -p 5000:5000 --name unregistry \
249 |   -v /run/containerd/containerd.sock:/run/containerd/containerd.sock \
250 |   ghcr.io/psviderski/unregistry
251 | 
252 | # Use it like any registry
253 | docker tag myapp:latest localhost:5000/myapp:latest
254 | docker push localhost:5000/myapp:latest
255 | ```
256 | 
257 | ### Custom SSH options
258 | 
259 | Need custom SSH settings? Use the standard SSH config file:
260 | 
261 | ```shell
262 | # ~/.ssh/config
263 | Host prod-server
264 |     HostName server.example.com
265 |     User deploy
266 |     Port 2222
267 |     IdentityFile ~/.ssh/deploy_key
268 | 
269 | # Now just use
270 | docker pussh myapp:latest prod-server
271 | ```
272 | 
273 | ## Contributing
274 | 
275 | Found a bug or have a feature idea? We'd love your help!
276 | 
277 | - 🐛 Found a bug? [Open an issue](https://github.com/psviderski/unregistry/issues)
278 | 
279 | * 💡 Have questions, ideas, or need help?
280 |     * Start a discussion or join an existing one in
281 |       the [Discussions](https://github.com/psviderski/unregistry/discussions).
282 |     * Join the [Uncloud Discord community](https://discord.gg/eR35KQJhPu) where we discuss features, roadmap,
283 |       implementation details, and help each other out.
284 | 
285 | ## Inspiration & acknowledgements
286 | 
287 | - [Spegel](https://github.com/spegel-org/spegel) - P2P container image registry that inspired me to implement a registry
288 |   that uses containerd image store as a backend.
289 | - [Docker Distribution](https://github.com/distribution/distribution) - the bulletproof Docker registry implementation
290 |   that unregistry is based on.
291 | 
292 | ##
293 | 
294 | <div align="center">
295 |   Built with ❤️ by <a href="https://github.com/psviderski">Pasha Sviderski</a> who just wanted to deploy his images
296 | </div>
297 | 


--------------------------------------------------------------------------------
/cmd/unregistry/main.go:
--------------------------------------------------------------------------------
  1 | package main
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"os"
  7 | 	"os/signal"
  8 | 	"syscall"
  9 | 	"time"
 10 | 
 11 | 	"github.com/sirupsen/logrus"
 12 | 	"github.com/spf13/cobra"
 13 | 	"github.com/uncloud/unregistry/internal/registry"
 14 | )
 15 | 
 16 | func main() {
 17 | 	var cfg registry.Config
 18 | 	cmd := &cobra.Command{
 19 | 		Use:   "unregistry",
 20 | 		Short: "A container registry that uses local Docker/containerd for storing images.",
 21 | 		Long: `Unregistry is a lightweight OCI-compliant container registry that uses the local Docker (containerd)
 22 | image store as its backend. It provides a standard registry API interface for pushing and pulling
 23 | container images without requiring a separate storage backend.
 24 | 
 25 | Key use cases:
 26 | - Push built images straight to remote servers without an external registry such as Docker Hub
 27 |   as intermediary
 28 | - Pull images once and serve them to multiple nodes in a cluster environment
 29 | - Distribute images in air-gapped environments
 30 | - Development and testing workflows that need a local registry
 31 | - Expose pre-loaded images through a standard registry API`,
 32 | 		SilenceUsage:  true,
 33 | 		SilenceErrors: true,
 34 | 		PreRun: func(cmd *cobra.Command, args []string) {
 35 | 			bindEnvToFlag(cmd, "addr", "UNREGISTRY_ADDR")
 36 | 			bindEnvToFlag(cmd, "log-format", "UNREGISTRY_LOG_FORMAT")
 37 | 			bindEnvToFlag(cmd, "log-level", "UNREGISTRY_LOG_LEVEL")
 38 | 			bindEnvToFlag(cmd, "namespace", "UNREGISTRY_CONTAINERD_NAMESPACE")
 39 | 			bindEnvToFlag(cmd, "sock", "UNREGISTRY_CONTAINERD_SOCK")
 40 | 		},
 41 | 		RunE: func(cmd *cobra.Command, args []string) error {
 42 | 			return run(cfg)
 43 | 		},
 44 | 	}
 45 | 
 46 | 	cmd.Flags().StringVarP(&cfg.Addr, "addr", "a", ":5000",
 47 | 		"Address and port to listen on (e.g., 0.0.0.0:5000)")
 48 | 	cmd.Flags().StringVarP(&cfg.LogFormatter, "log-format", "f", "text",
 49 | 		"Log output format (text or json)")
 50 | 	cmd.Flags().StringVarP(&cfg.LogLevel, "log-level", "l", "info",
 51 | 		"Log verbosity level (debug, info, warn, error)")
 52 | 	cmd.Flags().StringVarP(&cfg.ContainerdNamespace, "namespace", "n", "moby",
 53 | 		"Containerd namespace to use for image storage")
 54 | 	cmd.Flags().StringVarP(&cfg.ContainerdSock, "sock", "s", "/run/containerd/containerd.sock",
 55 | 		"Path to containerd socket file")
 56 | 
 57 | 	if err := cmd.Execute(); err != nil {
 58 | 		logrus.WithError(err).Fatal("Registry server failed.")
 59 | 	}
 60 | }
 61 | 
 62 | func run(cfg registry.Config) error {
 63 | 	reg, err := registry.NewRegistry(cfg)
 64 | 	if err != nil {
 65 | 		return fmt.Errorf("create registry server: %w", err)
 66 | 	}
 67 | 
 68 | 	errCh := make(chan error, 1)
 69 | 	go func() {
 70 | 		if err := reg.ListenAndServe(); err != nil {
 71 | 			errCh <- err
 72 | 		}
 73 | 	}()
 74 | 
 75 | 	// Wait for interrupt signal to gracefully shutdown the server.
 76 | 	quit := make(chan os.Signal, 1)
 77 | 	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
 78 | 
 79 | 	select {
 80 | 	case err = <-errCh:
 81 | 		return err
 82 | 	case <-quit:
 83 | 		timeout := 30 * time.Second
 84 | 		logrus.Infof("Shutting down server... Draining connections for %s", timeout)
 85 | 		ctx, cancel := context.WithTimeout(context.Background(), timeout)
 86 | 		defer cancel()
 87 | 
 88 | 		if err = reg.Shutdown(ctx); err != nil {
 89 | 			return fmt.Errorf("registry server forced to shutdown: %w", err)
 90 | 		}
 91 | 		logrus.Info("Registry server stopped gracefully.")
 92 | 	}
 93 | 
 94 | 	return nil
 95 | }
 96 | 
 97 | func bindEnvToFlag(cmd *cobra.Command, flagName, envVar string) {
 98 | 	if value := os.Getenv(envVar); value != "" && !cmd.Flags().Changed(flagName) {
 99 | 		if err := cmd.Flags().Set(flagName, value); err != nil {
100 | 			logrus.WithError(err).Fatalf("Failed to bind environment variable '%s' to flag '%s'.", envVar, flagName)
101 | 		}
102 | 	}
103 | }
104 | 


--------------------------------------------------------------------------------
/docker-pussh:
--------------------------------------------------------------------------------
  1 | #!/usr/bin/env bash
  2 | set -euo pipefail
  3 | 
  4 | if [[ "${UNREGISTRY_DEBUG:-}" == "1" ]]; then
  5 |     set -x
  6 | fi
  7 | 
  8 | # Script version
  9 | VERSION="0.1.3"
 10 | 
 11 | # Return metadata expected by the Docker CLI plugin framework: https://github.com/docker/cli/pull/1564
 12 | if [ "${1:-}" = "docker-cli-plugin-metadata" ]; then
 13 |     cat <<EOF
 14 | {
 15 |   "SchemaVersion": "0.1.0",
 16 |   "Vendor": "https://github.com/psviderski",
 17 |   "Version": "${VERSION}",
 18 |   "ShortDescription": "Upload image to remote Docker daemon via SSH without external registry"
 19 | }
 20 | EOF
 21 |     exit 0
 22 | fi
 23 | 
 24 | # Pin the unregistry image version. The image doesn't change too often compared to the script,
 25 | # so we want to avoid unnecessary pulls of the latest image version.
 26 | UNREGISTRY_VERSION=0.1.3
 27 | UNREGISTRY_IMAGE=${UNREGISTRY_IMAGE:-ghcr.io/psviderski/unregistry:${UNREGISTRY_VERSION}}
 28 | 
 29 | # Colors and symbols for output.
 30 | RED='\033[0;31m'
 31 | GREEN='\033[0;32m'
 32 | YELLOW='\033[0;33m'
 33 | BLUE='\033[0;34m'
 34 | NC='\033[0m' # no color
 35 | 
 36 | info() {
 37 |     echo -e " ${BLUE}•${NC} $1"
 38 | }
 39 | 
 40 | success() {
 41 |     echo -e " ${GREEN}✓${NC} $1"
 42 | }
 43 | 
 44 | warning() {
 45 |     echo -e " ${YELLOW}!${NC} $1"
 46 | }
 47 | 
 48 | error() {
 49 |     echo -e "${RED}ERROR:${NC} $1" >&2
 50 |     exit 1
 51 | }
 52 | 
 53 | usage() {
 54 |     echo "Usage: docker pussh [OPTIONS] IMAGE[:TAG] [USER@]HOST[:PORT]"
 55 |     echo ""
 56 |     echo "Upload a Docker image to a remote Docker daemon via SSH without an external registry."
 57 |     echo ""
 58 |     echo "Options:"
 59 |     echo "  -h, --help              Show this help message."
 60 |     echo "  -i, --ssh-key path      Path to SSH private key for remote login (if not already added to SSH agent)."
 61 |     echo "      --platform string   Push a specific platform for a multi-platform image (e.g., linux/amd64, linux/arm64)."
 62 |     echo "                          Local Docker has to use containerd image store to support multi-platform images."
 63 |     echo ""
 64 |     echo "Examples:"
 65 |     echo "  docker pussh myimage:latest user@host"
 66 |     echo "  docker pussh --platform linux/amd64 myimage:latest host"
 67 |     echo "  docker pussh myimage:latest user@host:2222 -i ~/.ssh/id_ed25519"
 68 | }
 69 | 
 70 | # SSH command arguments to be used for all ssh commands after establishing a shared "master" connection
 71 | # using ssh_remote.
 72 | declare -a SSH_ARGS=()
 73 | 
 74 | # Establish SSH connection to the remote server that will be reused by subsequent ssh commands via the control socket.
 75 | # It populates the SSH_ARGS array with arguments for reuse.
 76 | ssh_remote() {
 77 |     local ssh_addr="$1"
 78 |     local target port
 79 |     # Split out the port component, if exists
 80 |     if [[ "$ssh_addr" =~ ^([^:]+)(:([0-9]+))?$ ]]; then
 81 |         target="${BASH_REMATCH[1]}"
 82 |         port="${BASH_REMATCH[3]:-}"
 83 |     else
 84 |         error "Invalid SSH address format. Expected format: [USER@]HOST[:PORT]"
 85 |     fi
 86 | 
 87 |     local ssh_opts=(
 88 |         -o "ControlMaster=auto"
 89 |         # Unique control socket path for this invocation.
 90 |         -o "ControlPath=/tmp/docker-pussh-$.sock"
 91 |         # The connection will be automatically terminated after 1 minute of inactivity.
 92 |         -o "ControlPersist=1m"
 93 |         -o "ConnectTimeout=15"
 94 |     )
 95 |     # Add port if specified
 96 |     if [ -n "$port" ]; then
 97 |         ssh_opts+=(-p "$port")
 98 |     fi
 99 |     # Add SSH key option if provided.
100 |     if [ -n "$SSH_KEY" ]; then
101 |         ssh_opts+=(-i "$SSH_KEY")
102 |     fi
103 | 
104 |     # Establish ControlMaster connection in the background.
105 |     if ! ssh "${ssh_opts[@]}" -f -N "${target}"; then
106 |         error "Failed to connect to remote host via SSH: $ssh_addr"
107 |     fi
108 | 
109 |     # Populate SSH_ARGS array for reuse in all subsequent commands.
110 |     SSH_ARGS=("${ssh_opts[@]}")
111 |     SSH_ARGS+=("${target}")
112 | }
113 | 
114 | # sudo prefix for remote docker commands. It's set to "sudo" if the remote user is not root and requires sudo
115 | # to run docker commands.
116 | REMOTE_SUDO=""
117 | 
118 | # Check if the remote host has Docker installed and if we can run docker commands.
119 | # If sudo is required, it sets the REMOTE_SUDO variable to "sudo".
120 | check_remote_docker() {
121 |     # Check if docker command is available.
122 |     if ! ssh "${SSH_ARGS[@]}" "command -v docker" >/dev/null 2>&1; then
123 |         error "'docker' command not found on remote host. Please ensure Docker is installed."
124 |     fi
125 |     # Check if we need sudo to run docker commands.
126 |     if ! ssh "${SSH_ARGS[@]}" "docker version" >/dev/null 2>&1; then
127 |         # Check if we're not root and if sudo docker works.
128 |         if ssh "${SSH_ARGS[@]}" "[ \$(id -u) -ne 0 ] && sudo docker version" >/dev/null; then
129 |             REMOTE_SUDO="sudo"
130 |         else
131 |             error "Failed to run docker commands on remote host. Please ensure:
132 |   - Docker is installed and running on the remote host
133 |   - SSH user has permissions to run docker commands (user is root or non-root user is in 'docker' group)
134 |   - If sudo is required, ensure the user can run 'sudo docker' without a password prompt"
135 |         fi
136 |     fi
137 | }
138 | 
139 | # Generate a random port in range 55000-65535.
140 | random_port() {
141 |     echo $((55000 + RANDOM % 10536))
142 | }
143 | 
144 | # Container name for the unregistry instance on remote host. It's populated by run_unregistry function.
145 | UNREGISTRY_CONTAINER=""
146 | # Unregistry port on the remote host that is bound to localhost. It's populated by run_unregistry function.
147 | UNREGISTRY_PORT=""
148 | 
149 | # Run unregistry container on remote host with retry logic for port binding conflicts.
150 | # Sets UNREGISTRY_PORT and UNREGISTRY_CONTAINER global variables.
151 | run_unregistry() {
152 |     local output
153 | 
154 |     # Pull unregistry image if it doesn't exist on the remote host. This is done separately to not capture the output
155 |     # and print the pull progress to the terminal.
156 |     # shellcheck disable=SC2029
157 |     if ! ssh "${SSH_ARGS[@]}" "$REMOTE_SUDO docker image inspect $UNREGISTRY_IMAGE" >/dev/null 2>&1; then
158 |         ssh "${SSH_ARGS[@]}" "$REMOTE_SUDO docker pull $UNREGISTRY_IMAGE"
159 |     fi
160 | 
161 |     for _ in {1..10}; do
162 |         UNREGISTRY_PORT=$(random_port)
163 |         UNREGISTRY_CONTAINER="unregistry-pussh-$-$UNREGISTRY_PORT"
164 | 
165 |         # shellcheck disable=SC2029
166 |         if output=$(ssh "${SSH_ARGS[@]}" "$REMOTE_SUDO docker run -d \
167 |             --name $UNREGISTRY_CONTAINER \
168 |             -p 127.0.0.1:$UNREGISTRY_PORT:5000 \
169 |             -v /run/containerd/containerd.sock:/run/containerd/containerd.sock \
170 |             --userns=host \
171 |             --user root:root \
172 |             $UNREGISTRY_IMAGE" 2>&1);
173 |         then
174 |             return 0
175 |         fi
176 | 
177 |         # Remove the container that failed to start if it was created.
178 |         # shellcheck disable=SC2029
179 |         ssh "${SSH_ARGS[@]}" "$REMOTE_SUDO docker rm -f $UNREGISTRY_CONTAINER" >/dev/null 2>&1 || true
180 |         # Check if the error is due to port binding.
181 |         if ! echo "$output" | grep -q --ignore-case "bind.*$UNREGISTRY_PORT"; then
182 |             error "Failed to start unregistry container:\n$output"
183 |         fi
184 |     done
185 | 
186 |     error "Failed to start unregistry container:\n$output"
187 | }
188 | 
189 | # Forward a local port to a remote port over the established SSH connection.
190 | # Returns the local port that was successfully bound.
191 | forward_port() {
192 |     local remote_port="$1"
193 |     local local_port
194 |     local output
195 | 
196 |     for _ in {1..10}; do
197 |         local_port=$(random_port)
198 | 
199 |         # Check if port is already in use locally.
200 |         # TODO: handle the case when nc is not available.
201 |         if command -v nc >/dev/null && nc -z 127.0.0.1 "$local_port" 2>/dev/null; then
202 |             continue
203 |         fi
204 | 
205 |         if output=$(ssh "${SSH_ARGS[@]}" -O forward -L "$local_port:127.0.0.1:$remote_port" 2>&1); then
206 |             echo "$local_port"
207 |             return 0
208 |         fi
209 | 
210 |         error "Failed to forward local port $local_port to remote unregistry port 127.0.0.1:$remote_port: $output"
211 |     done
212 | 
213 |     error "Failed to find an available local port to forward to remote unregistry port. Please try again."
214 | }
215 | 
216 | # Check if the local Docker server needs additional tunneling.
217 | is_additional_tunneling_needed() {
218 |     # Read all output to a variable to avoid issues with pipefail when 'grep -q' exits early.
219 |     local output
220 |     output=$(docker version 2>/dev/null)
221 |     echo "$output" | grep -E -q "Docker Desktop|colima" && return 0
222 |     return 1
223 | }
224 | 
225 | # Container name for the Docker Desktop tunnel. It's populated by run_docker_desktop_tunnel function.
226 | DOCKER_DESKTOP_TUNNEL_CONTAINER=""
227 | # Port on localhost that docker in Docker Desktop should push to. It's populated by run_docker_desktop_tunnel function.
228 | DOCKER_DESKTOP_TUNNEL_PORT=""
229 | 
230 | # Run a socat tunnel container for pushing images from Docker Desktop VM to the forwarded port on the host.
231 | run_docker_desktop_tunnel() {
232 |     local host_port="$1"
233 |     local output
234 | 
235 |     DOCKER_DESKTOP_TUNNEL_CONTAINER="docker-pussh-tunnel-$"
236 |     for _ in {1..10}; do
237 |         DOCKER_DESKTOP_TUNNEL_PORT=$(random_port)
238 | 
239 |         if output=$(docker run -d --rm \
240 |             --name "$DOCKER_DESKTOP_TUNNEL_CONTAINER" \
241 |             -p "127.0.0.1:$DOCKER_DESKTOP_TUNNEL_PORT:5000" \
242 |             alpine/socat \
243 |             TCP-LISTEN:5000,fork,reuseaddr \
244 |             "TCP-CONNECT:host.docker.internal:$host_port" 2>&1);
245 |         then
246 |             return 0
247 |         fi
248 | 
249 |         # Remove the container that failed to start if it was created.
250 |         docker rm -f "$DOCKER_DESKTOP_TUNNEL_CONTAINER" >/dev/null 2>&1 || true
251 |         # Check if error is due to port binding.
252 |         if ! echo "$output" | grep -q --ignore-case "bind.*$DOCKER_DESKTOP_TUNNEL_PORT"; then
253 |             error "Failed to create a tunnel from Docker Desktop VM to localhost:$host_port:\n$output"
254 |         fi
255 |     done
256 | 
257 |     error "Failed to create a tunnel from Docker Desktop VM to localhost:$host_port:\n$output"
258 | }
259 | 
260 | DOCKER_PLATFORM=""
261 | SSH_KEY=""
262 | IMAGE=""
263 | SSH_ADDRESS=""
264 | 
265 | # Skip 'pussh' if called as Docker CLI plugin.
266 | if [ "${1:-}" = "pussh" ]; then
267 |     shift
268 | fi
269 | 
270 | # Parse options and arguments.
271 | help_command="Run 'docker pussh --help' for usage information."
272 | while [ $# -gt 0 ]; do
273 |     case "$1" in
274 |         -i|--ssh-key)
275 |             if [ -z "${2:-}" ]; then
276 |                 error "-i/--ssh-key option requires an argument.\n$help_command"
277 |             fi
278 |             SSH_KEY="$2"
279 |             shift 2
280 |             ;;
281 |         --platform)
282 |             if [ -z "${2:-}" ]; then
283 |                 error "--platform option requires an argument.\n$help_command"
284 |             fi
285 |             DOCKER_PLATFORM="$2"
286 |             shift 2
287 |             ;;
288 |         -h|--help)
289 |             usage
290 |             exit 0
291 |             ;;
292 |         -v|--version)
293 |             echo "docker-pussh, version ${VERSION}"
294 |             echo "unregistry image: ${UNREGISTRY_IMAGE}"
295 |             exit 0
296 |             ;;
297 |         -*)
298 |             error "Unknown option: $1\n$help_command"
299 |             ;;
300 |         *)
301 |             # First non-option argument is the image.
302 |             if [ -z "$IMAGE" ]; then
303 |                 IMAGE="$1"
304 |             # Second non-option argument is the SSH address.
305 |             elif [ -z "$SSH_ADDRESS" ]; then
306 |                 SSH_ADDRESS="$1"
307 |             else
308 |                 error "Too many arguments.\n$help_command"
309 |             fi
310 |             shift
311 |             ;;
312 |     esac
313 | done
314 | 
315 | # Validate required arguments.
316 | if [ -z "$IMAGE" ] || [ -z "$SSH_ADDRESS" ]; then
317 |     error "IMAGE and HOST are required.\n$help_command"
318 | fi
319 | # Validate SSH key file exists if provided.
320 | if [ -n "$SSH_KEY" ] && [ ! -f "$SSH_KEY" ]; then
321 |     error "SSH key file not found: $SSH_KEY"
322 | fi
323 | 
324 | 
325 | # Function to cleanup resources
326 | # TODO: review cleanup
327 | cleanup() {
328 |     local exit_code=$?
329 | 
330 |     if [ $exit_code -ne 0 ]; then
331 |         warning "Cleaning up after error..."
332 |     fi
333 | 
334 |     # Remove Docker Desktop tunnel container if exists.
335 |     if [ -n "$DOCKER_DESKTOP_TUNNEL_CONTAINER" ]; then
336 |         docker rm -f "$DOCKER_DESKTOP_TUNNEL_CONTAINER" >/dev/null 2>&1 || true
337 |     fi
338 | 
339 |     # Clean up the temporary registry image tag.
340 |     if [ -n "${REGISTRY_IMAGE:-}" ]; then
341 |         docker rmi "$REGISTRY_IMAGE" >/dev/null 2>&1 || true
342 |     fi
343 | 
344 |     # Stop and remove unregistry container on remote host.
345 |     if [ -n "$UNREGISTRY_CONTAINER" ]; then
346 |         # shellcheck disable=SC2029
347 |         ssh "${SSH_ARGS[@]}" "$REMOTE_SUDO docker rm -f $UNREGISTRY_CONTAINER" >/dev/null 2>&1 || true
348 |     fi
349 | 
350 |     # Terminate the shared SSH connection if it was established.
351 |     if [ ${#SSH_ARGS[@]} -ne 0 ]; then
352 |         ssh "${SSH_ARGS[@]}" -O exit 2>/dev/null || true
353 |     fi
354 | }
355 | trap cleanup EXIT SIGINT SIGTERM
356 | 
357 | info "Connecting to $SSH_ADDRESS..."
358 | ssh_remote "$SSH_ADDRESS"
359 | check_remote_docker
360 | 
361 | info "Starting unregistry container on remote host..."
362 | run_unregistry
363 | success "Unregistry is listening localhost:$UNREGISTRY_PORT on remote host."
364 | 
365 | # Forward random local port to remote unregistry port through established SSH connection.
366 | LOCAL_PORT=$(forward_port "$UNREGISTRY_PORT")
367 | success "Forwarded localhost:$LOCAL_PORT to unregistry over SSH connection."
368 | 
369 | # Handle virtualized Docker on macOS (e.g., Docker Desktop, Colima, etc.)
370 | PUSH_PORT=$LOCAL_PORT
371 | if is_additional_tunneling_needed; then
372 |     info "Detected virtualized Docker, creating additional tunnel to localhost:$LOCAL_PORT..."
373 |     run_docker_desktop_tunnel "$LOCAL_PORT"
374 |     PUSH_PORT=$DOCKER_DESKTOP_TUNNEL_PORT
375 |     success "Additional tunnel created: localhost:$PUSH_PORT → localhost:$LOCAL_PORT"
376 | fi
377 | 
378 | # Tag and push the image to unregistry through the forwarded port.
379 | REGISTRY_IMAGE="localhost:$PUSH_PORT/$IMAGE"
380 | docker tag "$IMAGE" "$REGISTRY_IMAGE"
381 | info "Pushing '$REGISTRY_IMAGE' to unregistry..."
382 | 
383 | DOCKER_PUSH_OPTS=()
384 | if [ -n "$DOCKER_PLATFORM" ]; then
385 |     DOCKER_PUSH_OPTS+=("--platform" "$DOCKER_PLATFORM")
386 | fi
387 | 
388 | # That DOCKER_PUSH_OPTS expansion is needed to avoid issues with empty array expansion in older bash versions.
389 | if ! docker push ${DOCKER_PUSH_OPTS[@]+"${DOCKER_PUSH_OPTS[@]}"} "$REGISTRY_IMAGE"; then
390 |     error "Failed to push image."
391 | fi
392 | 
393 | # Pull image from unregistry if remote Docker doesn't uses containerd image store.
394 | # shellcheck disable=SC2029
395 | if ! ssh "${SSH_ARGS[@]}" "$REMOTE_SUDO docker info -f '{{ .DriverStatus }}' | grep -q 'containerd.snapshotter'"; then
396 |     info "Remote Docker doesn't use containerd image store. Pulling image from unregistry..."
397 | 
398 |     remote_registry_image="localhost:$UNREGISTRY_PORT/$IMAGE"
399 |     if ! ssh "${SSH_ARGS[@]}" "$REMOTE_SUDO docker pull $remote_registry_image"; then
400 |         error "Failed to pull image from unregistry on remote host."
401 |     fi
402 |     if ! ssh "${SSH_ARGS[@]}" "$REMOTE_SUDO docker tag $remote_registry_image $IMAGE"; then
403 |         error "Failed to retag image on remote host $remote_registry_image → $IMAGE"
404 |     fi
405 |     ssh "${SSH_ARGS[@]}" "$REMOTE_SUDO docker rmi $remote_registry_image" >/dev/null || true
406 | fi
407 | 
408 | info "Removing unregistry container on remote host..."
409 | # shellcheck disable=SC2029
410 | ssh "${SSH_ARGS[@]}" "$REMOTE_SUDO docker rm -f $UNREGISTRY_CONTAINER" >/dev/null || true
411 | 
412 | success "Successfully pushed '$IMAGE' to $SSH_ADDRESS"
413 | 


--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
  1 | module github.com/uncloud/unregistry
  2 | 
  3 | go 1.24
  4 | 
  5 | toolchain go1.24.3
  6 | 
  7 | require (
  8 | 	github.com/containerd/containerd/v2 v2.1.1
  9 | 	github.com/containerd/errdefs v1.0.0
 10 | 	github.com/distribution/distribution/v3 v3.0.0
 11 | 	github.com/distribution/reference v0.6.0
 12 | 	github.com/google/uuid v1.6.0
 13 | 	github.com/opencontainers/go-digest v1.0.0
 14 | 	github.com/opencontainers/image-spec v1.1.1
 15 | 	github.com/sirupsen/logrus v1.9.3
 16 | 	github.com/spf13/cobra v1.9.1
 17 | )
 18 | 
 19 | require (
 20 | 	github.com/Microsoft/go-winio v0.6.2 // indirect
 21 | 	github.com/Microsoft/hcsshim v0.13.0 // indirect
 22 | 	github.com/beorn7/perks v1.0.1 // indirect
 23 | 	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
 24 | 	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 25 | 	github.com/containerd/cgroups/v3 v3.0.5 // indirect
 26 | 	github.com/containerd/containerd/api v1.9.0 // indirect
 27 | 	github.com/containerd/continuity v0.4.5 // indirect
 28 | 	github.com/containerd/errdefs/pkg v0.3.0 // indirect
 29 | 	github.com/containerd/fifo v1.1.0 // indirect
 30 | 	github.com/containerd/log v0.1.0 // indirect
 31 | 	github.com/containerd/platforms v1.0.0-rc.1 // indirect
 32 | 	github.com/containerd/plugin v1.0.0 // indirect
 33 | 	github.com/containerd/ttrpc v1.2.7 // indirect
 34 | 	github.com/containerd/typeurl/v2 v2.2.3 // indirect
 35 | 	github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
 36 | 	github.com/docker/docker-credential-helpers v0.9.3 // indirect
 37 | 	github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c // indirect
 38 | 	github.com/docker/go-metrics v0.0.1 // indirect
 39 | 	github.com/felixge/httpsnoop v1.0.4 // indirect
 40 | 	github.com/go-logr/logr v1.4.2 // indirect
 41 | 	github.com/go-logr/stdr v1.2.2 // indirect
 42 | 	github.com/gogo/protobuf v1.3.2 // indirect
 43 | 	github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
 44 | 	github.com/google/go-cmp v0.7.0 // indirect
 45 | 	github.com/gorilla/handlers v1.5.2 // indirect
 46 | 	github.com/gorilla/mux v1.8.1 // indirect
 47 | 	github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.1 // indirect
 48 | 	github.com/hashicorp/golang-lru/arc/v2 v2.0.5 // indirect
 49 | 	github.com/hashicorp/golang-lru/v2 v2.0.5 // indirect
 50 | 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 51 | 	github.com/klauspost/compress v1.18.0 // indirect
 52 | 	github.com/moby/locker v1.0.1 // indirect
 53 | 	github.com/moby/sys/mountinfo v0.7.2 // indirect
 54 | 	github.com/moby/sys/sequential v0.6.0 // indirect
 55 | 	github.com/moby/sys/signal v0.7.1 // indirect
 56 | 	github.com/moby/sys/user v0.4.0 // indirect
 57 | 	github.com/moby/sys/userns v0.1.0 // indirect
 58 | 	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 59 | 	github.com/opencontainers/runtime-spec v1.2.1 // indirect
 60 | 	github.com/opencontainers/selinux v1.12.0 // indirect
 61 | 	github.com/pkg/errors v0.9.1 // indirect
 62 | 	github.com/prometheus/client_golang v1.22.0 // indirect
 63 | 	github.com/prometheus/client_model v0.6.1 // indirect
 64 | 	github.com/prometheus/common v0.62.0 // indirect
 65 | 	github.com/prometheus/procfs v0.15.1 // indirect
 66 | 	github.com/redis/go-redis/extra/rediscmd/v9 v9.0.5 // indirect
 67 | 	github.com/redis/go-redis/extra/redisotel/v9 v9.0.5 // indirect
 68 | 	github.com/redis/go-redis/v9 v9.7.3 // indirect
 69 | 	github.com/spf13/pflag v1.0.6 // indirect
 70 | 	go.opencensus.io v0.24.0 // indirect
 71 | 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 72 | 	go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 // indirect
 73 | 	go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 // indirect
 74 | 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
 75 | 	go.opentelemetry.io/otel v1.35.0 // indirect
 76 | 	go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 // indirect
 77 | 	go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 // indirect
 78 | 	go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 // indirect
 79 | 	go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 // indirect
 80 | 	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
 81 | 	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
 82 | 	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.35.0 // indirect
 83 | 	go.opentelemetry.io/otel/exporters/prometheus v0.54.0 // indirect
 84 | 	go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 // indirect
 85 | 	go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 // indirect
 86 | 	go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 // indirect
 87 | 	go.opentelemetry.io/otel/log v0.8.0 // indirect
 88 | 	go.opentelemetry.io/otel/metric v1.35.0 // indirect
 89 | 	go.opentelemetry.io/otel/sdk v1.35.0 // indirect
 90 | 	go.opentelemetry.io/otel/sdk/log v0.8.0 // indirect
 91 | 	go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
 92 | 	go.opentelemetry.io/otel/trace v1.35.0 // indirect
 93 | 	go.opentelemetry.io/proto/otlp v1.5.0 // indirect
 94 | 	golang.org/x/net v0.38.0 // indirect
 95 | 	golang.org/x/sync v0.14.0 // indirect
 96 | 	golang.org/x/sys v0.33.0 // indirect
 97 | 	golang.org/x/text v0.23.0 // indirect
 98 | 	google.golang.org/genproto/googleapis/api v0.0.0-20250218202821-56aae31c358a // indirect
 99 | 	google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect
100 | 	google.golang.org/grpc v1.72.0 // indirect
101 | 	google.golang.org/protobuf v1.36.6 // indirect
102 | 	gopkg.in/yaml.v2 v2.4.0 // indirect
103 | )
104 | 


--------------------------------------------------------------------------------
/internal/registry/config.go:
--------------------------------------------------------------------------------
 1 | package registry
 2 | 
 3 | // Config represents the registry configuration.
 4 | type Config struct {
 5 | 	// Addr is the address on which the registry server will listen.
 6 | 	Addr string
 7 | 	// ContainerdSock is the path to the containerd.sock socket.
 8 | 	ContainerdSock string
 9 | 	// ContainerdNamespace is the containerd namespace to use for storing images.
10 | 	ContainerdNamespace string
11 | 	// LogLevel is one of "debug", "info", "warn", "error".
12 | 	LogLevel string
13 | 	// LogFormatter to use for the logs. Either "text" or "json".
14 | 	LogFormatter string
15 | }
16 | 


--------------------------------------------------------------------------------
/internal/registry/registry.go:
--------------------------------------------------------------------------------
 1 | package registry
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"errors"
 6 | 	"fmt"
 7 | 	"net/http"
 8 | 
 9 | 	"github.com/distribution/distribution/v3/configuration"
10 | 	"github.com/distribution/distribution/v3/registry/handlers"
11 | 	_ "github.com/distribution/distribution/v3/registry/storage/driver/filesystem"
12 | 	"github.com/sirupsen/logrus"
13 | 	"github.com/uncloud/unregistry/internal/storage/containerd"
14 | 	_ "github.com/uncloud/unregistry/internal/storage/containerd"
15 | )
16 | 
17 | // Registry represents a complete instance of the registry.
18 | type Registry struct {
19 | 	app    *handlers.App
20 | 	server *http.Server
21 | }
22 | 
23 | // NewRegistry creates a new registry from the given configuration.
24 | func NewRegistry(cfg Config) (*Registry, error) {
25 | 	// Configure logging.
26 | 	level, err := logrus.ParseLevel(cfg.LogLevel)
27 | 	if err != nil {
28 | 		return nil, fmt.Errorf("invalid log level: %w", err)
29 | 	}
30 | 	logrus.SetLevel(level)
31 | 
32 | 	switch cfg.LogFormatter {
33 | 	case "json":
34 | 		logrus.SetFormatter(&logrus.JSONFormatter{})
35 | 	case "text":
36 | 		logrus.SetFormatter(&logrus.TextFormatter{})
37 | 	default:
38 | 		return nil, fmt.Errorf("invalid log formatter: '%s'; expected 'json' or 'text'", cfg.LogFormatter)
39 | 	}
40 | 
41 | 	distConfig := &configuration.Configuration{
42 | 		Storage: configuration.Storage{
43 | 			"filesystem": configuration.Parameters{
44 | 				"rootdirectory": "/tmp/registry", // Dummy storage driver
45 | 			},
46 | 			"maintenance": configuration.Parameters{
47 | 				"uploadpurging": map[interface{}]interface{}{
48 | 					"enabled": false,
49 | 				},
50 | 			},
51 | 		},
52 | 		Middleware: map[string][]configuration.Middleware{
53 | 			"registry": {
54 | 				{
55 | 					Name: containerd.MiddlewareName,
56 | 					Options: configuration.Parameters{
57 | 						"namespace": cfg.ContainerdNamespace,
58 | 						"sock":      cfg.ContainerdSock,
59 | 					},
60 | 				},
61 | 			},
62 | 		},
63 | 	}
64 | 	app := handlers.NewApp(context.Background(), distConfig)
65 | 	server := &http.Server{
66 | 		Addr:    cfg.Addr,
67 | 		Handler: app,
68 | 	}
69 | 
70 | 	return &Registry{
71 | 		app:    app,
72 | 		server: server,
73 | 	}, nil
74 | }
75 | 
76 | // ListenAndServe starts the HTTP server for the registry.
77 | func (r *Registry) ListenAndServe() error {
78 | 	logrus.WithField("addr", r.server.Addr).Info("Starting registry server.")
79 | 	if err := r.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
80 | 		return err
81 | 	}
82 | 	return nil
83 | }
84 | 
85 | // Shutdown gracefully shuts down the registry's HTTP server and application object.
86 | func (r *Registry) Shutdown(ctx context.Context) error {
87 | 	err := r.server.Shutdown(ctx)
88 | 	if appErr := r.app.Shutdown(); appErr != nil {
89 | 		err = errors.Join(err, appErr)
90 | 	}
91 | 	return err
92 | }
93 | 


--------------------------------------------------------------------------------
/internal/storage/containerd/blob.go:
--------------------------------------------------------------------------------
  1 | package containerd
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"io"
  7 | 	"net/http"
  8 | 	"strconv"
  9 | 
 10 | 	"github.com/containerd/containerd/v2/client"
 11 | 	"github.com/containerd/containerd/v2/core/content"
 12 | 	"github.com/containerd/errdefs"
 13 | 	"github.com/distribution/distribution/v3"
 14 | 	"github.com/distribution/reference"
 15 | 	"github.com/opencontainers/go-digest"
 16 | 	ocispec "github.com/opencontainers/image-spec/specs-go/v1"
 17 | )
 18 | 
 19 | // blobStore implements distribution.BlobStore backed by containerd image store.
 20 | type blobStore struct {
 21 | 	client *client.Client
 22 | 	repo   reference.Named
 23 | }
 24 | 
 25 | // Stat returns metadata about a blob in the containerd content store by its digest.
 26 | // If the blob doesn't exist, distribution.ErrBlobUnknown will be returned.
 27 | func (b *blobStore) Stat(ctx context.Context, dgst digest.Digest) (distribution.Descriptor, error) {
 28 | 	info, err := b.client.ContentStore().Info(ctx, dgst)
 29 | 	if err != nil {
 30 | 		if errdefs.IsNotFound(err) {
 31 | 			return distribution.Descriptor{}, distribution.ErrBlobUnknown
 32 | 		}
 33 | 		return distribution.Descriptor{}, fmt.Errorf(
 34 | 			"get metadata for blob '%s' from containerd content store: %w", dgst, err,
 35 | 		)
 36 | 	}
 37 | 
 38 | 	return distribution.Descriptor{
 39 | 		MediaType: "application/octet-stream",
 40 | 		Digest:    info.Digest,
 41 | 		Size:      info.Size,
 42 | 	}, nil
 43 | }
 44 | 
 45 | // Get retrieves the content of a blob in the containerd content store by its digest.
 46 | // If the blob doesn't exist, distribution.ErrBlobUnknown will be returned.
 47 | func (b *blobStore) Get(ctx context.Context, dgst digest.Digest) ([]byte, error) {
 48 | 	blob, err := content.ReadBlob(ctx, b.client.ContentStore(), ocispec.Descriptor{Digest: dgst})
 49 | 	if err != nil {
 50 | 		if errdefs.IsNotFound(err) {
 51 | 			return nil, distribution.ErrBlobUnknown
 52 | 		}
 53 | 		return nil, fmt.Errorf("read blob '%s' from containerd content store: %w", dgst, err)
 54 | 	}
 55 | 
 56 | 	return blob, nil
 57 | }
 58 | 
 59 | // Open returns a reader for the blob in the containerd content store by its digest.
 60 | func (b *blobStore) Open(ctx context.Context, dgst digest.Digest) (io.ReadSeekCloser, error) {
 61 | 	reader, err := newBlobReadSeekCloser(ctx, b.client.ContentStore(), ocispec.Descriptor{Digest: dgst})
 62 | 	if err != nil {
 63 | 		if errdefs.IsNotFound(err) {
 64 | 			return nil, distribution.ErrBlobUnknown
 65 | 		}
 66 | 		return nil, fmt.Errorf("open blob '%s' from containerd content store: %w", dgst, err)
 67 | 	}
 68 | 
 69 | 	return reader, nil
 70 | }
 71 | 
 72 | // Put stores a blob in the containerd content store with the given media type. If the blob already exists,
 73 | // it will return the existing descriptor without re-uploading the content. It should be used for small objects,
 74 | // such as manifests.
 75 | func (b *blobStore) Put(ctx context.Context, mediaType string, blob []byte) (distribution.Descriptor, error) {
 76 | 	writer, err := newBlobWriter(ctx, b.client, b.repo, "")
 77 | 	if err != nil {
 78 | 		return distribution.Descriptor{}, err
 79 | 	}
 80 | 	defer func() {
 81 | 		if err != nil {
 82 | 			// Clean up resources occupied by the writer if an error occurs.
 83 | 			_ = writer.Cancel(ctx)
 84 | 		}
 85 | 		writer.Close()
 86 | 	}()
 87 | 
 88 | 	if _, err = writer.Write(blob); err != nil {
 89 | 		return distribution.Descriptor{}, err
 90 | 	}
 91 | 
 92 | 	desc := distribution.Descriptor{
 93 | 		MediaType: mediaType,
 94 | 		Digest:    digest.FromBytes(blob),
 95 | 		Size:      int64(len(blob)),
 96 | 	}
 97 | 	if desc, err = writer.Commit(ctx, desc); err != nil {
 98 | 		return distribution.Descriptor{}, err
 99 | 	}
100 | 
101 | 	return desc, nil
102 | }
103 | 
104 | // Create creates a blob writer to add a blob to the containerd content store.`
105 | func (b *blobStore) Create(ctx context.Context, _ ...distribution.BlobCreateOption) (
106 | 	distribution.BlobWriter, error,
107 | ) {
108 | 	return newBlobWriter(ctx, b.client, b.repo, "")
109 | }
110 | 
111 | // Resume creates a blob writer for resuming an upload with a specific ID.
112 | func (b *blobStore) Resume(ctx context.Context, id string) (distribution.BlobWriter, error) {
113 | 	return newBlobWriter(ctx, b.client, b.repo, id)
114 | }
115 | 
116 | // Mount is not supported for simplicity.
117 | // We could implement cross-repository mounting here by checking if the blob exists and returning its descriptor.
118 | // However, the content in containerd is not repository-namespaced so checking if a blob exists in a new repository
119 | // will return true if it exists in the content store, regardless of the repository. Given that, we don't really
120 | // need the mount operation in this implementation.
121 | func (b *blobStore) Mount(ctx context.Context, sourceRepo reference.Named, dgst digest.Digest) (
122 | 	distribution.Descriptor, error,
123 | ) {
124 | 	return distribution.Descriptor{}, distribution.ErrUnsupported
125 | }
126 | 
127 | // ServeBlob serves the blob from containerd content store over HTTP.
128 | func (b *blobStore) ServeBlob(ctx context.Context, w http.ResponseWriter, r *http.Request, dgst digest.Digest) error {
129 | 	// Get the blob info to check if it exists and populate the response headers.
130 | 	desc, err := b.Stat(ctx, dgst)
131 | 	if err != nil {
132 | 		return err
133 | 	}
134 | 
135 | 	w.Header().Set("Content-Type", desc.MediaType)
136 | 	w.Header().Set("Content-Length", strconv.FormatInt(desc.Size, 10))
137 | 	w.Header().Set("Docker-Content-Digest", dgst.String())
138 | 	w.Header().Set("Etag", dgst.String())
139 | 
140 | 	if r.Method == http.MethodHead {
141 | 		return nil
142 | 	}
143 | 
144 | 	reader, err := b.Open(ctx, dgst)
145 | 	if err != nil {
146 | 		return err
147 | 	}
148 | 	defer reader.Close()
149 | 
150 | 	_, err = io.CopyN(w, reader, desc.Size)
151 | 	return err
152 | }
153 | 
154 | // Delete is not supported for simplicity.
155 | // Deletion can be done by deleting images in containerd, which will clean up the blobs.
156 | func (b *blobStore) Delete(ctx context.Context, dgst digest.Digest) error {
157 | 	return distribution.ErrUnsupported
158 | }
159 | 
160 | // blobReadSeekCloser is an io.ReadSeekCloser that wraps a content.ReaderAt.
161 | type blobReadSeekCloser struct {
162 | 	*io.SectionReader
163 | 	ra content.ReaderAt
164 | }
165 | 
166 | func newBlobReadSeekCloser(ctx context.Context, provider content.Provider, desc ocispec.Descriptor) (
167 | 	io.ReadSeekCloser, error,
168 | ) {
169 | 	ra, err := provider.ReaderAt(ctx, desc)
170 | 	if err != nil {
171 | 		return nil, err
172 | 	}
173 | 
174 | 	return &blobReadSeekCloser{
175 | 		SectionReader: io.NewSectionReader(ra, 0, ra.Size()),
176 | 		ra:            ra,
177 | 	}, nil
178 | }
179 | 
180 | func (rsc *blobReadSeekCloser) Close() error {
181 | 	return rsc.ra.Close()
182 | }
183 | 


--------------------------------------------------------------------------------
/internal/storage/containerd/blobwriter.go:
--------------------------------------------------------------------------------
  1 | package containerd
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"errors"
  6 | 	"fmt"
  7 | 	"io"
  8 | 	"time"
  9 | 
 10 | 	"github.com/google/uuid"
 11 | 	"github.com/sirupsen/logrus"
 12 | 
 13 | 	"github.com/containerd/containerd/v2/client"
 14 | 	"github.com/containerd/containerd/v2/core/content"
 15 | 	"github.com/containerd/containerd/v2/core/leases"
 16 | 	"github.com/containerd/errdefs"
 17 | 	"github.com/distribution/distribution/v3"
 18 | 	"github.com/distribution/reference"
 19 | )
 20 | 
 21 | const leaseExpiration = 1 * time.Hour
 22 | 
 23 | // blobWriter is a resumable blob uploader to the containerd content store.
 24 | // Implements distribution.BlobWriter.
 25 | type blobWriter struct {
 26 | 	client *client.Client
 27 | 	repo   reference.Named
 28 | 	id     string
 29 | 
 30 | 	// lease is a containerd lease for writer that prevents garbage collection of the content. It's intentionally not
 31 | 	// deleted on successful blob commit to keep it while the registry is uploading other blobs and manifests and
 32 | 	// creating an image referencing them. Otherwise, the blob would be garbage collected immediately after lease is
 33 | 	// deleted if the blob is not referenced by an image.
 34 | 	// In the worst case, the lease and unreferenced blob will be garbage collected after leaseExpiration.
 35 | 	lease  leases.Lease
 36 | 	writer content.Writer
 37 | 	// size is the total number of bytes written to writer.
 38 | 	size int64
 39 | 	log  *logrus.Entry
 40 | }
 41 | 
 42 | func newBlobWriter(
 43 | 	ctx context.Context, client *client.Client, repo reference.Named, id string,
 44 | ) (distribution.BlobWriter, error) {
 45 | 	if id == "" {
 46 | 		id = uuid.NewString()
 47 | 	}
 48 | 
 49 | 	// Create a containerd lease to prevent garbage collection.
 50 | 	opts := []leases.Opt{
 51 | 		leases.WithRandomID(),
 52 | 		leases.WithExpiration(leaseExpiration),
 53 | 	}
 54 | 	lease, err := client.LeasesService().Create(ctx, opts...)
 55 | 	if err != nil {
 56 | 		return nil, fmt.Errorf("create containerd lease: %w", err)
 57 | 	}
 58 | 
 59 | 	// Open a containerd content writer with the lease.
 60 | 	ctx = leases.WithLease(ctx, lease.ID)
 61 | 	writer, err := client.ContentStore().Writer(ctx, content.WithRef("upload-"+id))
 62 | 	if err != nil {
 63 | 		_ = client.LeasesService().Delete(ctx, lease)
 64 | 		return nil, fmt.Errorf("create containerd content writer: %w", err)
 65 | 	}
 66 | 
 67 | 	// Get the status of the writer to get the written offset (size) if the writer was resumed.
 68 | 	status, err := writer.Status()
 69 | 	if err != nil {
 70 | 		return nil, fmt.Errorf("get containerd content writer status: %w", err)
 71 | 	}
 72 | 
 73 | 	log := logrus.WithFields(
 74 | 		logrus.Fields{
 75 | 			"writer.id": id,
 76 | 			"repo":      repo.Name(),
 77 | 		},
 78 | 	)
 79 | 	log.WithField("size", status.Offset).Debug("Created new containerd blob writer.")
 80 | 
 81 | 	return &blobWriter{
 82 | 		client: client,
 83 | 		repo:   repo,
 84 | 		id:     id,
 85 | 		lease:  lease,
 86 | 		writer: writer,
 87 | 		size:   status.Offset,
 88 | 		log:    log,
 89 | 	}, nil
 90 | }
 91 | 
 92 | // ID returns the identifier for this blob upload.
 93 | func (bw *blobWriter) ID() string {
 94 | 	return bw.id
 95 | }
 96 | 
 97 | // StartedAt returns the time the upload started.
 98 | func (bw *blobWriter) StartedAt() time.Time {
 99 | 	return bw.lease.CreatedAt
100 | }
101 | 
102 | // Size returns the number of bytes written to the containerd blob writer.
103 | func (bw *blobWriter) Size() int64 {
104 | 	return bw.size
105 | }
106 | 
107 | // ReadFrom reads from the provided reader and writes to the containerd blob writer.
108 | func (bw *blobWriter) ReadFrom(r io.Reader) (int64, error) {
109 | 	n, err := io.Copy(bw.writer, r)
110 | 	bw.size += n
111 | 
112 | 	log := bw.log.WithField("size", n)
113 | 	if err != nil {
114 | 		err = fmt.Errorf("copy data to containerd blob writer: %w", err)
115 | 		log = log.WithError(err)
116 | 	}
117 | 	log.Debug("Copied data to containerd blob writer.")
118 | 
119 | 	return n, err
120 | }
121 | 
122 | // Write writes data to the containerd blob writer.
123 | func (bw *blobWriter) Write(data []byte) (int, error) {
124 | 	n, err := bw.writer.Write(data)
125 | 	bw.size += int64(n)
126 | 
127 | 	log := bw.log.WithField("size", n)
128 | 	if err != nil {
129 | 		err = fmt.Errorf("write data to containerd blob writer: %w", err)
130 | 		log = log.WithError(err)
131 | 	}
132 | 	log.Debug("Wrote data to containerd blob writer.")
133 | 
134 | 	return n, err
135 | }
136 | 
137 | // Commit finalizes the blob upload.
138 | func (bw *blobWriter) Commit(ctx context.Context, desc distribution.Descriptor) (distribution.Descriptor, error) {
139 | 	log := bw.log.WithFields(
140 | 		logrus.Fields{
141 | 			"digest":    desc.Digest,
142 | 			"mediatype": desc.MediaType,
143 | 			"size":      bw.size,
144 | 		},
145 | 	)
146 | 
147 | 	log.Debug("Committing blob to containerd content store.")
148 | 	// The caller may not provide a size in the descriptor if it doesn't know it so we use the calculated size from
149 | 	// the writer.
150 | 	if err := bw.writer.Commit(ctx, bw.size, desc.Digest); err != nil {
151 | 		// The writer didn't create a new blob so we don't need to keep the lease.
152 | 		_ = bw.client.LeasesService().Delete(ctx, bw.lease)
153 | 
154 | 		if errdefs.IsAlreadyExists(err) {
155 | 			log.Debug("Blob already exists in containerd content store.")
156 | 		} else {
157 | 			return distribution.Descriptor{}, fmt.Errorf("commit blob to containerd content store: %w", err)
158 | 		}
159 | 	} else {
160 | 		log.Debug("Successfully committed blob to containerd content store.")
161 | 	}
162 | 
163 | 	if desc.Size == 0 {
164 | 		desc.Size = bw.size
165 | 	}
166 | 	if desc.MediaType == "" {
167 | 		// Not sure if this is needed but the default registry blob writer assigns this.
168 | 		desc.MediaType = "application/octet-stream"
169 | 	}
170 | 
171 | 	return desc, nil
172 | }
173 | 
174 | // Cancel cancels the blob upload by deleting the containerd lease.
175 | func (bw *blobWriter) Cancel(ctx context.Context) error {
176 | 	bw.log.Debug("Canceling upload: deleting containerd lease.")
177 | 	return bw.client.LeasesService().Delete(ctx, bw.lease)
178 | }
179 | 
180 | // Close closes the containerd blob writer.
181 | func (bw *blobWriter) Close() error {
182 | 	bw.log.Debug("Closing containerd blob writer.")
183 | 	err := bw.writer.Close()
184 | 
185 | 	if bw.size == 0 {
186 | 		// It's safe to delete the lease if no data was written to the writer. Deletion is idempotent.
187 | 		err = errors.Join(bw.client.LeasesService().Delete(context.Background(), bw.lease))
188 | 	}
189 | 
190 | 	return err
191 | }
192 | 


--------------------------------------------------------------------------------
/internal/storage/containerd/manifest.go:
--------------------------------------------------------------------------------
  1 | package containerd
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"errors"
  6 | 	"fmt"
  7 | 
  8 | 	"github.com/distribution/distribution/v3"
  9 | 	"github.com/distribution/distribution/v3/manifest/manifestlist"
 10 | 	"github.com/distribution/distribution/v3/manifest/ocischema"
 11 | 	"github.com/distribution/distribution/v3/manifest/schema2"
 12 | 	"github.com/distribution/reference"
 13 | 	"github.com/opencontainers/go-digest"
 14 | 	"github.com/sirupsen/logrus"
 15 | )
 16 | 
 17 | // manifestService implements distribution.ManifestService backed by containerd content store.
 18 | type manifestService struct {
 19 | 	repo      reference.Named
 20 | 	blobStore *blobStore
 21 | }
 22 | 
 23 | // Exists checks if a manifest exists in the blob store by digest.
 24 | func (m *manifestService) Exists(ctx context.Context, dgst digest.Digest) (bool, error) {
 25 | 	_, err := m.blobStore.Stat(ctx, dgst)
 26 | 	if errors.Is(err, distribution.ErrBlobUnknown) {
 27 | 		return false, nil
 28 | 	}
 29 | 	return err == nil, err
 30 | }
 31 | 
 32 | // Get retrieves a manifest from the blob store by its digest.
 33 | func (m *manifestService) Get(
 34 | 	ctx context.Context, dgst digest.Digest, _ ...distribution.ManifestServiceOption,
 35 | ) (distribution.Manifest, error) {
 36 | 	blob, err := m.blobStore.Get(ctx, dgst)
 37 | 	if err != nil {
 38 | 		if errors.Is(err, distribution.ErrBlobUnknown) {
 39 | 			return nil, distribution.ErrManifestUnknownRevision{
 40 | 				Name:     m.repo.Name(),
 41 | 				Revision: dgst,
 42 | 			}
 43 | 		}
 44 | 		return nil, err
 45 | 	}
 46 | 
 47 | 	manifest, err := unmarshalManifest(blob)
 48 | 	if err != nil {
 49 | 		return nil, fmt.Errorf("unmarshal manifest: %w", err)
 50 | 	}
 51 | 
 52 | 	if mediaType, _, err := manifest.Payload(); err == nil {
 53 | 		logrus.WithFields(
 54 | 			logrus.Fields{
 55 | 				"repo":      m.repo.Name(),
 56 | 				"digest":    dgst,
 57 | 				"mediatype": mediaType,
 58 | 			},
 59 | 		).Debug("Got manifest from blob store.")
 60 | 	}
 61 | 
 62 | 	return manifest, nil
 63 | }
 64 | 
 65 | // Put stores a manifest in the blob store and returns its digest.
 66 | func (m *manifestService) Put(
 67 | 	ctx context.Context, manifest distribution.Manifest, _ ...distribution.ManifestServiceOption,
 68 | ) (digest.Digest, error) {
 69 | 	mediaType, payload, err := manifest.Payload()
 70 | 	if err != nil {
 71 | 		return "", fmt.Errorf("get manifest payload: %w", err)
 72 | 	}
 73 | 
 74 | 	desc, err := m.blobStore.Put(ctx, mediaType, payload)
 75 | 	if err != nil {
 76 | 		return "", fmt.Errorf("put manifest in blob store: %w", err)
 77 | 	}
 78 | 
 79 | 	return desc.Digest, nil
 80 | }
 81 | 
 82 | // Delete is not supported to keep things simple.
 83 | func (m *manifestService) Delete(_ context.Context, _ digest.Digest) error {
 84 | 	return distribution.ErrUnsupported
 85 | }
 86 | 
 87 | // unmarshalManifest attempts to unmarshal a manifest in various formats.
 88 | func unmarshalManifest(blob []byte) (distribution.Manifest, error) {
 89 | 	// Try OCI manifest.
 90 | 	var ociManifest ocischema.DeserializedManifest
 91 | 	if err := ociManifest.UnmarshalJSON(blob); err == nil {
 92 | 		return &ociManifest, nil
 93 | 	}
 94 | 
 95 | 	// Try Docker schema2 manifest.
 96 | 	var schema2Manifest schema2.DeserializedManifest
 97 | 	if err := schema2Manifest.UnmarshalJSON(blob); err == nil {
 98 | 		return &schema2Manifest, nil
 99 | 	}
100 | 
101 | 	// Try manifest list (OCI index or Docker manifest list).
102 | 	var manifestList manifestlist.DeserializedManifestList
103 | 	if err := manifestList.UnmarshalJSON(blob); err == nil {
104 | 		return &manifestList, nil
105 | 	}
106 | 
107 | 	return nil, distribution.ErrManifestVerification{errors.New("unknown manifest format")}
108 | }
109 | 


--------------------------------------------------------------------------------
/internal/storage/containerd/middleware.go:
--------------------------------------------------------------------------------
 1 | package containerd
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"fmt"
 6 | 
 7 | 	"github.com/containerd/containerd/v2/client"
 8 | 	"github.com/distribution/distribution/v3"
 9 | 	middleware "github.com/distribution/distribution/v3/registry/middleware/registry"
10 | 	storagedriver "github.com/distribution/distribution/v3/registry/storage/driver"
11 | )
12 | 
13 | const MiddlewareName = "containerd"
14 | 
15 | func init() {
16 | 	// Register the containerd middleware. In fact, this is not a middleware but a self-sufficient registry
17 | 	// implementation that uses containerd as the backend for storing images. It seems that using middleware
18 | 	// is the only way to register a custom registry in the distribution package.
19 | 	err := middleware.Register(MiddlewareName, registryMiddleware)
20 | 	if err != nil {
21 | 		panic(fmt.Sprintf("failed to register containerd middleware: %v", err))
22 | 	}
23 | }
24 | 
25 | // registryMiddleware is the registry middleware factory function that creates an instance of registry.
26 | func registryMiddleware(
27 | 	_ context.Context, _ distribution.Namespace, _ storagedriver.StorageDriver, options map[string]interface{},
28 | ) (distribution.Namespace, error) {
29 | 	sock, ok := options["sock"].(string)
30 | 	if !ok || sock == "" {
31 | 		return nil, fmt.Errorf("containerd socket path is required")
32 | 	}
33 | 	namespace, ok := options["namespace"].(string)
34 | 	if !ok || namespace == "" {
35 | 		return nil, fmt.Errorf("containerd namespace is required")
36 | 	}
37 | 
38 | 	cli, err := client.New(sock, client.WithDefaultNamespace(namespace))
39 | 	if err != nil {
40 | 		return nil, fmt.Errorf("create containerd client: %w", err)
41 | 	}
42 | 
43 | 	return &registry{client: cli}, nil
44 | }
45 | 


--------------------------------------------------------------------------------
/internal/storage/containerd/registry.go:
--------------------------------------------------------------------------------
 1 | package containerd
 2 | 
 3 | import (
 4 | 	"context"
 5 | 
 6 | 	"github.com/containerd/containerd/v2/client"
 7 | 	"github.com/distribution/distribution/v3"
 8 | 	"github.com/distribution/reference"
 9 | 	"github.com/opencontainers/go-digest"
10 | )
11 | 
12 | // registry implements distribution.Namespace backed by containerd image store.
13 | type registry struct {
14 | 	client *client.Client
15 | }
16 | 
17 | // Ensure registry implements distribution.registry.
18 | var _ distribution.Namespace = &registry{}
19 | 
20 | // Scope returns the global scope for this registry.
21 | func (r *registry) Scope() distribution.Scope {
22 | 	return distribution.GlobalScope
23 | }
24 | 
25 | // Repository returns an instance of repository for the given name.
26 | func (r *registry) Repository(_ context.Context, name reference.Named) (distribution.Repository, error) {
27 | 	return newRepository(r.client, name), nil
28 | }
29 | 
30 | // Repositories should return a list of repositories in the registry but it's not supported for simplicity.
31 | func (r *registry) Repositories(_ context.Context, _ []string, _ string) (int, error) {
32 | 	return 0, distribution.ErrUnsupported
33 | }
34 | 
35 | // Blobs returns a stub implementation of distribution.BlobEnumerator that doesn't support enumeration.
36 | func (r *registry) Blobs() distribution.BlobEnumerator {
37 | 	return &unsupportedBlobEnumerator{}
38 | }
39 | 
40 | // BlobStatter returns a blob store that can stat blobs in the containerd content store.
41 | // It doesn't seem BlobStatter is used in distribution, but it's part of the interface.
42 | func (r *registry) BlobStatter() distribution.BlobStatter {
43 | 	return &blobStore{
44 | 		client: r.client,
45 | 	}
46 | }
47 | 
48 | // unsupportedBlobEnumerator implements distribution.BlobEnumerator but doesn't support enumeration.
49 | type unsupportedBlobEnumerator struct{}
50 | 
51 | // Enumerate is not supported for containerd backend for now.
52 | // It looks like distribution.BlobEnumerator is used for garbage collection, but we don't need that because containerd
53 | // has its own garbage collection mechanism that works with content store directly.
54 | func (e *unsupportedBlobEnumerator) Enumerate(_ context.Context, _ func(digest.Digest) error) error {
55 | 	return distribution.ErrUnsupported
56 | }
57 | 


--------------------------------------------------------------------------------
/internal/storage/containerd/repository.go:
--------------------------------------------------------------------------------
 1 | package containerd
 2 | 
 3 | import (
 4 | 	"context"
 5 | 
 6 | 	"github.com/containerd/containerd/v2/client"
 7 | 	"github.com/distribution/distribution/v3"
 8 | 	"github.com/distribution/reference"
 9 | )
10 | 
11 | // repository implements distribution.Repository backed by the containerd content and image stores.
12 | type repository struct {
13 | 	client    *client.Client
14 | 	name      reference.Named
15 | 	blobStore *blobStore
16 | }
17 | 
18 | var _ distribution.Repository = &repository{}
19 | 
20 | func newRepository(client *client.Client, name reference.Named) *repository {
21 | 	return &repository{
22 | 		client: client,
23 | 		name:   name,
24 | 		blobStore: &blobStore{
25 | 			client: client,
26 | 			repo:   name,
27 | 		},
28 | 	}
29 | }
30 | 
31 | // Named returns the name of the repository.
32 | func (r *repository) Named() reference.Named {
33 | 	return r.name
34 | }
35 | 
36 | // Manifests returns the manifest service for the repository backed by the containerd content store.
37 | func (r *repository) Manifests(
38 | 	_ context.Context, _ ...distribution.ManifestServiceOption,
39 | ) (distribution.ManifestService, error) {
40 | 	return &manifestService{
41 | 		repo:      r.name,
42 | 		blobStore: r.blobStore,
43 | 	}, nil
44 | }
45 | 
46 | // Blobs returns the blob store for the repository backed by the containerd content store.
47 | func (r *repository) Blobs(_ context.Context) distribution.BlobStore {
48 | 	return r.blobStore
49 | }
50 | 
51 | // Tags returns the tag service for the repository backed by the containerd image store.
52 | func (r *repository) Tags(_ context.Context) distribution.TagService {
53 | 	// Shouldn't return an error as r.name is a valid reference.
54 | 	canonicalRepo, _ := reference.ParseNormalizedNamed(r.name.String())
55 | 	return &tagService{
56 | 		client:        r.client,
57 | 		canonicalRepo: canonicalRepo,
58 | 	}
59 | }
60 | 


--------------------------------------------------------------------------------
/internal/storage/containerd/tags.go:
--------------------------------------------------------------------------------
  1 | package containerd
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 
  7 | 	"github.com/sirupsen/logrus"
  8 | 
  9 | 	"github.com/containerd/containerd/v2/client"
 10 | 	"github.com/containerd/containerd/v2/core/images"
 11 | 	"github.com/containerd/errdefs"
 12 | 	"github.com/distribution/distribution/v3"
 13 | 	"github.com/distribution/reference"
 14 | )
 15 | 
 16 | // tagService implements distribution.TagService backed by the containerd image store.
 17 | type tagService struct {
 18 | 	client *client.Client
 19 | 	// canonicalRepo is the repository reference in a normalized form, the way containerd image store expects it,
 20 | 	// for example, "docker.io/library/ubuntu"
 21 | 	canonicalRepo reference.Named
 22 | }
 23 | 
 24 | // Get retrieves an image descriptor by its tag from the containerd image store.
 25 | func (t *tagService) Get(ctx context.Context, tag string) (distribution.Descriptor, error) {
 26 | 	ref, err := reference.WithTag(t.canonicalRepo, tag)
 27 | 	if err != nil {
 28 | 		return distribution.Descriptor{}, distribution.ErrManifestUnknown{
 29 | 			Name: t.canonicalRepo.Name(),
 30 | 			Tag:  tag,
 31 | 		}
 32 | 	}
 33 | 
 34 | 	img, err := t.client.ImageService().Get(ctx, ref.String())
 35 | 	if err != nil {
 36 | 		logrus.WithField("image", ref.String()).WithError(err).Debug("Failed to get image from containerd image store.")
 37 | 		if errdefs.IsNotFound(err) {
 38 | 			return distribution.Descriptor{}, distribution.ErrTagUnknown{Tag: tag}
 39 | 
 40 | 		}
 41 | 		return distribution.Descriptor{}, fmt.Errorf(
 42 | 			"get image '%s' from containerd image store: %w", ref.String(), err,
 43 | 		)
 44 | 	}
 45 | 	logrus.WithFields(
 46 | 		logrus.Fields{
 47 | 			"image":      ref.String(),
 48 | 			"descriptor": img.Target,
 49 | 		},
 50 | 	).Debug("Got image from containerd image store.")
 51 | 
 52 | 	return img.Target, nil
 53 | }
 54 | 
 55 | // Tag creates or updates the image tag in the containerd image store. The descriptor must be an image/index manifest
 56 | // that is already present in the containerd content store.
 57 | // It also sets garbage collection labels on the image content in the containerd content store to prevent it from being
 58 | // deleted by garbage collection.
 59 | func (t *tagService) Tag(ctx context.Context, tag string, desc distribution.Descriptor) error {
 60 | 	ref, err := reference.WithTag(t.canonicalRepo, tag)
 61 | 	if err != nil {
 62 | 		return err
 63 | 	}
 64 | 
 65 | 	img := images.Image{
 66 | 		Name:   ref.String(),
 67 | 		Target: desc,
 68 | 	}
 69 | 
 70 | 	// Just before creating or updating the image in the containerd image store, we need to assign appropriate garbage
 71 | 	// collection labels to its content (manifests, config, layers). This is necessary to ensure that the content is not
 72 | 	// deleted by GC once the leases that uploaded the content are expired or deleted.
 73 | 	// See for more details:
 74 | 	// https://github.com/containerd/containerd/blob/main/docs/garbage-collection.md#garbage-collection-labels
 75 | 	//
 76 | 	// TODO: delete unnecessary leases after setting the GC labels. It seems to be non-trivial to do so, because we need
 77 | 	//  to keep track of which leases were used to upload which content and share this info between
 78 | 	//  the blobStore/blobWriter and tagService. The downside of keeping them around is the image content will be kept
 79 | 	//  in the store even if the image is deleted, until the leases expire (default is leaseExpiration).
 80 | 
 81 | 	contentStore := t.client.ContentStore()
 82 | 	// Get all the children descriptors (manifests, config, layers) for an image index or manifest.
 83 | 	childrenHandler := images.ChildrenHandler(contentStore)
 84 | 	// Recursively set garbage collection labels on each descriptor for the content of its children to prevent them
 85 | 	// from being deleted by GC.
 86 | 	setGCLabelsHandler := images.SetChildrenMappedLabels(contentStore, childrenHandler, nil)
 87 | 	if err = images.Dispatch(ctx, setGCLabelsHandler, nil, desc); err != nil {
 88 | 		return fmt.Errorf(
 89 | 			"set garbage collection labels for content of image '%s' in containerd content store: %w", ref.String(),
 90 | 			err,
 91 | 		)
 92 | 	}
 93 | 	log := logrus.WithFields(
 94 | 		logrus.Fields{
 95 | 			"image":      ref.String(),
 96 | 			"descriptor": desc,
 97 | 		},
 98 | 	)
 99 | 	log.Debug("Set garbage collection labels for image content in containerd content store.")
100 | 
101 | 	imageService := t.client.ImageService()
102 | 	if _, err = imageService.Create(ctx, img); err != nil {
103 | 		if !errdefs.IsAlreadyExists(err) {
104 | 			return fmt.Errorf("create image '%s' in containerd image store: %w", ref.String(), err)
105 | 		}
106 | 
107 | 		_, err = imageService.Update(ctx, img)
108 | 		if err != nil {
109 | 			return fmt.Errorf("update image '%s' in containerd image store: %w", ref.String(), err)
110 | 		}
111 | 
112 | 		log.Debug("Updated existing image in containerd image store.")
113 | 	} else {
114 | 		log.Debug("Created new image in containerd image store.")
115 | 	}
116 | 
117 | 	return nil
118 | }
119 | 
120 | // Untag is not supported for simplicity.
121 | // An image could be untagged by deleting the image in containerd.
122 | func (t *tagService) Untag(ctx context.Context, tag string) error {
123 | 	return distribution.ErrUnsupported
124 | }
125 | 
126 | // All should return all tags associated with the repository but discovery operations are not supported for simplicity.
127 | func (t *tagService) All(ctx context.Context) ([]string, error) {
128 | 	return nil, distribution.ErrUnsupported
129 | }
130 | 
131 | // Lookup should find tags associated with a descriptor but discovery operations are not supported for simplicity.
132 | func (t *tagService) Lookup(ctx context.Context, desc distribution.Descriptor) ([]string, error) {
133 | 	return nil, distribution.ErrUnsupported
134 | }
135 | 


--------------------------------------------------------------------------------
/misc/dummy.go:
--------------------------------------------------------------------------------
 1 | // This is a dummy Go file to be built as part of the goreleaser.
 2 | // It is a workaround for the GoReleaser having difficulties with publishing
 3 | // homebrew casks without an actual built binary.
 4 | // Why not building a real unregistry binary? We want to release homebrew casks
 5 | // for multiple architectures and OSes, so building a real binary
 6 | // would be slow due to cross-compilation.
 7 | package main
 8 | 
 9 | func main() {}
10 | 


--------------------------------------------------------------------------------
/scripts/dind-entrypoint.sh:
--------------------------------------------------------------------------------
 1 | #!/bin/sh
 2 | set -eu
 3 | 
 4 | # Cleanup function to properly terminate background processes.
 5 | cleanup() {
 6 |     echo "Terminating container processes..."
 7 | 
 8 |     # Terminate the main process if it has been started.
 9 |     if [ -n "${MAIN_PID:-}" ]; then
10 |         kill "$MAIN_PID" 2>/dev/null || true
11 |     fi
12 | 
13 |     # Terminate Docker daemon if PID file exists.
14 |     if [ -f /run/docker.pid ]; then
15 |         kill "$(cat /run/docker.pid)" 2>/dev/null || true
16 |     fi
17 | 
18 |     # Wait for processes to terminate.
19 |     wait
20 | }
21 | trap cleanup INT TERM EXIT
22 | 
23 | if [ "${DOCKER_CONTAINERD_STORE:-true}" = "true" ]; then
24 |     echo "Using containerd image store for Docker."
25 |     mkdir -p /etc/docker
26 |     echo '{"features": {"containerd-snapshotter": true}}' > /etc/docker/daemon.json
27 | else
28 |     echo "Using the default Docker image store."
29 | fi
30 | 
31 | dind dockerd --host=tcp://0.0.0.0:2375 --tls=false &
32 | 
33 | # Execute the passed command and wait for it while maintaining signal handling.
34 | "$@" &
35 | MAIN_PID=$!
36 | wait $MAIN_PID
37 | 


--------------------------------------------------------------------------------
/scripts/release-version.sh:
--------------------------------------------------------------------------------
 1 | #!/usr/bin/env bash
 2 | set -euo pipefail
 3 | 
 4 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
 5 | cd "${SCRIPT_DIR}/.."
 6 | 
 7 | # Ensure a new version argument is provided
 8 | if [ "$#" -lt 1 ]; then
 9 |     echo "Usage: ${0} <new-version> [--execute]"
10 |     exit 1
11 | fi
12 | NEW_VERSION="$1"
13 | EXECUTE=0
14 | 
15 | # Exit if there are uncommitted changes
16 | if ! git diff-index --quiet HEAD --; then
17 |     echo "Error: There are uncommitted changes. Please commit or stash them before running this script."
18 |     exit 1
19 | fi
20 | 
21 | # Check if the --execute flag is passed
22 | if [ "${2:-}" == "--execute" ]; then
23 |     EXECUTE=1
24 | else
25 |     echo "Dry-run mode: No changes will be committed or tagged. Use '--execute' to apply changes."
26 | fi
27 | 
28 | # Validate semantic version format
29 | if ! [[ "$NEW_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
30 |     echo "Error: Version must be in semantic format (e.g., 1.2.3)"
31 |     exit 1
32 | fi
33 | 
34 | echo "Preparing to bump version to ${NEW_VERSION}..."
35 | echo "Updating version in relevant files..."
36 | 
37 | # Update version in README.md using backreference
38 | perl -pi -e 's|(https://raw.githubusercontent.com/psviderski/unregistry/v)[0-9]+\.[0-9]+\.[0-9]+(/docker-pussh)|${1}'"${NEW_VERSION}"'${2}|' README.md
39 | 
40 | # Update VERSION field in docker-pussh
41 | perl -pi -e "s|^VERSION=\"[0-9]+\.[0-9]+\.[0-9]+\"|VERSION=\"${NEW_VERSION}\"|" docker-pussh
42 | 
43 | echo -e "Changes pending:\n---"
44 | git diff
45 | echo "---"
46 | 
47 | TAG_NAME="v$NEW_VERSION"
48 | COMMIT_MESSAGE="release: Bump version to ${NEW_VERSION}"
49 | 
50 | echo "Building the project with goreleaser..."
51 | goreleaser build --clean --snapshot
52 | echo "Project built successfully."
53 | 
54 | if [ "$EXECUTE" = "1" ]; then
55 |     echo "Executing changes..."
56 |     git add -u
57 |     git commit -m "${COMMIT_MESSAGE}"
58 |     git tag "${TAG_NAME}"
59 |     git push origin main "${TAG_NAME}"
60 |     echo "Version bumped to ${NEW_VERSION} and git tag ${TAG_NAME} created."
61 |     echo "Running goreleaser..."
62 |     goreleaser release --clean
63 | else
64 |     echo "Would create commit with message: '${COMMIT_MESSAGE}'"
65 |     echo "Would create tag: ${TAG_NAME}"
66 |     echo "Would run 'goreleaser release --clean' to publish the release."
67 |     echo "Reverting back changes..."
68 |     git checkout .
69 | fi
70 | 


--------------------------------------------------------------------------------
/test/conformance/.gitignore:
--------------------------------------------------------------------------------
1 | junit.xml
2 | report.html
3 | 


--------------------------------------------------------------------------------
/test/conformance/00_conformance_suite_test.go:
--------------------------------------------------------------------------------
 1 | package conformance
 2 | 
 3 | import (
 4 | 	"log"
 5 | 	"os"
 6 | 	"testing"
 7 | 
 8 | 	g "github.com/onsi/ginkgo/v2"
 9 | 	"github.com/onsi/ginkgo/v2/reporters"
10 | 	. "github.com/onsi/gomega"
11 | )
12 | 
13 | func TestConformance(t *testing.T) {
14 | 	// Setup unregistry container before running tests.
15 | 	unregistryContainer, url := SetupUnregistry(t)
16 | 	// Clean up the container after all tests.
17 | 	t.Cleanup(func() {
18 | 		TeardownUnregistry(t, unregistryContainer)
19 | 	})
20 | 
21 | 	// Configure environment variables for conformance tests.
22 | 	os.Setenv("OCI_ROOT_URL", url)
23 | 	os.Setenv("OCI_NAMESPACE", "conformance")
24 | 	// Enable push and pull tests only. Discover and management are not supported yet.
25 | 	os.Setenv("OCI_TEST_PULL", "1")
26 | 	os.Setenv("OCI_TEST_PUSH", "1")
27 | 	//os.Setenv("OCI_TEST_CONTENT_DISCOVERY", "1")
28 | 	//os.Setenv("OCI_TEST_CONTENT_MANAGEMENT", "1")
29 | 	// Set debug mode for better logging.
30 | 	//os.Setenv("OCI_DEBUG", "1")
31 | 
32 | 	setup()
33 | 
34 | 	g.Describe(suiteDescription, func() {
35 | 		test01Pull()
36 | 		test02Push()
37 | 		test03ContentDiscovery()
38 | 		test04ContentManagement()
39 | 	})
40 | 
41 | 	RegisterFailHandler(g.Fail)
42 | 	suiteConfig, reporterConfig := g.GinkgoConfiguration()
43 | 	hr := newHTMLReporter(reportHTMLFilename)
44 | 	g.ReportAfterEach(hr.afterReport)
45 | 	g.ReportAfterSuite("html custom reporter", func(r g.Report) {
46 | 		if err := hr.endSuite(r); err != nil {
47 | 			log.Printf("\nWARNING: cannot write HTML summary report: %v", err)
48 | 		}
49 | 	})
50 | 	g.ReportAfterSuite("junit custom reporter", func(r g.Report) {
51 | 		if reportJUnitFilename != "" {
52 | 			_ = reporters.GenerateJUnitReportWithConfig(r, reportJUnitFilename, reporters.JunitReportConfig{
53 | 				OmitLeafNodeType: true,
54 | 			})
55 | 		}
56 | 	})
57 | 	g.RunSpecs(t, "conformance tests", suiteConfig, reporterConfig)
58 | }
59 | 


--------------------------------------------------------------------------------
/test/conformance/01_pull_test.go:
--------------------------------------------------------------------------------
  1 | package conformance
  2 | 
  3 | import (
  4 | 	"net/http"
  5 | 	"os"
  6 | 
  7 | 	"github.com/bloodorangeio/reggie"
  8 | 	g "github.com/onsi/ginkgo/v2"
  9 | 	. "github.com/onsi/gomega"
 10 | )
 11 | 
 12 | var test01Pull = func() {
 13 | 	g.Context(titlePull, func() {
 14 | 
 15 | 		var tag string
 16 | 
 17 | 		g.Context("Setup", func() {
 18 | 			g.Specify("Populate registry with test blob", func() {
 19 | 				SkipIfDisabled(pull)
 20 | 				RunOnlyIf(runPullSetup)
 21 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/")
 22 | 				resp, err := client.Do(req)
 23 | 				Expect(err).To(BeNil())
 24 | 				req = client.NewRequest(reggie.PUT, resp.GetRelativeLocation()).
 25 | 					SetQueryParam("digest", configs[0].Digest).
 26 | 					SetHeader("Content-Type", "application/octet-stream").
 27 | 					SetHeader("Content-Length", configs[0].ContentLength).
 28 | 					SetBody(configs[0].Content)
 29 | 				resp, err = client.Do(req)
 30 | 				Expect(err).To(BeNil())
 31 | 				Expect(resp.StatusCode()).To(SatisfyAll(
 32 | 					BeNumerically(">=", 200),
 33 | 					BeNumerically("<", 300)))
 34 | 			})
 35 | 
 36 | 			g.Specify("Populate registry with test blob", func() {
 37 | 				SkipIfDisabled(pull)
 38 | 				RunOnlyIf(runPullSetup)
 39 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/")
 40 | 				resp, err := client.Do(req)
 41 | 				Expect(err).To(BeNil())
 42 | 				req = client.NewRequest(reggie.PUT, resp.GetRelativeLocation()).
 43 | 					SetQueryParam("digest", configs[1].Digest).
 44 | 					SetHeader("Content-Type", "application/octet-stream").
 45 | 					SetHeader("Content-Length", configs[1].ContentLength).
 46 | 					SetBody(configs[1].Content)
 47 | 				resp, err = client.Do(req)
 48 | 				Expect(err).To(BeNil())
 49 | 				Expect(resp.StatusCode()).To(SatisfyAll(
 50 | 					BeNumerically(">=", 200),
 51 | 					BeNumerically("<", 300)))
 52 | 			})
 53 | 
 54 | 			g.Specify("Populate registry with test layer", func() {
 55 | 				SkipIfDisabled(pull)
 56 | 				RunOnlyIf(runPullSetup)
 57 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/")
 58 | 				resp, err := client.Do(req)
 59 | 				Expect(err).To(BeNil())
 60 | 				req = client.NewRequest(reggie.PUT, resp.GetRelativeLocation()).
 61 | 					SetQueryParam("digest", layerBlobDigest).
 62 | 					SetHeader("Content-Type", "application/octet-stream").
 63 | 					SetHeader("Content-Length", layerBlobContentLength).
 64 | 					SetBody(layerBlobData)
 65 | 				resp, err = client.Do(req)
 66 | 				Expect(err).To(BeNil())
 67 | 				Expect(resp.StatusCode()).To(SatisfyAll(
 68 | 					BeNumerically(">=", 200),
 69 | 					BeNumerically("<", 300)))
 70 | 			})
 71 | 
 72 | 			g.Specify("Populate registry with test manifest", func() {
 73 | 				SkipIfDisabled(pull)
 74 | 				RunOnlyIf(runPullSetup)
 75 | 				tag = testTagName
 76 | 				req := client.NewRequest(reggie.PUT, "/v2/<name>/manifests/<reference>",
 77 | 					reggie.WithReference(tag)).
 78 | 					SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json").
 79 | 					SetBody(manifests[0].Content)
 80 | 				resp, err := client.Do(req)
 81 | 				Expect(err).To(BeNil())
 82 | 				Expect(resp.StatusCode()).To(SatisfyAll(
 83 | 					BeNumerically(">=", 200),
 84 | 					BeNumerically("<", 300)))
 85 | 			})
 86 | 
 87 | 			g.Specify("Populate registry with test manifest", func() {
 88 | 				SkipIfDisabled(pull)
 89 | 				RunOnlyIf(runPullSetup)
 90 | 				req := client.NewRequest(reggie.PUT, "/v2/<name>/manifests/<reference>",
 91 | 					reggie.WithReference(manifests[1].Digest)).
 92 | 					SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json").
 93 | 					SetBody(manifests[1].Content)
 94 | 				resp, err := client.Do(req)
 95 | 				Expect(err).To(BeNil())
 96 | 				Expect(resp.StatusCode()).To(SatisfyAll(
 97 | 					BeNumerically(">=", 200),
 98 | 					BeNumerically("<", 300)))
 99 | 			})
100 | 
101 | 			g.Specify("Get tag name from environment", func() {
102 | 				SkipIfDisabled(pull)
103 | 				RunOnlyIfNot(runPullSetup)
104 | 				tmp := os.Getenv(envVarTagName)
105 | 				if tmp != "" {
106 | 					tag = tmp
107 | 				}
108 | 			})
109 | 		})
110 | 
111 | 		g.Context("Pull blobs", func() {
112 | 			g.Specify("HEAD request to nonexistent blob should result in 404 response", func() {
113 | 				SkipIfDisabled(pull)
114 | 				req := client.NewRequest(reggie.HEAD, "/v2/<name>/blobs/<digest>",
115 | 					reggie.WithDigest(dummyDigest))
116 | 				resp, err := client.Do(req)
117 | 				Expect(err).To(BeNil())
118 | 				Expect(resp.StatusCode()).To(Equal(http.StatusNotFound))
119 | 			})
120 | 
121 | 			g.Specify("HEAD request to existing blob should yield 200", func() {
122 | 				SkipIfDisabled(pull)
123 | 				req := client.NewRequest(reggie.HEAD, "/v2/<name>/blobs/<digest>",
124 | 					reggie.WithDigest(configs[0].Digest))
125 | 				resp, err := client.Do(req)
126 | 				Expect(err).To(BeNil())
127 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
128 | 				if h := resp.Header().Get("Docker-Content-Digest"); h != "" {
129 | 					Expect(h).To(Equal(configs[0].Digest))
130 | 				}
131 | 			})
132 | 
133 | 			g.Specify("GET nonexistent blob should result in 404 response", func() {
134 | 				SkipIfDisabled(pull)
135 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/blobs/<digest>",
136 | 					reggie.WithDigest(dummyDigest))
137 | 				resp, err := client.Do(req)
138 | 				Expect(err).To(BeNil())
139 | 				Expect(resp.StatusCode()).To(Equal(http.StatusNotFound))
140 | 			})
141 | 
142 | 			g.Specify("GET request to existing blob URL should yield 200", func() {
143 | 				SkipIfDisabled(pull)
144 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/blobs/<digest>", reggie.WithDigest(configs[0].Digest))
145 | 				resp, err := client.Do(req)
146 | 				Expect(err).To(BeNil())
147 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
148 | 			})
149 | 		})
150 | 
151 | 		g.Context("Pull manifests", func() {
152 | 			g.Specify("HEAD request to nonexistent manifest should return 404", func() {
153 | 				SkipIfDisabled(pull)
154 | 				req := client.NewRequest(reggie.HEAD, "/v2/<name>/manifests/<reference>",
155 | 					reggie.WithReference(nonexistentManifest))
156 | 				resp, err := client.Do(req)
157 | 				Expect(err).To(BeNil())
158 | 				Expect(resp.StatusCode()).To(Equal(http.StatusNotFound))
159 | 			})
160 | 
161 | 			g.Specify("HEAD request to manifest[0] path (digest) should yield 200 response", func() {
162 | 				SkipIfDisabled(pull)
163 | 				req := client.NewRequest(reggie.HEAD, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[0].Digest)).
164 | 					SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
165 | 				resp, err := client.Do(req)
166 | 				Expect(err).To(BeNil())
167 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
168 | 				if h := resp.Header().Get("Docker-Content-Digest"); h != "" {
169 | 					Expect(h).To(Equal(manifests[0].Digest))
170 | 				}
171 | 			})
172 | 
173 | 			g.Specify("HEAD request to manifest[1] path (digest) should yield 200 response", func() {
174 | 				SkipIfDisabled(pull)
175 | 				req := client.NewRequest(reggie.HEAD, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[1].Digest)).
176 | 					SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
177 | 				resp, err := client.Do(req)
178 | 				Expect(err).To(BeNil())
179 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
180 | 				if h := resp.Header().Get("Docker-Content-Digest"); h != "" {
181 | 					Expect(h).To(Equal(manifests[1].Digest))
182 | 				}
183 | 			})
184 | 
185 | 			g.Specify("HEAD request to manifest path (tag) should yield 200 response", func() {
186 | 				SkipIfDisabled(pull)
187 | 				Expect(tag).ToNot(BeEmpty())
188 | 				req := client.NewRequest(reggie.HEAD, "/v2/<name>/manifests/<reference>", reggie.WithReference(tag)).
189 | 					SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
190 | 				resp, err := client.Do(req)
191 | 				Expect(err).To(BeNil())
192 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
193 | 				if h := resp.Header().Get("Docker-Content-Digest"); h != "" {
194 | 					Expect(h).To(Equal(manifests[0].Digest))
195 | 				}
196 | 			})
197 | 
198 | 			g.Specify("GET nonexistent manifest should return 404", func() {
199 | 				SkipIfDisabled(pull)
200 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/manifests/<reference>",
201 | 					reggie.WithReference(nonexistentManifest))
202 | 				resp, err := client.Do(req)
203 | 				Expect(err).To(BeNil())
204 | 				Expect(resp.StatusCode()).To(Equal(http.StatusNotFound))
205 | 			})
206 | 
207 | 			g.Specify("GET request to manifest[0] path (digest) should yield 200 response", func() {
208 | 				SkipIfDisabled(pull)
209 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[0].Digest)).
210 | 					SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
211 | 				resp, err := client.Do(req)
212 | 				Expect(err).To(BeNil())
213 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
214 | 			})
215 | 
216 | 			g.Specify("GET request to manifest[1] path (digest) should yield 200 response", func() {
217 | 				SkipIfDisabled(pull)
218 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[1].Digest)).
219 | 					SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
220 | 				resp, err := client.Do(req)
221 | 				Expect(err).To(BeNil())
222 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
223 | 			})
224 | 
225 | 			g.Specify("GET request to manifest path (tag) should yield 200 response", func() {
226 | 				SkipIfDisabled(pull)
227 | 				Expect(tag).ToNot(BeEmpty())
228 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/manifests/<reference>", reggie.WithReference(tag)).
229 | 					SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
230 | 				resp, err := client.Do(req)
231 | 				Expect(err).To(BeNil())
232 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
233 | 			})
234 | 		})
235 | 
236 | 		g.Context("Error codes", func() {
237 | 			g.Specify("400 response body should contain OCI-conforming JSON message", func() {
238 | 				g.Skip("Skipped as the distribution package returns 500 for invalid manifests")
239 | 				SkipIfDisabled(pull)
240 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/manifests/<reference>",
241 | 					reggie.WithReference("sha256:totallywrong")).
242 | 					SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json").
243 | 					SetBody(invalidManifestContent)
244 | 				resp, err := client.Do(req)
245 | 				Expect(err).To(BeNil())
246 | 				Expect(resp.StatusCode()).To(SatisfyAny(
247 | 					Equal(http.StatusBadRequest),
248 | 					Equal(http.StatusNotFound)))
249 | 				if resp.StatusCode() == http.StatusBadRequest {
250 | 					errorResponses, err := resp.Errors()
251 | 					Expect(err).To(BeNil())
252 | 
253 | 					Expect(errorResponses).ToNot(BeEmpty())
254 | 					Expect(errorCodes).To(ContainElement(errorResponses[0].Code))
255 | 				}
256 | 			})
257 | 		})
258 | 
259 | 		g.Context("Teardown", func() {
260 | 			if deleteManifestBeforeBlobs {
261 | 				g.Specify("Delete manifest[0] created in setup", func() {
262 | 					SkipIfDisabled(pull)
263 | 					RunOnlyIf(runPullSetup)
264 | 					req := client.NewRequest(reggie.DELETE, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[0].Digest))
265 | 					resp, err := client.Do(req)
266 | 					Expect(err).To(BeNil())
267 | 					Expect(resp.StatusCode()).To(SatisfyAny(
268 | 						SatisfyAll(
269 | 							BeNumerically(">=", 200),
270 | 							BeNumerically("<", 300),
271 | 						),
272 | 						Equal(http.StatusMethodNotAllowed),
273 | 					))
274 | 				})
275 | 				g.Specify("Delete manifest[1] created in setup", func() {
276 | 					SkipIfDisabled(pull)
277 | 					RunOnlyIf(runPullSetup)
278 | 					req := client.NewRequest(reggie.DELETE, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[1].Digest))
279 | 					resp, err := client.Do(req)
280 | 					Expect(err).To(BeNil())
281 | 					Expect(resp.StatusCode()).To(SatisfyAny(
282 | 						SatisfyAll(
283 | 							BeNumerically(">=", 200),
284 | 							BeNumerically("<", 300),
285 | 						),
286 | 						Equal(http.StatusMethodNotAllowed),
287 | 					))
288 | 				})
289 | 			}
290 | 
291 | 			g.Specify("Delete config[0] blob created in setup", func() {
292 | 				SkipIfDisabled(pull)
293 | 				RunOnlyIf(runPullSetup)
294 | 				req := client.NewRequest(reggie.DELETE, "/v2/<name>/blobs/<digest>", reggie.WithDigest(configs[0].Digest))
295 | 				resp, err := client.Do(req)
296 | 				Expect(err).To(BeNil())
297 | 				Expect(resp.StatusCode()).To(SatisfyAny(
298 | 					SatisfyAll(
299 | 						BeNumerically(">=", 200),
300 | 						BeNumerically("<", 300),
301 | 					),
302 | 					Equal(http.StatusNotFound),
303 | 					Equal(http.StatusMethodNotAllowed),
304 | 				))
305 | 			})
306 | 			g.Specify("Delete config[1] blob created in setup", func() {
307 | 				SkipIfDisabled(pull)
308 | 				RunOnlyIf(runPullSetup)
309 | 				req := client.NewRequest(reggie.DELETE, "/v2/<name>/blobs/<digest>", reggie.WithDigest(configs[1].Digest))
310 | 				resp, err := client.Do(req)
311 | 				Expect(err).To(BeNil())
312 | 				Expect(resp.StatusCode()).To(SatisfyAny(
313 | 					SatisfyAll(
314 | 						BeNumerically(">=", 200),
315 | 						BeNumerically("<", 300),
316 | 					),
317 | 					Equal(http.StatusNotFound),
318 | 					Equal(http.StatusMethodNotAllowed),
319 | 				))
320 | 			})
321 | 
322 | 			g.Specify("Delete layer blob created in setup", func() {
323 | 				SkipIfDisabled(pull)
324 | 				RunOnlyIf(runPullSetup)
325 | 				req := client.NewRequest(reggie.DELETE, "/v2/<name>/blobs/<digest>", reggie.WithDigest(layerBlobDigest))
326 | 				resp, err := client.Do(req)
327 | 				Expect(err).To(BeNil())
328 | 				Expect(resp.StatusCode()).To(SatisfyAny(
329 | 					SatisfyAll(
330 | 						BeNumerically(">=", 200),
331 | 						BeNumerically("<", 300),
332 | 					),
333 | 					Equal(http.StatusNotFound),
334 | 					Equal(http.StatusMethodNotAllowed),
335 | 				))
336 | 			})
337 | 
338 | 			if !deleteManifestBeforeBlobs {
339 | 				g.Specify("Delete manifest[0] created in setup", func() {
340 | 					SkipIfDisabled(pull)
341 | 					RunOnlyIf(runPullSetup)
342 | 					req := client.NewRequest(reggie.DELETE, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[0].Digest))
343 | 					resp, err := client.Do(req)
344 | 					Expect(err).To(BeNil())
345 | 					Expect(resp.StatusCode()).To(SatisfyAny(
346 | 						SatisfyAll(
347 | 							BeNumerically(">=", 200),
348 | 							BeNumerically("<", 300),
349 | 						),
350 | 						Equal(http.StatusMethodNotAllowed),
351 | 					))
352 | 				})
353 | 				g.Specify("Delete manifest[1] created in setup", func() {
354 | 					SkipIfDisabled(pull)
355 | 					RunOnlyIf(runPullSetup)
356 | 					req := client.NewRequest(reggie.DELETE, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[1].Digest))
357 | 					resp, err := client.Do(req)
358 | 					Expect(err).To(BeNil())
359 | 					Expect(resp.StatusCode()).To(SatisfyAny(
360 | 						SatisfyAll(
361 | 							BeNumerically(">=", 200),
362 | 							BeNumerically("<", 300),
363 | 						),
364 | 						Equal(http.StatusMethodNotAllowed),
365 | 					))
366 | 				})
367 | 			}
368 | 		})
369 | 	})
370 | }
371 | 


--------------------------------------------------------------------------------
/test/conformance/02_push_test.go:
--------------------------------------------------------------------------------
  1 | package conformance
  2 | 
  3 | import (
  4 | 	"fmt"
  5 | 	"net/http"
  6 | 
  7 | 	"strconv"
  8 | 
  9 | 	"github.com/bloodorangeio/reggie"
 10 | 	g "github.com/onsi/ginkgo/v2"
 11 | 	. "github.com/onsi/gomega"
 12 | )
 13 | 
 14 | var test02Push = func() {
 15 | 	g.Context(titlePush, func() {
 16 | 
 17 | 		var lastResponse, prevResponse *reggie.Response
 18 | 		var emptyLayerManifestRef string
 19 | 
 20 | 		g.Context("Setup", func() {
 21 | 			// No setup required at this time for push tests
 22 | 		})
 23 | 
 24 | 		g.Context("Blob Upload Streamed", func() {
 25 | 			g.Specify("PATCH request with blob in body should yield 202 response", func() {
 26 | 				SkipIfDisabled(push)
 27 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/")
 28 | 				resp, err := client.Do(req)
 29 | 				Expect(err).To(BeNil())
 30 | 				location := resp.Header().Get("Location")
 31 | 				Expect(location).ToNot(BeEmpty())
 32 | 
 33 | 				req = client.NewRequest(reggie.PATCH, resp.GetRelativeLocation()).
 34 | 					SetHeader("Content-Type", "application/octet-stream").
 35 | 					SetBody(testBlobA)
 36 | 				resp, err = client.Do(req)
 37 | 				Expect(err).To(BeNil())
 38 | 				Expect(resp.StatusCode()).To(Equal(http.StatusAccepted))
 39 | 				lastResponse = resp
 40 | 			})
 41 | 
 42 | 			g.Specify("PUT request to session URL with digest should yield 201 response", func() {
 43 | 				SkipIfDisabled(push)
 44 | 				Expect(lastResponse).ToNot(BeNil())
 45 | 				req := client.NewRequest(reggie.PUT, lastResponse.GetRelativeLocation()).
 46 | 					SetQueryParam("digest", testBlobADigest).
 47 | 					SetHeader("Content-Type", "application/octet-stream").
 48 | 					SetHeader("Content-Length", testBlobALength)
 49 | 				resp, err := client.Do(req)
 50 | 				Expect(err).To(BeNil())
 51 | 				Expect(resp.StatusCode()).To(Equal(http.StatusCreated))
 52 | 				location := resp.Header().Get("Location")
 53 | 				Expect(location).ToNot(BeEmpty())
 54 | 			})
 55 | 		})
 56 | 
 57 | 		g.Context("Blob Upload Monolithic", func() {
 58 | 			g.Specify("GET nonexistent blob should result in 404 response", func() {
 59 | 				SkipIfDisabled(push)
 60 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/blobs/<digest>",
 61 | 					reggie.WithDigest(dummyDigest))
 62 | 				resp, err := client.Do(req)
 63 | 				Expect(err).To(BeNil())
 64 | 				Expect(resp.StatusCode()).To(Equal(http.StatusNotFound))
 65 | 			})
 66 | 
 67 | 			g.Specify("POST request with digest and blob should yield a 201 or 202", func() {
 68 | 				SkipIfDisabled(push)
 69 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/").
 70 | 					SetHeader("Content-Length", configs[1].ContentLength).
 71 | 					SetHeader("Content-Type", "application/octet-stream").
 72 | 					SetQueryParam("digest", configs[1].Digest).
 73 | 					SetBody(configs[1].Content)
 74 | 				resp, err := client.Do(req)
 75 | 				Expect(err).To(BeNil())
 76 | 				location := resp.Header().Get("Location")
 77 | 				Expect(location).ToNot(BeEmpty())
 78 | 				Expect(resp.StatusCode()).To(SatisfyAny(
 79 | 					Equal(http.StatusCreated),
 80 | 					Equal(http.StatusAccepted),
 81 | 				))
 82 | 				lastResponse = resp
 83 | 			})
 84 | 
 85 | 			g.Specify("GET request to blob URL from prior request should yield 200 or 404 based on response code", func() {
 86 | 				g.Skip("Skipped as the distribution package returns 202 for monolithic uploads, but the spec requires 201")
 87 | 				SkipIfDisabled(push)
 88 | 				Expect(lastResponse).ToNot(BeNil())
 89 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/blobs/<digest>", reggie.WithDigest(configs[1].Digest))
 90 | 				resp, err := client.Do(req)
 91 | 				Expect(err).To(BeNil())
 92 | 				if lastResponse.StatusCode() == http.StatusAccepted {
 93 | 					Expect(resp.StatusCode()).To(Equal(http.StatusNotFound))
 94 | 				} else {
 95 | 					Expect(resp.StatusCode()).To(Equal(http.StatusOK))
 96 | 				}
 97 | 			})
 98 | 
 99 | 			g.Specify("POST request should yield a session ID", func() {
100 | 				SkipIfDisabled(push)
101 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/")
102 | 				resp, err := client.Do(req)
103 | 				Expect(err).To(BeNil())
104 | 				Expect(resp.StatusCode()).To(Equal(http.StatusAccepted))
105 | 				lastResponse = resp
106 | 			})
107 | 
108 | 			g.Specify("PUT upload of a blob should yield a 201 Response", func() {
109 | 				SkipIfDisabled(push)
110 | 				req := client.NewRequest(reggie.PUT, lastResponse.GetRelativeLocation()).
111 | 					SetHeader("Content-Length", configs[1].ContentLength).
112 | 					SetHeader("Content-Type", "application/octet-stream").
113 | 					SetQueryParam("digest", configs[1].Digest).
114 | 					SetBody(configs[1].Content)
115 | 				resp, err := client.Do(req)
116 | 				Expect(err).To(BeNil())
117 | 				location := resp.Header().Get("Location")
118 | 				Expect(location).ToNot(BeEmpty())
119 | 				Expect(resp.StatusCode()).To(Equal(http.StatusCreated))
120 | 			})
121 | 
122 | 			g.Specify("GET request to existing blob should yield 200 response", func() {
123 | 				SkipIfDisabled(push)
124 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/blobs/<digest>", reggie.WithDigest(configs[1].Digest))
125 | 				resp, err := client.Do(req)
126 | 				Expect(err).To(BeNil())
127 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
128 | 			})
129 | 
130 | 			g.Specify("PUT upload of a layer blob should yield a 201 Response", func() {
131 | 				SkipIfDisabled(push)
132 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/")
133 | 				resp, err := client.Do(req)
134 | 				Expect(err).To(BeNil())
135 | 				req = client.NewRequest(reggie.PUT, resp.GetRelativeLocation()).
136 | 					SetHeader("Content-Length", layerBlobContentLength).
137 | 					SetHeader("Content-Type", "application/octet-stream").
138 | 					SetQueryParam("digest", layerBlobDigest).
139 | 					SetBody(layerBlobData)
140 | 				resp, err = client.Do(req)
141 | 				Expect(err).To(BeNil())
142 | 				location := resp.Header().Get("Location")
143 | 				Expect(location).ToNot(BeEmpty())
144 | 				Expect(resp.StatusCode()).To(Equal(http.StatusCreated))
145 | 			})
146 | 
147 | 			g.Specify("GET request to existing layer should yield 200 response", func() {
148 | 				SkipIfDisabled(push)
149 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/blobs/<digest>", reggie.WithDigest(layerBlobDigest))
150 | 				resp, err := client.Do(req)
151 | 				Expect(err).To(BeNil())
152 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
153 | 			})
154 | 		})
155 | 
156 | 		g.Context("Blob Upload Chunked", func() {
157 | 			g.Specify("Out-of-order blob upload should return 416", func() {
158 | 				SkipIfDisabled(push)
159 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/").
160 | 					SetHeader("Content-Length", "0")
161 | 				resp, err := client.Do(req)
162 | 				Expect(err).To(BeNil())
163 | 				location := resp.Header().Get("Location")
164 | 				Expect(location).ToNot(BeEmpty())
165 | 
166 | 				// rebuild chunked blob if min size is above our chunk size
167 | 				minSizeStr := resp.Header().Get("OCI-Chunk-Min-Length")
168 | 				if minSizeStr != "" {
169 | 					minSize, err := strconv.Atoi(minSizeStr)
170 | 					Expect(err).To(BeNil())
171 | 					if minSize > len(testBlobBChunk1) {
172 | 						setupChunkedBlob(minSize*2 - 2)
173 | 					}
174 | 				}
175 | 
176 | 				req = client.NewRequest(reggie.PATCH, resp.GetRelativeLocation()).
177 | 					SetHeader("Content-Type", "application/octet-stream").
178 | 					SetHeader("Content-Length", testBlobBChunk2Length).
179 | 					SetHeader("Content-Range", testBlobBChunk2Range).
180 | 					SetBody(testBlobBChunk2)
181 | 				resp, err = client.Do(req)
182 | 				Expect(err).To(BeNil())
183 | 				Expect(resp.StatusCode()).To(Equal(http.StatusRequestedRangeNotSatisfiable))
184 | 			})
185 | 
186 | 			g.Specify("PATCH request with first chunk should return 202", func() {
187 | 				SkipIfDisabled(push)
188 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/").
189 | 					SetHeader("Content-Length", "0")
190 | 				resp, err := client.Do(req)
191 | 				Expect(err).To(BeNil())
192 | 				location := resp.Header().Get("Location")
193 | 				Expect(location).ToNot(BeEmpty())
194 | 				prevResponse = resp
195 | 				req = client.NewRequest(reggie.PATCH, resp.GetRelativeLocation()).
196 | 					SetHeader("Content-Type", "application/octet-stream").
197 | 					SetHeader("Content-Length", testBlobBChunk1Length).
198 | 					SetHeader("Content-Range", testBlobBChunk1Range).
199 | 					SetBody(testBlobBChunk1)
200 | 				resp, err = client.Do(req)
201 | 				Expect(err).To(BeNil())
202 | 				Expect(resp.StatusCode()).To(Equal(http.StatusAccepted))
203 | 				Expect(resp.Header().Get("Range")).To(Equal(testBlobBChunk1Range))
204 | 				lastResponse = resp
205 | 			})
206 | 
207 | 			g.Specify("Retry previous blob chunk should return 416", func() {
208 | 				SkipIfDisabled(push)
209 | 				req := client.NewRequest(reggie.PATCH, prevResponse.GetRelativeLocation()).
210 | 					SetHeader("Content-Type", "application/octet-stream").
211 | 					SetHeader("Content-Length", testBlobBChunk1Length).
212 | 					SetHeader("Content-Range", testBlobBChunk1Range).
213 | 					SetBody(testBlobBChunk1)
214 | 				resp, err := client.Do(req)
215 | 				Expect(err).To(BeNil())
216 | 				Expect(resp.StatusCode()).To(Equal(http.StatusRequestedRangeNotSatisfiable))
217 | 			})
218 | 
219 | 			g.Specify("Get on stale blob upload should return 204 with a range and location", func() {
220 | 				SkipIfDisabled(push)
221 | 				req := client.NewRequest(reggie.GET, prevResponse.GetRelativeLocation())
222 | 				resp, err := client.Do(req)
223 | 				Expect(err).To(BeNil())
224 | 				Expect(resp.StatusCode()).To(Equal(http.StatusNoContent))
225 | 				Expect(resp.Header().Get("Location")).ToNot(BeEmpty())
226 | 				Expect(resp.Header().Get("Range")).To(Equal(testBlobBChunk1Range))
227 | 				lastResponse = resp
228 | 			})
229 | 
230 | 			g.Specify("PATCH request with second chunk should return 202", func() {
231 | 				SkipIfDisabled(push)
232 | 				req := client.NewRequest(reggie.PATCH, lastResponse.GetRelativeLocation()).
233 | 					SetHeader("Content-Length", testBlobBChunk2Length).
234 | 					SetHeader("Content-Range", testBlobBChunk2Range).
235 | 					SetHeader("Content-Type", "application/octet-stream").
236 | 					SetBody(testBlobBChunk2)
237 | 				resp, err := client.Do(req)
238 | 				Expect(err).To(BeNil())
239 | 				location := resp.Header().Get("Location")
240 | 				Expect(resp.StatusCode()).To(Equal(http.StatusAccepted))
241 | 				Expect(location).ToNot(BeEmpty())
242 | 				lastResponse = resp
243 | 			})
244 | 
245 | 			g.Specify("PUT request with digest should return 201", func() {
246 | 				SkipIfDisabled(push)
247 | 				req := client.NewRequest(reggie.PUT, lastResponse.GetRelativeLocation()).
248 | 					SetHeader("Content-Length", "0").
249 | 					SetHeader("Content-Type", "application/octet-stream").
250 | 					SetQueryParam("digest", testBlobBDigest)
251 | 				resp, err := client.Do(req)
252 | 				Expect(err).To(BeNil())
253 | 				Expect(resp.StatusCode()).To(Equal(http.StatusCreated))
254 | 				location := resp.Header().Get("Location")
255 | 				Expect(location).ToNot(BeEmpty())
256 | 			})
257 | 		})
258 | 
259 | 		g.Context("Cross-Repository Blob Mount", func() {
260 | 			g.Specify("Cross-mounting of a blob without the from argument should yield session id", func() {
261 | 				SkipIfDisabled(push)
262 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/",
263 | 					reggie.WithName(crossmountNamespace)).
264 | 					SetQueryParam("mount", dummyDigest)
265 | 				resp, err := client.Do(req)
266 | 				Expect(err).To(BeNil())
267 | 				Expect(resp.StatusCode()).To(Equal(http.StatusAccepted))
268 | 				Expect(resp.GetAbsoluteLocation()).To(Not(BeEmpty()))
269 | 			})
270 | 
271 | 			g.Specify("POST request to mount another repository's blob should return 201 or 202", func() {
272 | 				SkipIfDisabled(push)
273 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/",
274 | 					reggie.WithName(crossmountNamespace)).
275 | 					SetQueryParam("mount", testBlobADigest).
276 | 					SetQueryParam("from", client.Config.DefaultName)
277 | 				resp, err := client.Do(req)
278 | 				Expect(err).To(BeNil())
279 | 				Expect(resp.StatusCode()).To(SatisfyAny(
280 | 					Equal(http.StatusCreated),
281 | 					Equal(http.StatusAccepted),
282 | 				))
283 | 				lastResponse = resp
284 | 			})
285 | 
286 | 			g.Specify("GET request to test digest within cross-mount namespace should return 200", func() {
287 | 				SkipIfDisabled(push)
288 | 				RunOnlyIf(lastResponse.StatusCode() == http.StatusCreated)
289 | 				Expect(lastResponse.GetRelativeLocation()).To(Equal(fmt.Sprintf("/v2/%s/blobs/%s", crossmountNamespace, testBlobADigest)))
290 | 				req := client.NewRequest(reggie.GET, lastResponse.GetRelativeLocation())
291 | 				resp, err := client.Do(req)
292 | 				Expect(err).To(BeNil())
293 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
294 | 			})
295 | 
296 | 			g.Specify("Cross-mounting of nonexistent blob should yield session id", func() {
297 | 				SkipIfDisabled(push)
298 | 				RunOnlyIf(lastResponse.StatusCode() == http.StatusAccepted)
299 | 				Expect(lastResponse.GetRelativeLocation()).To(HavePrefix(fmt.Sprintf("/v2/%s/blobs/uploads/", crossmountNamespace)))
300 | 			})
301 | 
302 | 			g.Specify("Cross-mounting without from, and automatic content discovery enabled should return a 201", func() {
303 | 				SkipIfDisabled(push)
304 | 				RunOnlyIf(runAutomaticCrossmountTest)
305 | 				RunOnlyIf(lastResponse.StatusCode() == http.StatusCreated)
306 | 				RunOnlyIf(automaticCrossmountEnabled)
307 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/",
308 | 					reggie.WithName(crossmountNamespace)).
309 | 					SetQueryParam("mount", testBlobADigest)
310 | 				resp, err := client.Do(req)
311 | 				Expect(err).To(BeNil())
312 | 				Expect(resp.StatusCode()).To(Equal(http.StatusCreated))
313 | 			})
314 | 
315 | 			g.Specify("Cross-mounting without from, and automatic content discovery disabled should return a 202", func() {
316 | 				SkipIfDisabled(push)
317 | 				RunOnlyIf(runAutomaticCrossmountTest)
318 | 				RunOnlyIf(lastResponse.StatusCode() == http.StatusCreated)
319 | 				RunOnlyIfNot(automaticCrossmountEnabled)
320 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/",
321 | 					reggie.WithName(crossmountNamespace)).
322 | 					SetQueryParam("mount", testBlobADigest)
323 | 				resp, err := client.Do(req)
324 | 				Expect(err).To(BeNil())
325 | 				Expect(resp.StatusCode()).To(Equal(http.StatusAccepted))
326 | 			})
327 | 		})
328 | 
329 | 		g.Context("Manifest Upload", func() {
330 | 			g.Specify("GET nonexistent manifest should return 404", func() {
331 | 				SkipIfDisabled(push)
332 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/manifests/<reference>",
333 | 					reggie.WithReference(nonexistentManifest))
334 | 				resp, err := client.Do(req)
335 | 				Expect(err).To(BeNil())
336 | 				Expect(resp.StatusCode()).To(Equal(http.StatusNotFound))
337 | 			})
338 | 
339 | 			g.Specify("PUT should accept a manifest upload", func() {
340 | 				SkipIfDisabled(push)
341 | 				for i := 0; i < 4; i++ {
342 | 					tag := fmt.Sprintf("test%d", i)
343 | 					req := client.NewRequest(reggie.PUT, "/v2/<name>/manifests/<reference>",
344 | 						reggie.WithReference(tag)).
345 | 						SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json").
346 | 						SetBody(manifests[1].Content)
347 | 					resp, err := client.Do(req)
348 | 					Expect(err).To(BeNil())
349 | 					location := resp.Header().Get("Location")
350 | 					Expect(location).ToNot(BeEmpty())
351 | 					Expect(resp.StatusCode()).To(Equal(http.StatusCreated))
352 | 				}
353 | 			})
354 | 
355 | 			g.Specify("Registry should accept a manifest upload with no layers", func() {
356 | 				SkipIfDisabled(push)
357 | 				req := client.NewRequest(reggie.PUT, "/v2/<name>/manifests/<reference>",
358 | 					reggie.WithReference(emptyLayerTestTag)).
359 | 					SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json").
360 | 					SetBody(emptyLayerManifestContent)
361 | 				resp, err := client.Do(req)
362 | 				Expect(err).To(BeNil())
363 | 				if resp.StatusCode() == http.StatusCreated {
364 | 					location := resp.Header().Get("Location")
365 | 					emptyLayerManifestRef = location
366 | 					Expect(location).ToNot(BeEmpty())
367 | 					Expect(resp.StatusCode()).To(Equal(http.StatusCreated))
368 | 				} else {
369 | 					Warn("image manifest with no layers is not supported")
370 | 				}
371 | 			})
372 | 
373 | 			g.Specify("GET request to manifest URL (digest) should yield 200 response", func() {
374 | 				SkipIfDisabled(push)
375 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[1].Digest)).
376 | 					SetHeader("Accept", "application/vnd.oci.image.manifest.v1+json")
377 | 				resp, err := client.Do(req)
378 | 				Expect(err).To(BeNil())
379 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
380 | 			})
381 | 		})
382 | 
383 | 		g.Context("Teardown", func() {
384 | 			if deleteManifestBeforeBlobs {
385 | 				g.Specify("Delete manifest created in tests", func() {
386 | 					SkipIfDisabled(push)
387 | 					RunOnlyIf(runPushSetup)
388 | 					req := client.NewRequest(reggie.DELETE, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[1].Digest))
389 | 					resp, err := client.Do(req)
390 | 					Expect(err).To(BeNil())
391 | 					Expect(resp.StatusCode()).To(SatisfyAny(
392 | 						SatisfyAll(
393 | 							BeNumerically(">=", 200),
394 | 							BeNumerically("<", 300),
395 | 						),
396 | 						Equal(http.StatusMethodNotAllowed),
397 | 					))
398 | 					if emptyLayerManifestRef != "" {
399 | 						req = client.NewRequest(reggie.DELETE, "/v2/<name>/manifests/<reference>", reggie.WithReference(emptyLayerManifestDigest))
400 | 						resp, err = client.Do(req)
401 | 						Expect(err).To(BeNil())
402 | 						Expect(resp.StatusCode()).To(SatisfyAny(
403 | 							SatisfyAll(
404 | 								BeNumerically(">=", 200),
405 | 								BeNumerically("<", 300),
406 | 							),
407 | 							Equal(http.StatusMethodNotAllowed),
408 | 						))
409 | 					}
410 | 				})
411 | 			}
412 | 
413 | 			g.Specify("Delete config blob created in tests", func() {
414 | 				SkipIfDisabled(push)
415 | 				RunOnlyIf(runPushSetup)
416 | 				req := client.NewRequest(reggie.DELETE, "/v2/<name>/blobs/<digest>", reggie.WithDigest(configs[1].Digest))
417 | 				resp, err := client.Do(req)
418 | 				Expect(err).To(BeNil())
419 | 				Expect(resp.StatusCode()).To(SatisfyAny(
420 | 					SatisfyAll(
421 | 						BeNumerically(">=", 200),
422 | 						BeNumerically("<", 300),
423 | 					),
424 | 					Equal(http.StatusNotFound),
425 | 					Equal(http.StatusMethodNotAllowed),
426 | 				))
427 | 			})
428 | 
429 | 			g.Specify("Delete layer blob created in setup", func() {
430 | 				SkipIfDisabled(push)
431 | 				RunOnlyIf(runPushSetup)
432 | 				req := client.NewRequest(reggie.DELETE, "/v2/<name>/blobs/<digest>", reggie.WithDigest(layerBlobDigest))
433 | 				resp, err := client.Do(req)
434 | 				Expect(err).To(BeNil())
435 | 				Expect(resp.StatusCode()).To(SatisfyAny(
436 | 					SatisfyAll(
437 | 						BeNumerically(">=", 200),
438 | 						BeNumerically("<", 300),
439 | 					),
440 | 					Equal(http.StatusNotFound),
441 | 					Equal(http.StatusMethodNotAllowed),
442 | 				))
443 | 			})
444 | 
445 | 			if !deleteManifestBeforeBlobs {
446 | 				g.Specify("Delete manifest created in tests", func() {
447 | 					SkipIfDisabled(push)
448 | 					RunOnlyIf(runPushSetup)
449 | 					req := client.NewRequest(reggie.DELETE, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[1].Digest))
450 | 					resp, err := client.Do(req)
451 | 					Expect(err).To(BeNil())
452 | 					Expect(resp.StatusCode()).To(SatisfyAny(
453 | 						SatisfyAll(
454 | 							BeNumerically(">=", 200),
455 | 							BeNumerically("<", 300),
456 | 						),
457 | 						Equal(http.StatusMethodNotAllowed),
458 | 					))
459 | 					if emptyLayerManifestRef != "" {
460 | 						req = client.NewRequest(reggie.DELETE, "/v2/<name>/manifests/<reference>", reggie.WithReference(emptyLayerManifestDigest))
461 | 						resp, err = client.Do(req)
462 | 						Expect(err).To(BeNil())
463 | 						Expect(resp.StatusCode()).To(SatisfyAny(
464 | 							SatisfyAll(
465 | 								BeNumerically(">=", 200),
466 | 								BeNumerically("<", 300),
467 | 							),
468 | 							Equal(http.StatusMethodNotAllowed),
469 | 						))
470 | 					}
471 | 				})
472 | 			}
473 | 		})
474 | 	})
475 | }
476 | 


--------------------------------------------------------------------------------
/test/conformance/04_management_test.go:
--------------------------------------------------------------------------------
  1 | package conformance
  2 | 
  3 | import (
  4 | 	"encoding/json"
  5 | 	"net/http"
  6 | 
  7 | 	"github.com/bloodorangeio/reggie"
  8 | 	g "github.com/onsi/ginkgo/v2"
  9 | 	. "github.com/onsi/gomega"
 10 | )
 11 | 
 12 | var test04ContentManagement = func() {
 13 | 	g.Context(titleContentManagement, func() {
 14 | 
 15 | 		const defaultTagName = "tagtest0"
 16 | 		var tagToDelete string
 17 | 		var numTags int
 18 | 		var blobDeleteAllowed = true
 19 | 
 20 | 		g.Context("Setup", func() {
 21 | 			g.Specify("Populate registry with test config blob", func() {
 22 | 				SkipIfDisabled(contentManagement)
 23 | 				RunOnlyIf(runContentManagementSetup)
 24 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/")
 25 | 				resp, err := client.Do(req)
 26 | 				Expect(err).To(BeNil())
 27 | 				req = client.NewRequest(reggie.PUT, resp.GetRelativeLocation()).
 28 | 					SetHeader("Content-Length", configs[3].ContentLength).
 29 | 					SetHeader("Content-Type", "application/octet-stream").
 30 | 					SetQueryParam("digest", configs[3].Digest).
 31 | 					SetBody(configs[3].Content)
 32 | 				resp, err = client.Do(req)
 33 | 				Expect(err).To(BeNil())
 34 | 				Expect(resp.StatusCode()).To(SatisfyAll(
 35 | 					BeNumerically(">=", 200),
 36 | 					BeNumerically("<", 300)))
 37 | 			})
 38 | 
 39 | 			g.Specify("Populate registry with test layer", func() {
 40 | 				SkipIfDisabled(contentManagement)
 41 | 				RunOnlyIf(runContentManagementSetup)
 42 | 				req := client.NewRequest(reggie.POST, "/v2/<name>/blobs/uploads/")
 43 | 				resp, err := client.Do(req)
 44 | 				Expect(err).To(BeNil())
 45 | 				req = client.NewRequest(reggie.PUT, resp.GetRelativeLocation()).
 46 | 					SetQueryParam("digest", layerBlobDigest).
 47 | 					SetHeader("Content-Type", "application/octet-stream").
 48 | 					SetHeader("Content-Length", layerBlobContentLength).
 49 | 					SetBody(layerBlobData)
 50 | 				resp, err = client.Do(req)
 51 | 				Expect(err).To(BeNil())
 52 | 				Expect(resp.StatusCode()).To(SatisfyAll(
 53 | 					BeNumerically(">=", 200),
 54 | 					BeNumerically("<", 300)))
 55 | 			})
 56 | 
 57 | 			g.Specify("Populate registry with test tag", func() {
 58 | 				SkipIfDisabled(contentManagement)
 59 | 				RunOnlyIf(runContentManagementSetup)
 60 | 				tagToDelete = defaultTagName
 61 | 				req := client.NewRequest(reggie.PUT, "/v2/<name>/manifests/<reference>",
 62 | 					reggie.WithReference(tagToDelete)).
 63 | 					SetHeader("Content-Type", "application/vnd.oci.image.manifest.v1+json").
 64 | 					SetBody(manifests[3].Content)
 65 | 				resp, err := client.Do(req)
 66 | 				Expect(err).To(BeNil())
 67 | 				Expect(resp.StatusCode()).To(SatisfyAll(
 68 | 					BeNumerically(">=", 200),
 69 | 					BeNumerically("<", 300)))
 70 | 			})
 71 | 
 72 | 			g.Specify("Check how many tags there are before anything gets deleted", func() {
 73 | 				SkipIfDisabled(contentManagement)
 74 | 				RunOnlyIf(runContentManagementSetup)
 75 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/tags/list")
 76 | 				resp, err := client.Do(req)
 77 | 				Expect(err).To(BeNil())
 78 | 				Expect(resp.StatusCode()).To(Equal(http.StatusOK))
 79 | 				tagList := &TagList{}
 80 | 				jsonData := []byte(resp.String())
 81 | 				err = json.Unmarshal(jsonData, tagList)
 82 | 				Expect(err).To(BeNil())
 83 | 				numTags = len(tagList.Tags)
 84 | 			})
 85 | 		})
 86 | 
 87 | 		g.Context("Manifest delete", func() {
 88 | 			g.Specify("DELETE request to manifest tag should return 202, unless tag deletion is disallowed (400/405)", func() {
 89 | 				SkipIfDisabled(contentManagement)
 90 | 				req := client.NewRequest(reggie.DELETE, "/v2/<name>/manifests/<reference>",
 91 | 					reggie.WithReference(tagToDelete))
 92 | 				resp, err := client.Do(req)
 93 | 				Expect(err).To(BeNil())
 94 | 				Expect(resp.StatusCode()).To(SatisfyAny(
 95 | 					Equal(http.StatusBadRequest),
 96 | 					Equal(http.StatusAccepted),
 97 | 					Equal(http.StatusMethodNotAllowed)))
 98 | 				if resp.StatusCode() == http.StatusBadRequest {
 99 | 					errorResponses, err := resp.Errors()
100 | 					Expect(err).To(BeNil())
101 | 					Expect(errorResponses).ToNot(BeEmpty())
102 | 					Expect(errorResponses[0].Code).To(Equal(errorCodes[UNSUPPORTED]))
103 | 				}
104 | 			})
105 | 
106 | 			g.Specify("DELETE request to manifest (digest) should yield 202 response unless already deleted", func() {
107 | 				SkipIfDisabled(contentManagement)
108 | 				req := client.NewRequest(reggie.DELETE, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[3].Digest))
109 | 				resp, err := client.Do(req)
110 | 				Expect(err).To(BeNil())
111 | 				// In the case that the previous request was accepted, this may or may not fail (which is ok)
112 | 				Expect(resp.StatusCode()).To(SatisfyAny(
113 | 					Equal(http.StatusAccepted),
114 | 					Equal(http.StatusNotFound),
115 | 				))
116 | 			})
117 | 
118 | 			g.Specify("GET request to deleted manifest URL should yield 404 response, unless delete is disallowed", func() {
119 | 				SkipIfDisabled(contentManagement)
120 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/manifests/<digest>", reggie.WithDigest(manifests[3].Digest))
121 | 				resp, err := client.Do(req)
122 | 				Expect(err).To(BeNil())
123 | 				Expect(resp.StatusCode()).To(SatisfyAny(
124 | 					Equal(http.StatusNotFound),
125 | 					Equal(http.StatusOK),
126 | 				))
127 | 			})
128 | 
129 | 			g.Specify("GET request to tags list should reflect manifest deletion", func() {
130 | 				SkipIfDisabled(contentManagement)
131 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/tags/list")
132 | 				resp, err := client.Do(req)
133 | 				Expect(err).To(BeNil())
134 | 				Expect(resp.StatusCode()).To(SatisfyAny(
135 | 					Equal(http.StatusNotFound),
136 | 					Equal(http.StatusOK),
137 | 				))
138 | 				expectTags := numTags - 1
139 | 				if resp.StatusCode() == http.StatusOK {
140 | 					tagList := &TagList{}
141 | 					jsonData := []byte(resp.String())
142 | 					err = json.Unmarshal(jsonData, tagList)
143 | 					Expect(err).To(BeNil())
144 | 					Expect(len(tagList.Tags)).To(Equal(expectTags))
145 | 				}
146 | 				if resp.StatusCode() == http.StatusNotFound {
147 | 					Expect(expectTags).To(Equal(0))
148 | 				}
149 | 			})
150 | 		})
151 | 
152 | 		g.Context("Blob delete", func() {
153 | 			g.Specify("DELETE request to blob URL should yield 202 response", func() {
154 | 				SkipIfDisabled(contentManagement)
155 | 				RunOnlyIf(runContentManagementSetup)
156 | 				// config blob
157 | 				req := client.NewRequest(reggie.DELETE, "/v2/<name>/blobs/<digest>", reggie.WithDigest(configs[3].Digest))
158 | 				resp, err := client.Do(req)
159 | 				Expect(err).To(BeNil())
160 | 				Expect(resp.StatusCode()).To(SatisfyAny(
161 | 					Equal(http.StatusAccepted),
162 | 					Equal(http.StatusNotFound),
163 | 					Equal(http.StatusMethodNotAllowed),
164 | 				))
165 | 				// layer blob
166 | 				req = client.NewRequest(reggie.DELETE, "/v2/<name>/blobs/<digest>", reggie.WithDigest(layerBlobDigest))
167 | 				resp, err = client.Do(req)
168 | 				Expect(err).To(BeNil())
169 | 				Expect(err).To(BeNil())
170 | 				Expect(resp.StatusCode()).To(SatisfyAny(
171 | 					Equal(http.StatusAccepted),
172 | 					Equal(http.StatusNotFound),
173 | 					Equal(http.StatusMethodNotAllowed),
174 | 				))
175 | 				if resp.StatusCode() == http.StatusMethodNotAllowed {
176 | 					blobDeleteAllowed = false
177 | 				}
178 | 			})
179 | 
180 | 			g.Specify("GET request to deleted blob URL should yield 404 response", func() {
181 | 				SkipIfDisabled(contentManagement)
182 | 				RunOnlyIf(runContentManagementSetup)
183 | 				RunOnlyIf(blobDeleteAllowed)
184 | 				req := client.NewRequest(reggie.GET, "/v2/<name>/blobs/<digest>", reggie.WithDigest(configs[3].Digest))
185 | 				resp, err := client.Do(req)
186 | 				Expect(err).To(BeNil())
187 | 				Expect(resp.StatusCode()).To(Equal(http.StatusNotFound))
188 | 			})
189 | 		})
190 | 
191 | 		g.Context("Teardown", func() {
192 | 			// TODO: delete blob+tag?
193 | 			// No teardown required at this time for content management tests
194 | 		})
195 | 	})
196 | }
197 | 


--------------------------------------------------------------------------------
/test/conformance/README.md:
--------------------------------------------------------------------------------
1 | ## Conformance Tests
2 | 
3 | This is a fork of the [Conformance Tests](https://github.com/opencontainers/distribution-spec/tree/main/conformance)
4 | from the official [OCI Distribution Specification](https://github.com/opencontainers/distribution-spec).
5 | 
6 | The changes include setting up Uncloud in a Docker container and skipping a few tests that aren't conformant with the
7 | [distribution](https://github.com/distribution/distribution) implementation.
8 | 


--------------------------------------------------------------------------------
/test/conformance/image.go:
--------------------------------------------------------------------------------
  1 | package conformance
  2 | 
  3 | import (
  4 | 	digest "github.com/opencontainers/go-digest"
  5 | )
  6 | 
  7 | // These types are copied from github.com/opencontainers/image-spec/specs-go/v1
  8 | // Modifications have been made to remove fields that aren't used in these
  9 | // conformance tests, and to add new unspecified fields, to test registry
 10 | // conformance in handling unknown fields.
 11 | 
 12 | // manifest provides `application/vnd.oci.image.manifest.v1+json` mediatype structure when marshalled to JSON.
 13 | type manifest struct {
 14 | 	// SchemaVersion is the image manifest schema that this image follows
 15 | 	SchemaVersion int `json:"schemaVersion"`
 16 | 
 17 | 	// MediaType specifies the type of this document data structure e.g. `application/vnd.oci.image.manifest.v1+json`
 18 | 	MediaType string `json:"mediaType,omitempty"`
 19 | 
 20 | 	// ArtifactType specifies the IANA media type of artifact when the manifest is used for an artifact.
 21 | 	ArtifactType string `json:"artifactType,omitempty"`
 22 | 
 23 | 	// Config references a configuration object for a container, by digest.
 24 | 	// The referenced configuration object is a JSON blob that the runtime uses to set up the container.
 25 | 	Config descriptor `json:"config"`
 26 | 
 27 | 	// Layers is an indexed list of layers referenced by the manifest.
 28 | 	Layers []descriptor `json:"layers"`
 29 | 
 30 | 	// Subject is an optional link from the image manifest to another manifest forming an association between the image manifest and the other manifest.
 31 | 	Subject *descriptor `json:"subject,omitempty"`
 32 | 
 33 | 	// Annotations contains arbitrary metadata for the image index.
 34 | 	Annotations map[string]string `json:"annotations,omitempty"`
 35 | }
 36 | 
 37 | // descriptor describes the disposition of targeted content.
 38 | // This structure provides `application/vnd.oci.descriptor.v1+json` mediatype
 39 | // when marshalled to JSON.
 40 | type descriptor struct {
 41 | 	// MediaType is the media type of the object this schema refers to.
 42 | 	MediaType string `json:"mediaType"`
 43 | 
 44 | 	// Digest is the digest of the targeted content.
 45 | 	Digest digest.Digest `json:"digest"`
 46 | 
 47 | 	// Size specifies the size in bytes of the blob.
 48 | 	Size int64 `json:"size"`
 49 | 
 50 | 	// URLs specifies a list of URLs from which this object MAY be downloaded
 51 | 	URLs []string `json:"urls,omitempty"`
 52 | 
 53 | 	// Annotations contains arbitrary metadata relating to the targeted content.
 54 | 	Annotations map[string]string `json:"annotations,omitempty"`
 55 | 
 56 | 	// Data specifies the data of the object described by the descriptor.
 57 | 	Data []byte `json:"data,omitempty"`
 58 | 
 59 | 	// Platform describes the platform which the image in the manifest runs on.
 60 | 	//
 61 | 	// This should only be used when referring to a manifest.
 62 | 	Platform *platform `json:"platform,omitempty"`
 63 | 
 64 | 	// ArtifactType is the IANA media type of this artifact.
 65 | 	ArtifactType string `json:"artifactType,omitempty"`
 66 | 
 67 | 	// NewUnspecifiedField is not covered by image-spec.
 68 | 	// Registry implementations should still successfully store and serve
 69 | 	// manifests containing this data.
 70 | 	NewUnspecifiedField []byte `json:"newUnspecifiedField"`
 71 | }
 72 | 
 73 | // platform describes the platform which the image in the manifest runs on.
 74 | type platform struct {
 75 | 	// Architecture field specifies the CPU architecture, for example
 76 | 	// `amd64` or `ppc64le`.
 77 | 	Architecture string `json:"architecture"`
 78 | 
 79 | 	// OS specifies the operating system, for example `linux` or `windows`.
 80 | 	OS string `json:"os"`
 81 | 
 82 | 	// OSVersion is an optional field specifying the operating system
 83 | 	// version, for example on Windows `10.0.14393.1066`.
 84 | 	OSVersion string `json:"os.version,omitempty"`
 85 | 
 86 | 	// OSFeatures is an optional field specifying an array of strings,
 87 | 	// each listing a required OS feature (for example on Windows `win32k`).
 88 | 	OSFeatures []string `json:"os.features,omitempty"`
 89 | 
 90 | 	// Variant is an optional field specifying a variant of the CPU, for
 91 | 	// example `v7` to specify ARMv7 when architecture is `arm`.
 92 | 	Variant string `json:"variant,omitempty"`
 93 | }
 94 | 
 95 | // rootFS describes a layer content addresses
 96 | type rootFS struct {
 97 | 	// Type is the type of the rootfs.
 98 | 	Type string `json:"type"`
 99 | 
100 | 	// DiffIDs is an array of layer content hashes (DiffIDs), in order from bottom-most to top-most.
101 | 	DiffIDs []digest.Digest `json:"diff_ids"`
102 | }
103 | 
104 | // image is the JSON structure which describes some basic information about the image.
105 | // This provides the `application/vnd.oci.image.config.v1+json` mediatype when marshalled to JSON.
106 | type image struct {
107 | 	// Author defines the name and/or email address of the person or entity which created and is responsible for maintaining the image.
108 | 	Author string `json:"author,omitempty"`
109 | 
110 | 	// Architecture is the CPU architecture which the binaries in this image are built to run on.
111 | 	Architecture string `json:"architecture"`
112 | 
113 | 	// Variant is the variant of the specified CPU architecture which image binaries are intended to run on.
114 | 	Variant string `json:"variant,omitempty"`
115 | 
116 | 	// OS is the name of the operating system which the image is built to run on.
117 | 	OS string `json:"os"`
118 | 
119 | 	// RootFS references the layer content addresses used by the image.
120 | 	RootFS rootFS `json:"rootfs"`
121 | }
122 | 
123 | // index references manifests for various platforms.
124 | // This structure provides `application/vnd.oci.image.index.v1+json` mediatype when marshalled to JSON.
125 | type index struct {
126 | 	// SchemaVersion is the image manifest schema that this image follows
127 | 	SchemaVersion int `json:"schemaVersion"`
128 | 
129 | 	// MediaType specifies the type of this document data structure e.g. `application/vnd.oci.image.index.v1+json`
130 | 	MediaType string `json:"mediaType,omitempty"`
131 | 
132 | 	// ArtifactType specifies the IANA media type of artifact when the manifest is used for an artifact.
133 | 	ArtifactType string `json:"artifactType,omitempty"`
134 | 
135 | 	// Manifests references platform specific manifests.
136 | 	Manifests []descriptor `json:"manifests"`
137 | 
138 | 	// Subject is an optional link from the image manifest to another manifest forming an association between the image manifest and the other manifest.
139 | 	Subject *descriptor `json:"subject,omitempty"`
140 | 
141 | 	// Annotations contains arbitrary metadata for the image index.
142 | 	Annotations map[string]string `json:"annotations,omitempty"`
143 | }
144 | 


--------------------------------------------------------------------------------
/test/conformance/reporter.go:
--------------------------------------------------------------------------------
  1 | package conformance
  2 | 
  3 | import (
  4 | 	"bytes"
  5 | 	"fmt"
  6 | 	"html/template"
  7 | 	"io"
  8 | 	"log"
  9 | 	"math"
 10 | 	"os"
 11 | 	"path/filepath"
 12 | 	"regexp"
 13 | 	"strings"
 14 | 	"time"
 15 | 
 16 | 	"github.com/onsi/ginkgo/v2/types"
 17 | )
 18 | 
 19 | const (
 20 | 	suiteIndex           = 1
 21 | 	categoryIndex        = 2
 22 | 	setupString          = "Setup"
 23 | 	htmlTemplate  string = `<html>
 24 |   <head>
 25 |     <title>OCI Distribution Conformance Tests</title>
 26 |     <style>
 27 |       body {
 28 |         padding: 10px 20px 10px 20px;
 29 |         font-family: -apple-system,BlinkMacSystemFont,Segoe UI,PingFang SC,Hiragino Sans GB,Microsoft YaHei,Helvetica Neue,Helvetica,Arial,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol;
 30 |         background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAQAAAAECAYAAACp8Z5+AAAAG0lEQVQYV2Pce7zwv7NlPyMDFMAZGAIwlRgqAFydCAVv5m4UAAAAAElFTkSuQmCC") repeat;
 31 |         /* background made with http://www.patternify.com/ */
 32 |       }
 33 |       table {
 34 |         border-collapse: collapse;
 35 |         width: 100%;
 36 |         background-color: white;
 37 |       }
 38 |       th, td {
 39 |         padding: 12px;
 40 |         text-align: left;
 41 |         border-bottom: 1px solid #ddd;
 42 |       }
 43 |       tr:hover {
 44 |         background-color: #ffe39b;
 45 |       }
 46 |       .result {
 47 |         padding: 1.25em 0 .25em 0.8em;
 48 |         border: 1px solid #e1e1e1;
 49 |         border-radius: 5px;
 50 |         margin-top: 10px;
 51 |       }
 52 |       .red {
 53 |         background: #ffc8c8;
 54 |       }
 55 |       pre.fail-message {
 56 |         background: #f9a5a5;
 57 |         padding: 20px;
 58 |         margin-right: 10px;
 59 |         display: inline-block;
 60 |         border-radius: 4px;
 61 |         font-size: 1.25em;
 62 |         width: 94%;
 63 |         overflow-x: auto;
 64 |         max-width: 85%;
 65 |       }
 66 |       .green {
 67 |         background: #c8ffc8;
 68 |         padding: 1.25em 0 1.25em 0.8em;
 69 |       }
 70 |       .grey {
 71 |         background: lightgrey;
 72 |         padding: 1.25em 0 1.25em 0.8em;
 73 |       }
 74 |       .toggle {
 75 |         border: 2px solid #3e3e3e;
 76 |         cursor: pointer;
 77 |         width: 1em;
 78 |         text-align: center;
 79 |         font-weight: bold;
 80 |         display: inline;
 81 |         font-family: monospace;
 82 |         padding: 0 .25em 0 .25em;
 83 |         margin: 1em 1em 1em 0;
 84 |         font-size: 12pt;
 85 |         color: #3e3e3e;
 86 |         border-radius: 3px;
 87 |       }
 88 |       pre.pre-box {
 89 |         background: #343a40;
 90 |         color: #fff;
 91 |         padding: 10px;
 92 |         border: 1px solid gray;
 93 |         display: inline-block;
 94 |         border-radius: 4px;
 95 |         width: 97%;
 96 |         font-size: 1.25em;
 97 |         overflow-x: auto;
 98 |         max-height: 60em;
 99 |         overflow-y: auto;
100 |         max-width: 85%;
101 |       }
102 |       .summary {
103 |         width: 100%;
104 |         height: auto;
105 |         padding: 0 0 .5em 0;
106 |         border-radius: 6px;
107 |         border: 1px solid #cccddd;
108 |         background: white;
109 |       }
110 |       .summary-bullet {
111 |         width: 100%;
112 |         height: auto;
113 |         display: flex;
114 |         flex-wrap: wrap;
115 |         padding: .5em .1em .1em .5em;
116 |       }
117 |       .bullet-left {
118 |         width: 25%;
119 |         font-weight: bold;
120 |         font-size: 100%;
121 |       }
122 |       .bullet-right {
123 |         width: auto;
124 |         font-family: monospace;
125 |         font-size: 110%;
126 |       }
127 |       .quick-summary {
128 |         width: 70%;
129 |         display: flex;
130 |         margin: 0 auto 0 0;
131 |         font-weight: bold;
132 |         font-size: 1.2em;
133 |       }
134 |       .darkgreen {
135 |         color: green;
136 |       }
137 |       .darkred {
138 |         color: red;
139 |         padding: 0 0 0 2em;
140 |       }
141 |       .darkgrey {
142 |         color: grey;
143 |         padding: 0 0 0 2em;
144 |       }
145 |       .meter {
146 |         border: 1px solid black;
147 |         margin: 0 .5em 0 auto;
148 |         display: flex;
149 |         height: 25px;
150 |         width: 45%;
151 |       }
152 |       @media only screen and (max-width: 600px) {
153 |         .meter {
154 |           display: none;
155 |         }
156 |       }
157 |       .meter-green {
158 |         height: 100%;
159 |         background: green;
160 |         width: {{ .PercentPassed -}}%;
161 |       }
162 |       .meter-red {
163 |         height: 100%;
164 |         background: red;
165 |         width: {{ .PercentFailed -}}%;
166 |       }
167 |       .meter-grey {
168 |         height: 100%;
169 |         background: grey;
170 |         width: {{ .PercentSkipped -}}%;
171 |       }
172 |       .subcategory {
173 |         background: white;
174 |         padding: 0px 20px 20px 20px;
175 |         border: 1px solid #cccddd;
176 |         border-radius: 6px;
177 |       }
178 |       h2 {
179 |         margin-top: 45px;
180 |       }
181 |       h4 {
182 |         vertical-align: bottom;
183 |         cursor: pointer;
184 |       }
185 |     </style>
186 |     <script>
187 |       function toggleOutput(id) {
188 |         var elem = document.getElementById(id);
189 |         var button = document.getElementById(id + "-button");
190 |         if (elem.style['display'] === 'block') {
191 |           button.innerHTML = "+";
192 |           elem.style['display'] = 'none';
193 |         } else {
194 |           button.innerHTML = "-";
195 |           elem.style['display'] = 'block';
196 |         }
197 |       }
198 |     </script>
199 |   </head>
200 |   <body>
201 |     <h1>OCI Distribution Conformance Tests</h1>
202 |     <table>
203 |       <tr>
204 |       </tr>
205 |       <tr>
206 |         <td class="bullet-left">Summary</td>
207 |         <td>
208 |           <div class="quick-summary">
209 |             {{- if gt .NumPassed 0 -}}
210 |               <span class="darkgreen">
211 |               {{- if .AllPassed -}}All {{ end -}}{{ .NumPassed }} passed</span>
212 |             {{- end -}}
213 |             {{- if gt .NumFailed 0 -}}
214 |               <span class="darkred">
215 |               {{- if .AllFailed -}}All {{ end -}}{{ .NumFailed }} failed</span>
216 |             {{- end -}}
217 |             {{- if gt .NumSkipped 0 -}}
218 |               <span class="darkgrey">
219 |               {{- if .AllSkipped -}}All {{ end -}}{{ .NumSkipped }} skipped</span>
220 |             {{- end -}}
221 |             <div class="meter">
222 |               <div class="meter-green"></div>
223 |               <div class="meter-red"></div>
224 |               <div class="meter-grey"></div>
225 |             </div>
226 |           </div>
227 |         </td>
228 |       </tr>
229 |       <tr>
230 |         <td class="bullet-left">Start Time</td>
231 |         <td>{{ .StartTimeString }}</td>
232 |       </tr>
233 |       <tr>
234 |         <td class="bullet-left">End Time</td>
235 |         <td>{{ .EndTimeString }}</td>
236 |       </tr>
237 |       <tr>
238 |         <td class="bullet-left">Time Elapsed</td>
239 |         <td>{{ .RunTime }}</td>
240 |       </tr>
241 |       <tr>
242 |         <td class="bullet-left">Test Version</td>
243 |         <td>{{ .Version }}</td>
244 |       </tr>
245 |       <tr>
246 |         <td class="bullet-left">Configuration</td>
247 |         <td><div class="bullet-right">
248 |           {{ range $i, $s := .EnvironmentVariables }}
249 |             {{ $s }}<br />
250 |           {{ end }}
251 |         </div></td>
252 |       </tr>
253 |     </table>
254 | 
255 |     <div>
256 |       {{with .Suite}}
257 |         {{$suite := .M}}
258 |         {{range $i, $suiteKey := .Keys}}
259 |           {{$wf := index $suite $suiteKey}}
260 |           {{with $wf}}
261 |             {{ if .IsEnabled }}
262 |               <h2>{{$suiteKey}}</h2>
263 |               <div class="subcategory">
264 |               {{$workflow := .M}}
265 |               {{range $j, $workflowKey := .Keys}}
266 |                 <h3>{{$workflowKey}}</h3>
267 |                 {{$ctg := index $workflow $workflowKey}}
268 |                 {{with $ctg}}
269 |                   {{$category := .M}}
270 |                   {{range $k, $categoryKey := .Keys}}
271 |                     {{$s := index $category $categoryKey}}
272 |                     {{if eq $s.State.String "failed"}}
273 |                       <div class="result red">
274 |                         <div id="output-box-{{$s.ID}}-button" class="toggle" onclick="javascript:toggleOutput('output-box-{{$s.ID}}')">+</div>
275 |                         <h4 style="display: inline;" onclick="javascript:toggleOutput('output-box-{{$s.ID}}')">{{$s.Title}}</h4>
276 |                         <br>
277 |                         <div>
278 |                           <div id="output-box-{{$s.ID}}" style="display: none;">
279 |                             <pre class="pre-box">{{$s.CombinedOutput}}</pre>
280 |                           </div>
281 |                         </div>
282 |                         <pre class="fail-message">{{$s.FailureMessage}}</pre>
283 |                         <br>
284 |                       </div>
285 |                     {{else if eq $s.State.String "passed"}}
286 |                       <div class="result green">
287 |                         <div id="output-box-{{$s.ID}}-button" class="toggle" onclick="javascript:toggleOutput('output-box-{{$s.ID}}')">+</div>
288 |                         <h4 style="display: inline;" onclick="javascript:toggleOutput('output-box-{{$s.ID}}')">{{$s.Title}}</h4>
289 |                         <br>
290 |                         <div id="output-box-{{$s.ID}}" style="display: none;">
291 |                           <pre class="pre-box">{{$s.CombinedOutput}}</pre>
292 |                         </div>
293 |                       </div>
294 |                     {{else if eq $s.State.String "skipped"}}
295 |                       <div class="result grey">
296 |                         <div id="output-box-{{$s.ID}}-button" class="toggle" onclick="javascript:toggleOutput('output-box-{{$s.ID}}')">+</div>
297 |                         <h4 style="display: inline;" onclick="javascript:toggleOutput('output-box-{{$s.ID}}')">{{$s.Title}}</h4>
298 |                         <br>
299 |                         <div id="output-box-{{$s.ID}}" style="display: none;">
300 |                           <pre class="pre-box">{{$s.FailureMessage}}</pre>
301 |                         </div>
302 |                       </div>
303 |                     {{else}}
304 |                       <div class="result grey">
305 |                         <div id="output-box-{{$s.ID}}-button" class="toggle" onclick="javascript:toggleOutput('output-box-{{$s.ID}}')">+</div>
306 |                         <h4 style="display: inline;" onclick="javascript:toggleOutput('output-box-{{$s.ID}}')">{{$s.Title}}</h4>
307 |                         <br>
308 |                         <div id="output-box-{{$s.ID}}" style="display: none;">
309 |                           <p>Unhandled state: {{ $s.State.String }}</p>
310 |                           <pre class="pre-box">{{$s.CombinedOutput}}</pre>
311 |                           <pre class="pre-box">{{$s.FailureMessage}}</pre>
312 |                         </div>
313 |                       </div>
314 |                     {{end}}
315 |                   {{end}}<br>
316 |                 {{end}}
317 |               {{end}}
318 |             {{end}}
319 |           {{end}}
320 |         </div>
321 |         {{end}}
322 |       {{end}}
323 |     </div>
324 |   </body>
325 | </html>
326 | `
327 | )
328 | 
329 | type (
330 | 	summaryMap struct {
331 | 		M    map[string]snapShotList
332 | 		Keys []string
333 | 		Size int
334 | 	}
335 | 
336 | 	suite struct {
337 | 		M    map[string]*workflow
338 | 		Keys []string
339 | 		Size int
340 | 	}
341 | 
342 | 	workflow struct {
343 | 		M         map[string]*category
344 | 		IsEnabled bool
345 | 		Keys      []string
346 | 	}
347 | 
348 | 	category struct {
349 | 		M    map[string]specSnapshot
350 | 		Keys []string
351 | 	}
352 | 
353 | 	specSnapshot struct {
354 | 		types.SpecReport
355 | 		ID       int
356 | 		Title    string
357 | 		Category string
358 | 		Suite    string
359 | 		IsSetup  bool
360 | 	}
361 | 
362 | 	snapShotList []specSnapshot
363 | 
364 | 	httpDebugWriter struct {
365 | 		CapturedOutput []string
366 | 		debug          bool
367 | 	}
368 | 
369 | 	httpDebugLogger struct {
370 | 		l *log.Logger
371 | 		w io.Writer
372 | 	}
373 | 
374 | 	HTMLReporter struct {
375 | 		htmlReportFilename   string
376 | 		Suite                suite
377 | 		SpecSummaryMap       summaryMap
378 | 		EnvironmentVariables []string
379 | 		Report               types.Report
380 | 		debugLogger          *httpDebugWriter
381 | 		debugIndex           int
382 | 		enabledMap           map[string]bool
383 | 		NumTotal             int
384 | 		NumPassed            int
385 | 		NumFailed            int
386 | 		NumSkipped           int
387 | 		PercentPassed        int
388 | 		PercentFailed        int
389 | 		PercentSkipped       int
390 | 		startTime            time.Time
391 | 		endTime              time.Time
392 | 		StartTimeString      string
393 | 		EndTimeString        string
394 | 		RunTime              string
395 | 		AllPassed            bool
396 | 		AllFailed            bool
397 | 		AllSkipped           bool
398 | 		Version              string
399 | 	}
400 | )
401 | 
402 | func (sm *summaryMap) Add(key string, sum *specSnapshot) {
403 | 	sm.M[key] = append(sm.M[key], *sum)
404 | 	sm.Size++
405 | 
406 | 	if !sm.containsKey(key) {
407 | 		sm.Keys = append(sm.Keys, key)
408 | 	}
409 | }
410 | 
411 | func (sm *summaryMap) containsKey(key string) bool {
412 | 	var containsKey bool
413 | 	for _, k := range sm.Keys {
414 | 		if k == key {
415 | 			containsKey = true
416 | 			break
417 | 		}
418 | 	}
419 | 	return containsKey
420 | }
421 | 
422 | func newHTTPDebugWriter(debug bool) *httpDebugWriter {
423 | 	return &httpDebugWriter{debug: debug}
424 | }
425 | 
426 | func (writer *httpDebugWriter) Write(b []byte) (int, error) {
427 | 	s := string(b)
428 | 	writer.CapturedOutput = append(writer.CapturedOutput, s)
429 | 	if writer.debug {
430 | 		fmt.Println(s)
431 | 	}
432 | 
433 | 	return len(b), nil
434 | }
435 | 
436 | func newHTTPDebugLogger(f io.Writer) *httpDebugLogger {
437 | 	debugLogger := &httpDebugLogger{w: f, l: log.New(f, "", log.Ldate|log.Lmicroseconds)}
438 | 	return debugLogger
439 | }
440 | 
441 | func (l *httpDebugLogger) Errorf(format string, v ...interface{}) {
442 | 	l.output("ERROR "+format, v...)
443 | }
444 | 
445 | func (l *httpDebugLogger) Warnf(format string, v ...interface{}) {
446 | 	l.output("WARN "+format, v...)
447 | }
448 | 
449 | func (l *httpDebugLogger) Debugf(format string, v ...interface{}) {
450 | 	l.output("DEBUG "+format, v...)
451 | }
452 | 
453 | var (
454 | 	redactRegexp  = regexp.MustCompile(`(?i)("?\w*(authorization|token|state)\w*"?(:|=)\s*)(")?\s*((bearer|basic)? )?[^\s&"]*(")?`)
455 | 	redactReplace = "$1$4$5*****$7"
456 | )
457 | 
458 | func (l *httpDebugLogger) output(format string, v ...interface{}) {
459 | 	if len(v) == 0 {
460 | 		l.l.Print(redactRegexp.ReplaceAllString(format, redactReplace))
461 | 		return
462 | 	}
463 | 	_, err := l.w.Write([]byte(redactRegexp.ReplaceAllString(fmt.Sprintf(format, v...), redactReplace)))
464 | 	if err != nil {
465 | 		l.Errorf(err.Error())
466 | 	}
467 | }
468 | 
469 | func newHTMLReporter(htmlReportFilename string) (h *HTMLReporter) {
470 | 	enabledMap := map[string]bool{
471 | 		titlePull:              true,
472 | 		titlePush:              true,
473 | 		titleContentDiscovery:  true,
474 | 		titleContentManagement: true,
475 | 	}
476 | 
477 | 	if os.Getenv(envVarHideSkippedWorkflows) == "1" {
478 | 		enabledMap = map[string]bool{
479 | 			titlePull:              !userDisabled(pull),
480 | 			titlePush:              !userDisabled(push),
481 | 			titleContentDiscovery:  !userDisabled(contentDiscovery),
482 | 			titleContentManagement: !userDisabled(contentManagement),
483 | 		}
484 | 	}
485 | 
486 | 	varsToCheck := []string{
487 | 		envVarRootURL,
488 | 		envVarNamespace,
489 | 		envVarUsername,
490 | 		envVarPassword,
491 | 		envVarDebug,
492 | 		envVarPull,
493 | 		envVarPush,
494 | 		envVarContentDiscovery,
495 | 		envVarContentManagement,
496 | 		envVarPushEmptyLayer,
497 | 		envVarBlobDigest,
498 | 		envVarManifestDigest,
499 | 		envVarTagName,
500 | 		envVarTagList,
501 | 		envVarHideSkippedWorkflows,
502 | 		envVarAuthScope,
503 | 		envVarCrossmountNamespace,
504 | 	}
505 | 	envVars := []string{}
506 | 	for _, v := range varsToCheck {
507 | 		var replacement string
508 | 		if envVar := os.Getenv(v); envVar != "" {
509 | 			replacement = envVar
510 | 			if strings.Contains(v, "PASSWORD") || strings.Contains(v, "USERNAME") {
511 | 				replacement = "*****"
512 | 			}
513 | 		} else {
514 | 			continue
515 | 		}
516 | 		envVars = append(envVars,
517 | 			fmt.Sprintf("%s=%s", v, replacement))
518 | 	}
519 | 
520 | 	return &HTMLReporter{
521 | 		htmlReportFilename: htmlReportFilename,
522 | 		debugLogger:        httpWriter,
523 | 		enabledMap:         enabledMap,
524 | 		SpecSummaryMap:     summaryMap{M: make(map[string]snapShotList)},
525 | 		Suite: suite{
526 | 			M:    make(map[string]*workflow),
527 | 			Keys: []string{},
528 | 		},
529 | 		EnvironmentVariables: envVars,
530 | 		startTime:            time.Now(),
531 | 		StartTimeString:      time.Now().Format("Jan 2 15:04:05.000 -0700 MST"),
532 | 		Version:              Version,
533 | 	}
534 | }
535 | 
536 | func (reporter *HTMLReporter) afterReport(r types.SpecReport) {
537 | 	b := new(bytes.Buffer)
538 | 	for _, co := range httpWriter.CapturedOutput[reporter.debugIndex:] {
539 | 		fmt.Fprintf(b, "%s\n", co)
540 | 	}
541 | 	r.CapturedStdOutErr = b.String()
542 | 	reporter.debugIndex = len(reporter.debugLogger.CapturedOutput)
543 | 
544 | 	ct := r.ContainerHierarchyTexts
545 | 	suiteName, categoryName, titleText := ct[suiteIndex], ct[categoryIndex], r.LeafNodeText
546 | 	suite := &reporter.Suite
547 | 	//make the map of categories
548 | 	if _, ok := suite.M[suiteName]; !ok {
549 | 		suite.M[suiteName] = &workflow{M: make(map[string]*category), Keys: []string{},
550 | 			IsEnabled: reporter.enabledMap[suiteName]}
551 | 		suite.Keys = append(suite.Keys, suiteName)
552 | 	}
553 | 	//make the map of snapshots
554 | 	if _, ok := suite.M[suiteName].M[categoryName]; !ok {
555 | 		suite.M[suiteName].M[categoryName] = &category{M: make(map[string]specSnapshot), Keys: []string{}}
556 | 		z := suite.M[suiteName]
557 | 		z.Keys = append(z.Keys, categoryName)
558 | 	}
559 | 	z := suite.M[suiteName].M[categoryName]
560 | 	z.Keys = append(z.Keys, titleText)
561 | 
562 | 	suite.M[suiteName].M[categoryName].M[titleText] = specSnapshot{
563 | 		SpecReport: r,
564 | 		Suite:      suiteName,
565 | 		Category:   categoryName,
566 | 		Title:      titleText,
567 | 		ID:         suite.Size,
568 | 		IsSetup:    (categoryName == setupString),
569 | 	}
570 | 	suite.Size++
571 | }
572 | 
573 | func (reporter *HTMLReporter) endSuite(report types.Report) error {
574 | 	if reporter.htmlReportFilename == "" {
575 | 		// Reporting is disabled.
576 | 		return nil
577 | 	}
578 | 	reporter.Report = report
579 | 	reporter.endTime = time.Now()
580 | 	reporter.EndTimeString = reporter.endTime.Format("Jan 2 15:04:05.000 -0700 MST")
581 | 	reporter.RunTime = reporter.endTime.Sub(reporter.startTime).String()
582 | 	reporter.NumTotal = len(report.SpecReports)
583 | 	reporter.NumPassed = report.SpecReports.CountWithState(types.SpecStatePassed)
584 | 	reporter.NumSkipped = report.SpecReports.CountWithState(types.SpecStateSkipped)
585 | 	reporter.NumFailed = report.SpecReports.CountWithState(types.SpecStateFailed)
586 | 	reporter.PercentPassed = getPercent(reporter.NumPassed, reporter.NumTotal)
587 | 	reporter.PercentSkipped = getPercent(reporter.NumSkipped, reporter.NumTotal)
588 | 	reporter.PercentFailed = getPercent(reporter.NumFailed, reporter.NumTotal)
589 | 	reporter.AllPassed = reporter.NumPassed == reporter.NumTotal
590 | 	reporter.AllSkipped = reporter.NumSkipped == reporter.NumTotal
591 | 	reporter.AllFailed = reporter.NumFailed == reporter.NumTotal
592 | 
593 | 	t, err := template.New("report").Parse(htmlTemplate)
594 | 	if err != nil {
595 | 		return fmt.Errorf("cannot parse report template: %v", err)
596 | 	}
597 | 
598 | 	htmlReportFilenameAbsPath, err := filepath.Abs(reporter.htmlReportFilename)
599 | 	if err != nil {
600 | 		return err
601 | 	}
602 | 
603 | 	htmlReportFile, err := os.Create(htmlReportFilenameAbsPath)
604 | 	if err != nil {
605 | 		return err
606 | 	}
607 | 	defer htmlReportFile.Close()
608 | 
609 | 	err = t.ExecuteTemplate(htmlReportFile, "report", &reporter)
610 | 	if err != nil {
611 | 		return err
612 | 	}
613 | 
614 | 	fmt.Printf("\nHTML report was created: %s", htmlReportFilenameAbsPath)
615 | 	return nil
616 | }
617 | 
618 | func getPercent(i, of int) int {
619 | 	return int(math.Round(float64(i) / float64(of) * 100))
620 | }
621 | 


--------------------------------------------------------------------------------
/test/conformance/unregistry.go:
--------------------------------------------------------------------------------
 1 | package conformance
 2 | 
 3 | import (
 4 | 	"context"
 5 | 	"fmt"
 6 | 	"io"
 7 | 	"path/filepath"
 8 | 	"strings"
 9 | 	"testing"
10 | 	"time"
11 | 
12 | 	"github.com/docker/docker/api/types"
13 | 	"github.com/stretchr/testify/assert"
14 | 	"github.com/stretchr/testify/require"
15 | 	"github.com/testcontainers/testcontainers-go"
16 | 	"github.com/testcontainers/testcontainers-go/wait"
17 | )
18 | 
19 | // SetupUnregistry starts unregistry in a Docker-in-Docker testcontainer.
20 | func SetupUnregistry(t *testing.T) (testcontainers.Container, string) {
21 | 	ctx := context.Background()
22 | 
23 | 	// Start unregistry in a Docker-in-Docker container with Docker using containerd image store.
24 | 	req := testcontainers.GenericContainerRequest{
25 | 		ContainerRequest: testcontainers.ContainerRequest{
26 | 			FromDockerfile: testcontainers.FromDockerfile{
27 | 				Context:    filepath.Join("..", ".."),
28 | 				Dockerfile: "Dockerfile.test",
29 | 				BuildOptionsModifier: func(buildOptions *types.ImageBuildOptions) {
30 | 					buildOptions.Target = "unregistry-dind"
31 | 				},
32 | 			},
33 | 			Env: map[string]string{
34 | 				"UNREGISTRY_LOG_LEVEL": "debug",
35 | 			},
36 | 			Privileged:   true,
37 | 			ExposedPorts: []string{"5000"},
38 | 			WaitingFor:   wait.ForListeningPort("5000").WithStartupTimeout(15 * time.Second),
39 | 		},
40 | 		Started: true,
41 | 	}
42 | 
43 | 	ctr, err := testcontainers.GenericContainer(ctx, req)
44 | 	require.NoError(t, err)
45 | 
46 | 	mappedRegistryPort, err := ctr.MappedPort(ctx, "5000")
47 | 	require.NoError(t, err)
48 | 
49 | 	url := fmt.Sprintf("http://localhost:%s", mappedRegistryPort.Port())
50 | 	t.Logf("Unregistry started at %s", url)
51 | 
52 | 	return ctr, url
53 | }
54 | 
55 | // TeardownUnregistry cleans up the unregistry container.
56 | func TeardownUnregistry(t *testing.T, ctr testcontainers.Container) {
57 | 	ctx := context.Background()
58 | 
59 | 	// Print last 20 lines of unregistry container logs.
60 | 	logs, err := ctr.Logs(ctx)
61 | 	assert.NoError(t, err, "Failed to get logs from unregistry container.")
62 | 	if err == nil {
63 | 		defer logs.Close()
64 | 		logsContent, err := io.ReadAll(logs)
65 | 		assert.NoError(t, err, "Failed to read logs from unregistry container.")
66 | 		if err == nil {
67 | 			lines := strings.Split(string(logsContent), "\n")
68 | 			start := len(lines) - 20
69 | 			if start < 0 {
70 | 				start = 0
71 | 			}
72 | 
73 | 			t.Log("=== Last 20 lines of unregistry container logs ===")
74 | 			for i := start; i < len(lines); i++ {
75 | 				if lines[i] != "" {
76 | 					t.Log(lines[i])
77 | 				}
78 | 			}
79 | 			t.Log("=== End of unregistry container logs ===")
80 | 		}
81 | 	}
82 | 
83 | 	// Ensure the container is terminated after the test.
84 | 	assert.NoError(t, ctr.Terminate(ctx))
85 | }
86 | 


--------------------------------------------------------------------------------
/test/e2e/images/busybox:1.36.0-musl_multi.tar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psviderski/unregistry/9633089a88a547bf1ba8f08420c9f0e8f077d8f9/test/e2e/images/busybox:1.36.0-musl_multi.tar


--------------------------------------------------------------------------------
/test/e2e/images/busybox:1.36.0-uclibc-arm64.tar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psviderski/unregistry/9633089a88a547bf1ba8f08420c9f0e8f077d8f9/test/e2e/images/busybox:1.36.0-uclibc-arm64.tar


--------------------------------------------------------------------------------
/test/e2e/images/busybox:1.36.1-musl-amd64_oci.tar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psviderski/unregistry/9633089a88a547bf1ba8f08420c9f0e8f077d8f9/test/e2e/images/busybox:1.36.1-musl-amd64_oci.tar


--------------------------------------------------------------------------------
/test/e2e/images/busybox:1.37.0-uclibc_multi_oci.tar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/psviderski/unregistry/9633089a88a547bf1ba8f08420c9f0e8f077d8f9/test/e2e/images/busybox:1.37.0-uclibc_multi_oci.tar


--------------------------------------------------------------------------------
/test/go.mod:
--------------------------------------------------------------------------------
 1 | module github.com/uncloud/unregistry/test/e2e
 2 | 
 3 | go 1.24
 4 | 
 5 | toolchain go1.24.3
 6 | 
 7 | require (
 8 | 	github.com/bloodorangeio/reggie v0.6.1
 9 | 	github.com/docker/docker v27.5.0+incompatible
10 | 	github.com/google/uuid v1.6.0
11 | 	github.com/onsi/ginkgo/v2 v2.23.4
12 | 	github.com/onsi/gomega v1.37.0
13 | 	github.com/opencontainers/go-digest v1.0.0
14 | 	github.com/opencontainers/image-spec v1.1.0
15 | 	github.com/pkg/errors v0.9.1
16 | 	github.com/regclient/regclient v0.8.3
17 | 	github.com/stretchr/testify v1.10.0
18 | 	github.com/testcontainers/testcontainers-go v0.34.0
19 | )
20 | 
21 | require (
22 | 	dario.cat/mergo v1.0.0 // indirect
23 | 	github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 // indirect
24 | 	github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
25 | 	github.com/Microsoft/go-winio v0.6.2 // indirect
26 | 	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
27 | 	github.com/cenkalti/backoff/v4 v4.2.1 // indirect
28 | 	github.com/containerd/log v0.1.0 // indirect
29 | 	github.com/containerd/platforms v0.2.1 // indirect
30 | 	github.com/cpuguy83/dockercfg v0.3.2 // indirect
31 | 	github.com/davecgh/go-spew v1.1.1 // indirect
32 | 	github.com/distribution/reference v0.6.0 // indirect
33 | 	github.com/docker/go-connections v0.5.0 // indirect
34 | 	github.com/docker/go-units v0.5.0 // indirect
35 | 	github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
36 | 	github.com/felixge/httpsnoop v1.0.4 // indirect
37 | 	github.com/go-logr/logr v1.4.2 // indirect
38 | 	github.com/go-logr/stdr v1.2.2 // indirect
39 | 	github.com/go-ole/go-ole v1.2.6 // indirect
40 | 	github.com/go-resty/resty/v2 v2.7.0 // indirect
41 | 	github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
42 | 	github.com/gogo/protobuf v1.3.2 // indirect
43 | 	github.com/google/go-cmp v0.7.0 // indirect
44 | 	github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect
45 | 	github.com/klauspost/compress v1.18.0 // indirect
46 | 	github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
47 | 	github.com/magiconair/properties v1.8.7 // indirect
48 | 	github.com/mitchellh/mapstructure v1.5.0 // indirect
49 | 	github.com/moby/docker-image-spec v1.3.1 // indirect
50 | 	github.com/moby/patternmatcher v0.6.0 // indirect
51 | 	github.com/moby/sys/sequential v0.5.0 // indirect
52 | 	github.com/moby/sys/user v0.1.0 // indirect
53 | 	github.com/moby/sys/userns v0.1.0 // indirect
54 | 	github.com/moby/term v0.5.0 // indirect
55 | 	github.com/morikuni/aec v1.0.0 // indirect
56 | 	github.com/pmezard/go-difflib v1.0.0 // indirect
57 | 	github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
58 | 	github.com/shirou/gopsutil/v3 v3.23.12 // indirect
59 | 	github.com/shoenig/go-m1cpu v0.1.6 // indirect
60 | 	github.com/sirupsen/logrus v1.9.3 // indirect
61 | 	github.com/tklauser/go-sysconf v0.3.12 // indirect
62 | 	github.com/tklauser/numcpus v0.6.1 // indirect
63 | 	github.com/ulikunitz/xz v0.5.12 // indirect
64 | 	github.com/yusufpapurcu/wmi v1.2.3 // indirect
65 | 	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
66 | 	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
67 | 	go.opentelemetry.io/otel v1.36.0 // indirect
68 | 	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 // indirect
69 | 	go.opentelemetry.io/otel/metric v1.36.0 // indirect
70 | 	go.opentelemetry.io/otel/sdk v1.36.0 // indirect
71 | 	go.opentelemetry.io/otel/trace v1.36.0 // indirect
72 | 	go.uber.org/automaxprocs v1.6.0 // indirect
73 | 	golang.org/x/crypto v0.38.0 // indirect
74 | 	golang.org/x/net v0.40.0 // indirect
75 | 	golang.org/x/sys v0.33.0 // indirect
76 | 	golang.org/x/text v0.25.0 // indirect
77 | 	golang.org/x/time v0.11.0 // indirect
78 | 	golang.org/x/tools v0.31.0 // indirect
79 | 	google.golang.org/protobuf v1.36.6 // indirect
80 | 	gopkg.in/yaml.v3 v3.0.1 // indirect
81 | )
82 | 


--------------------------------------------------------------------------------
/test/go.sum:
--------------------------------------------------------------------------------
  1 | dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
  2 | dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
  3 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
  4 | github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
  5 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
  6 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
  7 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
  8 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
  9 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
 10 | github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
 11 | github.com/bloodorangeio/reggie v0.6.1 h1:rSpfPN8oU9kflRI7aQVjImjhY5meRsXDIXnJQrr11zs=
 12 | github.com/bloodorangeio/reggie v0.6.1/go.mod h1:Jkvg7UBdlXVNOlvXU6hgysdtG1XNVCB3B4/k7+PtlfM=
 13 | github.com/cenkalti/backoff/v4 v4.2.1 h1:y4OZtCnogmCPw98Zjyt5a6+QwPLGkiQsYW5oUqylYbM=
 14 | github.com/cenkalti/backoff/v4 v4.2.1/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 15 | github.com/cenkalti/backoff/v5 v5.0.2 h1:rIfFVxEf1QsI7E1ZHfp/B4DF/6QBAUhmgkxc0H7Zss8=
 16 | github.com/cenkalti/backoff/v5 v5.0.2/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
 17 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
 18 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
 19 | github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
 20 | github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
 21 | github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
 22 | github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
 23 | github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
 24 | github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 25 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 26 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 27 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 28 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
 29 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 30 | github.com/docker/docker v27.5.0+incompatible h1:um++2NcQtGRTz5eEgO6aJimo6/JxrTXC941hd05JO6U=
 31 | github.com/docker/docker v27.5.0+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 32 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
 33 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
 34 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
 35 | github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
 36 | github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 h1:UhxFibDNY/bfvqU5CAUmr9zpesgbU6SWc8/B4mflAE4=
 37 | github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7/go.mod h1:cyGadeNEkKy96OOhEzfZl+yxihPEzKnqJwvfuSUqbZE=
 38 | github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
 39 | github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 40 | github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 41 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
 42 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
 43 | github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
 44 | github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
 45 | github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
 46 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
 47 | github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY=
 48 | github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I=
 49 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI=
 50 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8=
 51 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
 52 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
 53 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 54 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 55 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 56 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 57 | github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 58 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8=
 59 | github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
 60 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
 61 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
 62 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
 63 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
 64 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
 65 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
 66 | github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
 67 | github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
 68 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
 69 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
 70 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
 71 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
 72 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
 73 | github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
 74 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
 75 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
 76 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
 77 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
 78 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
 79 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
 80 | github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
 81 | github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
 82 | github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc=
 83 | github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo=
 84 | github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg=
 85 | github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU=
 86 | github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
 87 | github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
 88 | github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
 89 | github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
 90 | github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
 91 | github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 92 | github.com/olareg/olareg v0.1.2 h1:75G8X6E9FUlzL/CSjgFcYfMgNzlc7CxULpUUNsZBIvI=
 93 | github.com/olareg/olareg v0.1.2/go.mod h1:TWs+N6pO1S4bdB6eerzUm/ITRQ6kw91mVf9ZYeGtw+Y=
 94 | github.com/onsi/ginkgo/v2 v2.23.4 h1:ktYTpKJAVZnDT4VjxSbiBenUjmlL/5QkBEocaWXiQus=
 95 | github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8=
 96 | github.com/onsi/gomega v1.37.0 h1:CdEG8g0S133B4OswTDC/5XPSzE1OeP29QOioj2PID2Y=
 97 | github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0=
 98 | github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
 99 | github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
100 | github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
101 | github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
102 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
103 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
104 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
105 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
106 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
107 | github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
108 | github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
109 | github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
110 | github.com/regclient/regclient v0.8.3 h1:AFAPu/vmOYGyY22AIgzdBUKbzH+83lEpRioRYJ/reCs=
111 | github.com/regclient/regclient v0.8.3/go.mod h1:gjQh5uBVZoo/CngchghtQh9Hx81HOMKRRDd5WPcPkbk=
112 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
113 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
114 | github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4=
115 | github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM=
116 | github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM=
117 | github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ=
118 | github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=
119 | github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k=
120 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
121 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
122 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
123 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
124 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
125 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
126 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
127 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
128 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
129 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
130 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
131 | github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
132 | github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
133 | github.com/testcontainers/testcontainers-go v0.34.0 h1:5fbgF0vIN5u+nD3IWabQwRybuB4GY8G2HHgCkbMzMHo=
134 | github.com/testcontainers/testcontainers-go v0.34.0/go.mod h1:6P/kMkQe8yqPHfPWNulFGdFHTD8HB2vLq/231xY2iPQ=
135 | github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
136 | github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
137 | github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
138 | github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
139 | github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
140 | github.com/ulikunitz/xz v0.5.12/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
141 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
142 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
143 | github.com/yusufpapurcu/wmi v1.2.3 h1:E1ctvB7uKFMOJw3fdOW32DwGE9I7t++CRUEMKvFoFiw=
144 | github.com/yusufpapurcu/wmi v1.2.3/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
145 | go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
146 | go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
147 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
148 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
149 | go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg=
150 | go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E=
151 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0=
152 | go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0=
153 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM=
154 | go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ=
155 | go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE=
156 | go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs=
157 | go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs=
158 | go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY=
159 | go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
160 | go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
161 | go.opentelemetry.io/proto/otlp v1.6.0 h1:jQjP+AQyTf+Fe7OKj/MfkDrmK4MNVtw2NpXsf9fefDI=
162 | go.opentelemetry.io/proto/otlp v1.6.0/go.mod h1:cicgGehlFuNdgZkcALOCh3VE6K/u2tAjzlRhDwmVpZc=
163 | go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
164 | go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
165 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
166 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
167 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
168 | golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
169 | golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
170 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
171 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
172 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
173 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
174 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
175 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
176 | golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
177 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY=
178 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds=
179 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
180 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
181 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
182 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
183 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
184 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
185 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
186 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
187 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
188 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
189 | golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
190 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
191 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
192 | golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
193 | golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
194 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
195 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
196 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
197 | golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
198 | golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
199 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
200 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
201 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
202 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
203 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
204 | golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
205 | golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
206 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
207 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
208 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
209 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
210 | golang.org/x/tools v0.31.0 h1:0EedkvKDbh+qistFTd0Bcwe/YLh4vHwWEkiI0toFIBU=
211 | golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ=
212 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
213 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
214 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
215 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
216 | google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237 h1:Kog3KlB4xevJlAcbbbzPfRG0+X9fdoGM+UBRKVz6Wr0=
217 | google.golang.org/genproto/googleapis/api v0.0.0-20250519155744-55703ea1f237/go.mod h1:ezi0AVyMKDWy5xAncvjLWH7UcLBB5n7y2fQ8MzjJcto=
218 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237 h1:cJfm9zPbe1e873mHJzmQ1nwVEeRDU/T1wXDK2kUSU34=
219 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250519155744-55703ea1f237/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
220 | google.golang.org/grpc v1.72.1 h1:HR03wO6eyZ7lknl75XlxABNVLLFc2PAb6mHlYh756mA=
221 | google.golang.org/grpc v1.72.1/go.mod h1:wH5Aktxcg25y1I3w7H69nHfXdOG3UiadoBtjh3izSDM=
222 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
223 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
224 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
225 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
226 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
227 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
228 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
229 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
230 | gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
231 | gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
232 | 


--------------------------------------------------------------------------------