diff --git a/go.work b/go.work index 6ebafbd..f56a54a 100644 --- a/go.work +++ b/go.work @@ -7,6 +7,7 @@ use ( ./tools/cmdutils ./tools/grafanactl ./tools/helm + ./tools/imagemirror ./tools/prow-job-executor ./tools/registration ./tools/release diff --git a/go.work.sum b/go.work.sum index dd5876a..a35107c 100644 --- a/go.work.sum +++ b/go.work.sum @@ -336,6 +336,8 @@ github.com/Azure/ARO-Tools/tools/cmdutils v0.0.0-20260223232408-d2d595acc2e6/go. github.com/Azure/azure-amqp-common-go/v3 v3.2.3 h1:uDF62mbd9bypXWi19V1bN5NZEO84JqgmI5G73ibAmrk= github.com/Azure/azure-amqp-common-go/v3 v3.2.3/go.mod h1:7rPmbSfszeovxGfc5fSAXE4ehlXQZHpMja2OtxC2Tas= github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3 h1:ldKsKtEIblsgsr6mPwrd9yRntoX6uLz/K89wsldwx/k= +github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3/go.mod h1:MAm7bk0oDLmD8yIkvfbxPW04fxzphPyL+7GzwHxOp6Y= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0 h1:m/sWOGCREuSBqg2htVQTBY8nOZpyajYztF0vUvSZTuM= github.com/Azure/azure-sdk-for-go/sdk/keyvault/azkeys v0.10.0/go.mod h1:Pu5Zksi2KrU7LPbZbNINx6fuVrUp/ffvpxdDj+i8LeE= github.com/Azure/azure-sdk-for-go/sdk/keyvault/internal v0.7.1 h1:FbH3BbSb4bvGluTesZZ+ttN/MDsnMmQP36OSnDuSXqw= @@ -397,6 +399,7 @@ github.com/alecthomas/kingpin/v2 v2.4.0 h1:f48lwail6p8zpO1bC4TxtqACaGqHYA22qkHjH github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= github.com/alecthomas/template v0.0.0-20190718012654-fb15b899a751 h1:JYp7IbQjafoB+tBA3gMyHYHrpOtNuDiK/uB5uXxq5wM= github.com/alecthomas/units v0.0.0-20211218093645-b94a6e3cc137 h1:s6gZFSlWYmbqAuRjVTiNNhvNRfY2Wxp9nhfyel4rklc= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b h1:mimo19zliBX/vSQ6PWWSL9lK8qwHozUj03+zLoEB8O0= github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= @@ -418,6 +421,7 @@ github.com/armon/go-metrics v0.4.0/go.mod h1:E6amYzXo6aW1tqzoZGT755KkbgrJsSdpwZ+ github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/armon/go-radix v1.0.0 h1:F4z6KzEeeQIMeLFa97iZU6vupzoecKdU5TX24SNppXI= github.com/armon/go-radix v1.0.0/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/aws/aws-sdk-go-v2/service/ecr v1.18.11 h1:wlTgmb/sCmVRJrN5De3CiHj4v/bTCgL5+qpdEd0CPtw= github.com/aws/aws-sdk-go-v2/service/ecr v1.18.11/go.mod h1:Ce1q2jlNm8BVpjLaOnwnm5v2RClAbK6txwPljFzyW6c= @@ -457,6 +461,7 @@ github.com/campoy/embedmd v1.0.0 h1:V4kI2qTJJLf4J29RzI/MAt2c3Bl4dQSYPuflzwFH2hY= github.com/campoy/embedmd v1.0.0/go.mod h1:oxyr9RCiSXg0M3VJ3ks0UGfp98BpSSGr0kpiX3MzVl8= github.com/carapace-sh/carapace-shlex v1.0.1 h1:ww0JCgWpOVuqWG7k3724pJ18Lq8gh5pHQs9j3ojUs1c= github.com/carapace-sh/carapace-shlex v1.0.1/go.mod h1:lJ4ZsdxytE0wHJ8Ta9S7Qq0XpjgjU0mdfCqiI2FHx7M= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30= github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= @@ -629,6 +634,7 @@ github.com/godror/godror v0.40.4 h1:X1e7hUd02GDaLWKZj40Z7L0CP0W9TrGgmPQZw6+anBg= github.com/godror/godror v0.40.4/go.mod h1:i8YtVTHUJKfFT3wTat4A9UoqScUtZXiYB9Rf3SVARgc= github.com/godror/knownpb v0.1.1 h1:A4J7jdx7jWBhJm18NntafzSC//iZDHkDi1+juwQ5pTI= github.com/godror/knownpb v0.1.1/go.mod h1:4nRFbQo1dDuwKnblRXDxrfCFYeT4hjg3GjMqef58eRE= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/golang-jwt/jwt/v4 v4.4.3 h1:Hxl6lhQFj4AnOX6MLrsCb/+7tCj7DxP7VA+2rDIq5AU= github.com/golang-jwt/jwt/v4 v4.4.3/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= @@ -707,6 +713,7 @@ github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pw github.com/gorilla/sessions v1.2.0 h1:S7P+1Hm5V/AT9cjEcUD5uDaQSX0OE577aCXgoaKpYbQ= github.com/gorilla/sessions v1.2.0/go.mod h1:dk2InVEVJ0sfLlnXv9EAgkf6ecYs/i80K/zI+bUmuGM= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0 h1:+9834+KizmvFV7pXQGSXQTsaWhq2GjuNUt0aUU0YBYw= github.com/grpc-ecosystem/go-grpc-middleware v1.3.0/go.mod h1:z0ButlSOZa5vEBq9m2m2hlwIgKw+rp3sdCBRoJY+30Y= @@ -860,6 +867,7 @@ github.com/mitchellh/cli v1.1.5/go.mod h1:v8+iFts2sPIKUV1ltktPXMCC8fumSKFItNcD2c github.com/mitchellh/mapstructure v0.0.0-20160808181253-ca63d7c062ee/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/spdystream v0.5.0 h1:7r0J1Si3QO/kjRitvSLVVFUjxMEb/YLj6S9FF62JBCU= github.com/moby/spdystream v0.5.0/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= github.com/montanaflynn/stats v0.7.0 h1:r3y12KyNxj/Sb/iOE46ws+3mS1+MZca1wlHQFPsY/JU= @@ -867,6 +875,7 @@ github.com/montanaflynn/stats v0.7.0/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt github.com/mozilla/tls-observatory v0.0.0-20210609171429-7bc42856d2e5 h1:0KqC6/sLy7fDpBdybhVkkv4Yz+PmB7c9Dz9z3dLW804= github.com/mozilla/tls-observatory v0.0.0-20210609171429-7bc42856d2e5/go.mod h1:FUqVoUPHSEdDR0MnFM3Dh8AU0pZHLXUD127SAJGER/s= github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f h1:KUppIJq7/+SVif2QVs3tOP0zanoHgBEVAwHxUSIzRqU= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f h1:y5//uYreIhSUg3J1GEMiLbxo1LJaP8RfCpH6pymGZus= github.com/nelsam/hel/v2 v2.3.3 h1:Z3TAKd9JS3BoKi6fW+d1bKD2Mf0FzTqDUEAwLWzYPRQ= github.com/nelsam/hel/v2 v2.3.3/go.mod h1:1ZTGfU2PFTOd5mx22i5O0Lc2GY933lQ2wb/ggy+rL3w= github.com/oklog/ulid v1.3.1 h1:EGfNDEx6MqHz8B3uNV6QAib1UR2Lm97sHi3ocA6ESJ4= @@ -1263,6 +1272,7 @@ golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053 h1:dHQOQddU4YHS5gY33/6 golang.org/x/telemetry v0.0.0-20250908211612-aef8a434d053/go.mod h1:+nZKN+XVh4LCiA9DV3ywrzN4gumyCnKjau3NGb9SGoE= golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc h1:bH6xUXay0AIFMElXG2rQ4uiE+7ncwtiOdPfYK1NK2XA= golang.org/x/telemetry v0.0.0-20251203150158-8fff8a5912fc/go.mod h1:hKdjCMrbv9skySur+Nek8Hd0uJ0GuxJIoIX2payrIdQ= +golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2 h1:O1cMQHRfwNpDfDJerqRoE2oD+AFlyid87D40L/OkkJo= golang.org/x/telemetry v0.0.0-20260109210033-bd525da824e2/go.mod h1:b7fPSJ0pKZ3ccUh8gnTONJxhn3c/PS6tyzQvyqw4iA8= golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= @@ -1444,12 +1454,14 @@ k8s.io/code-generator v0.34.1 h1:WpphT26E+j7tEgIUfFr5WfbJrktCGzB3JoJH9149xYc= k8s.io/code-generator v0.34.1/go.mod h1:DeWjekbDnJWRwpw3s0Jat87c+e0TgkxoR4ar608yqvg= k8s.io/code-generator v0.35.0 h1:TvrtfKYZTm9oDF2z+veFKSCcgZE3Igv0svY+ehCmjHQ= k8s.io/code-generator v0.35.0/go.mod h1:iS1gvVf3c/T71N5DOGYO+Gt3PdJ6B9LYSvIyQ4FHzgc= +k8s.io/code-generator v0.35.1 h1:yLKR2la7Z9cWT5qmk67ayx8xXLM4RRKQMnC8YPvTWRI= k8s.io/code-generator v0.35.1/go.mod h1:F2Fhm7aA69tC/VkMXLDokdovltXEF026Tb9yfQXQWKg= k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= k8s.io/component-helpers v0.34.1 h1:gWhH3CCdwAx5P3oJqZKb4Lg5FYZTWVbdWtOI8n9U4XY= k8s.io/component-helpers v0.34.1/go.mod h1:4VgnUH7UA/shuBur+OWoQC0xfb69sy/93ss0ybZqm3c= k8s.io/component-helpers v0.35.0 h1:wcXv7HJRksgVjM4VlXJ1CNFBpyDHruRI99RrBtrJceA= k8s.io/component-helpers v0.35.0/go.mod h1:ahX0m/LTYmu7fL3W8zYiIwnQ/5gT28Ex4o2pymF63Co= +k8s.io/component-helpers v0.35.1 h1:vwQ/cAfnVwaPeSXTu4DdK3d3n11Lugc5vMb6EV809ZY= k8s.io/component-helpers v0.35.1/go.mod h1:HQqMwUk68Yyxgj92dJ+J1w/qbx9M0QR0eZ680m/o+Rk= k8s.io/gengo v0.0.0-20240404160639-a0386bf69313 h1:wBIDZID8ju9pwOiLlV22YYKjFGtiNSWgHf5CnKLRUuM= k8s.io/gengo v0.0.0-20240404160639-a0386bf69313/go.mod h1:FiNAH4ZV3gBg2Kwh89tzAEV2be7d5xI0vBa/VySYy3E= @@ -1464,12 +1476,14 @@ k8s.io/kms v0.34.1 h1:iCFOvewDPzWM9fMTfyIPO+4MeuZ0tcZbugxLNSHFG4w= k8s.io/kms v0.34.1/go.mod h1:s1CFkLG7w9eaTYvctOxosx88fl4spqmixnNpys0JAtM= k8s.io/kms v0.35.0 h1:/x87FED2kDSo66csKtcYCEHsxF/DBlNl7LfJ1fVQs1o= k8s.io/kms v0.35.0/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= +k8s.io/kms v0.35.1 h1:kjv2r9g1mY7uL+l1RhyAZvWVZIA/4qIfBHXyjFGLRhU= k8s.io/kms v0.35.1/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= k8s.io/kube-openapi v0.0.0-20241212222426-2c72e554b1e7/go.mod h1:GewRfANuJ70iYzvn+i4lezLDAFzvjxZYK1gn1lWcfas= k8s.io/metrics v0.34.1 h1:374Rexmp1xxgRt64Bi0TsjAM8cA/Y8skwCoPdjtIslE= k8s.io/metrics v0.34.1/go.mod h1:Drf5kPfk2NJrlpcNdSiAAHn/7Y9KqxpRNagByM7Ei80= k8s.io/metrics v0.35.0 h1:xVFoqtAGm2dMNJAcB5TFZJPCen0uEqqNt52wW7ABbX8= k8s.io/metrics v0.35.0/go.mod h1:g2Up4dcBygZi2kQSEQVDByFs+VUwepJMzzQLJJLpq4M= +k8s.io/metrics v0.35.1 h1:MUcrUcWlq81XiripkydzCGsY9zQawDXfP9IICNNcVVw= k8s.io/metrics v0.35.1/go.mod h1:9x7xWOAOiWzHA0vaqLgSE4PXF3vyT5ts5XIbx8OSjiI= k8s.io/utils v0.0.0-20240711033017-18e509b52bc8/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= k8s.io/utils v0.0.0-20251002143259-bc988d571ff4/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= @@ -1490,6 +1504,7 @@ sigs.k8s.io/kind v0.20.0/go.mod h1:aBlbxg08cauDgZ612shr017/rZwqd7AS563FvpWKPVs= sigs.k8s.io/kustomize/kustomize/v5 v5.7.1 h1:sYJsarwy/SDJfjjLMUqwFDGPwzUtMOQ1i1Ed49+XSbw= sigs.k8s.io/kustomize/kustomize/v5 v5.7.1/go.mod h1:+5/SrBcJ4agx1SJknGuR/c9thwRSKLxnKoI5BzXFaLU= sigs.k8s.io/randfill v0.0.0-20250304075658-069ef1bbf016/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/structured-merge-diff/v4 v4.5.0 h1:nbCitCK2hfnhyiKo6uf2HxUPTCodY6Qaf85SbDIaMBk= sigs.k8s.io/structured-merge-diff/v4 v4.5.0/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= sigs.k8s.io/structured-merge-diff/v4 v4.7.0 h1:qPeWmscJcXP0snki5IYF79Z8xrl8ETFxgMd7wez1XkI= sigs.k8s.io/structured-merge-diff/v4 v4.7.0/go.mod h1:dDy58f92j70zLsuZVuUX5Wp9vtxXpaZnkPGWeqDfCps= diff --git a/pipelines/types/imagemirror.go b/pipelines/types/imagemirror.go index 2fda7f9..204b9bc 100644 --- a/pipelines/types/imagemirror.go +++ b/pipelines/types/imagemirror.go @@ -17,6 +17,7 @@ package types import ( "fmt" "slices" + "strings" _ "embed" ) @@ -46,6 +47,10 @@ type ImageMirrorStep struct { ADOProject string `json:"adoProject,omitempty"` ArtifactName string `json:"artifactName,omitempty"` BuildID string `json:"buildId,omitempty"` + + // UseNativeMirror opts into using the native Go imagemirror CLI binary instead of the on-demand.sh shell script. + // When set to true, the resolved ShellStep will invoke the imagemirror binary specified in ResolveOptions. + UseNativeMirror bool `json:"useNativeMirror,omitempty"` } func (s *ImageMirrorStep) Description() string { @@ -64,10 +69,46 @@ func (s *ImageMirrorStep) RequiredInputs() []StepDependency { return deps } -// ResolveImageMirrorStep resolves an image mirror step to a shell step. It's up to the user to write the contents of -// the OnDemandSyncScript to disk somewhere and pass the file name in as a parameter here, as we likely don't want to -// inline 100+ lines of shell into a `bash -C ""` call and hope all the string interpolations work. -func ResolveImageMirrorStep(input ImageMirrorStep, scriptFile string) (*ShellStep, error) { +// ResolveOptions holds parameters for resolving an ImageMirrorStep to a ShellStep. +type ResolveOptions struct { + // ScriptFile is the path to the on-demand.sh script file on disk. Used when UseNativeMirror is false. + ScriptFile string + // ImageMirrorBinary is the path to the imagemirror binary. Used when UseNativeMirror is true. + ImageMirrorBinary string + // Cloud is the Azure cloud name (e.g. "public", "ff", "mc"). Used when UseNativeMirror is true. + Cloud string + // ACRSuffix is the DNS suffix for the ACR (e.g. ".azurecr.io"). Used when UseNativeMirror is true. + ACRSuffix string +} + +// ResolveImageMirrorStep resolves an image mirror step to a shell step. When the input does not use native mirroring, +// it's up to the user to write the contents of the OnDemandSyncScript to disk somewhere and pass the file name in via +// ResolveOptions.ScriptFile, as we likely don't want to inline 100+ lines of shell into a `bash -C ""` call +// and hope all the string interpolations work. When using native mirroring, the resolved step invokes the imagemirror +// binary directly with explicit flags. +func ResolveImageMirrorStep(input ImageMirrorStep, opts ResolveOptions) (*ShellStep, error) { + if input.UseNativeMirror { + for _, item := range []struct { + field string + value string + }{ + {field: "ImageMirrorBinary", value: opts.ImageMirrorBinary}, + {field: "Cloud", value: opts.Cloud}, + {field: "ACRSuffix", value: opts.ACRSuffix}, + } { + if item.value == "" { + return nil, fmt.Errorf("ResolveOptions.%s must be set when UseNativeMirror is true", item.field) + } + } + return resolveNativeMirrorStep(input, opts) + } + if opts.ScriptFile == "" { + return nil, fmt.Errorf("ResolveOptions.ScriptFile must be set when UseNativeMirror is false") + } + return resolveScriptMirrorStep(input, opts) +} + +func resolveScriptMirrorStep(input ImageMirrorStep, opts ResolveOptions) (*ShellStep, error) { variables := []Variable{ namedVariable("TARGET_ACR", input.TargetACR), namedVariable("REPOSITORY", input.Repository), @@ -93,7 +134,7 @@ func ResolveImageMirrorStep(input ImageMirrorStep, scriptFile string) (*ShellSte Action: "Shell", DependsOn: input.DependsOn, }, - Command: fmt.Sprintf("/bin/bash %s", scriptFile), + Command: fmt.Sprintf("/bin/bash %s", opts.ScriptFile), Variables: variables, DryRun: DryRun{ Variables: []Variable{{ @@ -107,6 +148,73 @@ func ResolveImageMirrorStep(input ImageMirrorStep, scriptFile string) (*ShellSte }, nil } +func resolveNativeMirrorStep(input ImageMirrorStep, opts ResolveOptions) (*ShellStep, error) { + var command string + var variables []Variable + + switch input.CopyFrom { + case "oci-layout": + variables = []Variable{ + namedVariable("TARGET_ACR", input.TargetACR), + namedVariable("REPOSITORY", input.Repository), + namedVariable("IMAGE_TAR", input.ImageTarFileName), + namedVariable("IMAGE_METADATA", input.ImageMetadataFileName), + } + parts := []string{ + opts.ImageMirrorBinary, "from-oci-layout", + "--target-acr", "${TARGET_ACR}", + "--acr-suffix", opts.ACRSuffix, + "--repository", "${REPOSITORY}", + "--image-tar", "${IMAGE_TAR}", + "--image-metadata", "${IMAGE_METADATA}", + "--cloud", opts.Cloud, + } + command = strings.Join(parts, " ") + default: + variables = []Variable{ + namedVariable("TARGET_ACR", input.TargetACR), + namedVariable("SOURCE_REGISTRY", input.SourceRegistry), + namedVariable("REPOSITORY", input.Repository), + namedVariable("DIGEST", input.Digest), + } + parts := []string{ + opts.ImageMirrorBinary, "from-registry", + "--target-acr", "${TARGET_ACR}", + "--acr-suffix", opts.ACRSuffix, + "--source-registry", "${SOURCE_REGISTRY}", + "--repository", "${REPOSITORY}", + "--digest", "${DIGEST}", + "--cloud", opts.Cloud, + } + if input.PublicSource { + parts = append(parts, "--auth.anonymous") + } else { + variables = append(variables, namedVariable("PULL_SECRET_KV", input.PullSecretKeyVault)) + variables = append(variables, namedVariable("PULL_SECRET", input.PullSecretName)) + parts = append(parts, "--auth.pull-secret.keyvault", "${PULL_SECRET_KV}") + parts = append(parts, "--auth.pull-secret.name", "${PULL_SECRET}") + } + command = strings.Join(parts, " ") + } + + // For native mirroring, dry-run is a flag on the command itself. + dryRunCommand := command + " --dry-run" + + return &ShellStep{ + StepMeta: StepMeta{ + Name: input.Name, + Action: "Shell", + DependsOn: input.DependsOn, + }, + Command: command, + Variables: variables, + DryRun: DryRun{ + Command: dryRunCommand, + }, + ShellIdentity: input.ShellIdentity, + }, nil +} + func namedVariable(name string, value Value) Variable { return Variable{ Name: name, diff --git a/pipelines/types/imagemirror_test.go b/pipelines/types/imagemirror_test.go index b0f2aff..7e9726e 100644 --- a/pipelines/types/imagemirror_test.go +++ b/pipelines/types/imagemirror_test.go @@ -22,9 +22,9 @@ import ( func TestResolveImageMirrorStep(t *testing.T) { tests := []struct { - name string - input ImageMirrorStep - scriptFile string + name string + input ImageMirrorStep + opts ResolveOptions }{ { name: "image-mirror-step", @@ -41,7 +41,7 @@ func TestResolveImageMirrorStep(t *testing.T) { PullSecretName: Value{Value: "my-pull-secret"}, ShellIdentity: Value{Value: "my-identity"}, }, - scriptFile: "/path/to/script.sh", + opts: ResolveOptions{ScriptFile: "/path/to/script.sh"}, }, { name: "image-mirror-step-with-deps", @@ -59,7 +59,7 @@ func TestResolveImageMirrorStep(t *testing.T) { PullSecretName: Value{Value: "my-pull-secret"}, ShellIdentity: Value{Value: "my-identity"}, }, - scriptFile: "/path/to/script.sh", + opts: ResolveOptions{ScriptFile: "/path/to/script.sh"}, }, { name: "image-mirror-step-with-oci-layout", @@ -75,7 +75,7 @@ func TestResolveImageMirrorStep(t *testing.T) { ImageMetadataFileName: Value{Value: "image-metadata.json"}, ShellIdentity: Value{Value: "my-identity"}, }, - scriptFile: "/path/to/script.sh", + opts: ResolveOptions{ScriptFile: "/path/to/script.sh"}, }, { name: "image-mirror-step-with-public-registry", @@ -91,13 +91,77 @@ func TestResolveImageMirrorStep(t *testing.T) { PublicSource: true, ShellIdentity: Value{Value: "my-identity"}, }, - scriptFile: "/path/to/script.sh", + opts: ResolveOptions{ScriptFile: "/path/to/script.sh"}, + }, + { + name: "native-mirror-from-registry", + input: ImageMirrorStep{ + StepMeta: StepMeta{ + Name: "image-mirror-step", + Action: "ImageMirror", + }, + TargetACR: Value{Value: "myacr"}, + SourceRegistry: Value{Value: "docker.io"}, + Repository: Value{Value: "nginx"}, + Digest: Value{Value: "sha256:123456"}, + PullSecretKeyVault: Value{Value: "my-keyvault"}, + PullSecretName: Value{Value: "my-pull-secret"}, + ShellIdentity: Value{Value: "my-identity"}, + UseNativeMirror: true, + }, + opts: ResolveOptions{ + ImageMirrorBinary: "/usr/local/bin/imagemirror", + Cloud: "public", + ACRSuffix: ".azurecr.io", + }, + }, + { + name: "native-mirror-from-registry-public-source", + input: ImageMirrorStep{ + StepMeta: StepMeta{ + Name: "image-mirror-step", + Action: "ImageMirror", + }, + TargetACR: Value{Value: "myacr"}, + SourceRegistry: Value{Value: "mcr.microsoft.com"}, + Repository: Value{Value: "nginx"}, + Digest: Value{Value: "sha256:123456"}, + PublicSource: true, + ShellIdentity: Value{Value: "my-identity"}, + UseNativeMirror: true, + }, + opts: ResolveOptions{ + ImageMirrorBinary: "/usr/local/bin/imagemirror", + Cloud: "public", + ACRSuffix: ".azurecr.io", + }, + }, + { + name: "native-mirror-from-oci-layout", + input: ImageMirrorStep{ + StepMeta: StepMeta{ + Name: "image-mirror-step", + Action: "ImageMirror", + }, + TargetACR: Value{Value: "myacr"}, + Repository: Value{Value: "nginx"}, + CopyFrom: "oci-layout", + ImageTarFileName: Value{Value: "image.tar"}, + ImageMetadataFileName: Value{Value: "image-metadata.json"}, + ShellIdentity: Value{Value: "my-identity"}, + UseNativeMirror: true, + }, + opts: ResolveOptions{ + ImageMirrorBinary: "/usr/local/bin/imagemirror", + Cloud: "public", + ACRSuffix: ".azurecr.io", + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - result, err := ResolveImageMirrorStep(tt.input, tt.scriptFile) + result, err := ResolveImageMirrorStep(tt.input, tt.opts) if err != nil { t.Fatalf("ResolveImageMirrorStep() error = %v", err) } diff --git a/pipelines/types/pipeline.schema.v1.json b/pipelines/types/pipeline.schema.v1.json index c788401..264d1ab 100644 --- a/pipelines/types/pipeline.schema.v1.json +++ b/pipelines/types/pipeline.schema.v1.json @@ -1380,6 +1380,9 @@ }, "adoProject": { "type": "string" + }, + "useNativeMirror": { + "type": "boolean" } }, "anyOf": [ diff --git a/pipelines/types/testdata/zz_fixture_TestResolveImageMirrorStep_native_mirror_from_oci_layout.yaml b/pipelines/types/testdata/zz_fixture_TestResolveImageMirrorStep_native_mirror_from_oci_layout.yaml new file mode 100644 index 0000000..495d85b --- /dev/null +++ b/pipelines/types/testdata/zz_fixture_TestResolveImageMirrorStep_native_mirror_from_oci_layout.yaml @@ -0,0 +1,20 @@ +action: Shell +command: /usr/local/bin/imagemirror from-oci-layout --target-acr ${TARGET_ACR} --acr-suffix + .azurecr.io --repository ${REPOSITORY} --image-tar ${IMAGE_TAR} --image-metadata + ${IMAGE_METADATA} --cloud public +dryRun: + command: /usr/local/bin/imagemirror from-oci-layout --target-acr ${TARGET_ACR} --acr-suffix + .azurecr.io --repository ${REPOSITORY} --image-tar ${IMAGE_TAR} --image-metadata + ${IMAGE_METADATA} --cloud public --dry-run +name: image-mirror-step +shellIdentity: + value: my-identity +variables: +- name: TARGET_ACR + value: myacr +- name: REPOSITORY + value: nginx +- name: IMAGE_TAR + value: image.tar +- name: IMAGE_METADATA + value: image-metadata.json diff --git a/pipelines/types/testdata/zz_fixture_TestResolveImageMirrorStep_native_mirror_from_registry.yaml b/pipelines/types/testdata/zz_fixture_TestResolveImageMirrorStep_native_mirror_from_registry.yaml new file mode 100644 index 0000000..136481f --- /dev/null +++ b/pipelines/types/testdata/zz_fixture_TestResolveImageMirrorStep_native_mirror_from_registry.yaml @@ -0,0 +1,26 @@ +action: Shell +command: /usr/local/bin/imagemirror from-registry --target-acr ${TARGET_ACR} --acr-suffix + .azurecr.io --source-registry ${SOURCE_REGISTRY} --repository ${REPOSITORY} --digest + ${DIGEST} --cloud public --auth.pull-secret.keyvault ${PULL_SECRET_KV} --auth.pull-secret.name + ${PULL_SECRET} +dryRun: + command: /usr/local/bin/imagemirror from-registry --target-acr ${TARGET_ACR} --acr-suffix + .azurecr.io --source-registry ${SOURCE_REGISTRY} --repository ${REPOSITORY} --digest + ${DIGEST} --cloud public --auth.pull-secret.keyvault ${PULL_SECRET_KV} --auth.pull-secret.name + ${PULL_SECRET} --dry-run +name: image-mirror-step +shellIdentity: + value: my-identity +variables: +- name: TARGET_ACR + value: myacr +- name: SOURCE_REGISTRY + value: docker.io +- name: REPOSITORY + value: nginx +- name: DIGEST + value: sha256:123456 +- name: PULL_SECRET_KV + value: my-keyvault +- name: PULL_SECRET + value: my-pull-secret diff --git a/pipelines/types/testdata/zz_fixture_TestResolveImageMirrorStep_native_mirror_from_registry_public_source.yaml b/pipelines/types/testdata/zz_fixture_TestResolveImageMirrorStep_native_mirror_from_registry_public_source.yaml new file mode 100644 index 0000000..558b7ff --- /dev/null +++ b/pipelines/types/testdata/zz_fixture_TestResolveImageMirrorStep_native_mirror_from_registry_public_source.yaml @@ -0,0 +1,20 @@ +action: Shell +command: /usr/local/bin/imagemirror from-registry --target-acr ${TARGET_ACR} --acr-suffix + .azurecr.io --source-registry ${SOURCE_REGISTRY} --repository ${REPOSITORY} --digest + ${DIGEST} --cloud public --auth.anonymous +dryRun: + command: /usr/local/bin/imagemirror from-registry --target-acr ${TARGET_ACR} --acr-suffix + .azurecr.io --source-registry ${SOURCE_REGISTRY} --repository ${REPOSITORY} --digest + ${DIGEST} --cloud public --auth.anonymous --dry-run +name: image-mirror-step +shellIdentity: + value: my-identity +variables: +- name: TARGET_ACR + value: myacr +- name: SOURCE_REGISTRY + value: mcr.microsoft.com +- name: REPOSITORY + value: nginx +- name: DIGEST + value: sha256:123456 diff --git a/tools/imagemirror/acr.go b/tools/imagemirror/acr.go new file mode 100644 index 0000000..791a086 --- /dev/null +++ b/tools/imagemirror/acr.go @@ -0,0 +1,159 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package imagemirror + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/url" + "time" + + "k8s.io/apimachinery/pkg/util/wait" + + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/policy" + "github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry" +) + +// exchangeACRAccessToken exchanges an ARM access token for an ACR refresh token. +func exchangeACRAccessToken(ctx context.Context, armToken azcore.AccessToken, acrFQDN string) (azcore.AccessToken, error) { + endpoint, err := url.Parse(fmt.Sprintf("https://%s", acrFQDN)) + if err != nil { + return azcore.AccessToken{}, fmt.Errorf("failed to parse ACR endpoint: %w", err) + } + + client, err := azcontainerregistry.NewAuthenticationClient(endpoint.String(), nil) + if err != nil { + return azcore.AccessToken{}, fmt.Errorf("failed to create ACR authentication client: %w", err) + } + + refreshResponse, err := client.ExchangeAADAccessTokenForACRRefreshToken(ctx, azcontainerregistry.PostContentSchemaGrantTypeAccessToken, endpoint.Hostname(), &azcontainerregistry.AuthenticationClientExchangeAADAccessTokenForACRRefreshTokenOptions{ + AccessToken: &armToken.Token, + }) + if err != nil { + return azcore.AccessToken{}, fmt.Errorf("failed to exchange AAD access token for ACR refresh token: %w", err) + } + + if refreshResponse.RefreshToken == nil { + return azcore.AccessToken{}, errors.New("got an empty response when exchanging AAD access token for ACR refresh token") + } + + accessToken := *refreshResponse.RefreshToken + + // Parse the JWT to extract the expiry, so we can report it. + parts := splitJWT(accessToken) + if len(parts) != 3 { + // If we can't parse the token, just set a reasonable default expiry. + return azcore.AccessToken{ + Token: accessToken, + ExpiresOn: time.Now().Add(1 * time.Hour), + }, nil + } + + claims, err := parseJWTClaims(parts[1]) + if err != nil { + return azcore.AccessToken{ + Token: accessToken, + ExpiresOn: time.Now().Add(1 * time.Hour), + }, nil + } + + expiry := extractExpiry(claims) + return azcore.AccessToken{ + Token: accessToken, + ExpiresOn: expiry, + }, nil +} + +// exchangeACRAccessTokenWithRetry wraps exchangeACRAccessToken with exponential backoff retry. +func exchangeACRAccessTokenWithRetry(ctx context.Context, cred azcore.TokenCredential, acrFQDN string) (azcore.AccessToken, error) { + var acrToken azcore.AccessToken + var lastErr error + backoff := wait.Backoff{ + Steps: 5, + Duration: 2 * time.Second, + Factor: 2.0, + Jitter: 0.1, + } + + err := wait.ExponentialBackoffWithContext(ctx, backoff, func(ctx context.Context) (bool, error) { + armToken, err := cred.GetToken(ctx, policy.TokenRequestOptions{ + Scopes: []string{"https://management.azure.com/.default"}, + }) + if err != nil { + return false, fmt.Errorf("failed to get ARM token: %w", err) + } + + acrToken, err = exchangeACRAccessToken(ctx, armToken, acrFQDN) + if err != nil { + lastErr = err + // Retry on failure. + return false, nil + } + return true, nil + }) + if err != nil { + if lastErr != nil { + return azcore.AccessToken{}, fmt.Errorf("failed to exchange ACR access token after retries: %w: last exchange error: %w", err, lastErr) + } + return azcore.AccessToken{}, fmt.Errorf("failed to exchange ACR access token after retries: %w", err) + } + + return acrToken, nil +} + +// splitJWT splits a JWT into its three parts without importing a JWT library. +func splitJWT(token string) []string { + var parts []string + start := 0 + for i := range token { + if token[i] == '.' { + parts = append(parts, token[start:i]) + start = i + 1 + } + } + parts = append(parts, token[start:]) + return parts +} + +// parseJWTClaims base64-decodes and parses the claims section of a JWT. +func parseJWTClaims(encoded string) (map[string]any, error) { + // JWT uses base64url encoding without padding. + decoded, err := base64.RawURLEncoding.DecodeString(encoded) + if err != nil { + return nil, err + } + var claims map[string]any + if err := json.Unmarshal(decoded, &claims); err != nil { + return nil, err + } + return claims, nil +} + +// extractExpiry extracts the "exp" claim from JWT claims as a time.Time. +func extractExpiry(claims map[string]any) time.Time { + switch exp := claims["exp"].(type) { + case float64: + return time.Unix(int64(exp), 0) + case json.Number: + timestamp, _ := exp.Int64() + return time.Unix(timestamp, 0) + default: + return time.Now().Add(1 * time.Hour) + } +} diff --git a/tools/imagemirror/command.go b/tools/imagemirror/command.go new file mode 100644 index 0000000..6934b1a --- /dev/null +++ b/tools/imagemirror/command.go @@ -0,0 +1,44 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package imagemirror + +import ( + "github.com/spf13/cobra" +) + +// NewCommand returns the root imagemirror cobra.Command with subcommands for mirroring +// container images to an Azure Container Registry. +func NewCommand() (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "imagemirror", + Short: "Mirror container images to an Azure Container Registry.", + SilenceUsage: true, + SilenceErrors: true, + } + + fromRegistry, err := newFromRegistryCommand() + if err != nil { + return nil, err + } + cmd.AddCommand(fromRegistry) + + fromOCILayout, err := newFromOCILayoutCommand() + if err != nil { + return nil, err + } + cmd.AddCommand(fromOCILayout) + + return cmd, nil +} diff --git a/tools/imagemirror/fromocilayout.go b/tools/imagemirror/fromocilayout.go new file mode 100644 index 0000000..c952534 --- /dev/null +++ b/tools/imagemirror/fromocilayout.go @@ -0,0 +1,252 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package imagemirror + +import ( + "context" + "encoding/json" + "fmt" + "os" + "os/signal" + + "github.com/go-logr/logr" + "github.com/spf13/cobra" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/content/oci" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + + "github.com/Azure/ARO-Tools/tools/cmdutils" +) + +func newFromOCILayoutCommand() (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "from-oci-layout", + Short: "Mirror an image from a local OCI layout tar to an Azure Container Registry.", + SilenceUsage: true, + SilenceErrors: true, + } + + opts := defaultFromOCILayoutOptions() + if err := bindFromOCILayoutOptions(opts, cmd); err != nil { + return nil, fmt.Errorf("failed to bind options: %w", err) + } + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer cancel() + + validated, err := opts.Validate() + if err != nil { + return err + } + completed, err := validated.Complete(ctx) + if err != nil { + return err + } + return completed.Run(ctx) + } + + return cmd, nil +} + +// FromOCILayoutRawOptions holds raw input values for the from-oci-layout subcommand. +type FromOCILayoutRawOptions struct { + *cmdutils.RawOptions + + TargetACR string + ACRSuffix string + Repository string + ImageTar string + ImageMetadata string + DryRun bool +} + +func defaultFromOCILayoutOptions() *FromOCILayoutRawOptions { + return &FromOCILayoutRawOptions{ + RawOptions: cmdutils.DefaultOptions(), + } +} + +func bindFromOCILayoutOptions(opts *FromOCILayoutRawOptions, cmd *cobra.Command) error { + cmd.Flags().StringVar(&opts.TargetACR, "target-acr", opts.TargetACR, "Name of the target Azure Container Registry.") + cmd.Flags().StringVar(&opts.ACRSuffix, "acr-suffix", opts.ACRSuffix, "DNS suffix for the ACR (e.g. .azurecr.io).") + cmd.Flags().StringVar(&opts.Repository, "repository", opts.Repository, "Target image repository.") + cmd.Flags().StringVar(&opts.ImageTar, "image-tar", opts.ImageTar, "Path to the OCI layout tar file.") + cmd.Flags().StringVar(&opts.ImageMetadata, "image-metadata", opts.ImageMetadata, "Path to the image metadata JSON file.") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", opts.DryRun, "Exit before copying, logging what would be done.") + + return cmdutils.BindOptions(opts.RawOptions, cmd) +} + +// fromOCILayoutValidatedOptions is a private wrapper enforcing Validate() before Complete(). +type fromOCILayoutValidatedOptions struct { + *FromOCILayoutRawOptions + *cmdutils.ValidatedOptions + + BuildTag string +} + +// FromOCILayoutValidatedOptions wraps the validated options with a private pointer. +type FromOCILayoutValidatedOptions struct { + *fromOCILayoutValidatedOptions +} + +func (o *FromOCILayoutRawOptions) Validate() (*FromOCILayoutValidatedOptions, error) { + for _, item := range []struct { + flag string + name string + value *string + }{ + {flag: "target-acr", name: "target ACR name", value: &o.TargetACR}, + {flag: "acr-suffix", name: "ACR DNS suffix", value: &o.ACRSuffix}, + {flag: "repository", name: "repository", value: &o.Repository}, + {flag: "image-tar", name: "OCI layout tar file", value: &o.ImageTar}, + {flag: "image-metadata", name: "image metadata file", value: &o.ImageMetadata}, + } { + if item.value == nil || *item.value == "" { + return nil, fmt.Errorf("the %s must be provided with --%s", item.name, item.flag) + } + } + + // Validate tar file exists. + if _, err := os.Stat(o.ImageTar); err != nil { + return nil, fmt.Errorf("image tar file does not exist at %s: %w", o.ImageTar, err) + } + + // Validate metadata file exists and contains build_tag. + if _, err := os.Stat(o.ImageMetadata); err != nil { + return nil, fmt.Errorf("image metadata file does not exist at %s: %w", o.ImageMetadata, err) + } + + metadataBytes, err := os.ReadFile(o.ImageMetadata) + if err != nil { + return nil, fmt.Errorf("failed to read image metadata file %s: %w", o.ImageMetadata, err) + } + + var metadata struct { + BuildTag string `json:"build_tag"` + } + if err := json.Unmarshal(metadataBytes, &metadata); err != nil { + return nil, fmt.Errorf("failed to parse image metadata file %s: %w", o.ImageMetadata, err) + } + if metadata.BuildTag == "" { + return nil, fmt.Errorf("build_tag not found in %s", o.ImageMetadata) + } + + validated, err := o.RawOptions.Validate() + if err != nil { + return nil, err + } + + return &FromOCILayoutValidatedOptions{ + fromOCILayoutValidatedOptions: &fromOCILayoutValidatedOptions{ + FromOCILayoutRawOptions: o, + ValidatedOptions: validated, + BuildTag: metadata.BuildTag, + }, + }, nil +} + +// fromOCILayoutCompletedOptions holds the fully resolved state for execution. +type fromOCILayoutCompletedOptions struct { + TargetACRLoginServer string + Repository string + ImageTar string + BuildTag string + DryRun bool + + ACRCredential auth.Credential +} + +// FromOCILayoutOptions wraps the completed options with a private pointer. +type FromOCILayoutOptions struct { + *fromOCILayoutCompletedOptions +} + +func (o *FromOCILayoutValidatedOptions) Complete(ctx context.Context) (*FromOCILayoutOptions, error) { + completed, err := o.ValidatedOptions.Complete() + if err != nil { + return nil, err + } + + targetLoginServer := o.TargetACR + o.ACRSuffix + + // Get Azure credentials and exchange for ACR refresh token. + cred, err := cmdutils.GetAzureTokenCredentialsForCloud(completed.Configuration) + if err != nil { + return nil, fmt.Errorf("failed to create Azure credentials: %w", err) + } + + acrToken, err := exchangeACRAccessTokenWithRetry(ctx, cred, targetLoginServer) + if err != nil { + return nil, fmt.Errorf("failed to authenticate to target ACR %s: %w", targetLoginServer, err) + } + + acrCredential := auth.Credential{ + Username: "00000000-0000-0000-0000-000000000000", + Password: acrToken.Token, + } + + return &FromOCILayoutOptions{ + fromOCILayoutCompletedOptions: &fromOCILayoutCompletedOptions{ + TargetACRLoginServer: targetLoginServer, + Repository: o.Repository, + ImageTar: o.ImageTar, + BuildTag: o.BuildTag, + DryRun: o.DryRun, + ACRCredential: acrCredential, + }, + }, nil +} + +func (o *FromOCILayoutOptions) Run(ctx context.Context) error { + logger, err := logr.FromContext(ctx) + if err != nil { + return fmt.Errorf("failed to get logger from context: %w", err) + } + + targetImage := fmt.Sprintf("%s/%s:%s", o.TargetACRLoginServer, o.Repository, o.BuildTag) + + if o.DryRun { + logger.Info("DRY_RUN is enabled. Exiting without making changes.", "source", o.ImageTar, "tag", o.BuildTag, "target", targetImage) + return nil + } + + logger.Info("Mirroring image from OCI layout.", "source", o.ImageTar, "tag", o.BuildTag, "target", targetImage) + + // Load the OCI layout from tar. + srcStore, err := oci.NewFromTar(ctx, o.ImageTar) + if err != nil { + return fmt.Errorf("failed to load OCI layout from %s: %w", o.ImageTar, err) + } + + // Configure target repository. + dstRepo, err := remote.NewRepository(fmt.Sprintf("%s/%s", o.TargetACRLoginServer, o.Repository)) + if err != nil { + return fmt.Errorf("failed to create target repository client: %w", err) + } + dstRepo.Client = &auth.Client{ + Credential: auth.StaticCredential(o.TargetACRLoginServer, o.ACRCredential), + } + + // Copy from OCI layout to remote registry. + desc, err := oras.Copy(ctx, srcStore, o.BuildTag, dstRepo, o.BuildTag, oras.DefaultCopyOptions) + if err != nil { + return fmt.Errorf("failed to copy image from OCI layout to %s: %w", targetImage, err) + } + + logger.Info("Successfully mirrored image from OCI layout.", "target", targetImage, "digest", desc.Digest.String(), "mediaType", desc.MediaType, "size", desc.Size) + return nil +} diff --git a/tools/imagemirror/fromregistry.go b/tools/imagemirror/fromregistry.go new file mode 100644 index 0000000..8e32406 --- /dev/null +++ b/tools/imagemirror/fromregistry.go @@ -0,0 +1,378 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package imagemirror + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/signal" + "strings" + + "github.com/go-logr/logr" + "github.com/spf13/cobra" + "oras.land/oras-go/v2" + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" + + "github.com/Azure/ARO-Tools/tools/cmdutils" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" + "github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud" + "github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets" +) + +func newFromRegistryCommand() (*cobra.Command, error) { + cmd := &cobra.Command{ + Use: "from-registry", + Short: "Mirror an image from a source registry to an Azure Container Registry.", + SilenceUsage: true, + SilenceErrors: true, + } + + opts := defaultFromRegistryOptions() + if err := bindFromRegistryOptions(opts, cmd); err != nil { + return nil, fmt.Errorf("failed to bind options: %w", err) + } + cmd.RunE = func(cmd *cobra.Command, args []string) error { + ctx, cancel := signal.NotifyContext(cmd.Context(), os.Interrupt) + defer cancel() + + validated, err := opts.Validate() + if err != nil { + return err + } + completed, err := validated.Complete(ctx) + if err != nil { + return err + } + return completed.Run(ctx) + } + + return cmd, nil +} + +// FromRegistryRawOptions holds raw input values for the from-registry subcommand. +type FromRegistryRawOptions struct { + *cmdutils.RawOptions + + TargetACR string + ACRSuffix string + SourceRegistry string + Repository string + Digest string + DryRun bool + + // Auth flags - mutually exclusive + AuthAnonymous bool + AuthSourceConfig string + AuthPullSecretKV string + AuthPullSecretName string +} + +func defaultFromRegistryOptions() *FromRegistryRawOptions { + return &FromRegistryRawOptions{ + RawOptions: cmdutils.DefaultOptions(), + } +} + +func bindFromRegistryOptions(opts *FromRegistryRawOptions, cmd *cobra.Command) error { + cmd.Flags().StringVar(&opts.TargetACR, "target-acr", opts.TargetACR, "Name of the target Azure Container Registry.") + cmd.Flags().StringVar(&opts.ACRSuffix, "acr-suffix", opts.ACRSuffix, "DNS suffix for the ACR (e.g. .azurecr.io).") + cmd.Flags().StringVar(&opts.SourceRegistry, "source-registry", opts.SourceRegistry, "Hostname of the source registry.") + cmd.Flags().StringVar(&opts.Repository, "repository", opts.Repository, "Image repository to mirror.") + cmd.Flags().StringVar(&opts.Digest, "digest", opts.Digest, "Image digest (e.g. sha256:abc123...).") + cmd.Flags().BoolVar(&opts.DryRun, "dry-run", opts.DryRun, "Exit before copying, logging what would be done.") + + cmd.Flags().BoolVar(&opts.AuthAnonymous, "auth.anonymous", opts.AuthAnonymous, "Pull from the source registry without authentication.") + cmd.Flags().StringVar(&opts.AuthSourceConfig, "auth.source-config", opts.AuthSourceConfig, "Path to a Docker auth config JSON for the source registry.") + cmd.Flags().StringVar(&opts.AuthPullSecretKV, "auth.pull-secret.keyvault", opts.AuthPullSecretKV, "Key Vault name containing the pull secret.") + cmd.Flags().StringVar(&opts.AuthPullSecretName, "auth.pull-secret.name", opts.AuthPullSecretName, "Name of the pull secret in Key Vault.") + + return cmdutils.BindOptions(opts.RawOptions, cmd) +} + +// fromRegistryValidatedOptions is a private wrapper enforcing Validate() before Complete(). +type fromRegistryValidatedOptions struct { + *FromRegistryRawOptions + *cmdutils.ValidatedOptions +} + +// FromRegistryValidatedOptions wraps the validated options with a private pointer. +type FromRegistryValidatedOptions struct { + *fromRegistryValidatedOptions +} + +func (o *FromRegistryRawOptions) Validate() (*FromRegistryValidatedOptions, error) { + for _, item := range []struct { + flag string + name string + value *string + }{ + {flag: "target-acr", name: "target ACR name", value: &o.TargetACR}, + {flag: "acr-suffix", name: "ACR DNS suffix", value: &o.ACRSuffix}, + {flag: "source-registry", name: "source registry", value: &o.SourceRegistry}, + {flag: "repository", name: "repository", value: &o.Repository}, + {flag: "digest", name: "digest", value: &o.Digest}, + } { + if item.value == nil || *item.value == "" { + return nil, fmt.Errorf("the %s must be provided with --%s", item.name, item.flag) + } + } + + // Validate mutually exclusive auth flags. + authModes := 0 + if o.AuthAnonymous { + authModes++ + } + if o.AuthSourceConfig != "" { + authModes++ + } + if o.AuthPullSecretKV != "" || o.AuthPullSecretName != "" { + authModes++ + } + if authModes == 0 { + return nil, fmt.Errorf("exactly one auth mode must be specified: --auth.anonymous, --auth.source-config, or --auth.pull-secret.{keyvault,name}") + } + if authModes > 1 { + return nil, fmt.Errorf("auth modes are mutually exclusive: --auth.anonymous, --auth.source-config, and --auth.pull-secret.{keyvault,name} cannot be combined") + } + + // If pull secret mode, both flags are required. + if (o.AuthPullSecretKV != "") != (o.AuthPullSecretName != "") { + return nil, fmt.Errorf("both --auth.pull-secret.keyvault and --auth.pull-secret.name must be provided together") + } + + // Validate digest format: must be algorithm:hex. + if !strings.Contains(o.Digest, ":") { + return nil, fmt.Errorf("invalid --digest %q: expected format algorithm:hex (e.g. sha256:abc123...)", o.Digest) + } + algorithm, hex, _ := strings.Cut(o.Digest, ":") + if algorithm != "sha256" { + return nil, fmt.Errorf("unsupported digest algorithm %q in --digest: only sha256 is supported", algorithm) + } + if hex == "" { + return nil, fmt.Errorf("invalid --digest %q: hex portion after algorithm prefix must not be empty", o.Digest) + } + + validated, err := o.RawOptions.Validate() + if err != nil { + return nil, err + } + + return &FromRegistryValidatedOptions{ + fromRegistryValidatedOptions: &fromRegistryValidatedOptions{ + FromRegistryRawOptions: o, + ValidatedOptions: validated, + }, + }, nil +} + +// fromRegistryCompletedOptions holds the fully resolved state for execution. +type fromRegistryCompletedOptions struct { + TargetACRLoginServer string + SourceRegistry string + Repository string + Digest string + DryRun bool + + ACRCredential auth.Credential + SourceCredFunc auth.CredentialFunc +} + +// FromRegistryOptions wraps the completed options with a private pointer. +type FromRegistryOptions struct { + *fromRegistryCompletedOptions +} + +func (o *FromRegistryValidatedOptions) Complete(ctx context.Context) (*FromRegistryOptions, error) { + completed, err := o.ValidatedOptions.Complete() + if err != nil { + return nil, err + } + + targetLoginServer := o.TargetACR + o.ACRSuffix + + // Get Azure credentials and exchange for ACR refresh token. + cred, err := cmdutils.GetAzureTokenCredentialsForCloud(completed.Configuration) + if err != nil { + return nil, fmt.Errorf("failed to create Azure credentials: %w", err) + } + + acrToken, err := exchangeACRAccessTokenWithRetry(ctx, cred, targetLoginServer) + if err != nil { + return nil, fmt.Errorf("failed to authenticate to target ACR %s: %w", targetLoginServer, err) + } + + acrCredential := auth.Credential{ + Username: "00000000-0000-0000-0000-000000000000", + Password: acrToken.Token, + } + + // Resolve source registry credentials. + var sourceCredFunc auth.CredentialFunc + switch { + case o.AuthAnonymous: + sourceCredFunc = auth.StaticCredential(o.SourceRegistry, auth.EmptyCredential) + case o.AuthSourceConfig != "": + store, err := credentials.NewStore(o.AuthSourceConfig, credentials.StoreOptions{}) + if err != nil { + return nil, fmt.Errorf("failed to load source auth config from %s: %w", o.AuthSourceConfig, err) + } + sourceCredFunc = credentials.Credential(store) + case o.AuthPullSecretKV != "": + dockerConfig, err := fetchPullSecretFromKeyVault(ctx, cred, completed.Configuration, o.AuthPullSecretKV, o.AuthPullSecretName) + if err != nil { + return nil, fmt.Errorf("failed to fetch pull secret from Key Vault: %w", err) + } + store := credentials.NewMemoryStore() + if err := loadDockerConfigIntoStore(ctx, store, dockerConfig); err != nil { + return nil, fmt.Errorf("failed to parse pull secret as Docker auth config: %w", err) + } + sourceCredFunc = credentials.Credential(store) + } + + return &FromRegistryOptions{ + fromRegistryCompletedOptions: &fromRegistryCompletedOptions{ + TargetACRLoginServer: targetLoginServer, + SourceRegistry: o.SourceRegistry, + Repository: o.Repository, + Digest: o.Digest, + DryRun: o.DryRun, + ACRCredential: acrCredential, + SourceCredFunc: sourceCredFunc, + }, + }, nil +} + +func (o *FromRegistryOptions) Run(ctx context.Context) error { + logger, err := logr.FromContext(ctx) + if err != nil { + return fmt.Errorf("failed to get logger from context: %w", err) + } + + // Short-circuit if source and target are the same. + if o.SourceRegistry == o.TargetACRLoginServer { + logger.Info("Source and target registry are the same. No mirroring needed.") + return nil + } + + srcImage := fmt.Sprintf("%s/%s@%s", o.SourceRegistry, o.Repository, o.Digest) + digestNoPrefix := strings.TrimPrefix(o.Digest, "sha256:") + targetImage := fmt.Sprintf("%s/%s:%s", o.TargetACRLoginServer, o.Repository, digestNoPrefix) + + if o.DryRun { + logger.Info("DRY_RUN is enabled. Exiting without making changes.", "source", srcImage, "target", targetImage) + return nil + } + + logger.Info("Mirroring image.", "source", srcImage, "target", targetImage) + logger.Info("The image will still be available under its original digest in the target registry.", "digest", o.Digest) + + // Configure source repository. + srcRepo, err := remote.NewRepository(fmt.Sprintf("%s/%s", o.SourceRegistry, o.Repository)) + if err != nil { + return fmt.Errorf("failed to create source repository client: %w", err) + } + srcRepo.Client = &auth.Client{ + Credential: o.SourceCredFunc, + } + + // Configure target repository. + dstRepo, err := remote.NewRepository(fmt.Sprintf("%s/%s", o.TargetACRLoginServer, o.Repository)) + if err != nil { + return fmt.Errorf("failed to create target repository client: %w", err) + } + dstRepo.Client = &auth.Client{ + Credential: auth.StaticCredential(o.TargetACRLoginServer, o.ACRCredential), + } + + // Copy the image. + desc, err := oras.Copy(ctx, srcRepo, o.Digest, dstRepo, digestNoPrefix, oras.DefaultCopyOptions) + if err != nil { + return fmt.Errorf("failed to copy image from %s to %s: %w", srcImage, targetImage, err) + } + + logger.Info("Successfully mirrored image.", "target", targetImage, "digest", desc.Digest.String(), "mediaType", desc.MediaType, "size", desc.Size) + return nil +} + +// fetchPullSecretFromKeyVault fetches a pull secret from Azure Key Vault and base64-decodes it. +func fetchPullSecretFromKeyVault(ctx context.Context, cred azcore.TokenCredential, cloudConfig cloud.Configuration, vaultName, secretName string) ([]byte, error) { + vaultURI := fmt.Sprintf("https://%s.vault.azure.net", vaultName) + client, err := azsecrets.NewClient(vaultURI, cred, &azsecrets.ClientOptions{ + ClientOptions: azcore.ClientOptions{Cloud: cloudConfig}, + }) + if err != nil { + return nil, fmt.Errorf("failed to create Key Vault secrets client: %w", err) + } + + resp, err := client.GetSecret(ctx, secretName, "", nil) + if err != nil { + return nil, fmt.Errorf("failed to get secret %s from vault %s: %w", secretName, vaultName, err) + } + + if resp.Value == nil { + return nil, fmt.Errorf("secret %s in vault %s has no value", secretName, vaultName) + } + + decoded, err := base64.StdEncoding.DecodeString(*resp.Value) + if err != nil { + return nil, fmt.Errorf("failed to base64-decode pull secret: %w", err) + } + + return decoded, nil +} + +// dockerConfig represents the structure of a Docker auth config JSON. +type dockerConfig struct { + Auths map[string]dockerAuthEntry `json:"auths"` +} + +type dockerAuthEntry struct { + Auth string `json:"auth"` +} + +// loadDockerConfigIntoStore parses a Docker auth config JSON and loads credentials into a memory store. +func loadDockerConfigIntoStore(ctx context.Context, store credentials.Store, configData []byte) error { + var config dockerConfig + if err := json.Unmarshal(configData, &config); err != nil { + return fmt.Errorf("failed to unmarshal Docker auth config: %w", err) + } + + for registry, entry := range config.Auths { + decoded, err := base64.StdEncoding.DecodeString(entry.Auth) + if err != nil { + return fmt.Errorf("failed to decode auth for registry %s: %w", registry, err) + } + + parts := strings.SplitN(string(decoded), ":", 2) + if len(parts) != 2 { + return fmt.Errorf("invalid auth format for registry %s", registry) + } + + cred := auth.Credential{ + Username: parts[0], + Password: parts[1], + } + if err := store.Put(ctx, registry, cred); err != nil { + return fmt.Errorf("failed to store credential for registry %s: %w", registry, err) + } + } + + return nil +} diff --git a/tools/imagemirror/go.mod b/tools/imagemirror/go.mod new file mode 100644 index 0000000..d02c00e --- /dev/null +++ b/tools/imagemirror/go.mod @@ -0,0 +1,36 @@ +module github.com/Azure/ARO-Tools/tools/imagemirror + +go 1.25.0 + +require ( + github.com/Azure/ARO-Tools/tools/cmdutils v0.0.0-20260227032723-11f678744bf9 + github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 + github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3 + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 + github.com/go-logr/logr v1.4.3 + github.com/spf13/cobra v1.10.2 + k8s.io/apimachinery v0.35.3 + oras.land/oras-go/v2 v2.6.0 +) + +require ( + github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 // indirect + github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect + github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 // indirect + github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 // indirect + github.com/golang-jwt/jwt/v5 v5.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/kylelemons/godebug v1.1.0 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/spf13/pflag v1.0.10 // indirect + golang.org/x/crypto v0.47.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect + golang.org/x/text v0.33.0 // indirect + k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect +) diff --git a/tools/imagemirror/go.sum b/tools/imagemirror/go.sum new file mode 100644 index 0000000..6f178ce --- /dev/null +++ b/tools/imagemirror/go.sum @@ -0,0 +1,76 @@ +github.com/Azure/ARO-Tools/tools/cmdutils v0.0.0-20260227032723-11f678744bf9 h1:Tsyb3xmWPTJplBF5g47rvp2tKjn1rX1KTLbTxykBcOY= +github.com/Azure/ARO-Tools/tools/cmdutils v0.0.0-20260227032723-11f678744bf9/go.mod h1:bBo5YOjQf47SHTl5ohULLUin2PAEt6s5D5GQZQRgO5w= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2 h1:yz1bePFlP5Vws5+8ez6T3HWXPmwOK7Yvq8QxDBD3SKY= +github.com/Azure/azure-sdk-for-go/sdk/azidentity/cache v0.3.2/go.mod h1:Pa9ZNPuoNu/GztvBSKk9J1cDJW6vk/n0zLtV4mgd8N8= +github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3 h1:ldKsKtEIblsgsr6mPwrd9yRntoX6uLz/K89wsldwx/k= +github.com/Azure/azure-sdk-for-go/sdk/containers/azcontainerregistry v0.2.3/go.mod h1:MAm7bk0oDLmD8yIkvfbxPW04fxzphPyL+7GzwHxOp6Y= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0 h1:/g8S6wk65vfC6m3FIxJ+i5QDyN9JWwXI8Hb0Img10hU= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.4.0/go.mod h1:gpl+q95AzZlKVI3xSoseF9QPrypk0hQqBiJYeB/cR/I= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM= +github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/keybase/go-keychain v0.0.1 h1:way+bWYa6lDppZoZcgMbYsvC7GxljxrskdNInRtuthU= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= +golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o=