detached 515ee12

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
4242 github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
4343 github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
4444 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=
4547 github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
4648 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=
4750 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4851 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
4952 github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
5053 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=
5156 github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
5257 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=
5360 github.com/docker/go-connections v0.5.0 h1:USnMq7hx7gwdVZq1L49hLXaFtUdTADjXGp+uj1Br63c=
5461 github.com/docker/go-connections v0.5.0/go.mod h1:ov60Kzw0kKElRwhNs9UlUHAE/F9Fe6GLaXnqyDdmEXc=
5562 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
8491 github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.7/go.mod h1:lW34nIZuQ8UDPdkon5fmfp2l3+ZkQ2me/+oecHYLOII=
8592 github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
8693 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=
8796 github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
8897 github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
8998 github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@@ -101,6 +110,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J
101110 github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
102111 github.com/mattn/go-runewidth v0.0.21 h1:jJKAZiQH+2mIinzCJIaIG9Be1+0NR+5sz/lYEEjdM8w=
103112 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=
104115 github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
105116 github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
106117 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=
150161 github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
151162 github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
152163 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=
153165 github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
154166 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=
155168 github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
156169 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
157170 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
158171 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=
159174 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
160175 github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
161176 go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
@@ -202,7 +217,10 @@ golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
202217 golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
203218 golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
204219 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=
205222 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=
206224 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
207225 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
208226 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
229247 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
230248 gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
231249 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=
232251 gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
233252 gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
234253 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
22
33 import (
44 "context"
5+ "crypto/sha256"
6+ "encoding/base64"
57 "fmt"
68 "io"
79 "net/http"
810 "net/url"
11+ "strings"
912
13+ "github.com/docker/docker/client"
14+ "github.com/google/go-containerregistry/pkg/authn"
15+ "github.com/google/go-containerregistry/pkg/name"
1016 v1 "github.com/google/go-containerregistry/pkg/v1"
17+ "github.com/google/go-containerregistry/pkg/v1/tarball"
1118 )
1219
1320 // DefaultChunkSize is 50MB - safely under Cloudflare's 100MB per-request limit
@@ -70,8 +77,38 @@ func New(opts ...Option) *Pusher {
7077 //
7178 // The ref must be fully qualified: registry.example.com/name:tag
7279 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)
75112 }
76113
77114 // 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
105142
106143 // defaultImageSource reads an image from the local Docker/Podman daemon.
107144 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
110280 }
111281
112282 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 limit20 // 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:tag78 // 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 tasks80+ parsedRef, err := name.ParseReference(ref)
74- return nil81+ 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 3145+ cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
109- return nil, nil146+ 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
33 import (
44 "bytes"
55 "context"
6+ "crypto/sha256"
67 "fmt"
78 "io"
89 "net/http"
910 "net/http/httptest"
11+ "net/url"
1012 "strconv"
1113 "strings"
1214 "testing"
1315
16+ "github.com/google/go-containerregistry/pkg/v1"
17+ "github.com/google/go-containerregistry/pkg/v1/random"
1418 "github.com/stretchr/testify/assert"
1519 "github.com/stretchr/testify/require"
1620
@@ -119,6 +123,100 @@ func TestPusher_UploadBlob(t *testing.T) {
119123 }
120124 }
121125
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+
122220 type fakeBlobUploadRegistry struct {
123221 t *testing.T
124222 expectedAuth string
@@ -226,3 +324,165 @@ func rangeHeaderValue(start, end int) string {
226324 }
227325 return fmt.Sprintf("%d-%d", start, end)
228326 }
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.T221 t *testing.T
124 expectedAuth string222 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close