├── .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 ®istry{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 = ®istry{} 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 | --------------------------------------------------------------------------------