feat: implement full native push with image reading and manifest upload
- commit
- 515ee12c6c9466923b5d22c8f609ea9e7145cea0
- author
- brice · · 2026-03-14 14:03 UTC
- parent
- a235943
- signature
- unsigned
3 files · +453 -4
M go.sum +19 -0
| @@ -42,14 +42,21 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X | |||
| 42 | 42 | github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= | |
| 43 | 43 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= | |
| 44 | 44 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= | |
| 45 | + | github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= | |
| 46 | + | github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= | |
| 45 | 47 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= | |
| 46 | 48 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | |
| 49 | + | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 47 | 50 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | |
| 48 | 51 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 49 | 52 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= | |
| 50 | 53 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | |
| 54 | + | github.com/docker/cli v29.2.1+incompatible h1:n3Jt0QVCN65eiVBoUTZQM9mcQICCJt3akW4pKAbKdJg= | |
| 55 | + | github.com/docker/cli v29.2.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= | |
| 51 | 56 | github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= | |
| 52 | 57 | github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= | |
| 58 | + | github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= | |
| 59 | + | github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= | |
| 53 | 60 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= | |
| 54 | 61 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= | |
| 55 | 62 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= | |
| @@ -84,6 +91,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZ | |||
| 84 | 91 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= | |
| 85 | 92 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | |
| 86 | 93 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | |
| 94 | + | github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= | |
| 95 | + | github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= | |
| 87 | 96 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | |
| 88 | 97 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | |
| 89 | 98 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | |
| @@ -101,6 +110,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J | |||
| 101 | 110 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= | |
| 102 | 111 | github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= | |
| 103 | 112 | github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= | |
| 113 | + | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= | |
| 114 | + | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= | |
| 104 | 115 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= | |
| 105 | 116 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= | |
| 106 | 117 | github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= | |
| @@ -150,12 +161,16 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= | |||
| 150 | 161 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | |
| 151 | 162 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= | |
| 152 | 163 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= | |
| 164 | + | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | |
| 153 | 165 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= | |
| 154 | 166 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= | |
| 167 | + | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | |
| 155 | 168 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | |
| 156 | 169 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | |
| 157 | 170 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= | |
| 158 | 171 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= | |
| 172 | + | github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= | |
| 173 | + | github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= | |
| 159 | 174 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= | |
| 160 | 175 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= | |
| 161 | 176 | go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= | |
| @@ -202,7 +217,10 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= | |||
| 202 | 217 | golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= | |
| 203 | 218 | golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= | |
| 204 | 219 | golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= | |
| 220 | + | golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= | |
| 221 | + | golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= | |
| 205 | 222 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 223 | + | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 206 | 224 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 207 | 225 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| 208 | 226 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |
| @@ -229,6 +247,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN | |||
| 229 | 247 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | |
| 230 | 248 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= | |
| 231 | 249 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= | |
| 250 | + | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | |
| 232 | 251 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | |
| 233 | 252 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | |
| 234 | 253 | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= | |
| @@ -42,14 +42,21 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X | |||||
| 42 | github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= | 42 | github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= | ||
| 43 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= | 43 | github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= | ||
| 44 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= | 44 | github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= | ||
| 45 | + | github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= | |||
| 46 | + | github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= | |||
| 45 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= | 47 | github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= | ||
| 46 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | 48 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= | ||
| 49 | + | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |||
| 47 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | 50 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= | ||
| 48 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | 51 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| 49 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= | 52 | github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= | ||
| 50 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | 53 | github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= | ||
| 54 | + | github.com/docker/cli v29.2.1+incompatible h1:n3Jt0QVCN65eiVBoUTZQM9mcQICCJt3akW4pKAbKdJg= | |||
| 55 | + | github.com/docker/cli v29.2.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= | |||
| 51 | github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= | 56 | github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= | ||
| 52 | github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= | 57 | github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= | ||
| 58 | + | github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= | |||
| 59 | + | github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo= | |||
| 53 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= | 60 | github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c= | ||
| 54 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= | 61 | github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc= | ||
| 55 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= | 62 | github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= | ||
| @@ -84,6 +91,8 @@ github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7 h1:X+2YciYSxvMQK0UZ7sg45ZVabVZ | |||||
| 84 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= | 91 | github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII= | ||
| 85 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | 92 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= | ||
| 86 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | 93 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= | ||
| 94 | + | github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= | |||
| 95 | + | github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= | |||
| 87 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | 96 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= | ||
| 88 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | 97 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= | ||
| 89 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | 98 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= | ||
| @@ -101,6 +110,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J | |||||
| 101 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= | 110 | github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= | ||
| 102 | github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= | 111 | github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w= | ||
| 103 | github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= | 112 | github.com/mattn/go-runewidth v0.0.21/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= | ||
| 113 | + | github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= | |||
| 114 | + | github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= | |||
| 104 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= | 115 | github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= | ||
| 105 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= | 116 | github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= | ||
| 106 | github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= | 117 | github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= | ||
| @@ -150,12 +161,16 @@ github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= | |||||
| 150 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | 161 | github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= | ||
| 151 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= | 162 | github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU= | ||
| 152 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= | 163 | github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= | ||
| 164 | + | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= | |||
| 153 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= | 165 | github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= | ||
| 154 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= | 166 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= | ||
| 167 | + | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= | |||
| 155 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | 168 | github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= | ||
| 156 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | 169 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | ||
| 157 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= | 170 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= | ||
| 158 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= | 171 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= | ||
| 172 | + | github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= | |||
| 173 | + | github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= | |||
| 159 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= | 174 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= | ||
| 160 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= | 175 | github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= | ||
| 161 | go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= | 176 | go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= | ||
| @@ -202,7 +217,10 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= | |||||
| 202 | golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= | 217 | golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= | ||
| 203 | golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= | 218 | golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= | ||
| 204 | golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= | 219 | golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= | ||
| 220 | + | golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= | |||
| 221 | + | golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= | |||
| 205 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 222 | golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 223 | + | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | |||
| 206 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 224 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 207 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 225 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| 208 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | 226 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= | ||
| @@ -229,6 +247,7 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN | |||||
| 229 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | 247 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= | ||
| 230 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= | 248 | gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc= | ||
| 231 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= | 249 | gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= | ||
| 250 | + | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | |||
| 232 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | 251 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
| 233 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | 252 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= | ||
| 234 | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= | 253 | gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q= | ||
M pkg/registrypush/push.go +174 -4
| @@ -2,12 +2,19 @@ package registrypush | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/sha256" | |
| 6 | + | "encoding/base64" | |
| 5 | 7 | "fmt" | |
| 6 | 8 | "io" | |
| 7 | 9 | "net/http" | |
| 8 | 10 | "net/url" | |
| 11 | + | "strings" | |
| 9 | 12 | ||
| 13 | + | "github.com/docker/docker/client" | |
| 14 | + | "github.com/google/go-containerregistry/pkg/authn" | |
| 15 | + | "github.com/google/go-containerregistry/pkg/name" | |
| 10 | 16 | v1 "github.com/google/go-containerregistry/pkg/v1" | |
| 17 | + | "github.com/google/go-containerregistry/pkg/v1/tarball" | |
| 11 | 18 | ) | |
| 12 | 19 | ||
| 13 | 20 | // DefaultChunkSize is 50MB - safely under Cloudflare's 100MB per-request limit | |
| @@ -70,8 +77,38 @@ func New(opts ...Option) *Pusher { | |||
| 70 | 77 | // | |
| 71 | 78 | // The ref must be fully qualified: registry.example.com/name:tag | |
| 72 | 79 | func (p *Pusher) Push(ctx context.Context, ref string) error { | |
| 73 | - | // Implementation in subsequent tasks | |
| 74 | - | return nil | |
| 80 | + | parsedRef, err := name.ParseReference(ref) | |
| 81 | + | if err != nil { | |
| 82 | + | return fmt.Errorf("failed to parse image ref %s: %w", ref, err) | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | tag, ok := parsedRef.(name.Tag) | |
| 86 | + | if !ok { | |
| 87 | + | return fmt.Errorf("image ref %s must include a tag", ref) | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | authHeader, err := resolveAuthHeader(parsedRef) | |
| 91 | + | if err != nil { | |
| 92 | + | return err | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | img, err := p.imageSource(ctx, ref) | |
| 96 | + | if err != nil { | |
| 97 | + | return err | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | baseURL := registryBaseURL(parsedRef.Context().RegistryStr()) | |
| 101 | + | repo := parsedRef.Context().RepositoryStr() | |
| 102 | + | ||
| 103 | + | if err := p.uploadImageLayers(ctx, img, baseURL, repo, authHeader); err != nil { | |
| 104 | + | return err | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | if err := p.uploadImageConfig(ctx, img, baseURL, repo, authHeader); err != nil { | |
| 108 | + | return err | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | return p.uploadManifest(ctx, baseURL, repo, tag.Identifier(), img, authHeader) | |
| 75 | 112 | } | |
| 76 | 113 | ||
| 77 | 114 | // UploadBlob uploads a single blob to the registry using chunked uploads. | |
| @@ -105,8 +142,141 @@ func (p *Pusher) UploadBlob(ctx context.Context, baseURL, repo, digest string, s | |||
| 105 | 142 | ||
| 106 | 143 | // defaultImageSource reads an image from the local Docker/Podman daemon. | |
| 107 | 144 | func defaultImageSource(ctx context.Context, ref string) (v1.Image, error) { | |
| 108 | - | // Implementation in Task 3 | |
| 109 | - | return nil, nil | |
| 145 | + | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) | |
| 146 | + | if err != nil { | |
| 147 | + | return nil, fmt.Errorf("failed to create Docker client: %w", err) | |
| 148 | + | } | |
| 149 | + | defer cli.Close() | |
| 150 | + | ||
| 151 | + | tag, err := name.NewTag(ref) | |
| 152 | + | if err != nil { | |
| 153 | + | return nil, fmt.Errorf("failed to parse image ref %s: %w", ref, err) | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | rc, err := cli.ImageSave(ctx, []string{ref}) | |
| 157 | + | if err != nil { | |
| 158 | + | return nil, fmt.Errorf("failed to export image %s: %w", ref, err) | |
| 159 | + | } | |
| 160 | + | defer rc.Close() | |
| 161 | + | ||
| 162 | + | img, err := tarball.Image(func() (io.ReadCloser, error) { | |
| 163 | + | return rc, nil | |
| 164 | + | }, &tag) | |
| 165 | + | if err != nil { | |
| 166 | + | return nil, fmt.Errorf("failed to parse image tar for %s: %w", ref, err) | |
| 167 | + | } | |
| 168 | + | return img, nil | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | func (p *Pusher) uploadImageLayers(ctx context.Context, img v1.Image, baseURL, repo, auth string) error { | |
| 172 | + | layers, err := img.Layers() | |
| 173 | + | if err != nil { | |
| 174 | + | return fmt.Errorf("failed to read image layers: %w", err) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | for _, layer := range layers { | |
| 178 | + | if err := p.uploadLayer(ctx, baseURL, repo, layer, auth); err != nil { | |
| 179 | + | return err | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | func (p *Pusher) uploadLayer(ctx context.Context, baseURL, repo string, layer v1.Layer, auth string) error { | |
| 187 | + | digest, err := layer.Digest() | |
| 188 | + | if err != nil { | |
| 189 | + | return fmt.Errorf("failed to get layer digest: %w", err) | |
| 190 | + | } | |
| 191 | + | ||
| 192 | + | size, err := layer.Size() | |
| 193 | + | if err != nil { | |
| 194 | + | return fmt.Errorf("failed to get layer size for %s: %w", digest, err) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | content, err := layer.Compressed() | |
| 198 | + | if err != nil { | |
| 199 | + | return fmt.Errorf("failed to read layer %s: %w", digest, err) | |
| 200 | + | } | |
| 201 | + | defer content.Close() | |
| 202 | + | ||
| 203 | + | if err := p.UploadBlob(ctx, baseURL, repo, digest.String(), size, content, auth); err != nil { | |
| 204 | + | return fmt.Errorf("failed to upload layer %s: %w", digest, err) | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | return nil | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | func (p *Pusher) uploadImageConfig(ctx context.Context, img v1.Image, baseURL, repo, auth string) error { | |
| 211 | + | config, err := img.RawConfigFile() | |
| 212 | + | if err != nil { | |
| 213 | + | return fmt.Errorf("failed to read image config: %w", err) | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(config)) | |
| 217 | + | if err := p.UploadBlob(ctx, baseURL, repo, digest, int64(len(config)), bytesReader(config), auth); err != nil { | |
| 218 | + | return fmt.Errorf("failed to upload image config %s: %w", digest, err) | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | return nil | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | func (p *Pusher) uploadManifest(ctx context.Context, baseURL, repo, tag string, img v1.Image, auth string) error { | |
| 225 | + | manifest, err := img.RawManifest() | |
| 226 | + | if err != nil { | |
| 227 | + | return fmt.Errorf("failed to read image manifest: %w", err) | |
| 228 | + | } | |
| 229 | + | ||
| 230 | + | mediaType, err := img.MediaType() | |
| 231 | + | if err != nil { | |
| 232 | + | return fmt.Errorf("failed to read image media type: %w", err) | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | req, err := http.NewRequestWithContext(ctx, http.MethodPut, fmt.Sprintf("%s/v2/%s/manifests/%s", baseURL, repo, tag), bytesReader(manifest)) | |
| 236 | + | if err != nil { | |
| 237 | + | return fmt.Errorf("failed to create manifest upload request: %w", err) | |
| 238 | + | } | |
| 239 | + | setAuthHeader(req, auth) | |
| 240 | + | req.Header.Set("Content-Type", string(mediaType)) | |
| 241 | + | req.ContentLength = int64(len(manifest)) | |
| 242 | + | ||
| 243 | + | resp, err := p.httpClient().Do(req) | |
| 244 | + | if err != nil { | |
| 245 | + | return fmt.Errorf("failed to upload manifest: %w", err) | |
| 246 | + | } | |
| 247 | + | defer resp.Body.Close() | |
| 248 | + | ||
| 249 | + | if resp.StatusCode != http.StatusCreated { | |
| 250 | + | return fmt.Errorf("manifest upload returned status %d", resp.StatusCode) | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | return nil | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | func resolveAuthHeader(ref name.Reference) (string, error) { | |
| 257 | + | authenticator, err := authn.DefaultKeychain.Resolve(ref.Context().Registry) | |
| 258 | + | if err != nil { | |
| 259 | + | return "", fmt.Errorf("failed to resolve registry credentials for %s: %w", ref.Context().RegistryStr(), err) | |
| 260 | + | } | |
| 261 | + | ||
| 262 | + | authConfig, err := authenticator.Authorization() | |
| 263 | + | if err != nil { | |
| 264 | + | return "", fmt.Errorf("failed to authorize for registry %s: %w", ref.Context().RegistryStr(), err) | |
| 265 | + | } | |
| 266 | + | ||
| 267 | + | if authConfig == nil || (authConfig.Username == "" && authConfig.Password == "") { | |
| 268 | + | return "", nil | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | encoded := base64.StdEncoding.EncodeToString([]byte(authConfig.Username + ":" + authConfig.Password)) | |
| 272 | + | return "Basic " + encoded, nil | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | func registryBaseURL(host string) string { | |
| 276 | + | if strings.HasPrefix(host, "localhost:") || strings.HasPrefix(host, "127.0.0.1:") || host == "localhost" || host == "127.0.0.1" { | |
| 277 | + | return "http://" + host | |
| 278 | + | } | |
| 279 | + | return "https://" + host | |
| 110 | 280 | } | |
| 111 | 281 | ||
| 112 | 282 | var errBlobMissing = fmt.Errorf("blob missing") | |
| @@ -2,12 +2,19 @@ package registrypush | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "crypto/sha256" | |||
| 6 | + | "encoding/base64" | |||
| 5 | "fmt" | 7 | "fmt" | ||
| 6 | "io" | 8 | "io" | ||
| 7 | "net/http" | 9 | "net/http" | ||
| 8 | "net/url" | 10 | "net/url" | ||
| 11 | + | "strings" | |||
| 9 | 12 | ||||
| 13 | + | "github.com/docker/docker/client" | |||
| 14 | + | "github.com/google/go-containerregistry/pkg/authn" | |||
| 15 | + | "github.com/google/go-containerregistry/pkg/name" | |||
| 10 | v1 "github.com/google/go-containerregistry/pkg/v1" | 16 | v1 "github.com/google/go-containerregistry/pkg/v1" | ||
| 17 | + | "github.com/google/go-containerregistry/pkg/v1/tarball" | |||
| 11 | ) | 18 | ) | ||
| 12 | 19 | ||||
| 13 | // DefaultChunkSize is 50MB - safely under Cloudflare's 100MB per-request limit | 20 | // DefaultChunkSize is 50MB - safely under Cloudflare's 100MB per-request limit | ||
| @@ -70,8 +77,38 @@ func New(opts ...Option) *Pusher { | |||||
| 70 | // | 77 | // | ||
| 71 | // The ref must be fully qualified: registry.example.com/name:tag | 78 | // The ref must be fully qualified: registry.example.com/name:tag | ||
| 72 | func (p *Pusher) Push(ctx context.Context, ref string) error { | 79 | func (p *Pusher) Push(ctx context.Context, ref string) error { | ||
| 73 | - | // Implementation in subsequent tasks | 80 | + | parsedRef, err := name.ParseReference(ref) |
| 74 | - | return nil | 81 | + | if err != nil { |
| 82 | + | return fmt.Errorf("failed to parse image ref %s: %w", ref, err) | |||
| 83 | + | } | |||
| 84 | + | ||||
| 85 | + | tag, ok := parsedRef.(name.Tag) | |||
| 86 | + | if !ok { | |||
| 87 | + | return fmt.Errorf("image ref %s must include a tag", ref) | |||
| 88 | + | } | |||
| 89 | + | ||||
| 90 | + | authHeader, err := resolveAuthHeader(parsedRef) | |||
| 91 | + | if err != nil { | |||
| 92 | + | return err | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | img, err := p.imageSource(ctx, ref) | |||
| 96 | + | if err != nil { | |||
| 97 | + | return err | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | baseURL := registryBaseURL(parsedRef.Context().RegistryStr()) | |||
| 101 | + | repo := parsedRef.Context().RepositoryStr() | |||
| 102 | + | ||||
| 103 | + | if err := p.uploadImageLayers(ctx, img, baseURL, repo, authHeader); err != nil { | |||
| 104 | + | return err | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | if err := p.uploadImageConfig(ctx, img, baseURL, repo, authHeader); err != nil { | |||
| 108 | + | return err | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | return p.uploadManifest(ctx, baseURL, repo, tag.Identifier(), img, authHeader) | |||
| 75 | } | 112 | } | ||
| 76 | 113 | ||||
| 77 | // UploadBlob uploads a single blob to the registry using chunked uploads. | 114 | // UploadBlob uploads a single blob to the registry using chunked uploads. | ||
| @@ -105,8 +142,141 @@ func (p *Pusher) UploadBlob(ctx context.Context, baseURL, repo, digest string, s | |||||
| 105 | 142 | ||||
| 106 | // defaultImageSource reads an image from the local Docker/Podman daemon. | 143 | // defaultImageSource reads an image from the local Docker/Podman daemon. | ||
| 107 | func defaultImageSource(ctx context.Context, ref string) (v1.Image, error) { | 144 | func defaultImageSource(ctx context.Context, ref string) (v1.Image, error) { | ||
| 108 | - | // Implementation in Task 3 | 145 | + | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) |
| 109 | - | return nil, nil | 146 | + | if err != nil { |
| 147 | + | return nil, fmt.Errorf("failed to create Docker client: %w", err) | |||
| 148 | + | } | |||
| 149 | + | defer cli.Close() | |||
| 150 | + | ||||
| 151 | + | tag, err := name.NewTag(ref) | |||
| 152 | + | if err != nil { | |||
| 153 | + | return nil, fmt.Errorf("failed to parse image ref %s: %w", ref, err) | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | rc, err := cli.ImageSave(ctx, []string{ref}) | |||
| 157 | + | if err != nil { | |||
| 158 | + | return nil, fmt.Errorf("failed to export image %s: %w", ref, err) | |||
| 159 | + | } | |||
| 160 | + | defer rc.Close() | |||
| 161 | + | ||||
| 162 | + | img, err := tarball.Image(func() (io.ReadCloser, error) { | |||
| 163 | + | return rc, nil | |||
| 164 | + | }, &tag) | |||
| 165 | + | if err != nil { | |||
| 166 | + | return nil, fmt.Errorf("failed to parse image tar for %s: %w", ref, err) | |||
| 167 | + | } | |||
| 168 | + | return img, nil | |||
| 169 | + | } | |||
| 170 | + | ||||
| 171 | + | func (p *Pusher) uploadImageLayers(ctx context.Context, img v1.Image, baseURL, repo, auth string) error { | |||
| 172 | + | layers, err := img.Layers() | |||
| 173 | + | if err != nil { | |||
| 174 | + | return fmt.Errorf("failed to read image layers: %w", err) | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | for _, layer := range layers { | |||
| 178 | + | if err := p.uploadLayer(ctx, baseURL, repo, layer, auth); err != nil { | |||
| 179 | + | return err | |||
| 180 | + | } | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | return nil | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | func (p *Pusher) uploadLayer(ctx context.Context, baseURL, repo string, layer v1.Layer, auth string) error { | |||
| 187 | + | digest, err := layer.Digest() | |||
| 188 | + | if err != nil { | |||
| 189 | + | return fmt.Errorf("failed to get layer digest: %w", err) | |||
| 190 | + | } | |||
| 191 | + | ||||
| 192 | + | size, err := layer.Size() | |||
| 193 | + | if err != nil { | |||
| 194 | + | return fmt.Errorf("failed to get layer size for %s: %w", digest, err) | |||
| 195 | + | } | |||
| 196 | + | ||||
| 197 | + | content, err := layer.Compressed() | |||
| 198 | + | if err != nil { | |||
| 199 | + | return fmt.Errorf("failed to read layer %s: %w", digest, err) | |||
| 200 | + | } | |||
| 201 | + | defer content.Close() | |||
| 202 | + | ||||
| 203 | + | if err := p.UploadBlob(ctx, baseURL, repo, digest.String(), size, content, auth); err != nil { | |||
| 204 | + | return fmt.Errorf("failed to upload layer %s: %w", digest, err) | |||
| 205 | + | } | |||
| 206 | + | ||||
| 207 | + | return nil | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | func (p *Pusher) uploadImageConfig(ctx context.Context, img v1.Image, baseURL, repo, auth string) error { | |||
| 211 | + | config, err := img.RawConfigFile() | |||
| 212 | + | if err != nil { | |||
| 213 | + | return fmt.Errorf("failed to read image config: %w", err) | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | digest := fmt.Sprintf("sha256:%x", sha256.Sum256(config)) | |||
| 217 | + | if err := p.UploadBlob(ctx, baseURL, repo, digest, int64(len(config)), bytesReader(config), auth); err != nil { | |||
| 218 | + | return fmt.Errorf("failed to upload image config %s: %w", digest, err) | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | return nil | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | func (p *Pusher) uploadManifest(ctx context.Context, baseURL, repo, tag string, img v1.Image, auth string) error { | |||
| 225 | + | manifest, err := img.RawManifest() | |||
| 226 | + | if err != nil { | |||
| 227 | + | return fmt.Errorf("failed to read image manifest: %w", err) | |||
| 228 | + | } | |||
| 229 | + | ||||
| 230 | + | mediaType, err := img.MediaType() | |||
| 231 | + | if err != nil { | |||
| 232 | + | return fmt.Errorf("failed to read image media type: %w", err) | |||
| 233 | + | } | |||
| 234 | + | ||||
| 235 | + | req, err := http.NewRequestWithContext(ctx, http.MethodPut, fmt.Sprintf("%s/v2/%s/manifests/%s", baseURL, repo, tag), bytesReader(manifest)) | |||
| 236 | + | if err != nil { | |||
| 237 | + | return fmt.Errorf("failed to create manifest upload request: %w", err) | |||
| 238 | + | } | |||
| 239 | + | setAuthHeader(req, auth) | |||
| 240 | + | req.Header.Set("Content-Type", string(mediaType)) | |||
| 241 | + | req.ContentLength = int64(len(manifest)) | |||
| 242 | + | ||||
| 243 | + | resp, err := p.httpClient().Do(req) | |||
| 244 | + | if err != nil { | |||
| 245 | + | return fmt.Errorf("failed to upload manifest: %w", err) | |||
| 246 | + | } | |||
| 247 | + | defer resp.Body.Close() | |||
| 248 | + | ||||
| 249 | + | if resp.StatusCode != http.StatusCreated { | |||
| 250 | + | return fmt.Errorf("manifest upload returned status %d", resp.StatusCode) | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | return nil | |||
| 254 | + | } | |||
| 255 | + | ||||
| 256 | + | func resolveAuthHeader(ref name.Reference) (string, error) { | |||
| 257 | + | authenticator, err := authn.DefaultKeychain.Resolve(ref.Context().Registry) | |||
| 258 | + | if err != nil { | |||
| 259 | + | return "", fmt.Errorf("failed to resolve registry credentials for %s: %w", ref.Context().RegistryStr(), err) | |||
| 260 | + | } | |||
| 261 | + | ||||
| 262 | + | authConfig, err := authenticator.Authorization() | |||
| 263 | + | if err != nil { | |||
| 264 | + | return "", fmt.Errorf("failed to authorize for registry %s: %w", ref.Context().RegistryStr(), err) | |||
| 265 | + | } | |||
| 266 | + | ||||
| 267 | + | if authConfig == nil || (authConfig.Username == "" && authConfig.Password == "") { | |||
| 268 | + | return "", nil | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | encoded := base64.StdEncoding.EncodeToString([]byte(authConfig.Username + ":" + authConfig.Password)) | |||
| 272 | + | return "Basic " + encoded, nil | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | func registryBaseURL(host string) string { | |||
| 276 | + | if strings.HasPrefix(host, "localhost:") || strings.HasPrefix(host, "127.0.0.1:") || host == "localhost" || host == "127.0.0.1" { | |||
| 277 | + | return "http://" + host | |||
| 278 | + | } | |||
| 279 | + | return "https://" + host | |||
| 110 | } | 280 | } | ||
| 111 | 281 | ||||
| 112 | var errBlobMissing = fmt.Errorf("blob missing") | 282 | var errBlobMissing = fmt.Errorf("blob missing") | ||
M pkg/registrypush/push_test.go +260 -0
| @@ -3,14 +3,18 @@ package registrypush_test | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "crypto/sha256" | |
| 6 | 7 | "fmt" | |
| 7 | 8 | "io" | |
| 8 | 9 | "net/http" | |
| 9 | 10 | "net/http/httptest" | |
| 11 | + | "net/url" | |
| 10 | 12 | "strconv" | |
| 11 | 13 | "strings" | |
| 12 | 14 | "testing" | |
| 13 | 15 | ||
| 16 | + | "github.com/google/go-containerregistry/pkg/v1" | |
| 17 | + | "github.com/google/go-containerregistry/pkg/v1/random" | |
| 14 | 18 | "github.com/stretchr/testify/assert" | |
| 15 | 19 | "github.com/stretchr/testify/require" | |
| 16 | 20 | ||
| @@ -119,6 +123,100 @@ func TestPusher_UploadBlob(t *testing.T) { | |||
| 119 | 123 | } | |
| 120 | 124 | } | |
| 121 | 125 | ||
| 126 | + | func TestPusher_Push(t *testing.T) { | |
| 127 | + | testCases := []struct { | |
| 128 | + | name string | |
| 129 | + | imageSize int64 | |
| 130 | + | layerCount int64 | |
| 131 | + | existingBlobDigests func(t *testing.T, img v1.Image) map[string]bool | |
| 132 | + | expectedUploadedBlobCount int | |
| 133 | + | expectedManifestMediaTypes []string | |
| 134 | + | }{ | |
| 135 | + | { | |
| 136 | + | name: "single-layer image uploads config layer and manifest", | |
| 137 | + | imageSize: 1024, | |
| 138 | + | layerCount: 1, | |
| 139 | + | existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool { return map[string]bool{} }, | |
| 140 | + | expectedUploadedBlobCount: 2, | |
| 141 | + | expectedManifestMediaTypes: []string{ | |
| 142 | + | "application/vnd.docker.distribution.manifest.v2+json", | |
| 143 | + | string(typesOCIManifestSchema1()), | |
| 144 | + | }, | |
| 145 | + | }, | |
| 146 | + | { | |
| 147 | + | name: "multi-layer image uploads all blobs and manifest", | |
| 148 | + | imageSize: 1024, | |
| 149 | + | layerCount: 2, | |
| 150 | + | existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool { return map[string]bool{} }, | |
| 151 | + | expectedUploadedBlobCount: 3, | |
| 152 | + | expectedManifestMediaTypes: []string{ | |
| 153 | + | "application/vnd.docker.distribution.manifest.v2+json", | |
| 154 | + | string(typesOCIManifestSchema1()), | |
| 155 | + | }, | |
| 156 | + | }, | |
| 157 | + | { | |
| 158 | + | name: "existing layer is skipped while others upload", | |
| 159 | + | imageSize: 1024, | |
| 160 | + | layerCount: 2, | |
| 161 | + | existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool { | |
| 162 | + | t.Helper() | |
| 163 | + | layers, err := img.Layers() | |
| 164 | + | require.NoError(t, err) | |
| 165 | + | digest, err := layers[0].Digest() | |
| 166 | + | require.NoError(t, err) | |
| 167 | + | return map[string]bool{digest.String(): true} | |
| 168 | + | }, | |
| 169 | + | expectedUploadedBlobCount: 2, | |
| 170 | + | expectedManifestMediaTypes: []string{ | |
| 171 | + | "application/vnd.docker.distribution.manifest.v2+json", | |
| 172 | + | string(typesOCIManifestSchema1()), | |
| 173 | + | }, | |
| 174 | + | }, | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | for _, tc := range testCases { | |
| 178 | + | t.Run(tc.name, func(t *testing.T) { | |
| 179 | + | img, err := random.Image(tc.imageSize, tc.layerCount) | |
| 180 | + | require.NoError(t, err) | |
| 181 | + | ||
| 182 | + | serverState := newFakePushRegistry(t, img, tc.existingBlobDigests(t, img)) | |
| 183 | + | server := httptest.NewServer(serverState.handler()) | |
| 184 | + | defer server.Close() | |
| 185 | + | ||
| 186 | + | serverURL, err := url.Parse(server.URL) | |
| 187 | + | require.NoError(t, err) | |
| 188 | + | ref := serverURL.Host + "/demo/app:v1" | |
| 189 | + | ||
| 190 | + | p := registrypush.New( | |
| 191 | + | registrypush.WithTransport(server.Client().Transport), | |
| 192 | + | registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) { | |
| 193 | + | return img, nil | |
| 194 | + | }), | |
| 195 | + | ) | |
| 196 | + | ||
| 197 | + | err = p.Push(context.Background(), ref) | |
| 198 | + | require.NoError(t, err) | |
| 199 | + | ||
| 200 | + | expectedDigests := make([]string, 0, len(serverState.expectedBlobs)) | |
| 201 | + | for digest := range serverState.expectedBlobs { | |
| 202 | + | expectedDigests = append(expectedDigests, digest) | |
| 203 | + | assert.Contains(t, serverState.headChecks, digest) | |
| 204 | + | } | |
| 205 | + | assert.Len(t, serverState.headChecks, len(expectedDigests)) | |
| 206 | + | ||
| 207 | + | assert.Len(t, serverState.uploadedBlobs, tc.expectedUploadedBlobCount) | |
| 208 | + | for digest, content := range serverState.uploadedBlobs { | |
| 209 | + | assert.Equal(t, serverState.expectedBlobs[digest], content) | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | manifestPut, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag] | |
| 213 | + | require.True(t, ok) | |
| 214 | + | assert.Contains(t, tc.expectedManifestMediaTypes, manifestPut.contentType) | |
| 215 | + | assert.Equal(t, serverState.expectedManifest, manifestPut.body) | |
| 216 | + | }) | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | ||
| 122 | 220 | type fakeBlobUploadRegistry struct { | |
| 123 | 221 | t *testing.T | |
| 124 | 222 | expectedAuth string | |
| @@ -226,3 +324,165 @@ func rangeHeaderValue(start, end int) string { | |||
| 226 | 324 | } | |
| 227 | 325 | return fmt.Sprintf("%d-%d", start, end) | |
| 228 | 326 | } | |
| 327 | + | ||
| 328 | + | type fakePushRegistry struct { | |
| 329 | + | t *testing.T | |
| 330 | + | existingBlobs map[string]bool | |
| 331 | + | uploadedBlobs map[string][]byte | |
| 332 | + | manifestPuts map[string]fakeManifestPut | |
| 333 | + | uploads map[string]*fakeUploadSession | |
| 334 | + | nextUploadID int | |
| 335 | + | headChecks []string | |
| 336 | + | expectedBlobs map[string][]byte | |
| 337 | + | expectedRepo string | |
| 338 | + | expectedTag string | |
| 339 | + | expectedMT string | |
| 340 | + | expectedManifest []byte | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | type fakeUploadSession struct { | |
| 344 | + | repo string | |
| 345 | + | data []byte | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | type fakeManifestPut struct { | |
| 349 | + | contentType string | |
| 350 | + | body []byte | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | func newFakePushRegistry(t *testing.T, img v1.Image, existingBlobs map[string]bool) *fakePushRegistry { | |
| 354 | + | t.Helper() | |
| 355 | + | ||
| 356 | + | layers, err := img.Layers() | |
| 357 | + | require.NoError(t, err) | |
| 358 | + | ||
| 359 | + | expectedBlobs := make(map[string][]byte, len(layers)+1) | |
| 360 | + | for _, layer := range layers { | |
| 361 | + | digest, err := layer.Digest() | |
| 362 | + | require.NoError(t, err) | |
| 363 | + | rc, err := layer.Compressed() | |
| 364 | + | require.NoError(t, err) | |
| 365 | + | content, err := io.ReadAll(rc) | |
| 366 | + | require.NoError(t, err) | |
| 367 | + | require.NoError(t, rc.Close()) | |
| 368 | + | expectedBlobs[digest.String()] = content | |
| 369 | + | } | |
| 370 | + | ||
| 371 | + | config, err := img.RawConfigFile() | |
| 372 | + | require.NoError(t, err) | |
| 373 | + | configDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(config)) | |
| 374 | + | expectedBlobs[configDigest] = config | |
| 375 | + | ||
| 376 | + | manifest, err := img.RawManifest() | |
| 377 | + | require.NoError(t, err) | |
| 378 | + | mediaType, err := img.MediaType() | |
| 379 | + | require.NoError(t, err) | |
| 380 | + | ||
| 381 | + | return &fakePushRegistry{ | |
| 382 | + | t: t, | |
| 383 | + | existingBlobs: existingBlobs, | |
| 384 | + | uploadedBlobs: make(map[string][]byte), | |
| 385 | + | manifestPuts: make(map[string]fakeManifestPut), | |
| 386 | + | uploads: make(map[string]*fakeUploadSession), | |
| 387 | + | expectedBlobs: expectedBlobs, | |
| 388 | + | expectedRepo: "demo/app", | |
| 389 | + | expectedTag: "v1", | |
| 390 | + | expectedMT: string(mediaType), | |
| 391 | + | expectedManifest: manifest, | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func (f *fakePushRegistry) handler() http.Handler { | |
| 396 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 397 | + | switch { | |
| 398 | + | case r.Method == http.MethodHead && strings.HasPrefix(r.URL.Path, "/v2/") && strings.Contains(r.URL.Path, "/blobs/"): | |
| 399 | + | f.handleBlobHead(w, r) | |
| 400 | + | case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/blobs/uploads/"): | |
| 401 | + | f.handleBlobStart(w, r) | |
| 402 | + | case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/blobs/uploads/"): | |
| 403 | + | f.handleBlobPatch(w, r) | |
| 404 | + | case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/blobs/uploads/"): | |
| 405 | + | f.handleBlobFinalize(w, r) | |
| 406 | + | case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/manifests/"): | |
| 407 | + | f.handleManifestPut(w, r) | |
| 408 | + | default: | |
| 409 | + | f.t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) | |
| 410 | + | } | |
| 411 | + | }) | |
| 412 | + | } | |
| 413 | + | ||
| 414 | + | func (f *fakePushRegistry) handleBlobHead(w http.ResponseWriter, r *http.Request) { | |
| 415 | + | digest := pathTail(r.URL.Path) | |
| 416 | + | f.headChecks = append(f.headChecks, digest) | |
| 417 | + | if f.existingBlobs[digest] { | |
| 418 | + | w.WriteHeader(http.StatusOK) | |
| 419 | + | return | |
| 420 | + | } | |
| 421 | + | w.WriteHeader(http.StatusNotFound) | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | func (f *fakePushRegistry) handleBlobStart(w http.ResponseWriter, r *http.Request) { | |
| 425 | + | repo := pathSegmentBetween(r.URL.Path, "/v2/", "/blobs/uploads/") | |
| 426 | + | assert.Equal(f.t, f.expectedRepo, repo) | |
| 427 | + | f.nextUploadID++ | |
| 428 | + | uploadPath := fmt.Sprintf("/v2/%s/blobs/uploads/upload-%d", repo, f.nextUploadID) | |
| 429 | + | f.uploads[uploadPath] = &fakeUploadSession{repo: repo} | |
| 430 | + | w.Header().Set("Location", uploadPath) | |
| 431 | + | w.WriteHeader(http.StatusAccepted) | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | func (f *fakePushRegistry) handleBlobPatch(w http.ResponseWriter, r *http.Request) { | |
| 435 | + | session := f.uploads[r.URL.Path] | |
| 436 | + | if session == nil { | |
| 437 | + | f.t.Fatalf("missing upload session for %s", r.URL.Path) | |
| 438 | + | } | |
| 439 | + | body, err := io.ReadAll(r.Body) | |
| 440 | + | require.NoError(f.t, err) | |
| 441 | + | session.data = append(session.data, body...) | |
| 442 | + | w.Header().Set("Location", r.URL.Path) | |
| 443 | + | w.WriteHeader(http.StatusAccepted) | |
| 444 | + | } | |
| 445 | + | ||
| 446 | + | func (f *fakePushRegistry) handleBlobFinalize(w http.ResponseWriter, r *http.Request) { | |
| 447 | + | session := f.uploads[r.URL.Path] | |
| 448 | + | if session == nil { | |
| 449 | + | f.t.Fatalf("missing upload session for %s", r.URL.Path) | |
| 450 | + | } | |
| 451 | + | digest := r.URL.Query().Get("digest") | |
| 452 | + | f.uploadedBlobs[digest] = append([]byte(nil), session.data...) | |
| 453 | + | delete(f.uploads, r.URL.Path) | |
| 454 | + | w.WriteHeader(http.StatusCreated) | |
| 455 | + | } | |
| 456 | + | ||
| 457 | + | func (f *fakePushRegistry) handleManifestPut(w http.ResponseWriter, r *http.Request) { | |
| 458 | + | repo := pathSegmentBetween(r.URL.Path, "/v2/", "/manifests/") | |
| 459 | + | tag := pathTail(r.URL.Path) | |
| 460 | + | assert.Equal(f.t, f.expectedRepo, repo) | |
| 461 | + | assert.Equal(f.t, f.expectedTag, tag) | |
| 462 | + | body, err := io.ReadAll(r.Body) | |
| 463 | + | require.NoError(f.t, err) | |
| 464 | + | f.manifestPuts[repo+":"+tag] = fakeManifestPut{contentType: r.Header.Get("Content-Type"), body: body} | |
| 465 | + | w.WriteHeader(http.StatusCreated) | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | func pathTail(path string) string { | |
| 469 | + | parts := strings.Split(path, "/") | |
| 470 | + | return parts[len(parts)-1] | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | func pathSegmentBetween(value, start, end string) string { | |
| 474 | + | startIdx := strings.Index(value, start) | |
| 475 | + | if startIdx == -1 { | |
| 476 | + | return "" | |
| 477 | + | } | |
| 478 | + | trimmed := value[startIdx+len(start):] | |
| 479 | + | endIdx := strings.Index(trimmed, end) | |
| 480 | + | if endIdx == -1 { | |
| 481 | + | return "" | |
| 482 | + | } | |
| 483 | + | return trimmed[:endIdx] | |
| 484 | + | } | |
| 485 | + | ||
| 486 | + | func typesOCIManifestSchema1() string { | |
| 487 | + | return "application/vnd.oci.image.manifest.v1+json" | |
| 488 | + | } | |
| @@ -3,14 +3,18 @@ package registrypush_test | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "crypto/sha256" | |||
| 6 | "fmt" | 7 | "fmt" | ||
| 7 | "io" | 8 | "io" | ||
| 8 | "net/http" | 9 | "net/http" | ||
| 9 | "net/http/httptest" | 10 | "net/http/httptest" | ||
| 11 | + | "net/url" | |||
| 10 | "strconv" | 12 | "strconv" | ||
| 11 | "strings" | 13 | "strings" | ||
| 12 | "testing" | 14 | "testing" | ||
| 13 | 15 | ||||
| 16 | + | "github.com/google/go-containerregistry/pkg/v1" | |||
| 17 | + | "github.com/google/go-containerregistry/pkg/v1/random" | |||
| 14 | "github.com/stretchr/testify/assert" | 18 | "github.com/stretchr/testify/assert" | ||
| 15 | "github.com/stretchr/testify/require" | 19 | "github.com/stretchr/testify/require" | ||
| 16 | 20 | ||||
| @@ -119,6 +123,100 @@ func TestPusher_UploadBlob(t *testing.T) { | |||||
| 119 | } | 123 | } | ||
| 120 | } | 124 | } | ||
| 121 | 125 | ||||
| 126 | + | func TestPusher_Push(t *testing.T) { | |||
| 127 | + | testCases := []struct { | |||
| 128 | + | name string | |||
| 129 | + | imageSize int64 | |||
| 130 | + | layerCount int64 | |||
| 131 | + | existingBlobDigests func(t *testing.T, img v1.Image) map[string]bool | |||
| 132 | + | expectedUploadedBlobCount int | |||
| 133 | + | expectedManifestMediaTypes []string | |||
| 134 | + | }{ | |||
| 135 | + | { | |||
| 136 | + | name: "single-layer image uploads config layer and manifest", | |||
| 137 | + | imageSize: 1024, | |||
| 138 | + | layerCount: 1, | |||
| 139 | + | existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool { return map[string]bool{} }, | |||
| 140 | + | expectedUploadedBlobCount: 2, | |||
| 141 | + | expectedManifestMediaTypes: []string{ | |||
| 142 | + | "application/vnd.docker.distribution.manifest.v2+json", | |||
| 143 | + | string(typesOCIManifestSchema1()), | |||
| 144 | + | }, | |||
| 145 | + | }, | |||
| 146 | + | { | |||
| 147 | + | name: "multi-layer image uploads all blobs and manifest", | |||
| 148 | + | imageSize: 1024, | |||
| 149 | + | layerCount: 2, | |||
| 150 | + | existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool { return map[string]bool{} }, | |||
| 151 | + | expectedUploadedBlobCount: 3, | |||
| 152 | + | expectedManifestMediaTypes: []string{ | |||
| 153 | + | "application/vnd.docker.distribution.manifest.v2+json", | |||
| 154 | + | string(typesOCIManifestSchema1()), | |||
| 155 | + | }, | |||
| 156 | + | }, | |||
| 157 | + | { | |||
| 158 | + | name: "existing layer is skipped while others upload", | |||
| 159 | + | imageSize: 1024, | |||
| 160 | + | layerCount: 2, | |||
| 161 | + | existingBlobDigests: func(t *testing.T, img v1.Image) map[string]bool { | |||
| 162 | + | t.Helper() | |||
| 163 | + | layers, err := img.Layers() | |||
| 164 | + | require.NoError(t, err) | |||
| 165 | + | digest, err := layers[0].Digest() | |||
| 166 | + | require.NoError(t, err) | |||
| 167 | + | return map[string]bool{digest.String(): true} | |||
| 168 | + | }, | |||
| 169 | + | expectedUploadedBlobCount: 2, | |||
| 170 | + | expectedManifestMediaTypes: []string{ | |||
| 171 | + | "application/vnd.docker.distribution.manifest.v2+json", | |||
| 172 | + | string(typesOCIManifestSchema1()), | |||
| 173 | + | }, | |||
| 174 | + | }, | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | for _, tc := range testCases { | |||
| 178 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 179 | + | img, err := random.Image(tc.imageSize, tc.layerCount) | |||
| 180 | + | require.NoError(t, err) | |||
| 181 | + | ||||
| 182 | + | serverState := newFakePushRegistry(t, img, tc.existingBlobDigests(t, img)) | |||
| 183 | + | server := httptest.NewServer(serverState.handler()) | |||
| 184 | + | defer server.Close() | |||
| 185 | + | ||||
| 186 | + | serverURL, err := url.Parse(server.URL) | |||
| 187 | + | require.NoError(t, err) | |||
| 188 | + | ref := serverURL.Host + "/demo/app:v1" | |||
| 189 | + | ||||
| 190 | + | p := registrypush.New( | |||
| 191 | + | registrypush.WithTransport(server.Client().Transport), | |||
| 192 | + | registrypush.WithImageSource(func(context.Context, string) (v1.Image, error) { | |||
| 193 | + | return img, nil | |||
| 194 | + | }), | |||
| 195 | + | ) | |||
| 196 | + | ||||
| 197 | + | err = p.Push(context.Background(), ref) | |||
| 198 | + | require.NoError(t, err) | |||
| 199 | + | ||||
| 200 | + | expectedDigests := make([]string, 0, len(serverState.expectedBlobs)) | |||
| 201 | + | for digest := range serverState.expectedBlobs { | |||
| 202 | + | expectedDigests = append(expectedDigests, digest) | |||
| 203 | + | assert.Contains(t, serverState.headChecks, digest) | |||
| 204 | + | } | |||
| 205 | + | assert.Len(t, serverState.headChecks, len(expectedDigests)) | |||
| 206 | + | ||||
| 207 | + | assert.Len(t, serverState.uploadedBlobs, tc.expectedUploadedBlobCount) | |||
| 208 | + | for digest, content := range serverState.uploadedBlobs { | |||
| 209 | + | assert.Equal(t, serverState.expectedBlobs[digest], content) | |||
| 210 | + | } | |||
| 211 | + | ||||
| 212 | + | manifestPut, ok := serverState.manifestPuts[serverState.expectedRepo+":"+serverState.expectedTag] | |||
| 213 | + | require.True(t, ok) | |||
| 214 | + | assert.Contains(t, tc.expectedManifestMediaTypes, manifestPut.contentType) | |||
| 215 | + | assert.Equal(t, serverState.expectedManifest, manifestPut.body) | |||
| 216 | + | }) | |||
| 217 | + | } | |||
| 218 | + | } | |||
| 219 | + | ||||
| 122 | type fakeBlobUploadRegistry struct { | 220 | type fakeBlobUploadRegistry struct { | ||
| 123 | t *testing.T | 221 | t *testing.T | ||
| 124 | expectedAuth string | 222 | expectedAuth string | ||
| @@ -226,3 +324,165 @@ func rangeHeaderValue(start, end int) string { | |||||
| 226 | } | 324 | } | ||
| 227 | return fmt.Sprintf("%d-%d", start, end) | 325 | return fmt.Sprintf("%d-%d", start, end) | ||
| 228 | } | 326 | } | ||
| 327 | + | ||||
| 328 | + | type fakePushRegistry struct { | |||
| 329 | + | t *testing.T | |||
| 330 | + | existingBlobs map[string]bool | |||
| 331 | + | uploadedBlobs map[string][]byte | |||
| 332 | + | manifestPuts map[string]fakeManifestPut | |||
| 333 | + | uploads map[string]*fakeUploadSession | |||
| 334 | + | nextUploadID int | |||
| 335 | + | headChecks []string | |||
| 336 | + | expectedBlobs map[string][]byte | |||
| 337 | + | expectedRepo string | |||
| 338 | + | expectedTag string | |||
| 339 | + | expectedMT string | |||
| 340 | + | expectedManifest []byte | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | type fakeUploadSession struct { | |||
| 344 | + | repo string | |||
| 345 | + | data []byte | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | type fakeManifestPut struct { | |||
| 349 | + | contentType string | |||
| 350 | + | body []byte | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | func newFakePushRegistry(t *testing.T, img v1.Image, existingBlobs map[string]bool) *fakePushRegistry { | |||
| 354 | + | t.Helper() | |||
| 355 | + | ||||
| 356 | + | layers, err := img.Layers() | |||
| 357 | + | require.NoError(t, err) | |||
| 358 | + | ||||
| 359 | + | expectedBlobs := make(map[string][]byte, len(layers)+1) | |||
| 360 | + | for _, layer := range layers { | |||
| 361 | + | digest, err := layer.Digest() | |||
| 362 | + | require.NoError(t, err) | |||
| 363 | + | rc, err := layer.Compressed() | |||
| 364 | + | require.NoError(t, err) | |||
| 365 | + | content, err := io.ReadAll(rc) | |||
| 366 | + | require.NoError(t, err) | |||
| 367 | + | require.NoError(t, rc.Close()) | |||
| 368 | + | expectedBlobs[digest.String()] = content | |||
| 369 | + | } | |||
| 370 | + | ||||
| 371 | + | config, err := img.RawConfigFile() | |||
| 372 | + | require.NoError(t, err) | |||
| 373 | + | configDigest := fmt.Sprintf("sha256:%x", sha256.Sum256(config)) | |||
| 374 | + | expectedBlobs[configDigest] = config | |||
| 375 | + | ||||
| 376 | + | manifest, err := img.RawManifest() | |||
| 377 | + | require.NoError(t, err) | |||
| 378 | + | mediaType, err := img.MediaType() | |||
| 379 | + | require.NoError(t, err) | |||
| 380 | + | ||||
| 381 | + | return &fakePushRegistry{ | |||
| 382 | + | t: t, | |||
| 383 | + | existingBlobs: existingBlobs, | |||
| 384 | + | uploadedBlobs: make(map[string][]byte), | |||
| 385 | + | manifestPuts: make(map[string]fakeManifestPut), | |||
| 386 | + | uploads: make(map[string]*fakeUploadSession), | |||
| 387 | + | expectedBlobs: expectedBlobs, | |||
| 388 | + | expectedRepo: "demo/app", | |||
| 389 | + | expectedTag: "v1", | |||
| 390 | + | expectedMT: string(mediaType), | |||
| 391 | + | expectedManifest: manifest, | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func (f *fakePushRegistry) handler() http.Handler { | |||
| 396 | + | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 397 | + | switch { | |||
| 398 | + | case r.Method == http.MethodHead && strings.HasPrefix(r.URL.Path, "/v2/") && strings.Contains(r.URL.Path, "/blobs/"): | |||
| 399 | + | f.handleBlobHead(w, r) | |||
| 400 | + | case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/blobs/uploads/"): | |||
| 401 | + | f.handleBlobStart(w, r) | |||
| 402 | + | case r.Method == http.MethodPatch && strings.Contains(r.URL.Path, "/blobs/uploads/"): | |||
| 403 | + | f.handleBlobPatch(w, r) | |||
| 404 | + | case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/blobs/uploads/"): | |||
| 405 | + | f.handleBlobFinalize(w, r) | |||
| 406 | + | case r.Method == http.MethodPut && strings.Contains(r.URL.Path, "/manifests/"): | |||
| 407 | + | f.handleManifestPut(w, r) | |||
| 408 | + | default: | |||
| 409 | + | f.t.Fatalf("unexpected request: %s %s", r.Method, r.URL.String()) | |||
| 410 | + | } | |||
| 411 | + | }) | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | func (f *fakePushRegistry) handleBlobHead(w http.ResponseWriter, r *http.Request) { | |||
| 415 | + | digest := pathTail(r.URL.Path) | |||
| 416 | + | f.headChecks = append(f.headChecks, digest) | |||
| 417 | + | if f.existingBlobs[digest] { | |||
| 418 | + | w.WriteHeader(http.StatusOK) | |||
| 419 | + | return | |||
| 420 | + | } | |||
| 421 | + | w.WriteHeader(http.StatusNotFound) | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | func (f *fakePushRegistry) handleBlobStart(w http.ResponseWriter, r *http.Request) { | |||
| 425 | + | repo := pathSegmentBetween(r.URL.Path, "/v2/", "/blobs/uploads/") | |||
| 426 | + | assert.Equal(f.t, f.expectedRepo, repo) | |||
| 427 | + | f.nextUploadID++ | |||
| 428 | + | uploadPath := fmt.Sprintf("/v2/%s/blobs/uploads/upload-%d", repo, f.nextUploadID) | |||
| 429 | + | f.uploads[uploadPath] = &fakeUploadSession{repo: repo} | |||
| 430 | + | w.Header().Set("Location", uploadPath) | |||
| 431 | + | w.WriteHeader(http.StatusAccepted) | |||
| 432 | + | } | |||
| 433 | + | ||||
| 434 | + | func (f *fakePushRegistry) handleBlobPatch(w http.ResponseWriter, r *http.Request) { | |||
| 435 | + | session := f.uploads[r.URL.Path] | |||
| 436 | + | if session == nil { | |||
| 437 | + | f.t.Fatalf("missing upload session for %s", r.URL.Path) | |||
| 438 | + | } | |||
| 439 | + | body, err := io.ReadAll(r.Body) | |||
| 440 | + | require.NoError(f.t, err) | |||
| 441 | + | session.data = append(session.data, body...) | |||
| 442 | + | w.Header().Set("Location", r.URL.Path) | |||
| 443 | + | w.WriteHeader(http.StatusAccepted) | |||
| 444 | + | } | |||
| 445 | + | ||||
| 446 | + | func (f *fakePushRegistry) handleBlobFinalize(w http.ResponseWriter, r *http.Request) { | |||
| 447 | + | session := f.uploads[r.URL.Path] | |||
| 448 | + | if session == nil { | |||
| 449 | + | f.t.Fatalf("missing upload session for %s", r.URL.Path) | |||
| 450 | + | } | |||
| 451 | + | digest := r.URL.Query().Get("digest") | |||
| 452 | + | f.uploadedBlobs[digest] = append([]byte(nil), session.data...) | |||
| 453 | + | delete(f.uploads, r.URL.Path) | |||
| 454 | + | w.WriteHeader(http.StatusCreated) | |||
| 455 | + | } | |||
| 456 | + | ||||
| 457 | + | func (f *fakePushRegistry) handleManifestPut(w http.ResponseWriter, r *http.Request) { | |||
| 458 | + | repo := pathSegmentBetween(r.URL.Path, "/v2/", "/manifests/") | |||
| 459 | + | tag := pathTail(r.URL.Path) | |||
| 460 | + | assert.Equal(f.t, f.expectedRepo, repo) | |||
| 461 | + | assert.Equal(f.t, f.expectedTag, tag) | |||
| 462 | + | body, err := io.ReadAll(r.Body) | |||
| 463 | + | require.NoError(f.t, err) | |||
| 464 | + | f.manifestPuts[repo+":"+tag] = fakeManifestPut{contentType: r.Header.Get("Content-Type"), body: body} | |||
| 465 | + | w.WriteHeader(http.StatusCreated) | |||
| 466 | + | } | |||
| 467 | + | ||||
| 468 | + | func pathTail(path string) string { | |||
| 469 | + | parts := strings.Split(path, "/") | |||
| 470 | + | return parts[len(parts)-1] | |||
| 471 | + | } | |||
| 472 | + | ||||
| 473 | + | func pathSegmentBetween(value, start, end string) string { | |||
| 474 | + | startIdx := strings.Index(value, start) | |||
| 475 | + | if startIdx == -1 { | |||
| 476 | + | return "" | |||
| 477 | + | } | |||
| 478 | + | trimmed := value[startIdx+len(start):] | |||
| 479 | + | endIdx := strings.Index(trimmed, end) | |||
| 480 | + | if endIdx == -1 { | |||
| 481 | + | return "" | |||
| 482 | + | } | |||
| 483 | + | return trimmed[:endIdx] | |||
| 484 | + | } | |||
| 485 | + | ||||
| 486 | + | func typesOCIManifestSchema1() string { | |||
| 487 | + | return "application/vnd.oci.image.manifest.v1+json" | |||
| 488 | + | } | |||