diff --git a/apps/alerting/notifications/go.mod b/apps/alerting/notifications/go.mod index d72fa208fa7..166921bb296 100644 --- a/apps/alerting/notifications/go.mod +++ b/apps/alerting/notifications/go.mod @@ -34,7 +34,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect diff --git a/apps/alerting/notifications/go.sum b/apps/alerting/notifications/go.sum index 718ed6be402..8a021d455cd 100644 --- a/apps/alerting/notifications/go.sum +++ b/apps/alerting/notifications/go.sum @@ -78,8 +78,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= diff --git a/apps/playlist/go.mod b/apps/playlist/go.mod index c20b05c9db1..08d9d1763ab 100644 --- a/apps/playlist/go.mod +++ b/apps/playlist/go.mod @@ -29,7 +29,7 @@ require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/grafana-app-sdk/logging v0.38.2 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect diff --git a/apps/playlist/go.sum b/apps/playlist/go.sum index 063e6449973..3711697f0ba 100644 --- a/apps/playlist/go.sum +++ b/apps/playlist/go.sum @@ -43,8 +43,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/grafana/grafana-app-sdk v0.39.0 h1:WC2E9BKXWDX/e2bajdAFjQEyyWf9BFp7Yzbjm6T1T1w= diff --git a/go.mod b/go.mod index de77ebfbb18..88a43b96fbb 100644 --- a/go.mod +++ b/go.mod @@ -96,6 +96,7 @@ require ( github.com/grafana/grafana-openapi-client-go v0.0.0-20231213163343-bd475d63fb79 // @grafana/grafana-backend-group github.com/grafana/grafana-plugin-sdk-go v0.277.0 // @grafana/plugins-platform-backend github.com/grafana/loki/v3 v3.2.1 // @grafana/observability-logs + github.com/grafana/nanogit v0.0.0-20250617163817-5673133b88c5 // @grafana-app-platform-squad0 github.com/grafana/otel-profiling-go v0.5.1 // @grafana/grafana-backend-group github.com/grafana/pyroscope-go/godeltaprof v0.1.8 // @grafana/observability-traces-and-profiling github.com/grafana/pyroscope/api v1.2.1-0.20250415190842-3ff7247547ae // @grafana/observability-traces-and-profiling diff --git a/go.sum b/go.sum index 607cfa5fc73..3019f4908de 100644 --- a/go.sum +++ b/go.sum @@ -1506,8 +1506,8 @@ github.com/google/pprof v0.0.0-20210226084205-cbba55b83ad5/go.mod h1:kpwsk12EmLe github.com/google/pprof v0.0.0-20210601050228-01bbb1931b22/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210609004039-a478d1d731e9/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= github.com/google/s2a-go v0.1.0/go.mod h1:OJpEgntRZo8ugHpF9hkoLJbS5dSI20XZeXJ9JVywLlM= github.com/google/s2a-go v0.1.3/go.mod h1:Ej+mSEMGRnqRzjc7VtF+jdBwYG5fuJfiZ8ELkjEwM0A= @@ -1637,6 +1637,8 @@ github.com/grafana/loki/pkg/push v0.0.0-20231124142027-e52380921608 h1:ZYk42718k github.com/grafana/loki/pkg/push v0.0.0-20231124142027-e52380921608/go.mod h1:f3JSoxBTPXX5ec4FxxeC19nTBSxoTz+cBgS3cYLMcr0= github.com/grafana/loki/v3 v3.2.1 h1:VB7u+KHfvL5aHAxgoVBvz5wVhsdGuqKC7uuOFOOe7jw= github.com/grafana/loki/v3 v3.2.1/go.mod h1:WvdLl6wOS+yahaeQY+xhD2m2XzkHDfKr5FZaX7D/X2Y= +github.com/grafana/nanogit v0.0.0-20250617163817-5673133b88c5 h1:VaJW9la/ey2DhlJxZRKuDE0kyMbkpILuXZxtKf827UY= +github.com/grafana/nanogit v0.0.0-20250617163817-5673133b88c5/go.mod h1:tN93IZUaAmnSWgL0IgnKdLv6DNeIhTJGvl1wvQMrWco= github.com/grafana/otel-profiling-go v0.5.1 h1:stVPKAFZSa7eGiqbYuG25VcqYksR6iWvF3YH66t4qL8= github.com/grafana/otel-profiling-go v0.5.1/go.mod h1:ftN/t5A/4gQI19/8MoWurBEtC6gFw8Dns1sJZ9W4Tls= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250604130045-92c8f6389b36 h1:AjZ58JRw1ZieFH/SdsddF5BXtsDKt5kSrKNPWrzYz3Y= diff --git a/go.work.sum b/go.work.sum index b6cf635b677..8c5a3e7ceb8 100644 --- a/go.work.sum +++ b/go.work.sum @@ -596,6 +596,8 @@ github.com/99designs/basicauth-go v0.0.0-20160802081356-2a93ba0f464d h1:j6oB/WPC github.com/99designs/httpsignatures-go v0.0.0-20170731043157-88528bf4ca7e h1:rl2Aq4ZODqTDkeSqQBy+fzpZPamacO1Srp8zq7jf2Sc= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= 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/sdk/azcore v1.17.1/go.mod h1:zGqV2R4Cr/k8Uye5w+dgQ06WJtEcbQG/8J7BB6hnCr4= @@ -879,12 +881,12 @@ github.com/circonus-labs/circonusllhist v0.1.3 h1:TJH+oke8D16535+jHExHj4nQvzlZrj github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng= github.com/clbanning/x2j v0.0.0-20191024224557-825249438eec/go.mod h1:jMjuTZXRI4dUb/I5gc9Hdhagfvm9+RyrPryS/auMzxE= github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk= +github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= github.com/cncf/udpa/go v0.0.0-20220112060539-c52dc94e7fbe h1:QQ3GSy+MqSHxm/d8nCtnAiZdYFd45cYZPs8vOOIYKfk= github.com/cncf/xds/go v0.0.0-20240723142845-024c85f92f20/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20240905190251-b4127c9b8d78/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cncf/xds/go v0.0.0-20241223141626-cff3c89139a3/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f h1:C5bqEmzEPLsHm9Mv73lSE9e9bKV23aB1vxOsmZrkl3k= -github.com/cncf/xds/go v0.0.0-20250326154945-ae57f3c0d45f/go.mod h1:W+zGtBO5Y1IgJhy4+A9GOqVhqLpfZi+vwmdNXUehLA8= github.com/cockroachdb/apd v1.1.0 h1:3LFP3629v+1aKXU5Q37mxmRxX/pIu1nijXydLShEq5I= github.com/cockroachdb/cockroach-go v0.0.0-20181001143604-e0a95dfd547c h1:2zRrJWIt/f9c9HhNHAgrRgq0San5gRRUJTBXLkchal0= github.com/cockroachdb/datadriven v0.0.0-20190809214429-80d97fb3cbaa h1:OaNxuTZr7kxeODyLWsRMC+OD03aFUH+mW6r2d+MWa5Y= @@ -919,6 +921,8 @@ github.com/containerd/go-runc v1.0.0 h1:oU+lLv1ULm5taqgV/CJivypVODI4SUz1znWjv3nN github.com/containerd/imgcrypt v1.1.4 h1:iKTstFebwy3Ak5UF0RHSeuCTahC5OIrPJa6vjMAM81s= github.com/containerd/imgcrypt v1.1.4/go.mod h1:LorQnPtzL/T0IyCeftcsMEO7AqxUDbdO8j/tSUpgxvo= github.com/containerd/nri v0.1.0 h1:6QioHRlThlKh2RkRTR4kIT3PKAcrLo3gIWnjkM4dQmQ= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/containerd/ttrpc v1.1.0 h1:GbtyLRxb0gOLR0TYQWt3O6B0NvT8tMdorEHqIQo/lWI= github.com/containerd/typeurl v1.0.2 h1:Chlt8zIieDbzQFzXzAeBEF92KhExuE4p9p92/QmY7aY= github.com/containerd/zfs v1.0.0 h1:cXLJbx+4Jj7rNsTiqVfm6i+RNLx6FFA2fMmDlEf+Wm8= @@ -949,6 +953,8 @@ github.com/couchbase/ghistogram v0.1.0 h1:b95QcQTCzjTUocDXp/uMgSNQi8oj1tGwnJ4bOD github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiGOEoyzgHt9i7k= github.com/couchbase/moss v0.2.0 h1:VCYrMzFwEryyhRSeI+/b3tRBSeTpi/8gn5Kf6dxqn+o= github.com/couchbase/moss v0.2.0/go.mod h1:9MaHIaRuy9pvLPUJxB8sh8OrLfyDczECVL37grCIubs= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/cpuguy83/go-md2man v1.0.10 h1:BSKMNlYxDvnunlTymqtgONjNnaRV1sTpcovwwjF22jk= github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= @@ -1283,12 +1289,8 @@ github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2 h1:qhugDMdQ4 github.com/grafana/cloudflare-go v0.0.0-20230110200409-c627cf6792f2/go.mod h1:w/aiO1POVIeXUQyl0VQSZjl5OAGDTL5aX+4v0RA1tcw= github.com/grafana/cog v0.0.23 h1:/0CCJ24Z8XXM2DnboSd2FzoIswUroqIZzVr8oJWmMQs= github.com/grafana/cog v0.0.23/go.mod h1:jrS9indvWuDs60RHEZpLaAkmZdgyoLKMOEUT0jiB1t0= -github.com/grafana/cog v0.0.35 h1:ZrGca+X17lczqhuXVCxHbLyGszP8VHGh1eAQLxbplkY= -github.com/grafana/cog v0.0.35/go.mod h1:UDstzYqMdgIROmbfkHL8fB9XWQO2lnf5z+4W/eJo4Dc= github.com/grafana/go-gelf/v2 v2.0.1 h1:BOChP0h/jLeD+7F9mL7tq10xVkDG15he3T1zHuQaWak= github.com/grafana/go-gelf/v2 v2.0.1/go.mod h1:lexHie0xzYGwCgiRGcvZ723bSNyNI8ZRD4s0CLobh90= -github.com/grafana/grafana-app-sdk v0.39.0 h1:WC2E9BKXWDX/e2bajdAFjQEyyWf9BFp7Yzbjm6T1T1w= -github.com/grafana/grafana-app-sdk v0.39.0/go.mod h1:xRyBQOttgWTc3tGe9pI0upnpEPVhzALf7Mh/61O4zyY= github.com/grafana/grafana-app-sdk/logging v0.38.0/go.mod h1:Y/bvbDhBiV/tkIle9RW49pgfSPIPSON8Q4qjx3pyqDk= github.com/grafana/grafana-app-sdk/logging v0.39.0 h1:3GgN5+dUZYqq74Q+GT9/ET+yo+V54zWQk/Q2/JsJQB4= github.com/grafana/grafana-app-sdk/logging v0.39.0/go.mod h1:WhDENSnaGHtyVVwZGVnAR7YLvh2xlLDYR3D7E6h7XVk= @@ -1301,6 +1303,8 @@ github.com/grafana/grafana/apps/advisor v0.0.0-20250220154326-6e5de80ef295/go.mo github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250121113133-e747350fee2d/go.mod h1:AvleS6icyPmcBjihtx5jYEvdzLmHGBp66NuE0AMR57A= github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250416173722-ec17e0e4ce03/go.mod h1:oemrhKvFxxc5m32xKHPxInEHAObH0/hPPyHUiBUZ1Cc= github.com/grafana/grafana/apps/alerting/notifications v0.0.0-20250506052906-7a2fc797fb4a/go.mod h1:VkX53kBiqIMHBoGgeEDJnzm5Nwcmv/726tuZuT5SvJY= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250616135341-59c2f154336b/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw= +github.com/grafana/grafana/apps/dashboard v0.0.0-20250616145019-8d27f12428cb/go.mod h1:OIlvNnUufYDhBXa4xK4CyzPI2C69ZJkHy5+aFDyPtXw= github.com/grafana/grafana/apps/investigation v0.0.0-20250121113133-e747350fee2d/go.mod h1:HQprw3MmiYj5OUV9CZnkwA1FKDZBmYACuAB3oDvUOmI= github.com/grafana/grafana/apps/playlist v0.0.0-20250121113133-e747350fee2d/go.mod h1:DjJe5osrW/BKrzN9hAAOSElNWutj1bcriExa7iDP7kA= github.com/grafana/grafana/pkg/aggregator v0.0.0-20250121113133-e747350fee2d/go.mod h1:1sq0guad+G4SUTlBgx7SXfhnzy7D86K/LcVOtiQCiMA= @@ -1311,6 +1315,8 @@ github.com/grafana/grafana/pkg/build v0.0.0-20250403075254-4918d8720c61/go.mod h github.com/grafana/grafana/pkg/semconv v0.0.0-20250121113133-e747350fee2d/go.mod h1:tfLnBpPYgwrBMRz4EXqPCZJyCjEG4Ev37FSlXnocJ2c= github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250121113133-e747350fee2d/go.mod h1:CXpwZ3Mkw6xVlGKc0SqUxqXCP3Uv182q6qAQnLaLxRg= github.com/grafana/grafana/pkg/storage/unified/apistore v0.0.0-20250514132646-acbc7b54ed9e/go.mod h1:xrKQcxQxz+IUF90ybtfENFeEXtlj9nAsX/3Fw0KEIeQ= +github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed h1:59JF1WhHLT+lNX89Tm1OzOEySMVMASAhaPbsRjtp8Kc= +github.com/grafana/nanogit v0.0.0-20250616082354-5e94194d02ed/go.mod h1:OIAAKNgG5fpuJQRNO1lUSj9nc18Xl3O7M8fjIlBO1cI= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3 h1:6D2gGAwyQBElSrp3E+9lSr7k8gLuP3Aiy20rweLWeBw= github.com/grafana/prometheus-alertmanager v0.25.1-0.20240930132144-b5e64e81e8d3/go.mod h1:YeND+6FDA7OuFgDzYODN8kfPhXLCehcpxe4T9mdnpCY= github.com/grafana/prometheus-alertmanager v0.25.1-0.20250331083058-4563aec7a975 h1:4/BZkGObFWZf4cLbE2Vqg/1VTz67Q0AJ7LHspWLKJoQ= @@ -1508,6 +1514,8 @@ github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0Q github.com/magiconair/properties v1.8.6 h1:5ibWZ6iY0NctNGWo87LalDlEZ6R41TqbbDamhfG/Qzo= github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailgun/raymond/v2 v2.0.48 h1:5dmlB680ZkFG2RN/0lvTAghrSxIESeu9/2aeDqACtjw= github.com/mailgun/raymond/v2 v2.0.48/go.mod h1:lsgvL50kgt1ylcFJYZiULi5fjPBkkhNfj4KA0W54Z18= github.com/marstr/guid v1.1.0 h1:/M4H/1G4avsieL6BbUwCOBzulmoeKVP5ux/3mQNnbyI= @@ -1525,6 +1533,8 @@ github.com/mattn/go-shellwords v1.0.3 h1:K/VxK7SZ+cvuPgFSLKi5QPI9Vr/ipOf4C1gN+nt github.com/mattn/go-sqlite3 v1.14.7/go.mod h1:NyWgC/yNuGj7Q9rpYnZvas74GogHl5/Z4A/KQRfk6bU= github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg= github.com/maxatome/go-testdeep v1.12.0/go.mod h1:lPZc/HAcJMP92l7yI6TRz1aZN5URwUBUAfUNvrclaNM= +github.com/maxbrunsfeld/counterfeiter/v6 v6.11.2 h1:yVCLo4+ACVroOEr4iFU1iH46Ldlzz2rTuu18Ra7M8sU= +github.com/maxbrunsfeld/counterfeiter/v6 v6.11.2/go.mod h1:VzB2VoMh1Y32/QqDfg9ZJYHj99oM4LiGtqPZydTiQSQ= github.com/mfridman/xflag v0.1.0 h1:TWZrZwG1QklFX5S4j1vxfF1sZbZeZSGofMwPMLAF29M= github.com/mfridman/xflag v0.1.0/go.mod h1:/483ywM5ZO5SuMVjrIGquYNE5CzLrj5Ux/LxWWnjRaE= github.com/mgechev/dots v0.0.0-20210922191527-e955255bf517 h1:zpIH83+oKzcpryru8ceC6BxnoG8TBrhgAvRg8obzup0= @@ -1554,15 +1564,22 @@ github.com/mitchellh/mapstructure v1.4.1/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RR github.com/mitchellh/osext v0.0.0-20151018003038-5e2d6d41470f h1:2+myh5ml7lgEU/51gbeLHfKGNfgEQQIWrlbdaOsidbQ= github.com/mithrandie/readline-csvq v1.3.0 h1:VTJEOGouJ8j27jJCD4kBBbNTxM0OdBvE1aY1tMhlqE8= github.com/mithrandie/readline-csvq v1.3.0/go.mod h1:FKyYqDgf/G4SNov7SMFXRWO6LQLXIOeTog/NB97FZl0= +github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo= github.com/moby/locker v1.0.1 h1:fOXqR41zeveg4fFODix+1Ch4mj/gT0NE1XJbp/epuBg= +github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= +github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/mountinfo v0.5.0 h1:2Ks8/r6lopsxWi9m58nlwjaeSzUX9iiL1vj5qB/9ObI= github.com/moby/sys/mountinfo v0.5.0/go.mod h1:3bMD3Rg+zkqx8MRYPi7Pyb0Ie97QEBmdxbhnCLlSvSU= +github.com/moby/sys/sequential v0.5.0 h1:OPvI35Lzn9K04PBbCLW0g4LcFAJgHsvXsRyewg5lXtc= +github.com/moby/sys/sequential v0.5.0/go.mod h1:tH2cOOs5V9MlPiXcQzRC+eEyab644PWKGRYaaV5ZZlo= github.com/moby/sys/signal v0.6.0 h1:aDpY94H8VlhTGa9sNYUFCFsMZIUh5wm0B6XkIoJj/iY= github.com/moby/sys/signal v0.6.0/go.mod h1:GQ6ObYZfqacOwTtlXvcmh9A26dVRul/hbOZn88Kg8Tg= github.com/moby/sys/symlink v0.2.0 h1:tk1rOM+Ljp0nFmfOIBtlV3rTDlWOwFRhjEeAhZB0nZc= github.com/moby/sys/symlink v0.2.0/go.mod h1:7uZVF2dqJjG/NsClqul95CqKOBRQyYSNnJ6BMgR/gFs= +github.com/moby/sys/user v0.1.0 h1:WmZ93f5Ux6het5iituh9x2zAG7NFY9Aqi49jjE1PaQg= +github.com/moby/sys/user v0.1.0/go.mod h1:fKJhFOnsCN6xZ5gSfbM6zaHGgDJMrqt9/reuj4T7MmU= github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= github.com/moby/term v0.0.0-20210619224110-3f7ff695adc6/go.mod h1:E2VnQOmVuvZB6UYnnDB0qG5Nq/1tD9acaOpo6xmt0Kw= @@ -1612,6 +1629,8 @@ github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7 github.com/onsi/ginkgo/v2 v2.19.0/go.mod h1:rlwLi9PilAFJ8jCg9UE1QP6VBpd6/xj3SRC0d6TU0To= github.com/onsi/ginkgo/v2 v2.20.1/go.mod h1:lG9ey2Z29hR41WMVthyJBGUBcBhGOtoPF2VFMvBXFCI= github.com/onsi/ginkgo/v2 v2.22.1/go.mod h1:S6aTpoRsSq2cZOd+pssHAlKW/Q/jZt6cPrPlnj4a1xM= +github.com/onsi/ginkgo/v2 v2.23.3/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= +github.com/onsi/ginkgo/v2 v2.23.4/go.mod h1:Bt66ApGPBFzHyR+JO10Zbt0Gsp4uWxu5mIOTusL46e8= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY= @@ -1619,6 +1638,8 @@ github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2 github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= github.com/onsi/gomega v1.33.1/go.mod h1:U4R44UsT+9eLIaYRB2a5qajjtQYn0hauxvRm16AVYg0= github.com/onsi/gomega v1.34.2/go.mod h1:v1xfxRgk0KIsG+QOdm7p8UosrOzPYRo60fd3B/1Dukc= +github.com/onsi/gomega v1.36.3/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= +github.com/onsi/gomega v1.37.0/go.mod h1:8D9+Txp43QWKhM24yyOBEdpkzN8FvJyAwecBgsU4KU0= github.com/op/go-logging v0.0.0-20160315200505-970db520ece7/go.mod h1:HzydrMdWErDVzsI23lYNej1Htcns9BCg93Dk0bBINWk= github.com/open-feature/go-sdk v1.11.0/go.mod h1:+rkJhLBtYsJ5PZNddAgFILhRAAxwrJ32aU7UEUm4zQI= github.com/open-telemetry/opentelemetry-collector-contrib/exporter/kafkaexporter v0.102.0 h1:R70PpK14trQfL/Vj5oAiGRqX09s2gOWuf6t1Ae5fevQ= @@ -1702,6 +1723,8 @@ github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceive github.com/open-telemetry/opentelemetry-collector-contrib/receiver/zipkinreceiver v0.124.1/go.mod h1:CuCZVPz+yn88b5vhZPAlxaMrVuhAVexUV6f8b07lpUc= github.com/opencontainers/image-spec v1.0.2/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= github.com/opencontainers/image-spec v1.0.3-0.20211202183452-c5a74bcca799/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0= +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/opencontainers/runc v1.1.2 h1:2VSZwLx5k/BfsBxMMipG/LYUnmqOD/BPkIVgQUcTlLw= github.com/opencontainers/runc v1.1.2/go.mod h1:Tj1hFw6eFWp/o33uxGf5yF2BX5yz2Z6iptFpuvbbKqc= github.com/opencontainers/runtime-spec v1.0.2 h1:UfAcuLBJB9Coz72x1hgl8O5RVzTdNiaglX6v2DM6FI0= @@ -1829,6 +1852,8 @@ github.com/segmentio/fasthash v1.0.3 h1:EI9+KE1EwvMLBWwjpRDc+fEM+prwxDYbslddQGtr github.com/segmentio/fasthash v1.0.3/go.mod h1:waKX8l2N8yckOgmSsXJi7x1ZfdKZ4x7KRMzBtS3oedY= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/shirou/gopsutil/v3 v3.23.12 h1:z90NtUkp3bMtmICZKpC4+WaknU1eXtp5vtbQ11DgpE4= +github.com/shirou/gopsutil/v3 v3.23.12/go.mod h1:1FrWgea594Jp7qmjHUUPlJDTPgcsb9mGnXDxavtikzM= github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU= github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= @@ -1879,6 +1904,8 @@ github.com/tdewolff/minify/v2 v2.12.8 h1:Q2BqOTmlMjoutkuD/OPCnJUpIqrzT3nRPkw+q+K github.com/tdewolff/minify/v2 v2.12.8/go.mod h1:YRgk7CC21LZnbuke2fmYnCTq+zhCgpb0yJACOTUNJ1E= github.com/tdewolff/parse/v2 v2.6.7 h1:WrFllrqmzAcrKHzoYgMupqgUBIfBVOb0yscFzDf8bBg= github.com/tdewolff/parse/v2 v2.6.7/go.mod h1:XHDhaU6IBgsryfdnpzUXBlT6leW/l25yrFBTEb4eIyM= +github.com/testcontainers/testcontainers-go v0.35.0 h1:uADsZpTKFAtp8SLK+hMwSaa+X+JiERHtd4sQAFmXeMo= +github.com/testcontainers/testcontainers-go v0.35.0/go.mod h1:oEVBj5zrfJTrgjwONs1SsRbnBtH9OKl+IGl3UMcr2B4= github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY= github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk= @@ -2253,8 +2280,6 @@ go.opentelemetry.io/otel v1.28.0/go.mod h1:q68ijF8Fc8CnMHKyzqL6akLO46ePnjkgfIMIj go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8= go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg= -go.opentelemetry.io/otel v1.36.0 h1:UumtzIklRBY6cI/lllNZlALOF5nNIzJVb16APdvgTXg= -go.opentelemetry.io/otel v1.36.0/go.mod h1:/TcFMXYjyRNh8khOAO9ybYkqaDBb/70aVwkNML4pP8E= go.opentelemetry.io/otel/bridge/opencensus v1.27.0 h1:ao9aGGHd+G4YfjBpGs6vbkvt5hoC67STlJA9fCnOAcs= go.opentelemetry.io/otel/bridge/opencensus v1.27.0/go.mod h1:uRvWtAAXzyVOST0WMPX5JHGBaAvBws+2F8PcC5gMnTk= go.opentelemetry.io/otel/bridge/opencensus v1.35.0 h1:4nJfffRbozhqnuukfRkiahA94mnpryCLJLiduMIDJKI= @@ -2270,14 +2295,8 @@ go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.28.0/go.mod go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.28.0 h1:aLmmtjRke7LPDQ3lvpFz+kNEH43faFhzW7v8BFIEydg= go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.28.0/go.mod h1:TC1pyCt6G9Sjb4bQpShH+P5R53pO6ZuGnHuuln9xMeE= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.28.0/go.mod h1:s75jGIWA9OfCMzF0xr+ZgfrB5FEbbV7UuYo32ahUiFI= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0 h1:dNzwXjZKpMpE2JhmO+9HsPl42NIXFIFSUSSs0fiqra0= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.36.0/go.mod h1:90PoxvaEB5n6AOdZvi+yWJQoE95U8Dhhw2bSyRqnTD0= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.27.0/go.mod h1:MOiCmryaYtc+V0Ei+Tx9o5S1ZjA7kzLucuVuyzBZloQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0 h1:JgtbA0xkWHnTmYk7YusopJFX6uleBmAuZ8n05NEh8nQ= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.36.0/go.mod h1:179AK5aar5R3eS9FucPy6rggvU0g52cvKId8pv4+v0c= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.22.0/go.mod h1:hYwym2nDEeZfG/motx0p7L7J1N1vyzIThemQsb4g2qY= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0 h1:nRVXXvf78e00EwY6Wp0YII8ww2JVWshZ20HfTlE11AM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.36.0/go.mod h1:r49hO7CgrxY9Voaj3Xe8pANWtr0Oq916d0XAmOoCZAQ= go.opentelemetry.io/otel/exporters/prometheus v0.50.0 h1:2Ewsda6hejmbhGFyUvWZjUThC98Cf8Zy6g0zkIimOng= go.opentelemetry.io/otel/exporters/prometheus v0.50.0/go.mod h1:pMm5PkUo5YwbLiuEf7t2xg4wbP0/eSJrMxIMxKosynY= go.opentelemetry.io/otel/exporters/prometheus v0.57.0 h1:AHh/lAP1BHrY5gBwk8ncc25FXWm/gmmY3BX258z5nuk= @@ -2297,16 +2316,12 @@ go.opentelemetry.io/otel/metric v1.28.0/go.mod h1:Fb1eVBFZmLVTMb6PPohq3TO9IIhUis go.opentelemetry.io/otel/metric v1.29.0/go.mod h1:auu/QWieFVWx+DmQOUMgj0F8LHWdgalxXqvp7BII/W8= go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8= -go.opentelemetry.io/otel/metric v1.36.0 h1:MoWPKVhQvJ+eeXWHFBOPoBOi20jh6Iq2CcCREuTYufE= -go.opentelemetry.io/otel/metric v1.36.0/go.mod h1:zC7Ks+yeyJt4xig9DEw9kuUFe5C3zLbVjV2PzT6qzbs= go.opentelemetry.io/otel/sdk v1.24.0/go.mod h1:KVrIYw6tEubO9E96HQpcmpTKDVn9gdv35HoYiQWGDFg= go.opentelemetry.io/otel/sdk v1.28.0/go.mod h1:oYj7ClPUA7Iw3m+r7GeEjz0qckQRJK2B8zjcZEfu7Pg= go.opentelemetry.io/otel/sdk v1.29.0/go.mod h1:pM8Dx5WKnvxLCb+8lG1PRNIDxu9g9b9g59Qr7hfAAok= go.opentelemetry.io/otel/sdk v1.30.0/go.mod h1:p14X4Ok8S+sygzblytT1nqG98QG2KYKv++HE0LY/mhg= go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU= -go.opentelemetry.io/otel/sdk v1.36.0 h1:b6SYIuLRs88ztox4EyrvRti80uXIFy+Sqzoh9kFULbs= -go.opentelemetry.io/otel/sdk v1.36.0/go.mod h1:+lC+mTgD+MUWfjJubi2vvXWcVxyr9rmlshZni72pXeY= go.opentelemetry.io/otel/sdk/metric v1.29.0/go.mod h1:6zZLdCl2fkauYoZIOn/soQIDSWFmNSRcICarHfuhNJQ= go.opentelemetry.io/otel/sdk/metric v1.30.0/go.mod h1:waS6P3YqFNzeP01kuo/MBBYqaoBJl7efRQHOaydhy1Y= go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ= @@ -2317,8 +2332,6 @@ go.opentelemetry.io/otel/trace v1.28.0/go.mod h1:jPyXzNPg6da9+38HEwElrQiHlVMTnVf go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ= go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8= -go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w= -go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA= go.opentelemetry.io/proto/otlp v1.0.0/go.mod h1:Sy6pihPLfYHkr3NkUbEhGHFhINUSI/v80hjKIs5JXpM= go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8= go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY= @@ -2359,8 +2372,6 @@ golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5 golang.org/x/crypto v0.35.0/go.mod h1:dy7dXNW32cAb/6/PRuTNsix8T+vJAqvuIy5Bli/x0YQ= golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= golang.org/x/exp v0.0.0-20230315142452-642cacee5cc0/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/exp v0.0.0-20230321023759-10a507213a29/go.mod h1:CxIveKay+FTh1D0yPZemJVgC/95VzuuOLq5Qi4xnoYc= golang.org/x/exp v0.0.0-20230515195305-f3d0a9c9a5cc/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= @@ -2388,8 +2399,6 @@ golang.org/x/mod v0.20.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= golang.org/x/mod v0.23.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= -golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= -golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= golang.org/x/net v0.0.0-20190813141303-74dc4d7220e7/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201202161906-c7110b5ffcbb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= @@ -2411,8 +2420,6 @@ golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= -golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= -golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM= golang.org/x/oauth2 v0.18.0/go.mod h1:Wf7knwG0MPoWIMMBgFlEaSUDaKskp0dCfrlJRJXbBi8= golang.org/x/oauth2 v0.19.0/go.mod h1:vYi7skDa1x015PmRRYZ7+s1cWyPgrPiSYRe4rnsexc8= @@ -2422,8 +2429,6 @@ golang.org/x/oauth2 v0.26.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbht golang.org/x/oauth2 v0.28.0/go.mod h1:onh5ek6nERTohokkhCD/y2cV4Do3fxFHFuAejCkRWT8= golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= -golang.org/x/sync v0.15.0 h1:KWH3jNZsfyT6xfAfKiz6MRNmd46ByHDYaZ7KSkCtdW8= -golang.org/x/sync v0.15.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20190130150945-aca44879d564/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190826190057-c7b8b68b1456/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -2457,8 +2462,6 @@ golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.0.0-20211116232009-f0f3c7e86c11/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= @@ -2483,8 +2486,6 @@ golang.org/x/tools v0.26.0/go.mod h1:TPVVj70c7JJ3WCazhD8OdXcZg/og+b9+tH/KxylGwH0 golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw= golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= golang.org/x/tools v0.31.0/go.mod h1:naFTU+Cev749tSJRXJlna0T3WxKvb1kWEx15xA4SdmQ= -golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= -golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= golang.org/x/xerrors v0.0.0-20240716161551-93cc26a95ae9/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= gonum.org/v1/netlib v0.0.0-20190313105609-8cb42192e0e0 h1:OE9mWmgKkjJyEmDAAtGMPjXu+YNeGvK9VTSHY6+Qihc= @@ -2598,8 +2599,6 @@ google.golang.org/grpc v1.67.3/go.mod h1:YGaHCc6Oap+FzBJTZLBzkGSYt/cvGPFTPxkn7Qf google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= google.golang.org/grpc v1.70.0/go.mod h1:ofIJqVKDXx/JiXrwr2IG4/zwdH9txy3IlF40RmcJSQw= -google.golang.org/grpc v1.73.0 h1:VIWSmpI2MegBtTuFt5/JWy2oXxtjJ/e89Z70ImfD2ok= -google.golang.org/grpc v1.73.0/go.mod h1:50sbHOUqWoCQGI8V2HQLJM0B+LMlIUjNSZmow7EVBQc= google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.1.0 h1:M1YKkFIboKNieVO5DLUEVzQfGwJD30Nv2jfUgzb5UcE= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20 h1:MLBCGN1O7GzIx+cBiwfYPwtmZ41U3Mn/cotLJciaArI= google.golang.org/grpc/examples v0.0.0-20230224211313-3775f633ce20/go.mod h1:Nr5H8+MlGWr5+xX/STzdoEqJrO+YteqFbMyCsrb6mH0= @@ -2633,6 +2632,7 @@ gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= gotest.tools/v3 v3.5.0/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU= +gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA= honnef.co/go/tools v0.3.2 h1:ytYb4rOqyp1TSa2EPvNVwtPQJctSELKaMyLfqNP4+34= honnef.co/go/tools v0.3.2/go.mod h1:jzwdWgg7Jdq75wlfblQxO4neNaFFSvgc1tD5Wv8U0Yw= howett.net/plist v0.0.0-20181124034731-591f970eefbb/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0= diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index abba4ca2c7e..fccdbeae07c 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -218,6 +218,10 @@ export interface FeatureToggles { */ provisioning?: boolean; /** + * Use experimental git library for provisioning + */ + nanoGit?: boolean; + /** * Start an additional https handler and write kubectl options */ grafanaAPIServerEnsureKubectlAccess?: boolean; diff --git a/pkg/aggregator/go.mod b/pkg/aggregator/go.mod index 1334c6c07cd..c17875eb397 100644 --- a/pkg/aggregator/go.mod +++ b/pkg/aggregator/go.mod @@ -58,7 +58,7 @@ require ( github.com/google/flatbuffers v25.2.10+incompatible // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/mux v1.8.1 // indirect github.com/grafana/otel-profiling-go v0.5.1 // indirect diff --git a/pkg/aggregator/go.sum b/pkg/aggregator/go.sum index 81dc0df7834..8ac3d47a5c0 100644 --- a/pkg/aggregator/go.sum +++ b/pkg/aggregator/go.sum @@ -121,8 +121,8 @@ github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= diff --git a/pkg/apis/secret/go.mod b/pkg/apis/secret/go.mod index 46d3c50a268..70aa997a7fc 100644 --- a/pkg/apis/secret/go.mod +++ b/pkg/apis/secret/go.mod @@ -34,7 +34,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/gnostic-models v0.6.9 // indirect github.com/google/go-cmp v0.7.0 // indirect - github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect github.com/grpc-ecosystem/go-grpc-prometheus v1.2.1-0.20191002090509-6af20e3a5340 // indirect diff --git a/pkg/apis/secret/go.sum b/pkg/apis/secret/go.sum index c4c3c78ddbf..3a0bd380b98 100644 --- a/pkg/apis/secret/go.sum +++ b/pkg/apis/secret/go.sum @@ -70,8 +70,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= diff --git a/pkg/apiserver/go.mod b/pkg/apiserver/go.mod index a183d0e8e7b..beb1305e52a 100644 --- a/pkg/apiserver/go.mod +++ b/pkg/apiserver/go.mod @@ -41,7 +41,7 @@ require ( github.com/golang/protobuf v1.5.4 // indirect github.com/google/btree v1.1.3 // indirect github.com/google/gnostic-models v0.6.9 // indirect - github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect + github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 // indirect github.com/google/uuid v1.6.0 // indirect github.com/grafana/authlib v0.0.0-20250515162837-2f4a8263eabb // indirect github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 // indirect diff --git a/pkg/apiserver/go.sum b/pkg/apiserver/go.sum index 27b606cb127..1c1bdfb2520 100644 --- a/pkg/apiserver/go.sum +++ b/pkg/apiserver/go.sum @@ -72,8 +72,8 @@ github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeN 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/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= -github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6 h1:BHT72Gu3keYf3ZEu2J0b1vyeLSOYI8bm5wbJM/8yDe8= +github.com/google/pprof v0.0.0-20250403155104-27863c87afa6/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= 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/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674 h1:JeSE6pjso5THxAzdVpqr6/geYxZytqFMBCOtn/ujyeo= diff --git a/pkg/registry/apis/provisioning/register.go b/pkg/registry/apis/provisioning/register.go index d0b494bbd52..8d8fd809377 100644 --- a/pkg/registry/apis/provisioning/register.go +++ b/pkg/registry/apis/provisioning/register.go @@ -47,6 +47,7 @@ import ( "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/nanogit" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources/signature" "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath" @@ -167,6 +168,13 @@ func RegisterAPIService( return nil, nil } + logger := logging.DefaultLogger.With("logger", "provisioning startup") + if features.IsEnabledGlobally(featuremgmt.FlagNanoGit) { + logger.Info("Using nanogit for repositories") + } else { + logger.Debug("Using go-git and Github API for repositories") + } + folderResolver := &repository.LocalFolderResolver{ PermittedPrefixes: cfg.PermittedProvisioningPaths, HomePath: safepath.Clean(cfg.HomePath), @@ -1122,7 +1130,39 @@ func (b *APIBuilder) AsRepository(ctx context.Context, r *provisioning.Repositor cloneFn := func(ctx context.Context, opts repository.CloneOptions) (repository.ClonedRepository, error) { return gogit.Clone(ctx, b.clonedir, r, opts, b.secrets) } - return repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, cloneFn) + + apiRepo, err := repository.NewGitHub(ctx, r, b.ghFactory, b.secrets, cloneFn) + if err != nil { + return nil, fmt.Errorf("create github API repository: %w", err) + } + + logger := logging.FromContext(ctx).With("url", r.Spec.GitHub.URL, "branch", r.Spec.GitHub.Branch, "path", r.Spec.GitHub.Path) + if !b.features.IsEnabledGlobally(featuremgmt.FlagNanoGit) { + logger.Debug("Instantiating Github repository with go-git and Github API") + return apiRepo, nil + } + + logger.Info("Instantiating Github repository with nanogit") + + ghCfg := r.Spec.GitHub + if ghCfg == nil { + return nil, fmt.Errorf("github configuration is required for nano git") + } + + gitCfg := nanogit.RepositoryConfig{ + URL: ghCfg.URL, + Branch: ghCfg.Branch, + Path: ghCfg.Path, + Token: ghCfg.Token, + EncryptedToken: ghCfg.EncryptedToken, + } + + nanogitRepo, err := nanogit.NewGitRepository(ctx, b.secrets, r, gitCfg) + if err != nil { + return nil, fmt.Errorf("error creating nanogit repository: %w", err) + } + + return nanogit.NewGithubRepository(apiRepo, nanogitRepo), nil default: return nil, fmt.Errorf("unknown repository type (%s)", r.Spec.Type) } diff --git a/pkg/registry/apis/provisioning/repository/git.go b/pkg/registry/apis/provisioning/repository/git.go new file mode 100644 index 00000000000..6a4244e76da --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/git.go @@ -0,0 +1,15 @@ +package repository + +// GitRepository is an interface that combines all repository capabilities +// needed for Git repositories. +// +//go:generate mockery --name GitRepository --structname MockGitRepository --inpackage --filename git_repository_mock.go --with-expecter +type GitRepository interface { + Repository + Versioned + Writer + Reader + ClonableRepository + URL() string + Branch() string +} diff --git a/pkg/registry/apis/provisioning/repository/git_repository_mock.go b/pkg/registry/apis/provisioning/repository/git_repository_mock.go new file mode 100644 index 00000000000..07ef1c73cba --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/git_repository_mock.go @@ -0,0 +1,834 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package repository + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + field "k8s.io/apimachinery/pkg/util/validation/field" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" +) + +// MockGitRepository is an autogenerated mock type for the GitRepository type +type MockGitRepository struct { + mock.Mock +} + +type MockGitRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *MockGitRepository) EXPECT() *MockGitRepository_Expecter { + return &MockGitRepository_Expecter{mock: &_m.Mock} +} + +// Branch provides a mock function with no fields +func (_m *MockGitRepository) Branch() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Branch") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// MockGitRepository_Branch_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Branch' +type MockGitRepository_Branch_Call struct { + *mock.Call +} + +// Branch is a helper method to define mock.On call +func (_e *MockGitRepository_Expecter) Branch() *MockGitRepository_Branch_Call { + return &MockGitRepository_Branch_Call{Call: _e.mock.On("Branch")} +} + +func (_c *MockGitRepository_Branch_Call) Run(run func()) *MockGitRepository_Branch_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockGitRepository_Branch_Call) Return(_a0 string) *MockGitRepository_Branch_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGitRepository_Branch_Call) RunAndReturn(run func() string) *MockGitRepository_Branch_Call { + _c.Call.Return(run) + return _c +} + +// Clone provides a mock function with given fields: ctx, opts +func (_m *MockGitRepository) Clone(ctx context.Context, opts CloneOptions) (ClonedRepository, error) { + ret := _m.Called(ctx, opts) + + if len(ret) == 0 { + panic("no return value specified for Clone") + } + + var r0 ClonedRepository + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) (ClonedRepository, error)); ok { + return rf(ctx, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) ClonedRepository); ok { + r0 = rf(ctx, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ClonedRepository) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, CloneOptions) error); ok { + r1 = rf(ctx, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGitRepository_Clone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Clone' +type MockGitRepository_Clone_Call struct { + *mock.Call +} + +// Clone is a helper method to define mock.On call +// - ctx context.Context +// - opts CloneOptions +func (_e *MockGitRepository_Expecter) Clone(ctx interface{}, opts interface{}) *MockGitRepository_Clone_Call { + return &MockGitRepository_Clone_Call{Call: _e.mock.On("Clone", ctx, opts)} +} + +func (_c *MockGitRepository_Clone_Call) Run(run func(ctx context.Context, opts CloneOptions)) *MockGitRepository_Clone_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(CloneOptions)) + }) + return _c +} + +func (_c *MockGitRepository_Clone_Call) Return(_a0 ClonedRepository, _a1 error) *MockGitRepository_Clone_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGitRepository_Clone_Call) RunAndReturn(run func(context.Context, CloneOptions) (ClonedRepository, error)) *MockGitRepository_Clone_Call { + _c.Call.Return(run) + return _c +} + +// CompareFiles provides a mock function with given fields: ctx, base, ref +func (_m *MockGitRepository) CompareFiles(ctx context.Context, base string, ref string) ([]VersionedFileChange, error) { + ret := _m.Called(ctx, base, ref) + + if len(ret) == 0 { + panic("no return value specified for CompareFiles") + } + + var r0 []VersionedFileChange + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]VersionedFileChange, error)); ok { + return rf(ctx, base, ref) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) []VersionedFileChange); ok { + r0 = rf(ctx, base, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]VersionedFileChange) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, base, ref) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGitRepository_CompareFiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CompareFiles' +type MockGitRepository_CompareFiles_Call struct { + *mock.Call +} + +// CompareFiles is a helper method to define mock.On call +// - ctx context.Context +// - base string +// - ref string +func (_e *MockGitRepository_Expecter) CompareFiles(ctx interface{}, base interface{}, ref interface{}) *MockGitRepository_CompareFiles_Call { + return &MockGitRepository_CompareFiles_Call{Call: _e.mock.On("CompareFiles", ctx, base, ref)} +} + +func (_c *MockGitRepository_CompareFiles_Call) Run(run func(ctx context.Context, base string, ref string)) *MockGitRepository_CompareFiles_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockGitRepository_CompareFiles_Call) Return(_a0 []VersionedFileChange, _a1 error) *MockGitRepository_CompareFiles_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGitRepository_CompareFiles_Call) RunAndReturn(run func(context.Context, string, string) ([]VersionedFileChange, error)) *MockGitRepository_CompareFiles_Call { + _c.Call.Return(run) + return _c +} + +// Config provides a mock function with no fields +func (_m *MockGitRepository) Config() *v0alpha1.Repository { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Config") + } + + var r0 *v0alpha1.Repository + if rf, ok := ret.Get(0).(func() *v0alpha1.Repository); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.Repository) + } + } + + return r0 +} + +// MockGitRepository_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config' +type MockGitRepository_Config_Call struct { + *mock.Call +} + +// Config is a helper method to define mock.On call +func (_e *MockGitRepository_Expecter) Config() *MockGitRepository_Config_Call { + return &MockGitRepository_Config_Call{Call: _e.mock.On("Config")} +} + +func (_c *MockGitRepository_Config_Call) Run(run func()) *MockGitRepository_Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockGitRepository_Config_Call) Return(_a0 *v0alpha1.Repository) *MockGitRepository_Config_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGitRepository_Config_Call) RunAndReturn(run func() *v0alpha1.Repository) *MockGitRepository_Config_Call { + _c.Call.Return(run) + return _c +} + +// Create provides a mock function with given fields: ctx, path, ref, data, message +func (_m *MockGitRepository) Create(ctx context.Context, path string, ref string, data []byte, message string) error { + ret := _m.Called(ctx, path, ref, data, message) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, []byte, string) error); ok { + r0 = rf(ctx, path, ref, data, message) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockGitRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type MockGitRepository_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +// - data []byte +// - message string +func (_e *MockGitRepository_Expecter) Create(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockGitRepository_Create_Call { + return &MockGitRepository_Create_Call{Call: _e.mock.On("Create", ctx, path, ref, data, message)} +} + +func (_c *MockGitRepository_Create_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockGitRepository_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string)) + }) + return _c +} + +func (_c *MockGitRepository_Create_Call) Return(_a0 error) *MockGitRepository_Create_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGitRepository_Create_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockGitRepository_Create_Call { + _c.Call.Return(run) + return _c +} + +// Delete provides a mock function with given fields: ctx, path, ref, message +func (_m *MockGitRepository) Delete(ctx context.Context, path string, ref string, message string) error { + ret := _m.Called(ctx, path, ref, message) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { + r0 = rf(ctx, path, ref, message) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockGitRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type MockGitRepository_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +// - message string +func (_e *MockGitRepository_Expecter) Delete(ctx interface{}, path interface{}, ref interface{}, message interface{}) *MockGitRepository_Delete_Call { + return &MockGitRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, path, ref, message)} +} + +func (_c *MockGitRepository_Delete_Call) Run(run func(ctx context.Context, path string, ref string, message string)) *MockGitRepository_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string)) + }) + return _c +} + +func (_c *MockGitRepository_Delete_Call) Return(_a0 error) *MockGitRepository_Delete_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGitRepository_Delete_Call) RunAndReturn(run func(context.Context, string, string, string) error) *MockGitRepository_Delete_Call { + _c.Call.Return(run) + return _c +} + +// History provides a mock function with given fields: ctx, path, ref +func (_m *MockGitRepository) History(ctx context.Context, path string, ref string) ([]v0alpha1.HistoryItem, error) { + ret := _m.Called(ctx, path, ref) + + if len(ret) == 0 { + panic("no return value specified for History") + } + + var r0 []v0alpha1.HistoryItem + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]v0alpha1.HistoryItem, error)); ok { + return rf(ctx, path, ref) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) []v0alpha1.HistoryItem); ok { + r0 = rf(ctx, path, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]v0alpha1.HistoryItem) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, path, ref) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGitRepository_History_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'History' +type MockGitRepository_History_Call struct { + *mock.Call +} + +// History is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +func (_e *MockGitRepository_Expecter) History(ctx interface{}, path interface{}, ref interface{}) *MockGitRepository_History_Call { + return &MockGitRepository_History_Call{Call: _e.mock.On("History", ctx, path, ref)} +} + +func (_c *MockGitRepository_History_Call) Run(run func(ctx context.Context, path string, ref string)) *MockGitRepository_History_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockGitRepository_History_Call) Return(_a0 []v0alpha1.HistoryItem, _a1 error) *MockGitRepository_History_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGitRepository_History_Call) RunAndReturn(run func(context.Context, string, string) ([]v0alpha1.HistoryItem, error)) *MockGitRepository_History_Call { + _c.Call.Return(run) + return _c +} + +// LatestRef provides a mock function with given fields: ctx +func (_m *MockGitRepository) LatestRef(ctx context.Context) (string, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for LatestRef") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (string, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) string); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGitRepository_LatestRef_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestRef' +type MockGitRepository_LatestRef_Call struct { + *mock.Call +} + +// LatestRef is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockGitRepository_Expecter) LatestRef(ctx interface{}) *MockGitRepository_LatestRef_Call { + return &MockGitRepository_LatestRef_Call{Call: _e.mock.On("LatestRef", ctx)} +} + +func (_c *MockGitRepository_LatestRef_Call) Run(run func(ctx context.Context)) *MockGitRepository_LatestRef_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockGitRepository_LatestRef_Call) Return(_a0 string, _a1 error) *MockGitRepository_LatestRef_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGitRepository_LatestRef_Call) RunAndReturn(run func(context.Context) (string, error)) *MockGitRepository_LatestRef_Call { + _c.Call.Return(run) + return _c +} + +// Read provides a mock function with given fields: ctx, path, ref +func (_m *MockGitRepository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) { + ret := _m.Called(ctx, path, ref) + + if len(ret) == 0 { + panic("no return value specified for Read") + } + + var r0 *FileInfo + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*FileInfo, error)); ok { + return rf(ctx, path, ref) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *FileInfo); ok { + r0 = rf(ctx, path, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*FileInfo) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, path, ref) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGitRepository_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' +type MockGitRepository_Read_Call struct { + *mock.Call +} + +// Read is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +func (_e *MockGitRepository_Expecter) Read(ctx interface{}, path interface{}, ref interface{}) *MockGitRepository_Read_Call { + return &MockGitRepository_Read_Call{Call: _e.mock.On("Read", ctx, path, ref)} +} + +func (_c *MockGitRepository_Read_Call) Run(run func(ctx context.Context, path string, ref string)) *MockGitRepository_Read_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockGitRepository_Read_Call) Return(_a0 *FileInfo, _a1 error) *MockGitRepository_Read_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGitRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*FileInfo, error)) *MockGitRepository_Read_Call { + _c.Call.Return(run) + return _c +} + +// ReadTree provides a mock function with given fields: ctx, ref +func (_m *MockGitRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) { + ret := _m.Called(ctx, ref) + + if len(ret) == 0 { + panic("no return value specified for ReadTree") + } + + var r0 []FileTreeEntry + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) ([]FileTreeEntry, error)); ok { + return rf(ctx, ref) + } + if rf, ok := ret.Get(0).(func(context.Context, string) []FileTreeEntry); ok { + r0 = rf(ctx, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]FileTreeEntry) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, ref) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGitRepository_ReadTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadTree' +type MockGitRepository_ReadTree_Call struct { + *mock.Call +} + +// ReadTree is a helper method to define mock.On call +// - ctx context.Context +// - ref string +func (_e *MockGitRepository_Expecter) ReadTree(ctx interface{}, ref interface{}) *MockGitRepository_ReadTree_Call { + return &MockGitRepository_ReadTree_Call{Call: _e.mock.On("ReadTree", ctx, ref)} +} + +func (_c *MockGitRepository_ReadTree_Call) Run(run func(ctx context.Context, ref string)) *MockGitRepository_ReadTree_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockGitRepository_ReadTree_Call) Return(_a0 []FileTreeEntry, _a1 error) *MockGitRepository_ReadTree_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGitRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]FileTreeEntry, error)) *MockGitRepository_ReadTree_Call { + _c.Call.Return(run) + return _c +} + +// Test provides a mock function with given fields: ctx +func (_m *MockGitRepository) Test(ctx context.Context) (*v0alpha1.TestResults, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Test") + } + + var r0 *v0alpha1.TestResults + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*v0alpha1.TestResults, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *v0alpha1.TestResults); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.TestResults) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGitRepository_Test_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Test' +type MockGitRepository_Test_Call struct { + *mock.Call +} + +// Test is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockGitRepository_Expecter) Test(ctx interface{}) *MockGitRepository_Test_Call { + return &MockGitRepository_Test_Call{Call: _e.mock.On("Test", ctx)} +} + +func (_c *MockGitRepository_Test_Call) Run(run func(ctx context.Context)) *MockGitRepository_Test_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockGitRepository_Test_Call) Return(_a0 *v0alpha1.TestResults, _a1 error) *MockGitRepository_Test_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGitRepository_Test_Call) RunAndReturn(run func(context.Context) (*v0alpha1.TestResults, error)) *MockGitRepository_Test_Call { + _c.Call.Return(run) + return _c +} + +// URL provides a mock function with no fields +func (_m *MockGitRepository) URL() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for URL") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// MockGitRepository_URL_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'URL' +type MockGitRepository_URL_Call struct { + *mock.Call +} + +// URL is a helper method to define mock.On call +func (_e *MockGitRepository_Expecter) URL() *MockGitRepository_URL_Call { + return &MockGitRepository_URL_Call{Call: _e.mock.On("URL")} +} + +func (_c *MockGitRepository_URL_Call) Run(run func()) *MockGitRepository_URL_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockGitRepository_URL_Call) Return(_a0 string) *MockGitRepository_URL_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGitRepository_URL_Call) RunAndReturn(run func() string) *MockGitRepository_URL_Call { + _c.Call.Return(run) + return _c +} + +// Update provides a mock function with given fields: ctx, path, ref, data, message +func (_m *MockGitRepository) Update(ctx context.Context, path string, ref string, data []byte, message string) error { + ret := _m.Called(ctx, path, ref, data, message) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, []byte, string) error); ok { + r0 = rf(ctx, path, ref, data, message) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockGitRepository_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type MockGitRepository_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +// - data []byte +// - message string +func (_e *MockGitRepository_Expecter) Update(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockGitRepository_Update_Call { + return &MockGitRepository_Update_Call{Call: _e.mock.On("Update", ctx, path, ref, data, message)} +} + +func (_c *MockGitRepository_Update_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockGitRepository_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string)) + }) + return _c +} + +func (_c *MockGitRepository_Update_Call) Return(_a0 error) *MockGitRepository_Update_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGitRepository_Update_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockGitRepository_Update_Call { + _c.Call.Return(run) + return _c +} + +// Validate provides a mock function with no fields +func (_m *MockGitRepository) Validate() field.ErrorList { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Validate") + } + + var r0 field.ErrorList + if rf, ok := ret.Get(0).(func() field.ErrorList); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(field.ErrorList) + } + } + + return r0 +} + +// MockGitRepository_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type MockGitRepository_Validate_Call struct { + *mock.Call +} + +// Validate is a helper method to define mock.On call +func (_e *MockGitRepository_Expecter) Validate() *MockGitRepository_Validate_Call { + return &MockGitRepository_Validate_Call{Call: _e.mock.On("Validate")} +} + +func (_c *MockGitRepository_Validate_Call) Run(run func()) *MockGitRepository_Validate_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockGitRepository_Validate_Call) Return(_a0 field.ErrorList) *MockGitRepository_Validate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGitRepository_Validate_Call) RunAndReturn(run func() field.ErrorList) *MockGitRepository_Validate_Call { + _c.Call.Return(run) + return _c +} + +// Write provides a mock function with given fields: ctx, path, ref, data, message +func (_m *MockGitRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error { + ret := _m.Called(ctx, path, ref, data, message) + + if len(ret) == 0 { + panic("no return value specified for Write") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, []byte, string) error); ok { + r0 = rf(ctx, path, ref, data, message) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockGitRepository_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' +type MockGitRepository_Write_Call struct { + *mock.Call +} + +// Write is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +// - data []byte +// - message string +func (_e *MockGitRepository_Expecter) Write(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockGitRepository_Write_Call { + return &MockGitRepository_Write_Call{Call: _e.mock.On("Write", ctx, path, ref, data, message)} +} + +func (_c *MockGitRepository_Write_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockGitRepository_Write_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string)) + }) + return _c +} + +func (_c *MockGitRepository_Write_Call) Return(_a0 error) *MockGitRepository_Write_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGitRepository_Write_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockGitRepository_Write_Call { + _c.Call.Return(run) + return _c +} + +// NewMockGitRepository creates a new instance of MockGitRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockGitRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *MockGitRepository { + mock := &MockGitRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/repository/github.go b/pkg/registry/apis/provisioning/repository/github.go index 7265568d08e..42c78e55c8e 100644 --- a/pkg/registry/apis/provisioning/repository/github.go +++ b/pkg/registry/apis/provisioning/repository/github.go @@ -35,6 +35,8 @@ type githubRepository struct { // GithubRepository is an interface that combines all repository capabilities // needed for GitHub repositories. + +//go:generate mockery --name GithubRepository --structname MockGithubRepository --inpackage --filename github_repository_mock.go --with-expecter type GithubRepository interface { Repository Versioned @@ -54,7 +56,7 @@ func NewGitHub( secrets secrets.Service, cloneFn CloneFn, ) (GithubRepository, error) { - owner, repo, err := parseOwnerRepo(config.Spec.GitHub.URL) + owner, repo, err := ParseOwnerRepoGithub(config.Spec.GitHub.URL) if err != nil { return nil, fmt.Errorf("parse owner and repo: %w", err) } @@ -104,7 +106,7 @@ func (r *githubRepository) Validate() (list field.ErrorList) { if gh.URL == "" { list = append(list, field.Required(field.NewPath("spec", "github", "url"), "a github url is required")) } else { - _, _, err := parseOwnerRepo(gh.URL) + _, _, err := ParseOwnerRepoGithub(gh.URL) if err != nil { list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, err.Error())) } else if !strings.HasPrefix(gh.URL, "https://github.com/") { @@ -113,7 +115,7 @@ func (r *githubRepository) Validate() (list field.ErrorList) { } if gh.Branch == "" { list = append(list, field.Required(field.NewPath("spec", "github", "branch"), "a github branch is required")) - } else if !isValidGitBranchName(gh.Branch) { + } else if !IsValidGitBranchName(gh.Branch) { list = append(list, field.Invalid(field.NewPath("spec", "github", "branch"), gh.Branch, "invalid branch name")) } // TODO: Use two fields for token @@ -132,7 +134,7 @@ func (r *githubRepository) Validate() (list field.ErrorList) { return list } -func parseOwnerRepo(giturl string) (owner string, repo string, err error) { +func ParseOwnerRepoGithub(giturl string) (owner string, repo string, err error) { parsed, e := url.Parse(strings.TrimSuffix(giturl, ".git")) if e != nil { err = e @@ -160,7 +162,7 @@ func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, } url := r.config.Spec.GitHub.URL - owner, repo, err := parseOwnerRepo(url) + owner, repo, err := ParseOwnerRepoGithub(url) if err != nil { return fromFieldError(field.Invalid( field.NewPath("spec", "github", "url"), url, err.Error())), nil @@ -459,7 +461,7 @@ func (r *githubRepository) History(ctx context.Context, path, ref string) ([]pro // it does not cover all cases as positive lookaheads are not supported in Go's regexp var basicGitBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\/\.]+$`) -// isValidGitBranchName checks if a branch name is valid. +// IsValidGitBranchName checks if a branch name is valid. // It uses the following regexp `^[a-zA-Z0-9\-\_\/\.]+$` to validate the branch name with some additional checks that must satisfy the following rules: // 1. The branch name must have at least one character and must not be empty. // 2. The branch name cannot start with `/` or end with `/`, `.`, or whitespace. @@ -467,7 +469,7 @@ var basicGitBranchNameRegex = regexp.MustCompile(`^[a-zA-Z0-9\-\_\/\.]+$`) // 4. The branch name cannot contain consecutive dots (`..`). // 5. The branch name cannot contain `@{`. // 6. The branch name cannot include the following characters: `~`, `^`, `:`, `?`, `*`, `[`, `\`, or `]`. -func isValidGitBranchName(branch string) bool { +func IsValidGitBranchName(branch string) bool { if !basicGitBranchNameRegex.MatchString(branch) { return false } @@ -483,7 +485,7 @@ func isValidGitBranchName(branch string) bool { } func (r *githubRepository) ensureBranchExists(ctx context.Context, branchName string) error { - if !isValidGitBranchName(branchName) { + if !IsValidGitBranchName(branchName) { return &apierrors.StatusError{ ErrStatus: metav1.Status{ Code: http.StatusBadRequest, diff --git a/pkg/registry/apis/provisioning/repository/github_repository_mock.go b/pkg/registry/apis/provisioning/repository/github_repository_mock.go new file mode 100644 index 00000000000..8d4dd2d58ff --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/github_repository_mock.go @@ -0,0 +1,942 @@ +// Code generated by mockery v2.52.4. DO NOT EDIT. + +package repository + +import ( + context "context" + + github "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" + field "k8s.io/apimachinery/pkg/util/validation/field" + + mock "github.com/stretchr/testify/mock" + + v0alpha1 "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" +) + +// MockGithubRepository is an autogenerated mock type for the GithubRepository type +type MockGithubRepository struct { + mock.Mock +} + +type MockGithubRepository_Expecter struct { + mock *mock.Mock +} + +func (_m *MockGithubRepository) EXPECT() *MockGithubRepository_Expecter { + return &MockGithubRepository_Expecter{mock: &_m.Mock} +} + +// Client provides a mock function with no fields +func (_m *MockGithubRepository) Client() github.Client { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Client") + } + + var r0 github.Client + if rf, ok := ret.Get(0).(func() github.Client); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(github.Client) + } + } + + return r0 +} + +// MockGithubRepository_Client_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Client' +type MockGithubRepository_Client_Call struct { + *mock.Call +} + +// Client is a helper method to define mock.On call +func (_e *MockGithubRepository_Expecter) Client() *MockGithubRepository_Client_Call { + return &MockGithubRepository_Client_Call{Call: _e.mock.On("Client")} +} + +func (_c *MockGithubRepository_Client_Call) Run(run func()) *MockGithubRepository_Client_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockGithubRepository_Client_Call) Return(_a0 github.Client) *MockGithubRepository_Client_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubRepository_Client_Call) RunAndReturn(run func() github.Client) *MockGithubRepository_Client_Call { + _c.Call.Return(run) + return _c +} + +// Clone provides a mock function with given fields: ctx, opts +func (_m *MockGithubRepository) Clone(ctx context.Context, opts CloneOptions) (ClonedRepository, error) { + ret := _m.Called(ctx, opts) + + if len(ret) == 0 { + panic("no return value specified for Clone") + } + + var r0 ClonedRepository + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) (ClonedRepository, error)); ok { + return rf(ctx, opts) + } + if rf, ok := ret.Get(0).(func(context.Context, CloneOptions) ClonedRepository); ok { + r0 = rf(ctx, opts) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(ClonedRepository) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, CloneOptions) error); ok { + r1 = rf(ctx, opts) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGithubRepository_Clone_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Clone' +type MockGithubRepository_Clone_Call struct { + *mock.Call +} + +// Clone is a helper method to define mock.On call +// - ctx context.Context +// - opts CloneOptions +func (_e *MockGithubRepository_Expecter) Clone(ctx interface{}, opts interface{}) *MockGithubRepository_Clone_Call { + return &MockGithubRepository_Clone_Call{Call: _e.mock.On("Clone", ctx, opts)} +} + +func (_c *MockGithubRepository_Clone_Call) Run(run func(ctx context.Context, opts CloneOptions)) *MockGithubRepository_Clone_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(CloneOptions)) + }) + return _c +} + +func (_c *MockGithubRepository_Clone_Call) Return(_a0 ClonedRepository, _a1 error) *MockGithubRepository_Clone_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGithubRepository_Clone_Call) RunAndReturn(run func(context.Context, CloneOptions) (ClonedRepository, error)) *MockGithubRepository_Clone_Call { + _c.Call.Return(run) + return _c +} + +// CompareFiles provides a mock function with given fields: ctx, base, ref +func (_m *MockGithubRepository) CompareFiles(ctx context.Context, base string, ref string) ([]VersionedFileChange, error) { + ret := _m.Called(ctx, base, ref) + + if len(ret) == 0 { + panic("no return value specified for CompareFiles") + } + + var r0 []VersionedFileChange + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]VersionedFileChange, error)); ok { + return rf(ctx, base, ref) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) []VersionedFileChange); ok { + r0 = rf(ctx, base, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]VersionedFileChange) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, base, ref) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGithubRepository_CompareFiles_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CompareFiles' +type MockGithubRepository_CompareFiles_Call struct { + *mock.Call +} + +// CompareFiles is a helper method to define mock.On call +// - ctx context.Context +// - base string +// - ref string +func (_e *MockGithubRepository_Expecter) CompareFiles(ctx interface{}, base interface{}, ref interface{}) *MockGithubRepository_CompareFiles_Call { + return &MockGithubRepository_CompareFiles_Call{Call: _e.mock.On("CompareFiles", ctx, base, ref)} +} + +func (_c *MockGithubRepository_CompareFiles_Call) Run(run func(ctx context.Context, base string, ref string)) *MockGithubRepository_CompareFiles_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockGithubRepository_CompareFiles_Call) Return(_a0 []VersionedFileChange, _a1 error) *MockGithubRepository_CompareFiles_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGithubRepository_CompareFiles_Call) RunAndReturn(run func(context.Context, string, string) ([]VersionedFileChange, error)) *MockGithubRepository_CompareFiles_Call { + _c.Call.Return(run) + return _c +} + +// Config provides a mock function with no fields +func (_m *MockGithubRepository) Config() *v0alpha1.Repository { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Config") + } + + var r0 *v0alpha1.Repository + if rf, ok := ret.Get(0).(func() *v0alpha1.Repository); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.Repository) + } + } + + return r0 +} + +// MockGithubRepository_Config_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Config' +type MockGithubRepository_Config_Call struct { + *mock.Call +} + +// Config is a helper method to define mock.On call +func (_e *MockGithubRepository_Expecter) Config() *MockGithubRepository_Config_Call { + return &MockGithubRepository_Config_Call{Call: _e.mock.On("Config")} +} + +func (_c *MockGithubRepository_Config_Call) Run(run func()) *MockGithubRepository_Config_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockGithubRepository_Config_Call) Return(_a0 *v0alpha1.Repository) *MockGithubRepository_Config_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubRepository_Config_Call) RunAndReturn(run func() *v0alpha1.Repository) *MockGithubRepository_Config_Call { + _c.Call.Return(run) + return _c +} + +// Create provides a mock function with given fields: ctx, path, ref, data, message +func (_m *MockGithubRepository) Create(ctx context.Context, path string, ref string, data []byte, message string) error { + ret := _m.Called(ctx, path, ref, data, message) + + if len(ret) == 0 { + panic("no return value specified for Create") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, []byte, string) error); ok { + r0 = rf(ctx, path, ref, data, message) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockGithubRepository_Create_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Create' +type MockGithubRepository_Create_Call struct { + *mock.Call +} + +// Create is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +// - data []byte +// - message string +func (_e *MockGithubRepository_Expecter) Create(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockGithubRepository_Create_Call { + return &MockGithubRepository_Create_Call{Call: _e.mock.On("Create", ctx, path, ref, data, message)} +} + +func (_c *MockGithubRepository_Create_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockGithubRepository_Create_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string)) + }) + return _c +} + +func (_c *MockGithubRepository_Create_Call) Return(_a0 error) *MockGithubRepository_Create_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubRepository_Create_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockGithubRepository_Create_Call { + _c.Call.Return(run) + return _c +} + +// Delete provides a mock function with given fields: ctx, path, ref, message +func (_m *MockGithubRepository) Delete(ctx context.Context, path string, ref string, message string) error { + ret := _m.Called(ctx, path, ref, message) + + if len(ret) == 0 { + panic("no return value specified for Delete") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) error); ok { + r0 = rf(ctx, path, ref, message) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockGithubRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete' +type MockGithubRepository_Delete_Call struct { + *mock.Call +} + +// Delete is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +// - message string +func (_e *MockGithubRepository_Expecter) Delete(ctx interface{}, path interface{}, ref interface{}, message interface{}) *MockGithubRepository_Delete_Call { + return &MockGithubRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, path, ref, message)} +} + +func (_c *MockGithubRepository_Delete_Call) Run(run func(ctx context.Context, path string, ref string, message string)) *MockGithubRepository_Delete_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].(string)) + }) + return _c +} + +func (_c *MockGithubRepository_Delete_Call) Return(_a0 error) *MockGithubRepository_Delete_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubRepository_Delete_Call) RunAndReturn(run func(context.Context, string, string, string) error) *MockGithubRepository_Delete_Call { + _c.Call.Return(run) + return _c +} + +// History provides a mock function with given fields: ctx, path, ref +func (_m *MockGithubRepository) History(ctx context.Context, path string, ref string) ([]v0alpha1.HistoryItem, error) { + ret := _m.Called(ctx, path, ref) + + if len(ret) == 0 { + panic("no return value specified for History") + } + + var r0 []v0alpha1.HistoryItem + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) ([]v0alpha1.HistoryItem, error)); ok { + return rf(ctx, path, ref) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) []v0alpha1.HistoryItem); ok { + r0 = rf(ctx, path, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]v0alpha1.HistoryItem) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, path, ref) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGithubRepository_History_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'History' +type MockGithubRepository_History_Call struct { + *mock.Call +} + +// History is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +func (_e *MockGithubRepository_Expecter) History(ctx interface{}, path interface{}, ref interface{}) *MockGithubRepository_History_Call { + return &MockGithubRepository_History_Call{Call: _e.mock.On("History", ctx, path, ref)} +} + +func (_c *MockGithubRepository_History_Call) Run(run func(ctx context.Context, path string, ref string)) *MockGithubRepository_History_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockGithubRepository_History_Call) Return(_a0 []v0alpha1.HistoryItem, _a1 error) *MockGithubRepository_History_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGithubRepository_History_Call) RunAndReturn(run func(context.Context, string, string) ([]v0alpha1.HistoryItem, error)) *MockGithubRepository_History_Call { + _c.Call.Return(run) + return _c +} + +// LatestRef provides a mock function with given fields: ctx +func (_m *MockGithubRepository) LatestRef(ctx context.Context) (string, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for LatestRef") + } + + var r0 string + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (string, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) string); ok { + r0 = rf(ctx) + } else { + r0 = ret.Get(0).(string) + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGithubRepository_LatestRef_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'LatestRef' +type MockGithubRepository_LatestRef_Call struct { + *mock.Call +} + +// LatestRef is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockGithubRepository_Expecter) LatestRef(ctx interface{}) *MockGithubRepository_LatestRef_Call { + return &MockGithubRepository_LatestRef_Call{Call: _e.mock.On("LatestRef", ctx)} +} + +func (_c *MockGithubRepository_LatestRef_Call) Run(run func(ctx context.Context)) *MockGithubRepository_LatestRef_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockGithubRepository_LatestRef_Call) Return(_a0 string, _a1 error) *MockGithubRepository_LatestRef_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGithubRepository_LatestRef_Call) RunAndReturn(run func(context.Context) (string, error)) *MockGithubRepository_LatestRef_Call { + _c.Call.Return(run) + return _c +} + +// Owner provides a mock function with no fields +func (_m *MockGithubRepository) Owner() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Owner") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// MockGithubRepository_Owner_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Owner' +type MockGithubRepository_Owner_Call struct { + *mock.Call +} + +// Owner is a helper method to define mock.On call +func (_e *MockGithubRepository_Expecter) Owner() *MockGithubRepository_Owner_Call { + return &MockGithubRepository_Owner_Call{Call: _e.mock.On("Owner")} +} + +func (_c *MockGithubRepository_Owner_Call) Run(run func()) *MockGithubRepository_Owner_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockGithubRepository_Owner_Call) Return(_a0 string) *MockGithubRepository_Owner_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubRepository_Owner_Call) RunAndReturn(run func() string) *MockGithubRepository_Owner_Call { + _c.Call.Return(run) + return _c +} + +// Read provides a mock function with given fields: ctx, path, ref +func (_m *MockGithubRepository) Read(ctx context.Context, path string, ref string) (*FileInfo, error) { + ret := _m.Called(ctx, path, ref) + + if len(ret) == 0 { + panic("no return value specified for Read") + } + + var r0 *FileInfo + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string, string) (*FileInfo, error)); ok { + return rf(ctx, path, ref) + } + if rf, ok := ret.Get(0).(func(context.Context, string, string) *FileInfo); ok { + r0 = rf(ctx, path, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*FileInfo) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string, string) error); ok { + r1 = rf(ctx, path, ref) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGithubRepository_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' +type MockGithubRepository_Read_Call struct { + *mock.Call +} + +// Read is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +func (_e *MockGithubRepository_Expecter) Read(ctx interface{}, path interface{}, ref interface{}) *MockGithubRepository_Read_Call { + return &MockGithubRepository_Read_Call{Call: _e.mock.On("Read", ctx, path, ref)} +} + +func (_c *MockGithubRepository_Read_Call) Run(run func(ctx context.Context, path string, ref string)) *MockGithubRepository_Read_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string)) + }) + return _c +} + +func (_c *MockGithubRepository_Read_Call) Return(_a0 *FileInfo, _a1 error) *MockGithubRepository_Read_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGithubRepository_Read_Call) RunAndReturn(run func(context.Context, string, string) (*FileInfo, error)) *MockGithubRepository_Read_Call { + _c.Call.Return(run) + return _c +} + +// ReadTree provides a mock function with given fields: ctx, ref +func (_m *MockGithubRepository) ReadTree(ctx context.Context, ref string) ([]FileTreeEntry, error) { + ret := _m.Called(ctx, ref) + + if len(ret) == 0 { + panic("no return value specified for ReadTree") + } + + var r0 []FileTreeEntry + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, string) ([]FileTreeEntry, error)); ok { + return rf(ctx, ref) + } + if rf, ok := ret.Get(0).(func(context.Context, string) []FileTreeEntry); ok { + r0 = rf(ctx, ref) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]FileTreeEntry) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = rf(ctx, ref) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGithubRepository_ReadTree_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ReadTree' +type MockGithubRepository_ReadTree_Call struct { + *mock.Call +} + +// ReadTree is a helper method to define mock.On call +// - ctx context.Context +// - ref string +func (_e *MockGithubRepository_Expecter) ReadTree(ctx interface{}, ref interface{}) *MockGithubRepository_ReadTree_Call { + return &MockGithubRepository_ReadTree_Call{Call: _e.mock.On("ReadTree", ctx, ref)} +} + +func (_c *MockGithubRepository_ReadTree_Call) Run(run func(ctx context.Context, ref string)) *MockGithubRepository_ReadTree_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string)) + }) + return _c +} + +func (_c *MockGithubRepository_ReadTree_Call) Return(_a0 []FileTreeEntry, _a1 error) *MockGithubRepository_ReadTree_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGithubRepository_ReadTree_Call) RunAndReturn(run func(context.Context, string) ([]FileTreeEntry, error)) *MockGithubRepository_ReadTree_Call { + _c.Call.Return(run) + return _c +} + +// Repo provides a mock function with no fields +func (_m *MockGithubRepository) Repo() string { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Repo") + } + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// MockGithubRepository_Repo_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Repo' +type MockGithubRepository_Repo_Call struct { + *mock.Call +} + +// Repo is a helper method to define mock.On call +func (_e *MockGithubRepository_Expecter) Repo() *MockGithubRepository_Repo_Call { + return &MockGithubRepository_Repo_Call{Call: _e.mock.On("Repo")} +} + +func (_c *MockGithubRepository_Repo_Call) Run(run func()) *MockGithubRepository_Repo_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockGithubRepository_Repo_Call) Return(_a0 string) *MockGithubRepository_Repo_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubRepository_Repo_Call) RunAndReturn(run func() string) *MockGithubRepository_Repo_Call { + _c.Call.Return(run) + return _c +} + +// ResourceURLs provides a mock function with given fields: ctx, file +func (_m *MockGithubRepository) ResourceURLs(ctx context.Context, file *FileInfo) (*v0alpha1.ResourceURLs, error) { + ret := _m.Called(ctx, file) + + if len(ret) == 0 { + panic("no return value specified for ResourceURLs") + } + + var r0 *v0alpha1.ResourceURLs + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, *FileInfo) (*v0alpha1.ResourceURLs, error)); ok { + return rf(ctx, file) + } + if rf, ok := ret.Get(0).(func(context.Context, *FileInfo) *v0alpha1.ResourceURLs); ok { + r0 = rf(ctx, file) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.ResourceURLs) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, *FileInfo) error); ok { + r1 = rf(ctx, file) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGithubRepository_ResourceURLs_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ResourceURLs' +type MockGithubRepository_ResourceURLs_Call struct { + *mock.Call +} + +// ResourceURLs is a helper method to define mock.On call +// - ctx context.Context +// - file *FileInfo +func (_e *MockGithubRepository_Expecter) ResourceURLs(ctx interface{}, file interface{}) *MockGithubRepository_ResourceURLs_Call { + return &MockGithubRepository_ResourceURLs_Call{Call: _e.mock.On("ResourceURLs", ctx, file)} +} + +func (_c *MockGithubRepository_ResourceURLs_Call) Run(run func(ctx context.Context, file *FileInfo)) *MockGithubRepository_ResourceURLs_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(*FileInfo)) + }) + return _c +} + +func (_c *MockGithubRepository_ResourceURLs_Call) Return(_a0 *v0alpha1.ResourceURLs, _a1 error) *MockGithubRepository_ResourceURLs_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGithubRepository_ResourceURLs_Call) RunAndReturn(run func(context.Context, *FileInfo) (*v0alpha1.ResourceURLs, error)) *MockGithubRepository_ResourceURLs_Call { + _c.Call.Return(run) + return _c +} + +// Test provides a mock function with given fields: ctx +func (_m *MockGithubRepository) Test(ctx context.Context) (*v0alpha1.TestResults, error) { + ret := _m.Called(ctx) + + if len(ret) == 0 { + panic("no return value specified for Test") + } + + var r0 *v0alpha1.TestResults + var r1 error + if rf, ok := ret.Get(0).(func(context.Context) (*v0alpha1.TestResults, error)); ok { + return rf(ctx) + } + if rf, ok := ret.Get(0).(func(context.Context) *v0alpha1.TestResults); ok { + r0 = rf(ctx) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*v0alpha1.TestResults) + } + } + + if rf, ok := ret.Get(1).(func(context.Context) error); ok { + r1 = rf(ctx) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockGithubRepository_Test_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Test' +type MockGithubRepository_Test_Call struct { + *mock.Call +} + +// Test is a helper method to define mock.On call +// - ctx context.Context +func (_e *MockGithubRepository_Expecter) Test(ctx interface{}) *MockGithubRepository_Test_Call { + return &MockGithubRepository_Test_Call{Call: _e.mock.On("Test", ctx)} +} + +func (_c *MockGithubRepository_Test_Call) Run(run func(ctx context.Context)) *MockGithubRepository_Test_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context)) + }) + return _c +} + +func (_c *MockGithubRepository_Test_Call) Return(_a0 *v0alpha1.TestResults, _a1 error) *MockGithubRepository_Test_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockGithubRepository_Test_Call) RunAndReturn(run func(context.Context) (*v0alpha1.TestResults, error)) *MockGithubRepository_Test_Call { + _c.Call.Return(run) + return _c +} + +// Update provides a mock function with given fields: ctx, path, ref, data, message +func (_m *MockGithubRepository) Update(ctx context.Context, path string, ref string, data []byte, message string) error { + ret := _m.Called(ctx, path, ref, data, message) + + if len(ret) == 0 { + panic("no return value specified for Update") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, []byte, string) error); ok { + r0 = rf(ctx, path, ref, data, message) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockGithubRepository_Update_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Update' +type MockGithubRepository_Update_Call struct { + *mock.Call +} + +// Update is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +// - data []byte +// - message string +func (_e *MockGithubRepository_Expecter) Update(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockGithubRepository_Update_Call { + return &MockGithubRepository_Update_Call{Call: _e.mock.On("Update", ctx, path, ref, data, message)} +} + +func (_c *MockGithubRepository_Update_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockGithubRepository_Update_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string)) + }) + return _c +} + +func (_c *MockGithubRepository_Update_Call) Return(_a0 error) *MockGithubRepository_Update_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubRepository_Update_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockGithubRepository_Update_Call { + _c.Call.Return(run) + return _c +} + +// Validate provides a mock function with no fields +func (_m *MockGithubRepository) Validate() field.ErrorList { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Validate") + } + + var r0 field.ErrorList + if rf, ok := ret.Get(0).(func() field.ErrorList); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(field.ErrorList) + } + } + + return r0 +} + +// MockGithubRepository_Validate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Validate' +type MockGithubRepository_Validate_Call struct { + *mock.Call +} + +// Validate is a helper method to define mock.On call +func (_e *MockGithubRepository_Expecter) Validate() *MockGithubRepository_Validate_Call { + return &MockGithubRepository_Validate_Call{Call: _e.mock.On("Validate")} +} + +func (_c *MockGithubRepository_Validate_Call) Run(run func()) *MockGithubRepository_Validate_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockGithubRepository_Validate_Call) Return(_a0 field.ErrorList) *MockGithubRepository_Validate_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubRepository_Validate_Call) RunAndReturn(run func() field.ErrorList) *MockGithubRepository_Validate_Call { + _c.Call.Return(run) + return _c +} + +// Write provides a mock function with given fields: ctx, path, ref, data, message +func (_m *MockGithubRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error { + ret := _m.Called(ctx, path, ref, data, message) + + if len(ret) == 0 { + panic("no return value specified for Write") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, []byte, string) error); ok { + r0 = rf(ctx, path, ref, data, message) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// MockGithubRepository_Write_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Write' +type MockGithubRepository_Write_Call struct { + *mock.Call +} + +// Write is a helper method to define mock.On call +// - ctx context.Context +// - path string +// - ref string +// - data []byte +// - message string +func (_e *MockGithubRepository_Expecter) Write(ctx interface{}, path interface{}, ref interface{}, data interface{}, message interface{}) *MockGithubRepository_Write_Call { + return &MockGithubRepository_Write_Call{Call: _e.mock.On("Write", ctx, path, ref, data, message)} +} + +func (_c *MockGithubRepository_Write_Call) Run(run func(ctx context.Context, path string, ref string, data []byte, message string)) *MockGithubRepository_Write_Call { + _c.Call.Run(func(args mock.Arguments) { + run(args[0].(context.Context), args[1].(string), args[2].(string), args[3].([]byte), args[4].(string)) + }) + return _c +} + +func (_c *MockGithubRepository_Write_Call) Return(_a0 error) *MockGithubRepository_Write_Call { + _c.Call.Return(_a0) + return _c +} + +func (_c *MockGithubRepository_Write_Call) RunAndReturn(run func(context.Context, string, string, []byte, string) error) *MockGithubRepository_Write_Call { + _c.Call.Return(run) + return _c +} + +// NewMockGithubRepository creates a new instance of MockGithubRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockGithubRepository(t interface { + mock.TestingT + Cleanup(func()) +}) *MockGithubRepository { + mock := &MockGithubRepository{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/registry/apis/provisioning/repository/github_test.go b/pkg/registry/apis/provisioning/repository/github_test.go index 5974cdfc0aa..3c0e499713e 100644 --- a/pkg/registry/apis/provisioning/repository/github_test.go +++ b/pkg/registry/apis/provisioning/repository/github_test.go @@ -212,7 +212,7 @@ func TestIsValidGitBranchName(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - assert.Equal(t, tt.expected, isValidGitBranchName(tt.branch)) + assert.Equal(t, tt.expected, IsValidGitBranchName(tt.branch)) }) } } @@ -661,7 +661,7 @@ func TestGitHubRepository_Test(t *testing.T) { // If the config has a different URL, parse and set the owner/repo if tt.config.Spec.GitHub.URL != "https://github.com/grafana/grafana" { - owner, githubRepo, _ := parseOwnerRepo(tt.config.Spec.GitHub.URL) + owner, githubRepo, _ := ParseOwnerRepoGithub(tt.config.Spec.GitHub.URL) repo.owner = owner repo.repo = githubRepo } diff --git a/pkg/registry/apis/provisioning/repository/nanogit/git.go b/pkg/registry/apis/provisioning/repository/nanogit/git.go new file mode 100644 index 00000000000..666bcf87f7e --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/nanogit/git.go @@ -0,0 +1,728 @@ +package nanogit + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "time" + + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/grafana/grafana-app-sdk/logging" + provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets" + "github.com/grafana/nanogit" + "github.com/grafana/nanogit/log" + "github.com/grafana/nanogit/options" + "github.com/grafana/nanogit/protocol" + "github.com/grafana/nanogit/protocol/hash" +) + +type RepositoryConfig struct { + URL string + Branch string + Token string + EncryptedToken []byte + Path string +} + +// Make sure all public functions of this struct call the (*gitRepository).logger function, to ensure the Git repo details are included. +type gitRepository struct { + config *provisioning.Repository + gitConfig RepositoryConfig + client nanogit.Client + secrets secrets.Service +} + +func NewGitRepository( + ctx context.Context, + secrets secrets.Service, + config *provisioning.Repository, + gitConfig RepositoryConfig, +) (repository.GitRepository, error) { + if gitConfig.Token == "" { + decrypted, err := secrets.Decrypt(ctx, gitConfig.EncryptedToken) + if err != nil { + return nil, fmt.Errorf("decrypt token: %w", err) + } + gitConfig.Token = string(decrypted) + } + + // Create nanogit client with authentication + client, err := nanogit.NewHTTPClient( + gitConfig.URL, + options.WithBasicAuth("git", gitConfig.Token), + ) + if err != nil { + return nil, fmt.Errorf("create nanogit client: %w", err) + } + + return &gitRepository{ + config: config, + gitConfig: gitConfig, + client: client, + secrets: secrets, + }, nil +} + +func (r *gitRepository) URL() string { + return r.gitConfig.URL +} + +func (r *gitRepository) Branch() string { + return r.gitConfig.Branch +} + +func (r *gitRepository) Config() *provisioning.Repository { + return r.config +} + +// Validate implements provisioning.Repository. +func (r *gitRepository) Validate() (list field.ErrorList) { + cfg := r.gitConfig + + t := string(r.config.Spec.Type) + if cfg.URL == "" { + list = append(list, field.Required(field.NewPath("spec", t, "url"), "a git url is required")) + } else { + if !isValidGitURL(cfg.URL) { + list = append(list, field.Invalid(field.NewPath("spec", t, "url"), cfg.URL, "invalid git URL format")) + } + } + if cfg.Branch == "" { + list = append(list, field.Required(field.NewPath("spec", t, "branch"), "a git branch is required")) + } else if !repository.IsValidGitBranchName(cfg.Branch) { + list = append(list, field.Invalid(field.NewPath("spec", t, "branch"), cfg.Branch, "invalid branch name")) + } + + if cfg.Token == "" && len(cfg.EncryptedToken) == 0 { + list = append(list, field.Required(field.NewPath("spec", t, "token"), "a git access token is required")) + } + + if err := safepath.IsSafe(cfg.Path); err != nil { + list = append(list, field.Invalid(field.NewPath("spec", t, "path"), cfg.Path, err.Error())) + } + + if safepath.IsAbs(cfg.Path) { + list = append(list, field.Invalid(field.NewPath("spec", t, "path"), cfg.Path, "path must be relative")) + } + + return list +} + +func isValidGitURL(gitURL string) bool { + // Parse URL + parsed, err := url.Parse(gitURL) + if err != nil { + return false + } + + // Must be HTTPS + if parsed.Scheme != "https" { + return false + } + + // Must have a host + if parsed.Host == "" { + return false + } + + // Must have a path + if parsed.Path == "" || parsed.Path == "/" { + return false + } + + return true +} + +// Test implements provisioning.Repository. +func (r *gitRepository) Test(ctx context.Context) (*provisioning.TestResults, error) { + ctx, _ = r.logger(ctx, "") + + t := string(r.config.Spec.Type) + + if ok, err := r.client.IsAuthorized(ctx); err != nil || !ok { + detail := "not authorized" + if err != nil { + detail = fmt.Sprintf("failed check if authorized: %v", err) + } + + return &provisioning.TestResults{ + Code: http.StatusBadRequest, + Success: false, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseTypeFieldValueInvalid, + Field: field.NewPath("spec", t, "token").String(), + Detail: detail, + }}}, nil + } + + if ok, err := r.client.RepoExists(ctx); err != nil || !ok { + detail := "repository not found" + if err != nil { + detail = fmt.Sprintf("failed check if repository exists: %v", err) + } + + return &provisioning.TestResults{ + Code: http.StatusBadRequest, + Success: false, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseTypeFieldValueInvalid, + Field: field.NewPath("spec", t, "url").String(), + Detail: detail, + }}}, nil + } + + // Test basic connectivity by getting the branch reference + _, err := r.client.GetRef(ctx, fmt.Sprintf("refs/heads/%s", r.gitConfig.Branch)) + if err != nil { + detail := "branch not found" + if errors.Is(err, nanogit.ErrObjectNotFound) { + return &provisioning.TestResults{ + Code: http.StatusBadRequest, + Success: false, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseTypeFieldValueInvalid, + Field: field.NewPath("spec", t, "branch").String(), + Detail: detail, + }}}, nil + } + + detail = fmt.Sprintf("failed to check if branch exists: %v", err) + + return &provisioning.TestResults{ + Code: http.StatusBadRequest, + Success: false, + Errors: []provisioning.ErrorDetails{{ + Type: metav1.CauseTypeFieldValueInvalid, + Field: field.NewPath("spec", t, "branch").String(), + Detail: detail, + }}}, nil + } + + return &provisioning.TestResults{ + Code: http.StatusOK, + Success: true, + }, nil +} + +// Read implements provisioning.Repository. +func (r *gitRepository) Read(ctx context.Context, filePath, ref string) (*repository.FileInfo, error) { + ctx, _ = r.logger(ctx, ref) + finalPath := safepath.Join(r.gitConfig.Path, filePath) + + // Resolve ref to commit hash + refHash, err := r.resolveRefToHash(ctx, ref) + if err != nil { + return nil, err + } + + // get root hash + // TODO: Fix GetTree in nanogit as it does not work commit hash + commit, err := r.client.GetCommit(ctx, refHash) + if err != nil { + return nil, fmt.Errorf("get commit: %w", err) + } + + // Check if the path represents a directory + if safepath.IsDir(filePath) { + tree, err := r.client.GetTreeByPath(ctx, commit.Tree, finalPath) + if err != nil { + if errors.Is(err, nanogit.ErrObjectNotFound) { + return nil, repository.ErrFileNotFound + } + + return nil, fmt.Errorf("get tree by path: %w", err) + } + + return &repository.FileInfo{ + Path: filePath, + Ref: refHash.String(), + Hash: tree.Hash.String(), + }, nil + } + + blob, err := r.client.GetBlobByPath(ctx, commit.Tree, finalPath) + if err != nil { + if errors.Is(err, nanogit.ErrObjectNotFound) { + return nil, repository.ErrFileNotFound + } + + return nil, fmt.Errorf("read blob: %w", err) + } + + return &repository.FileInfo{ + Path: filePath, + Ref: ref, + Data: blob.Content, + Hash: blob.Hash.String(), + }, nil +} + +func (r *gitRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) { + ctx, _ = r.logger(ctx, ref) + + // Resolve ref to commit hash + refHash, err := r.resolveRefToHash(ctx, ref) + if err != nil { + return nil, err + } + + // Get flat tree using nanogit's GetFlatTree + tree, err := r.client.GetFlatTree(ctx, refHash) + if err != nil { + if errors.Is(err, nanogit.ErrObjectNotFound) { + return nil, repository.ErrRefNotFound + } + return nil, fmt.Errorf("get flat tree: %w", err) + } + + entries := make([]repository.FileTreeEntry, 0, len(tree.Entries)) + for _, entry := range tree.Entries { + isBlob := entry.Type == protocol.ObjectTypeBlob + // Apply path prefix filtering + relativePath, err := safepath.RelativeTo(entry.Path, r.gitConfig.Path) + if err != nil { + // File is outside configured path, skip it + continue + } + + filePath := relativePath + if !isBlob && !safepath.IsDir(filePath) { + filePath = filePath + "/" + } + + converted := repository.FileTreeEntry{ + Path: filePath, + // TODO: Remove size from repository.FileTreeEntry. We don't need it per se. + Size: 0, // FlatTreeEntry doesn't have size, set to 0 + Hash: entry.Hash.String(), + Blob: isBlob, + } + entries = append(entries, converted) + } + return entries, nil +} + +func (r *gitRepository) Create(ctx context.Context, path, ref string, data []byte, comment string) error { + if ref == "" { + ref = r.gitConfig.Branch + } + ctx, _ = r.logger(ctx, ref) + branchRef, err := r.ensureBranchExists(ctx, ref) + if err != nil { + return err + } + + writer, err := r.client.NewStagedWriter(ctx, branchRef) + if err != nil { + return fmt.Errorf("create staged writer: %w", err) + } + + if err := r.create(ctx, path, data, writer); err != nil { + return err + } + + return r.commitAndPush(ctx, writer, comment) +} + +func (r *gitRepository) create(ctx context.Context, path string, data []byte, writer nanogit.StagedWriter) error { + finalPath := safepath.Join(r.gitConfig.Path, path) + // Create .keep file if it is a directory + if safepath.IsDir(finalPath) { + if data != nil { + return apierrors.NewBadRequest("data cannot be provided for a directory") + } + + finalPath = safepath.Join(finalPath, ".keep") + data = []byte{} + } + + if _, err := writer.CreateBlob(ctx, finalPath, data); err != nil { + if errors.Is(err, nanogit.ErrObjectAlreadyExists) { + return repository.ErrFileAlreadyExists + } + + return fmt.Errorf("create blob: %w", err) + } + + return nil +} + +func (r *gitRepository) Update(ctx context.Context, path, ref string, data []byte, comment string) error { + if ref == "" { + ref = r.gitConfig.Branch + } + ctx, _ = r.logger(ctx, ref) + + // Check if trying to update a directory + if safepath.IsDir(path) { + return apierrors.NewBadRequest("cannot update a directory") + } + + branchRef, err := r.ensureBranchExists(ctx, ref) + if err != nil { + return err + } + // Create a staged writer + writer, err := r.client.NewStagedWriter(ctx, branchRef) + if err != nil { + return fmt.Errorf("create staged writer: %w", err) + } + + if err := r.update(ctx, path, data, writer); err != nil { + return err + } + + return r.commitAndPush(ctx, writer, comment) +} + +func (r *gitRepository) update(ctx context.Context, path string, data []byte, writer nanogit.StagedWriter) error { + // Check if trying to update a directory + if safepath.IsDir(path) { + return apierrors.NewBadRequest("cannot update a directory") + } + + finalPath := safepath.Join(r.gitConfig.Path, path) + if _, err := writer.UpdateBlob(ctx, finalPath, data); err != nil { + if errors.Is(err, nanogit.ErrObjectNotFound) { + return repository.ErrFileNotFound + } + + return fmt.Errorf("update blob: %w", err) + } + + return nil +} + +func (r *gitRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error { + if ref == "" { + ref = r.gitConfig.Branch + } + + ctx, _ = r.logger(ctx, ref) + _, err := r.Read(ctx, path, ref) + if err != nil && !(errors.Is(err, repository.ErrFileNotFound)) { + return fmt.Errorf("check if file exists before writing: %w", err) + } + if err == nil { + return r.Update(ctx, path, ref, data, message) + } + + return r.Create(ctx, path, ref, data, message) +} + +func (r *gitRepository) Delete(ctx context.Context, path, ref, comment string) error { + if ref == "" { + ref = r.gitConfig.Branch + } + ctx, _ = r.logger(ctx, ref) + + branchRef, err := r.ensureBranchExists(ctx, ref) + if err != nil { + return err + } + // Create a staged writer + writer, err := r.client.NewStagedWriter(ctx, branchRef) + if err != nil { + return fmt.Errorf("create staged writer: %w", err) + } + + if err := r.delete(ctx, path, writer); err != nil { + return err + } + + return r.commitAndPush(ctx, writer, comment) +} + +func (r *gitRepository) delete(ctx context.Context, path string, writer nanogit.StagedWriter) error { + finalPath := safepath.Join(r.gitConfig.Path, path) + // Check if it's a directory - use DeleteTree for directories, DeleteBlob for files + if safepath.IsDir(path) { + if _, err := writer.DeleteTree(ctx, finalPath); err != nil { + if errors.Is(err, nanogit.ErrObjectNotFound) { + return repository.ErrFileNotFound + } + return fmt.Errorf("delete tree: %w", err) + } + } else { + if _, err := writer.DeleteBlob(ctx, finalPath); err != nil { + if errors.Is(err, nanogit.ErrObjectNotFound) { + return repository.ErrFileNotFound + } + return fmt.Errorf("delete blob: %w", err) + } + } + + return nil +} + +func (r *gitRepository) History(_ context.Context, _ string, _ string) ([]provisioning.HistoryItem, error) { + return nil, &apierrors.StatusError{ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusNotImplemented, + Reason: metav1.StatusReasonMethodNotAllowed, + Message: "history is not supported for pure git repositories", + }} +} + +func (r *gitRepository) LatestRef(ctx context.Context) (string, error) { + ctx, _ = r.logger(ctx, "") + branchRef, err := r.client.GetRef(ctx, fmt.Sprintf("refs/heads/%s", r.gitConfig.Branch)) + if err != nil { + return "", fmt.Errorf("get branch ref: %w", err) + } + + return branchRef.Hash.String(), nil +} + +func (r *gitRepository) CompareFiles(ctx context.Context, base, ref string) ([]repository.VersionedFileChange, error) { + if base == "" && ref == "" { + return nil, fmt.Errorf("base and ref cannot be empty") + } + if ref == "" { + return nil, fmt.Errorf("ref cannot be empty") + } + + ctx, logger := r.logger(ctx, ref) + + // Resolve base ref to hash + var baseHash hash.Hash + if base != "" { + var err error + baseHash, err = r.resolveRefToHash(ctx, base) + if err != nil { + return nil, fmt.Errorf("resolve base ref: %w", err) + } + } + + // Resolve ref to hash + refHash, err := r.resolveRefToHash(ctx, ref) + if err != nil { + return nil, fmt.Errorf("resolve ref: %w", err) + } + + // Get commit hashes for base and ref + // Compare commits using nanogit + files, err := r.client.CompareCommits(ctx, baseHash, refHash) + if err != nil { + return nil, fmt.Errorf("compare commits: %w", err) + } + + changes := make([]repository.VersionedFileChange, 0) + for _, f := range files { + switch f.Status { + case protocol.FileStatusAdded: + currentPath, err := safepath.RelativeTo(f.Path, r.gitConfig.Path) + if err != nil { + // do nothing as it's outside of configured path + continue + } + + changes = append(changes, repository.VersionedFileChange{ + Path: currentPath, + Ref: ref, + Action: repository.FileActionCreated, + }) + case protocol.FileStatusModified: + currentPath, err := safepath.RelativeTo(f.Path, r.gitConfig.Path) + if err != nil { + // do nothing as it's outside of configured path + continue + } + + changes = append(changes, repository.VersionedFileChange{ + Path: currentPath, + Ref: ref, + Action: repository.FileActionUpdated, + }) + case protocol.FileStatusDeleted: + currentPath, err := safepath.RelativeTo(f.Path, r.gitConfig.Path) + if err != nil { + // do nothing as it's outside of configured path + continue + } + + changes = append(changes, repository.VersionedFileChange{ + Ref: ref, + PreviousRef: base, + Path: currentPath, + PreviousPath: currentPath, + Action: repository.FileActionDeleted, + }) + case protocol.FileStatusTypeChanged: + // Handle type changes as modifications + currentPath, err := safepath.RelativeTo(f.Path, r.gitConfig.Path) + if err != nil { + // do nothing as it's outside of configured path + continue + } + + changes = append(changes, repository.VersionedFileChange{ + Path: currentPath, + Ref: ref, + Action: repository.FileActionUpdated, + }) + default: + logger.Error("ignore unhandled file", "file", f.Path, "status", string(f.Status)) + } + } + + return changes, nil +} + +func (r *gitRepository) Clone(ctx context.Context, opts repository.CloneOptions) (repository.ClonedRepository, error) { + return NewStagedGitRepository(ctx, r, opts) +} + +// resolveRefToHash resolves a ref (branch name or commit hash) to a commit hash +func (r *gitRepository) resolveRefToHash(ctx context.Context, ref string) (hash.Hash, error) { + // Use default branch if ref is empty + if ref == "" { + ref = fmt.Sprintf("refs/heads/%s", r.gitConfig.Branch) + } + + // Try to parse ref as a hash first + refHash, err := hash.FromHex(ref) + if err == nil && refHash != nil { + // Valid hash, return it + return refHash, nil + } + + // Not a valid hash, try to resolve as a branch reference + branchRef, err := r.client.GetRef(ctx, ref) + if err != nil { + if errors.Is(err, nanogit.ErrObjectNotFound) { + return nil, fmt.Errorf("ref not found: %s: %w", ref, repository.ErrRefNotFound) + } + return nil, fmt.Errorf("get ref %s: %w", ref, err) + } + + return branchRef.Hash, nil +} + +// ensureBranchExists checks if a branch exists and creates it if it doesn't, +// returning the branch reference to avoid duplicate GetRef calls +func (r *gitRepository) ensureBranchExists(ctx context.Context, branchName string) (nanogit.Ref, error) { + if !repository.IsValidGitBranchName(branchName) { + return nanogit.Ref{}, &apierrors.StatusError{ + ErrStatus: metav1.Status{ + Code: http.StatusBadRequest, + Message: "invalid branch name", + }, + } + } + + // Check if branch exists by trying to get the branch reference + branchRef, err := r.client.GetRef(ctx, fmt.Sprintf("refs/heads/%s", branchName)) + if err == nil { + // Branch exists, return it + logging.FromContext(ctx).Info("branch already exists", "branch", branchName) + return branchRef, nil + } + + // If error is not "ref not found", return the error + if !errors.Is(err, nanogit.ErrObjectNotFound) { + return nanogit.Ref{}, fmt.Errorf("check branch exists: %w", err) + } + + // Branch doesn't exist, create it based on the configured branch + srcBranch := r.gitConfig.Branch + srcRef, err := r.client.GetRef(ctx, fmt.Sprintf("refs/heads/%s", srcBranch)) + if err != nil { + return nanogit.Ref{}, fmt.Errorf("get source branch ref: %w", err) + } + + // Create the new branch reference + newRef := nanogit.Ref{ + Name: fmt.Sprintf("refs/heads/%s", branchName), + Hash: srcRef.Hash, + } + + if err := r.client.CreateRef(ctx, newRef); err != nil { + return nanogit.Ref{}, fmt.Errorf("create branch: %w", err) + } + + return newRef, nil +} + +// createSignature creates author and committer signatures using the context signature if available, +// falling back to default Grafana signature +func (r *gitRepository) createSignature(ctx context.Context) (nanogit.Author, nanogit.Committer) { + author := nanogit.Author{ + Name: "Grafana", + Email: "noreply@grafana.com", + Time: time.Now(), + } + committer := nanogit.Committer{ + Name: "Grafana", + Email: "noreply@grafana.com", + Time: time.Now(), + } + + // Use signature from context if available + if sig := repository.GetAuthorSignature(ctx); sig != nil && sig.Name != "" { + author.Name = sig.Name + author.Email = sig.Email + author.Time = sig.When + committer.Name = sig.Name + committer.Email = sig.Email + committer.Time = sig.When + } + + if author.Time.IsZero() { + author.Time = time.Now() + committer.Time = time.Now() + } + + return author, committer +} + +func (r *gitRepository) commit(ctx context.Context, writer nanogit.StagedWriter, comment string) error { + author, committer := r.createSignature(ctx) + if _, err := writer.Commit(ctx, comment, author, committer); err != nil { + return fmt.Errorf("commit changes: %w", err) + } + return nil +} + +func (r *gitRepository) commitAndPush(ctx context.Context, writer nanogit.StagedWriter, comment string) error { + if err := r.commit(ctx, writer, comment); err != nil { + return err + } + + if err := writer.Push(ctx); err != nil { + return fmt.Errorf("push changes: %w", err) + } + + return nil +} + +func (r *gitRepository) logger(ctx context.Context, ref string) (context.Context, logging.Logger) { + logger := logging.FromContext(ctx) + + type containsGit int + var containsGitKey containsGit + if ctx.Value(containsGitKey) != nil { + return ctx, logging.FromContext(ctx) + } + + if ref == "" { + ref = r.gitConfig.Branch + } + logger = logger.With(slog.Group("git_repository", "url", r.gitConfig.URL, "ref", ref, "nanogit", true)) + ctx = logging.Context(ctx, logger) + // We want to ensure we don't add multiple git_repository keys. With doesn't deduplicate the keys... + ctx = context.WithValue(ctx, containsGitKey, true) + + ctx = log.ToContext(ctx, logger) + + return ctx, logger +} diff --git a/pkg/registry/apis/provisioning/repository/nanogit/git_test.go b/pkg/registry/apis/provisioning/repository/nanogit/git_test.go new file mode 100644 index 00000000000..20df321fc74 --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/nanogit/git_test.go @@ -0,0 +1,3712 @@ +package nanogit + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets" + "github.com/grafana/nanogit" + "github.com/grafana/nanogit/mocks" + "github.com/grafana/nanogit/protocol" + "github.com/grafana/nanogit/protocol/hash" + "github.com/stretchr/testify/require" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func TestGitRepository_Validate(t *testing.T) { + tests := []struct { + name string + config *provisioning.Repository + gitConfig RepositoryConfig + want field.ErrorList // number of expected validation errors + }{ + { + name: "valid config", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "main", + Token: "token123", + Path: "configs", + }, + want: nil, + }, + { + name: "missing URL", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Token: "token123", + }, + want: field.ErrorList{ + field.Required(field.NewPath("spec", "test_type", "url"), "a git url is required"), + }, + }, + { + name: "invalid URL scheme", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + gitConfig: RepositoryConfig{ + URL: "http://git.example.com/repo.git", + Branch: "main", + Token: "token123", + }, + want: field.ErrorList{ + field.Invalid(field.NewPath("spec", "test_type", "url"), "http://git.example.com/repo.git", "invalid git URL format"), + }, + }, + { + name: "missing host", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + gitConfig: RepositoryConfig{ + URL: "https:///repo.git", // URL with missing host + Branch: "main", + Token: "token123", + }, + want: field.ErrorList{ + field.Invalid(field.NewPath("spec", "test_type", "url"), "https:///repo.git", "invalid git URL format"), + }, + }, + { + name: "unparseable url", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + gitConfig: RepositoryConfig{ + URL: "://not a valid url", + Branch: "main", + Token: "token123", + }, + want: field.ErrorList{ + field.Invalid(field.NewPath("spec", "test_type", "url"), "://not a valid url", "invalid git URL format"), + }, + }, + { + name: "missing branch", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "", // Empty branch + Token: "token123", + }, + want: field.ErrorList{ + field.Required(field.NewPath("spec", "test_type", "branch"), "a git branch is required"), + }, + }, + { + name: "invalid branch name", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "invalid/branch*name", // Invalid branch name + Token: "token123", + }, + want: field.ErrorList{ + field.Invalid(field.NewPath("spec", "test_type", "branch"), "invalid/branch*name", "invalid branch name"), + }, + }, + { + name: "missing token", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "main", + Token: "", // Empty token + }, + want: field.ErrorList{ + field.Required(field.NewPath("spec", "test_type", "token"), "a git access token is required"), + }, + }, + { + name: "unsafe path", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "main", + Token: "token123", + Path: "../unsafe/path", + }, + want: field.ErrorList{ + field.Invalid(field.NewPath("spec", "test_type", "path"), "../unsafe/path", "path contains traversal attempt (./ or ../)"), + }, + }, + { + name: "absolute path", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "main", + Token: "token123", + Path: "/absolute/path", + }, + want: field.ErrorList{ + field.Invalid(field.NewPath("spec", "test_type", "path"), "/absolute/path", "path must be relative"), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gitRepo := &gitRepository{ + config: tt.config, + gitConfig: tt.gitConfig, + } + + errors := gitRepo.Validate() + require.Equal(t, tt.want, errors) + }) + } +} + +func TestIsValidGitURL(t *testing.T) { + tests := []struct { + name string + url string + want bool + }{ + { + name: "valid HTTPS URL", + url: "https://git.example.com/owner/repo.git", + want: true, + }, + { + name: "invalid HTTP URL", + url: "http://git.example.com/owner/repo.git", + want: false, + }, + { + name: "missing scheme", + url: "git.example.com/owner/repo.git", + want: false, + }, + { + name: "empty path", + url: "https://git.example.com/", + want: false, + }, + { + name: "no path", + url: "https://git.example.com", + want: false, + }, + { + name: "missing host", + url: "https:///repo.git", + want: false, + }, + { + name: "unparseable url", + url: "://bad-url", + want: false, + }, + { + name: "invalid URL", + url: "not-a-url", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := isValidGitURL(tt.url) + require.Equal(t, tt.want, got) + }) + } +} + +// Mock secrets service for testing +type mockSecretsService struct{} + +func (m *mockSecretsService) Decrypt(ctx context.Context, data []byte) ([]byte, error) { + return []byte("decrypted-token"), nil +} + +func (m *mockSecretsService) Encrypt(ctx context.Context, data []byte) ([]byte, error) { + return []byte("encrypted-token"), nil +} + +func TestNewGit(t *testing.T) { + ctx := context.Background() + mockSecrets := &mockSecretsService{} + + config := &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + } + + gitConfig := RepositoryConfig{ + URL: "https://git.example.com/owner/repo.git", + Branch: "main", + Token: "test-token", + Path: "configs", + } + + // This should succeed in creating the client but won't be able to connect + // We just test that the basic structure is created correctly + gitRepo, err := NewGitRepository(ctx, mockSecrets, config, gitConfig) + require.NoError(t, err) + require.NotNil(t, gitRepo) + require.Equal(t, "https://git.example.com/owner/repo.git", gitRepo.URL()) + require.Equal(t, "main", gitRepo.Branch()) + require.Equal(t, config, gitRepo.Config()) +} + +func TestCreateSignature(t *testing.T) { + gitRepo := &gitRepository{ + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "main", + Token: "token123", + }, + } + + t.Run("should use default signature when no context signature", func(t *testing.T) { + ctx := context.Background() + author, committer := gitRepo.createSignature(ctx) + + require.Equal(t, "Grafana", author.Name) + require.Equal(t, "noreply@grafana.com", author.Email) + require.False(t, author.Time.IsZero()) + + require.Equal(t, "Grafana", committer.Name) + require.Equal(t, "noreply@grafana.com", committer.Email) + require.False(t, committer.Time.IsZero()) + }) + + t.Run("should use context signature when available", func(t *testing.T) { + sig := repository.CommitSignature{ + Name: "John Doe", + Email: "john@example.com", + When: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + } + ctx := repository.WithAuthorSignature(context.Background(), sig) + + author, committer := gitRepo.createSignature(ctx) + + require.Equal(t, "John Doe", author.Name) + require.Equal(t, "john@example.com", author.Email) + require.Equal(t, time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), author.Time) + + require.Equal(t, "John Doe", committer.Name) + require.Equal(t, "john@example.com", committer.Email) + require.Equal(t, time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), committer.Time) + }) + + t.Run("should fallback to default when context signature has empty name", func(t *testing.T) { + sig := repository.CommitSignature{ + Name: "", + Email: "john@example.com", + When: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + } + ctx := repository.WithAuthorSignature(context.Background(), sig) + + author, committer := gitRepo.createSignature(ctx) + + require.Equal(t, "Grafana", author.Name) + require.Equal(t, "noreply@grafana.com", author.Email) + require.False(t, author.Time.IsZero()) + + require.Equal(t, "Grafana", committer.Name) + require.Equal(t, "noreply@grafana.com", committer.Email) + require.False(t, committer.Time.IsZero()) + }) + + t.Run("should use current time when signature time is zero", func(t *testing.T) { + sig := repository.CommitSignature{ + Name: "John Doe", + Email: "john@example.com", + When: time.Time{}, // Zero time + } + ctx := repository.WithAuthorSignature(context.Background(), sig) + + before := time.Now() + author, committer := gitRepo.createSignature(ctx) + after := time.Now() + + require.Equal(t, "John Doe", author.Name) + require.Equal(t, "john@example.com", author.Email) + require.True(t, author.Time.After(before.Add(-time.Second))) + require.True(t, author.Time.Before(after.Add(time.Second))) + + require.Equal(t, "John Doe", committer.Name) + require.Equal(t, "john@example.com", committer.Email) + require.True(t, committer.Time.After(before.Add(-time.Second))) + require.True(t, committer.Time.Before(after.Add(time.Second))) + }) +} + +func TestEnsureBranchExists(t *testing.T) { + gitRepo := &gitRepository{ + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "main", + Token: "token123", + }, + } + + t.Run("should reject invalid branch name", func(t *testing.T) { + ctx := context.Background() + _, err := gitRepo.ensureBranchExists(ctx, "feature//branch") + + require.Error(t, err) + var statusErr *apierrors.StatusError + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, int32(400), statusErr.Status().Code) + require.Equal(t, "invalid branch name", statusErr.Status().Message) + }) + + t.Run("should validate branch names for validation errors only", func(t *testing.T) { + testCases := []struct { + name string + branchName string + shouldError bool + }{ + {"invalid double slash", "feature//branch", true}, + {"invalid double dot", "feature..branch", true}, + {"invalid ending with dot", "feature.", true}, + {"invalid starting with slash", "/feature", true}, + {"invalid ending with slash", "feature/", true}, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + ctx := context.Background() + _, err := gitRepo.ensureBranchExists(ctx, tc.branchName) + + if tc.shouldError { + require.Error(t, err) + var statusErr *apierrors.StatusError + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, int32(400), statusErr.Status().Code) + require.Equal(t, "invalid branch name", statusErr.Status().Message) + } + }) + } + }) +} + +func TestHistory(t *testing.T) { + t.Run("should return not implemented", func(t *testing.T) { + ctx := context.Background() + history, err := (&gitRepository{}).History(ctx, "", "") + + require.Error(t, err) + var statusErr *apierrors.StatusError + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, int32(http.StatusNotImplemented), statusErr.Status().Code) + require.Equal(t, metav1.StatusReasonMethodNotAllowed, statusErr.Status().Reason) + require.Equal(t, "history is not supported for pure git repositories", statusErr.Status().Message) + require.Nil(t, history) + }) +} + +func TestGitRepository_Test(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + gitConfig RepositoryConfig + wantResults *provisioning.TestResults + wantError error + }{ + { + name: "success - all checks pass", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.IsAuthorizedReturns(true, nil) + mockClient.RepoExistsReturns(true, nil) + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + wantResults: &provisioning.TestResults{ + Success: true, + Errors: nil, + Code: http.StatusOK, + }, + wantError: nil, + }, + { + name: "failure - not authorized (error)", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.IsAuthorizedReturns(false, errors.New("auth error")) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + wantResults: &provisioning.TestResults{ + Success: false, + Errors: []provisioning.ErrorDetails{ + { + Type: metav1.CauseTypeFieldValueInvalid, + Field: field.NewPath("spec", "test_type", "token").String(), + Detail: "failed check if authorized: auth error", + }, + }, + Code: http.StatusBadRequest, + }, + wantError: nil, + }, + { + name: "failure - not authorized (false result)", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.IsAuthorizedReturns(false, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + wantResults: &provisioning.TestResults{ + Success: false, + Errors: []provisioning.ErrorDetails{ + { + Type: metav1.CauseTypeFieldValueInvalid, + Field: field.NewPath("spec", "test_type", "token").String(), + Detail: "not authorized", + }, + }, + Code: http.StatusBadRequest, + }, + wantError: nil, + }, + { + name: "failure - repository not found (error)", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.IsAuthorizedReturns(true, nil) + mockClient.RepoExistsReturns(false, errors.New("repo error")) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + wantResults: &provisioning.TestResults{ + Success: false, + Errors: []provisioning.ErrorDetails{ + { + Type: metav1.CauseTypeFieldValueInvalid, + Field: field.NewPath("spec", "test_type", "url").String(), + Detail: "failed check if repository exists: repo error", + }, + }, + Code: http.StatusBadRequest, + }, + wantError: nil, + }, + { + name: "failure - repository not found (false result)", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.IsAuthorizedReturns(true, nil) + mockClient.RepoExistsReturns(false, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + wantResults: &provisioning.TestResults{ + Success: false, + Errors: []provisioning.ErrorDetails{ + { + Type: metav1.CauseTypeFieldValueInvalid, + Field: field.NewPath("spec", "test_type", "url").String(), + Detail: "repository not found", + }, + }, + Code: http.StatusBadRequest, + }, + wantError: nil, + }, + { + name: "failure - branch not found (error)", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.IsAuthorizedReturns(true, nil) + mockClient.RepoExistsReturns(true, nil) + mockClient.GetRefReturns(nanogit.Ref{}, errors.New("branch not found")) + }, + gitConfig: RepositoryConfig{ + Branch: "nonexistent", + }, + wantResults: &provisioning.TestResults{ + Success: false, + Errors: []provisioning.ErrorDetails{ + { + Type: metav1.CauseTypeFieldValueInvalid, + Field: field.NewPath("spec", "test_type", "branch").String(), + Detail: "failed to check if branch exists: branch not found", + }, + }, + Code: http.StatusBadRequest, + }, + wantError: nil, + }, + { + name: "failure - branch not found", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.IsAuthorizedReturns(true, nil) + mockClient.RepoExistsReturns(true, nil) + mockClient.GetRefReturns(nanogit.Ref{}, nanogit.ErrObjectNotFound) + }, + gitConfig: RepositoryConfig{ + Branch: "nonexistent", + }, + wantResults: &provisioning.TestResults{ + Success: false, + Errors: []provisioning.ErrorDetails{ + { + Type: metav1.CauseTypeFieldValueInvalid, + Field: field.NewPath("spec", "test_type", "branch").String(), + Detail: "branch not found", + }, + }, + Code: http.StatusBadRequest, + }, + wantError: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: tt.gitConfig, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + } + + results, err := gitRepo.Test(context.Background()) + require.NoError(t, err, "Test method should not return an error") + + require.Equal(t, tt.wantResults, results, "Test results mismatch") + require.Equal(t, tt.wantError, err, "Test error mismatch") + + // Verify the mock calls + require.Equal(t, 1, mockClient.IsAuthorizedCallCount(), "IsAuthorized should be called exactly once") + + if mockClient.RepoExistsCallCount() > 0 { + require.Equal(t, 1, mockClient.RepoExistsCallCount(), "RepoExists should be called at most once") + } + + if mockClient.GetRefCallCount() > 0 { + require.Equal(t, 1, mockClient.GetRefCallCount(), "GetRef should be called at most once") + _, ref := mockClient.GetRefArgsForCall(0) + require.Equal(t, "refs/heads/"+tt.gitConfig.Branch, ref, "GetRef should be called with correct branch reference") + } + }) + } +} + +func TestGitRepository_Read(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + gitConfig RepositoryConfig + filePath string + ref string + wantError bool + errorType error + }{ + { + name: "success - read file", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash{}, + }, nil) + mockClient.GetBlobByPathReturns(&nanogit.Blob{ + Content: []byte("file content"), + Hash: hash.Hash{}, + }, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + filePath: "test.yaml", + ref: "main", + wantError: false, + }, + { + name: "can a read directory", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash([]byte("tree-hash")), + }, nil) + mockClient.GetTreeByPathReturns(&nanogit.Tree{ + Hash: hash.Hash{}, + }, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + filePath: "subdir/", + ref: "main", + wantError: false, + }, + { + name: "failure - file not found", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash{}, + }, nil) + mockClient.GetBlobByPathReturns(&nanogit.Blob{}, nanogit.ErrObjectNotFound) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + filePath: "missing.yaml", + ref: "main", + wantError: true, + errorType: repository.ErrFileNotFound, + }, + { + name: "failure - ref not found", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{}, nanogit.ErrObjectNotFound) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + filePath: "test.yaml", + ref: "nonexistent", + wantError: true, + errorType: repository.ErrRefNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: tt.gitConfig, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + } + + fileInfo, err := gitRepo.Read(context.Background(), tt.filePath, tt.ref) + + if tt.wantError { + require.Error(t, err) + require.Nil(t, fileInfo) + if tt.errorType != nil { + require.ErrorIs(t, err, tt.errorType) + } + } else { + require.NoError(t, err) + require.NotNil(t, fileInfo) + require.Equal(t, tt.filePath, fileInfo.Path) + } + }) + } +} + +func TestGitRepository_ReadTree(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + gitConfig RepositoryConfig + ref string + wantError bool + errorType error + }{ + { + name: "success - read tree", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockClient.GetFlatTreeReturns(&nanogit.FlatTree{ + Entries: []nanogit.FlatTreeEntry{ + { + Path: "configs/test.yaml", + Hash: hash.Hash{}, + Type: protocol.ObjectTypeBlob, + }, + }, + }, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + ref: "main", + wantError: false, + }, + { + name: "failure - ref not found", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{}, nanogit.ErrObjectNotFound) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + ref: "nonexistent", + wantError: true, + errorType: repository.ErrRefNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: tt.gitConfig, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + entries, err := gitRepo.ReadTree(context.Background(), tt.ref) + + if tt.wantError { + require.Error(t, err) + require.Nil(t, entries) + if tt.errorType != nil { + require.ErrorIs(t, err, tt.errorType) + } + } else { + require.NoError(t, err) + require.NotNil(t, entries) + } + }) + } +} + +func TestGitRepository_Create(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + gitConfig RepositoryConfig + path string + ref string + data []byte + comment string + wantError bool + errorType error + }{ + { + name: "success - create file", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.CreateBlobReturns(hash.Hash{}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + path: "test.yaml", + ref: "main", + data: []byte("test content"), + comment: "Add test file", + wantError: false, + }, + { + name: "failure - file already exists", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.CreateBlobReturns(hash.Hash{}, nanogit.ErrObjectAlreadyExists) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + path: "existing.yaml", + ref: "main", + data: []byte("test content"), + comment: "Add existing file", + wantError: true, + errorType: repository.ErrFileAlreadyExists, + }, + { + name: "success - create directory", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.CreateBlobReturns(hash.Hash{}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + path: "newdir/", + ref: "main", + data: nil, + comment: "Add directory", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: tt.gitConfig, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + err := gitRepo.Create(context.Background(), tt.path, tt.ref, tt.data, tt.comment) + + if tt.wantError { + require.Error(t, err) + if tt.errorType != nil { + require.ErrorIs(t, err, tt.errorType) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGitRepository_Update(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + gitConfig RepositoryConfig + path string + ref string + data []byte + comment string + wantError bool + errorType error + }{ + { + name: "success - update file", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.UpdateBlobReturns(hash.Hash{}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + path: "test.yaml", + ref: "main", + data: []byte("updated content"), + comment: "Update test file", + wantError: false, + }, + { + name: "failure - file not found", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.UpdateBlobReturns(hash.Hash{}, nanogit.ErrObjectNotFound) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + path: "missing.yaml", + ref: "main", + data: []byte("content"), + comment: "Update missing file", + wantError: true, + errorType: repository.ErrFileNotFound, + }, + { + name: "failure - cannot update directory", + setupMock: func(mockClient *mocks.FakeClient) { + // No mock setup needed as error is caught early + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + path: "directory/", + ref: "main", + data: []byte("content"), + comment: "Update directory", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: tt.gitConfig, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + err := gitRepo.Update(context.Background(), tt.path, tt.ref, tt.data, tt.comment) + + if tt.wantError { + require.Error(t, err) + if tt.errorType != nil { + require.ErrorIs(t, err, tt.errorType) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGitRepository_Delete(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + gitConfig RepositoryConfig + path string + ref string + comment string + wantError bool + errorType error + }{ + { + name: "success - delete file", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.DeleteBlobReturns(hash.Hash{}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + path: "test.yaml", + ref: "main", + comment: "Delete test file", + wantError: false, + }, + { + name: "success - delete directory", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.DeleteTreeReturns(hash.Hash{}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + path: "testdir/", + ref: "main", + comment: "Delete test directory", + wantError: false, + }, + { + name: "failure - file not found", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.DeleteBlobReturns(hash.Hash{}, nanogit.ErrObjectNotFound) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + path: "missing.yaml", + ref: "main", + comment: "Delete missing file", + wantError: true, + errorType: repository.ErrFileNotFound, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: tt.gitConfig, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + err := gitRepo.Delete(context.Background(), tt.path, tt.ref, tt.comment) + + if tt.wantError { + require.Error(t, err) + if tt.errorType != nil { + require.ErrorIs(t, err, tt.errorType) + } + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGitRepository_LatestRef(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + gitConfig RepositoryConfig + wantError bool + wantRef string + }{ + { + name: "success - get latest ref", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, // Non-empty hash + }, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + wantError: false, + wantRef: "", // Hash would be converted to string - we just check it's not empty + }, + { + name: "failure - branch not found", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{}, errors.New("branch not found")) + }, + gitConfig: RepositoryConfig{ + Branch: "nonexistent", + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: tt.gitConfig, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + ref, err := gitRepo.LatestRef(context.Background()) + + if tt.wantError { + require.Error(t, err) + require.Empty(t, ref) + } else { + require.NoError(t, err) + // For success case with non-empty hash, ref should not be empty + if tt.name == "success - get latest ref" { + require.NotEmpty(t, ref) + } + } + + // Verify GetRef was called with correct branch + require.Equal(t, 1, mockClient.GetRefCallCount()) + _, branchRef := mockClient.GetRefArgsForCall(0) + require.Equal(t, "refs/heads/"+tt.gitConfig.Branch, branchRef) + }) + } +} + +func TestGitRepository_History(t *testing.T) { + gitRepo := &gitRepository{ + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + history, err := gitRepo.History(context.Background(), "test.yaml", "main") + + require.Error(t, err) + require.Nil(t, history) + + var statusErr *apierrors.StatusError + require.True(t, errors.As(err, &statusErr)) + require.Equal(t, int32(http.StatusNotImplemented), statusErr.Status().Code) + require.Equal(t, metav1.StatusReasonMethodNotAllowed, statusErr.Status().Reason) + require.Equal(t, "history is not supported for pure git repositories", statusErr.Status().Message) +} + +func TestGitRepository_Write(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + gitConfig RepositoryConfig + path string + ref string + data []byte + message string + fileExists bool + wantError bool + expectCreate bool + expectUpdate bool + }{ + { + name: "success - create new file", + setupMock: func(mockClient *mocks.FakeClient) { + // First call for Read check - file not found + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash{}, + }, nil) + mockClient.GetBlobByPathReturns(&nanogit.Blob{}, nanogit.ErrObjectNotFound) + + // Second call for Create + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.CreateBlobReturns(hash.Hash{}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + path: "newfile.yaml", + ref: "main", + data: []byte("content"), + message: "Add new file", + fileExists: false, + wantError: false, + expectCreate: true, + expectUpdate: false, + }, + { + name: "success - update existing file", + setupMock: func(mockClient *mocks.FakeClient) { + // First call for Read check - file exists + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash{}, + }, nil) + mockClient.GetBlobByPathReturns(&nanogit.Blob{ + Content: []byte("old content"), + Hash: hash.Hash{}, + }, nil) + + // Second call for Update + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.UpdateBlobReturns(hash.Hash{}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + path: "existing.yaml", + ref: "main", + data: []byte("updated content"), + message: "Update existing file", + fileExists: true, + wantError: false, + expectCreate: false, + expectUpdate: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: tt.gitConfig, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + err := gitRepo.Write(context.Background(), tt.path, tt.ref, tt.data, tt.message) + + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + // Verify Read was called first to check if file exists + require.GreaterOrEqual(t, mockClient.GetRefCallCount(), 1) + + if tt.expectCreate || tt.expectUpdate { + // Verify NewStagedWriter was called + require.Equal(t, 1, mockClient.NewStagedWriterCallCount()) + } + }) + } +} + +func TestGitRepository_CompareFiles(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + gitConfig RepositoryConfig + base string + ref string + wantError bool + wantChanges int + }{ + { + name: "success - compare commits with changes", + setupMock: func(mockClient *mocks.FakeClient) { + // Return refs for base and ref + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{ + Name: "refs/heads/feature", + Hash: hash.Hash{4, 5, 6}, + }, nil) + + // Return comparison results + mockClient.CompareCommitsReturns([]nanogit.CommitFile{ + { + Path: "configs/new-file.yaml", + Status: protocol.FileStatusAdded, + }, + { + Path: "configs/modified-file.yaml", + Status: protocol.FileStatusModified, + }, + { + Path: "configs/deleted-file.yaml", + Status: protocol.FileStatusDeleted, + }, + }, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + base: "main", + ref: "feature", + wantError: false, + wantChanges: 3, + }, + { + name: "success - no changes", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + + mockClient.CompareCommitsReturns([]nanogit.CommitFile{}, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + base: "main", + ref: "main", + wantError: false, + wantChanges: 0, + }, + { + name: "failure - ref not found", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{}, nanogit.ErrObjectNotFound) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + base: "main", + ref: "nonexistent", + wantError: true, + }, + { + name: "failure - empty ref", + setupMock: func(mockClient *mocks.FakeClient) { + // No mock setup needed as error is caught early + }, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + base: "main", + ref: "", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: tt.gitConfig, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + changes, err := gitRepo.CompareFiles(context.Background(), tt.base, tt.ref) + + if tt.wantError { + require.Error(t, err) + require.Nil(t, changes) + } else { + require.NoError(t, err) + require.NotNil(t, changes) + require.Len(t, changes, tt.wantChanges) + + // Verify the changes have correct actions + if tt.wantChanges > 0 { + for _, change := range changes { + require.NotEmpty(t, change.Path) + require.NotEmpty(t, change.Action) + require.Equal(t, tt.ref, change.Ref) + } + } + } + }) + } +} + +func TestGitRepository_ensureBranchExists(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + gitConfig RepositoryConfig + branchName string + wantError bool + expectCalls int // Expected number of GetRef calls + }{ + { + name: "success - branch already exists", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/feature", + Hash: hash.Hash{1, 2, 3}, + }, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + branchName: "feature", + wantError: false, + expectCalls: 1, + }, + { + name: "success - create new branch", + setupMock: func(mockClient *mocks.FakeClient) { + // First call - branch doesn't exist + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{}, nanogit.ErrObjectNotFound) + // Second call - get source branch + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + // CreateRef succeeds + mockClient.CreateRefReturns(nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + branchName: "new-feature", + wantError: false, + expectCalls: 2, + }, + { + name: "failure - invalid branch name", + setupMock: func(mockClient *mocks.FakeClient) { + // No mock setup needed as error is caught early + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + branchName: "invalid//branch", + wantError: true, + expectCalls: 0, + }, + { + name: "failure - source branch not found", + setupMock: func(mockClient *mocks.FakeClient) { + // First call - branch doesn't exist + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{}, nanogit.ErrObjectNotFound) + // Second call - source branch also doesn't exist + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{}, nanogit.ErrObjectNotFound) + }, + gitConfig: RepositoryConfig{ + Branch: "nonexistent", + }, + branchName: "new-feature", + wantError: true, + expectCalls: 2, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: tt.gitConfig, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + branchRef, err := gitRepo.ensureBranchExists(context.Background(), tt.branchName) + + if tt.wantError { + require.Error(t, err) + require.Equal(t, nanogit.Ref{}, branchRef) + } else { + require.NoError(t, err) + require.NotEqual(t, nanogit.Ref{}, branchRef) + require.Equal(t, "refs/heads/"+tt.branchName, branchRef.Name) + } + + // Verify expected number of GetRef calls + require.Equal(t, tt.expectCalls, mockClient.GetRefCallCount()) + }) + } +} + +func TestGitRepository_createSignature(t *testing.T) { + gitRepo := &gitRepository{ + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "main", + Token: "token123", + }, + } + + t.Run("should use default signature when no context signature", func(t *testing.T) { + ctx := context.Background() + author, committer := gitRepo.createSignature(ctx) + + require.Equal(t, "Grafana", author.Name) + require.Equal(t, "noreply@grafana.com", author.Email) + require.False(t, author.Time.IsZero()) + + require.Equal(t, "Grafana", committer.Name) + require.Equal(t, "noreply@grafana.com", committer.Email) + require.False(t, committer.Time.IsZero()) + }) + + t.Run("should use context signature when available", func(t *testing.T) { + sig := repository.CommitSignature{ + Name: "John Doe", + Email: "john@example.com", + When: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + } + ctx := repository.WithAuthorSignature(context.Background(), sig) + + author, committer := gitRepo.createSignature(ctx) + + require.Equal(t, "John Doe", author.Name) + require.Equal(t, "john@example.com", author.Email) + require.Equal(t, time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), author.Time) + + require.Equal(t, "John Doe", committer.Name) + require.Equal(t, "john@example.com", committer.Email) + require.Equal(t, time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), committer.Time) + }) + + t.Run("should fallback to default when context signature has empty name", func(t *testing.T) { + sig := repository.CommitSignature{ + Name: "", + Email: "john@example.com", + When: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + } + ctx := repository.WithAuthorSignature(context.Background(), sig) + + author, committer := gitRepo.createSignature(ctx) + + require.Equal(t, "Grafana", author.Name) + require.Equal(t, "noreply@grafana.com", author.Email) + require.False(t, author.Time.IsZero()) + + require.Equal(t, "Grafana", committer.Name) + require.Equal(t, "noreply@grafana.com", committer.Email) + require.False(t, committer.Time.IsZero()) + }) + + t.Run("should use current time when signature time is zero", func(t *testing.T) { + sig := repository.CommitSignature{ + Name: "John Doe", + Email: "john@example.com", + When: time.Time{}, // Zero time + } + ctx := repository.WithAuthorSignature(context.Background(), sig) + + before := time.Now() + author, committer := gitRepo.createSignature(ctx) + after := time.Now() + + require.Equal(t, "John Doe", author.Name) + require.Equal(t, "john@example.com", author.Email) + require.True(t, author.Time.After(before.Add(-time.Second))) + require.True(t, author.Time.Before(after.Add(time.Second))) + + require.Equal(t, "John Doe", committer.Name) + require.Equal(t, "john@example.com", committer.Email) + require.True(t, committer.Time.After(before.Add(-time.Second))) + require.True(t, committer.Time.Before(after.Add(time.Second))) + }) +} + +func TestNewGitRepository(t *testing.T) { + tests := []struct { + name string + setupMock func(*mockSecretsService) + gitConfig RepositoryConfig + wantError bool + expectURL string + expectToken string + }{ + { + name: "success - with token", + setupMock: func(mockSecrets *mockSecretsService) { + // No setup needed for token case + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/owner/repo.git", + Branch: "main", + Token: "plain-token", + Path: "configs", + }, + wantError: false, + expectURL: "https://git.example.com/owner/repo.git", + expectToken: "plain-token", + }, + { + name: "success - with encrypted token", + setupMock: func(mockSecrets *mockSecretsService) { + // Mock will return decrypted token + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/owner/repo.git", + Branch: "main", + Token: "", // Empty token, will use encrypted + EncryptedToken: []byte("encrypted-token"), + Path: "configs", + }, + wantError: false, + expectURL: "https://git.example.com/owner/repo.git", + expectToken: "decrypted-token", // From mock + }, + { + name: "failure - decryption error", + setupMock: func(mockSecrets *mockSecretsService) { + // This test will use the separate mockSecretsServiceWithError + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/owner/repo.git", + Branch: "main", + Token: "", + EncryptedToken: []byte("bad-encrypted-token"), + Path: "configs", + }, + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + + var mockSecrets secrets.Service + if tt.name == "failure - decryption error" { + mockSecrets = &mockSecretsServiceWithError{shouldError: true} + } else { + mockSecrets = &mockSecretsService{} + } + + config := &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + } + + gitRepo, err := NewGitRepository(ctx, mockSecrets, config, tt.gitConfig) + + if tt.wantError { + require.Error(t, err) + require.Nil(t, gitRepo) + } else { + require.NoError(t, err) + require.NotNil(t, gitRepo) + require.Equal(t, tt.expectURL, gitRepo.URL()) + require.Equal(t, tt.gitConfig.Branch, gitRepo.Branch()) + require.Equal(t, config, gitRepo.Config()) + } + }) + } +} + +func TestGitRepository_Getters(t *testing.T) { + config := &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + } + + gitConfig := RepositoryConfig{ + URL: "https://git.example.com/owner/repo.git", + Branch: "feature-branch", + Token: "test-token", + Path: "configs", + } + + gitRepo := &gitRepository{ + config: config, + gitConfig: gitConfig, + } + + t.Run("URL returns correct URL", func(t *testing.T) { + require.Equal(t, "https://git.example.com/owner/repo.git", gitRepo.URL()) + }) + + t.Run("Branch returns correct branch", func(t *testing.T) { + require.Equal(t, "feature-branch", gitRepo.Branch()) + }) + + t.Run("Config returns correct config", func(t *testing.T) { + require.Equal(t, config, gitRepo.Config()) + }) +} + +func TestGitRepository_resolveRefToHash(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + gitConfig RepositoryConfig + ref string + wantError bool + expectRef string + }{ + { + name: "success - empty ref uses default branch", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + ref: "", + wantError: false, + }, + { + name: "success - valid hex hash", + setupMock: func(mockClient *mocks.FakeClient) { + // No mock setup needed for valid hash + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + ref: "abcdef1234567890abcdef1234567890abcdef12", + wantError: false, + }, + { + name: "success - branch reference", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/feature", + Hash: hash.Hash{4, 5, 6}, + }, nil) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + ref: "refs/heads/feature", + wantError: false, + }, + { + name: "failure - ref not found", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{}, nanogit.ErrObjectNotFound) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + ref: "nonexistent-ref", + wantError: true, + }, + { + name: "failure - client error", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{}, errors.New("client error")) + }, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + ref: "some-ref", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: tt.gitConfig, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + refHash, err := gitRepo.resolveRefToHash(context.Background(), tt.ref) + + if tt.wantError { + require.Error(t, err) + require.Nil(t, refHash) + } else { + require.NoError(t, err) + require.NotNil(t, refHash) + } + }) + } +} + +func TestGitRepository_commit(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeStagedWriter) + comment string + wantError bool + authorName string + authorEmail string + }{ + { + name: "success - commit with default signature", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + }, + comment: "Test commit", + wantError: false, + authorName: "Grafana", + authorEmail: "noreply@grafana.com", + }, + { + name: "success - commit with context signature", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + }, + comment: "Test commit with author", + wantError: false, + authorName: "John Doe", + authorEmail: "john@example.com", + }, + { + name: "failure - commit error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed")) + }, + comment: "Failed commit", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockWriter := &mocks.FakeStagedWriter{} + tt.setupMock(mockWriter) + + gitRepo := &gitRepository{ + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "main", + Token: "token123", + }, + } + + ctx := context.Background() + if tt.authorName != "Grafana" { + // Add context signature for custom author + sig := repository.CommitSignature{ + Name: tt.authorName, + Email: tt.authorEmail, + When: time.Date(2024, 1, 1, 12, 0, 0, 0, time.UTC), + } + ctx = repository.WithAuthorSignature(ctx, sig) + } + + err := gitRepo.commit(ctx, mockWriter, tt.comment) + + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + // Verify commit was called once + require.Equal(t, 1, mockWriter.CommitCallCount()) + + // Verify commit parameters + if !tt.wantError { + ctxParam, commentParam, authorParam, committerParam := mockWriter.CommitArgsForCall(0) + require.NotNil(t, ctxParam) + require.Equal(t, tt.comment, commentParam) + require.Equal(t, tt.authorName, authorParam.Name) + require.Equal(t, tt.authorEmail, authorParam.Email) + require.Equal(t, tt.authorName, committerParam.Name) + require.Equal(t, tt.authorEmail, committerParam.Email) + } + }) + } +} + +func TestGitRepository_commitAndPush(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeStagedWriter) + comment string + wantError bool + expectCommit bool + expectPush bool + }{ + { + name: "success - commit and push", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + }, + comment: "Test commit and push", + wantError: false, + expectCommit: true, + expectPush: true, + }, + { + name: "failure - commit fails", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed")) + }, + comment: "Failed commit", + wantError: true, + expectCommit: true, + expectPush: false, + }, + { + name: "failure - push fails", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(errors.New("push failed")) + }, + comment: "Push failure", + wantError: true, + expectCommit: true, + expectPush: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockWriter := &mocks.FakeStagedWriter{} + tt.setupMock(mockWriter) + + gitRepo := &gitRepository{ + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "main", + Token: "token123", + }, + } + + err := gitRepo.commitAndPush(context.Background(), mockWriter, tt.comment) + + if tt.wantError { + require.Error(t, err) + } else { + require.NoError(t, err) + } + + if tt.expectCommit { + require.Equal(t, 1, mockWriter.CommitCallCount()) + } + + if tt.expectPush { + require.Equal(t, 1, mockWriter.PushCallCount()) + } else { + require.Equal(t, 0, mockWriter.PushCallCount()) + } + }) + } +} + +func TestGitRepository_logger(t *testing.T) { + gitRepo := &gitRepository{ + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "main", + Token: "token123", + }, + } + + t.Run("creates new logger context", func(t *testing.T) { + ctx := context.Background() + newCtx, logger := gitRepo.logger(ctx, "feature-branch") + + require.NotNil(t, newCtx) + require.NotNil(t, logger) + require.NotEqual(t, ctx, newCtx) + }) + + t.Run("uses default branch when ref is empty", func(t *testing.T) { + ctx := context.Background() + newCtx, logger := gitRepo.logger(ctx, "") + + require.NotNil(t, newCtx) + require.NotNil(t, logger) + }) + + t.Run("returns existing logger when already present", func(t *testing.T) { + ctx := context.Background() + + // First call creates the logger context + ctx1, logger1 := gitRepo.logger(ctx, "branch1") + + // Second call should return the existing logger + ctx2, logger2 := gitRepo.logger(ctx1, "branch2") + + require.Equal(t, ctx1, ctx2) + require.Equal(t, logger1, logger2) + }) +} + +func TestGitRepository_Clone(t *testing.T) { + gitRepo := &gitRepository{ + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "main", + Token: "token123", + }, + } + + t.Run("calls NewStagedGitRepository", func(t *testing.T) { + ctx := context.Background() + opts := repository.CloneOptions{ + CreateIfNotExists: true, + PushOnWrites: true, + } + + // Since NewStagedGitRepository is not mocked and may panic, we expect this to fail + // We're testing that the method exists and forwards correctly + defer func() { + if r := recover(); r != nil { + // Expected - NewStagedGitRepository isn't fully implemented for this test scenario + t.Logf("NewStagedGitRepository panicked as expected: %v", r) + } + }() + + cloned, err := gitRepo.Clone(ctx, opts) + + // This will likely error/panic since we don't have a real implementation + // but we're testing that the method exists and forwards to NewStagedGitRepository + _ = cloned + _ = err + }) +} + +func TestGitRepository_EdgeCases(t *testing.T) { + t.Run("create with data for directory should fail", func(t *testing.T) { + mockClient := &mocks.FakeClient{} + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + + mockWriter := &mocks.FakeStagedWriter{} + mockClient.NewStagedWriterReturns(mockWriter, nil) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{Branch: "main", Path: "configs"}, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{Type: provisioning.GitHubRepositoryType}, + }, + } + + err := gitRepo.Create(context.Background(), "newdir/", "main", []byte("data"), "comment") + + // This should fail because we're providing data for a directory + require.Error(t, err) + require.Contains(t, err.Error(), "data cannot be provided for a directory") + }) + + t.Run("update directory path should fail early", func(t *testing.T) { + gitRepo := &gitRepository{ + gitConfig: RepositoryConfig{Branch: "main", Path: "configs"}, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{Type: provisioning.GitHubRepositoryType}, + }, + } + + err := gitRepo.Update(context.Background(), "directory/", "main", []byte("data"), "comment") + + require.Error(t, err) + require.Contains(t, err.Error(), "cannot update a directory") + }) + + t.Run("write with read error should fail", func(t *testing.T) { + mockClient := &mocks.FakeClient{} + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{Tree: hash.Hash{}}, nil) + mockClient.GetBlobByPathReturns(&nanogit.Blob{}, errors.New("some read error")) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{Branch: "main", Path: "configs"}, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{Type: provisioning.GitHubRepositoryType}, + }, + } + + err := gitRepo.Write(context.Background(), "test.yaml", "main", []byte("data"), "message") + + require.Error(t, err) + require.Contains(t, err.Error(), "check if file exists before writing") + }) +} + +// Enhanced secrets service mock with error handling +type mockSecretsServiceWithError struct { + shouldError bool +} + +func (m *mockSecretsServiceWithError) Decrypt(ctx context.Context, data []byte) ([]byte, error) { + if m.shouldError { + return nil, errors.New("decryption failed") + } + return []byte("decrypted-token"), nil +} + +func (m *mockSecretsServiceWithError) Encrypt(ctx context.Context, data []byte) ([]byte, error) { + if m.shouldError { + return nil, errors.New("encryption failed") + } + return []byte("encrypted-token"), nil +} + +func TestNewGitRepository_DecryptionError(t *testing.T) { + ctx := context.Background() + mockSecrets := &mockSecretsServiceWithError{shouldError: true} + + config := &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + } + + gitConfig := RepositoryConfig{ + URL: "https://git.example.com/owner/repo.git", + Branch: "main", + Token: "", + EncryptedToken: []byte("encrypted-token"), + Path: "configs", + } + + gitRepo, err := NewGitRepository(ctx, mockSecrets, config, gitConfig) + + require.Error(t, err) + require.Nil(t, gitRepo) + require.Contains(t, err.Error(), "decrypt token") +} + +func TestGitRepository_ValidateBranchNames(t *testing.T) { + tests := []struct { + name string + branchName string + expectValid bool + }{ + {"valid simple branch", "main", true}, + {"valid feature branch", "feature/new-feature", true}, + {"valid branch with numbers", "release-v1.2.3", true}, + {"invalid double slash", "feature//branch", false}, + {"invalid double dot", "feature..branch", false}, + {"invalid ending with dot", "feature.", false}, + {"invalid starting with slash", "/feature", false}, + {"invalid ending with slash", "feature/", false}, + {"invalid with space", "feature branch", false}, + {"invalid with tilde", "feature~1", false}, + {"invalid with caret", "feature^1", false}, + {"invalid with colon", "feature:branch", false}, + {"invalid with question mark", "feature?", false}, + {"invalid with asterisk", "feature*", false}, + {"invalid with square brackets", "feature[1]", false}, + {"invalid with backslash", "feature\\branch", false}, + {"invalid empty string", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gitRepo := &gitRepository{ + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: tt.branchName, + Token: "token123", + }, + } + + errors := gitRepo.Validate() + + if tt.expectValid { + // Should not have a branch validation error for invalid branch name + for _, err := range errors { + if err.Field == "spec.test_type.branch" && err.Type == field.ErrorTypeInvalid { + require.NotContains(t, err.Detail, "invalid branch name") + } + } + } else { + // Should have a branch validation error (either required or invalid) + found := false + for _, err := range errors { + if err.Field == "spec.test_type.branch" && + (err.Type == field.ErrorTypeInvalid || err.Type == field.ErrorTypeRequired) { + found = true + break + } + } + require.True(t, found, "Expected branch validation error for: %s", tt.branchName) + } + }) + } +} + +func TestGitRepository_ResolveRefToHash_EdgeCases(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + ref string + wantError bool + wantHash string + }{ + { + name: "valid short hash", + setupMock: func(mockClient *mocks.FakeClient) { + // No setup needed for valid hash + }, + ref: "abc123", + wantError: false, + }, + { + name: "invalid hex string treated as ref", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/invalid-hex", + Hash: hash.Hash{1, 2, 3}, + }, nil) + }, + ref: "invalid-hex-zzz", + wantError: false, + }, + { + name: "refs/heads/ prefix handling", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/feature", + Hash: hash.Hash{4, 5, 6}, + }, nil) + }, + ref: "refs/heads/feature", + wantError: false, + }, + { + name: "refs/tags/ handling", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/tags/v1.0.0", + Hash: hash.Hash{7, 8, 9}, + }, nil) + }, + ref: "refs/tags/v1.0.0", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + refHash, err := gitRepo.resolveRefToHash(context.Background(), tt.ref) + + if tt.wantError { + require.Error(t, err) + require.Nil(t, refHash) + } else { + require.NoError(t, err) + require.NotNil(t, refHash) + } + }) + } +} + +func TestGitRepository_PathValidation(t *testing.T) { + tests := []struct { + name string + path string + expectError bool + errorMsg string + }{ + {"valid relative path", "configs/dir", false, ""}, + {"path traversal with ../", "../configs", true, "path contains traversal attempt (./ or ../)"}, + {"path traversal with ./", "./configs", true, "path contains traversal attempt (./ or ../)"}, + {"absolute path", "/absolute/path", true, "path must be relative"}, + {"empty path", "", false, ""}, + {"path with multiple traversals", "../../etc/passwd", true, "path contains traversal attempt (./ or ../)"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gitRepo := &gitRepository{ + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + gitConfig: RepositoryConfig{ + URL: "https://git.example.com/repo.git", + Branch: "main", + Token: "token123", + Path: tt.path, + }, + } + + errors := gitRepo.Validate() + + if tt.expectError { + found := false + for _, err := range errors { + if err.Field == "spec.test_type.path" && err.Type == field.ErrorTypeInvalid { + require.Contains(t, err.Detail, tt.errorMsg) + found = true + break + } + } + require.True(t, found, "Expected path validation error for: %s", tt.path) + } else { + // Should not have a path validation error + for _, err := range errors { + if err.Field == "spec.test_type.path" { + t.Errorf("Unexpected path validation error for %s: %s", tt.path, err.Detail) + } + } + } + }) + } +} + +func TestGitRepository_CompareFiles_EdgeCases(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + base string + ref string + wantError bool + errorMsg string + }{ + { + name: "both base and ref empty", + setupMock: func(mockClient *mocks.FakeClient) { + // No mock setup needed + }, + base: "", + ref: "", + wantError: true, + errorMsg: "base and ref cannot be empty", + }, + { + name: "compare commits error", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{ + Name: "refs/heads/feature", + Hash: hash.Hash{4, 5, 6}, + }, nil) + mockClient.CompareCommitsReturns(nil, errors.New("compare error")) + }, + base: "main", + ref: "feature", + wantError: true, + errorMsg: "compare commits", + }, + { + name: "file status type changed", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{ + Name: "refs/heads/feature", + Hash: hash.Hash{4, 5, 6}, + }, nil) + mockClient.CompareCommitsReturns([]nanogit.CommitFile{ + { + Path: "configs/changed-type.yaml", + Status: protocol.FileStatusTypeChanged, + }, + }, nil) + }, + base: "main", + ref: "feature", + wantError: false, + }, + { + name: "files outside configured path", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{ + Name: "refs/heads/feature", + Hash: hash.Hash{4, 5, 6}, + }, nil) + mockClient.CompareCommitsReturns([]nanogit.CommitFile{ + { + Path: "other/file.yaml", // Outside configs/ + Status: protocol.FileStatusAdded, + }, + { + Path: "configs/included.yaml", // Inside configs/ + Status: protocol.FileStatusAdded, + }, + }, nil) + }, + base: "main", + ref: "feature", + wantError: false, + }, + { + name: "unknown file status", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{ + Name: "refs/heads/feature", + Hash: hash.Hash{4, 5, 6}, + }, nil) + mockClient.CompareCommitsReturns([]nanogit.CommitFile{ + { + Path: "configs/unknown.yaml", + Status: protocol.FileStatus("unknown"), + }, + }, nil) + }, + base: "main", + ref: "feature", + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + changes, err := gitRepo.CompareFiles(context.Background(), tt.base, tt.ref) + + if tt.wantError { + require.Error(t, err) + require.Nil(t, changes) + if tt.errorMsg != "" { + require.Contains(t, err.Error(), tt.errorMsg) + } + } else { + require.NoError(t, err) + require.NotNil(t, changes) + + // Verify that files outside configured path are filtered out + if tt.name == "files outside configured path" { + require.Len(t, changes, 1) + require.Equal(t, "included.yaml", changes[0].Path) + } + + // Verify type changed files are handled as updates + if tt.name == "file status type changed" { + require.Len(t, changes, 1) + require.Equal(t, repository.FileActionUpdated, changes[0].Action) + } + } + }) + } +} + +func TestGitRepository_ReadTree_EdgeCases(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + wantError bool + errorType error + }{ + { + name: "get flat tree error", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetFlatTreeReturns(nil, errors.New("flat tree error")) + }, + wantError: true, + }, + { + name: "tree entries outside path", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetFlatTreeReturns(&nanogit.FlatTree{ + Entries: []nanogit.FlatTreeEntry{ + { + Path: "other/file.yaml", // Outside configs/ + Hash: hash.Hash{4, 5, 6}, + Type: protocol.ObjectTypeBlob, + }, + { + Path: "configs/included.yaml", // Inside configs/ + Hash: hash.Hash{7, 8, 9}, + Type: protocol.ObjectTypeBlob, + }, + { + Path: "configs/dir", // Directory without trailing slash + Hash: hash.Hash{10, 11, 12}, + Type: protocol.ObjectTypeTree, + }, + }, + }, nil) + }, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + entries, err := gitRepo.ReadTree(context.Background(), "main") + + if tt.wantError { + require.Error(t, err) + require.Nil(t, entries) + if tt.errorType != nil { + require.ErrorIs(t, err, tt.errorType) + } + } else { + require.NoError(t, err) + require.NotNil(t, entries) + + if tt.name == "tree entries outside path" { + // Should only include entries inside the configured path + require.Len(t, entries, 2) + + // Find the blob entry + var blobEntry *repository.FileTreeEntry + var dirEntry *repository.FileTreeEntry + for i := range entries { + if entries[i].Blob { + blobEntry = &entries[i] + } else { + dirEntry = &entries[i] + } + } + + require.NotNil(t, blobEntry) + require.Equal(t, "included.yaml", blobEntry.Path) + require.True(t, blobEntry.Blob) + + require.NotNil(t, dirEntry) + require.Equal(t, "dir/", dirEntry.Path) + require.False(t, dirEntry.Blob) + } + } + }) + } +} + +func TestGitRepository_NewGitRepository_ClientError(t *testing.T) { + // This test would require mocking nanogit.NewHTTPClient which is difficult + // We test the path where the client creation would fail by using invalid URL + ctx := context.Background() + mockSecrets := &mockSecretsService{} + + config := &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + } + + gitConfig := RepositoryConfig{ + URL: "://invalid-url", // This should cause nanogit.NewHTTPClient to fail + Branch: "main", + Token: "test-token", + Path: "configs", + } + + gitRepo, err := NewGitRepository(ctx, mockSecrets, config, gitConfig) + + // We expect this to fail during client creation + require.Error(t, err) + require.Nil(t, gitRepo) + require.Contains(t, err.Error(), "create nanogit client") +} + +func TestGitRepository_ensureBranchExists_ErrorConditions(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + wantError bool + errorMsg string + }{ + { + name: "GetRef error other than not found", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{}, errors.New("network error")) + }, + wantError: true, + errorMsg: "check branch exists", + }, + { + name: "CreateRef error", + setupMock: func(mockClient *mocks.FakeClient) { + // First call - branch doesn't exist + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{}, nanogit.ErrObjectNotFound) + // Second call - get source branch + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + // CreateRef fails + mockClient.CreateRefReturns(errors.New("create ref failed")) + }, + wantError: true, + errorMsg: "create branch", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + _, err := gitRepo.ensureBranchExists(context.Background(), "new-branch") + + if tt.wantError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGitRepository_Read_EdgeCases(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + filePath string + wantError bool + errorType error + }{ + { + name: "get tree by path error (not ErrObjectNotFound)", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash([]byte("tree-hash")), + }, nil) + mockClient.GetTreeByPathReturns(nil, errors.New("tree error")) + }, + filePath: "directory/", + wantError: true, + }, + { + name: "get commit error", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetCommitReturns(nil, errors.New("commit error")) + }, + filePath: "file.yaml", + wantError: true, + }, + { + name: "get blob by path error (not ErrObjectNotFound)", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash{4, 5, 6}, + }, nil) + mockClient.GetBlobByPathReturns(nil, errors.New("blob error")) + }, + filePath: "file.yaml", + wantError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + fileInfo, err := gitRepo.Read(context.Background(), tt.filePath, "main") + + if tt.wantError { + require.Error(t, err) + require.Nil(t, fileInfo) + if tt.errorType != nil { + require.ErrorIs(t, err, tt.errorType) + } + } else { + require.NoError(t, err) + require.NotNil(t, fileInfo) + } + }) + } +} + +func TestGitRepository_Create_ErrorConditions(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + wantError bool + errorMsg string + }{ + { + name: "NewStagedWriter error", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.NewStagedWriterReturns(nil, errors.New("staged writer error")) + }, + wantError: true, + errorMsg: "create staged writer", + }, + { + name: "CreateBlob error (not ErrObjectAlreadyExists)", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.CreateBlobReturns(hash.Hash{}, errors.New("create blob error")) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + wantError: true, + errorMsg: "create blob", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + err := gitRepo.Create(context.Background(), "test.yaml", "main", []byte("content"), "comment") + + if tt.wantError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGitRepository_Update_ErrorConditions(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + wantError bool + errorMsg string + }{ + { + name: "NewStagedWriter error", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.NewStagedWriterReturns(nil, errors.New("staged writer error")) + }, + wantError: true, + errorMsg: "create staged writer", + }, + { + name: "UpdateBlob error (not ErrObjectNotFound)", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.UpdateBlobReturns(hash.Hash{}, errors.New("update blob error")) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + wantError: true, + errorMsg: "update blob", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + err := gitRepo.Update(context.Background(), "test.yaml", "main", []byte("content"), "comment") + + if tt.wantError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGitRepository_Delete_ErrorConditions(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + path string + wantError bool + errorMsg string + }{ + { + name: "NewStagedWriter error", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.NewStagedWriterReturns(nil, errors.New("staged writer error")) + }, + path: "test.yaml", + wantError: true, + errorMsg: "create staged writer", + }, + { + name: "DeleteBlob error (not ErrObjectNotFound)", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.DeleteBlobReturns(hash.Hash{}, errors.New("delete blob error")) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + path: "test.yaml", + wantError: true, + errorMsg: "delete blob", + }, + { + name: "DeleteTree error (not ErrObjectNotFound)", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.DeleteTreeReturns(hash.Hash{}, errors.New("delete tree error")) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + path: "testdir/", + wantError: true, + errorMsg: "delete tree", + }, + { + name: "DeleteTree ErrObjectNotFound", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.DeleteTreeReturns(hash.Hash{}, nanogit.ErrObjectNotFound) + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + path: "missing-dir/", + wantError: true, + errorMsg: "file not found", // Should return repository.ErrFileNotFound + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + err := gitRepo.Delete(context.Background(), tt.path, "main", "comment") + + if tt.wantError { + require.Error(t, err) + require.Contains(t, err.Error(), tt.errorMsg) + } else { + require.NoError(t, err) + } + }) + } +} + +func TestGitRepository_CompareFiles_EmptyBase(t *testing.T) { + mockClient := &mocks.FakeClient{} + // Only setup for ref resolution + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/feature", + Hash: hash.Hash{4, 5, 6}, + }, nil) + mockClient.CompareCommitsReturns([]nanogit.CommitFile{ + { + Path: "configs/new-file.yaml", + Status: protocol.FileStatusAdded, + }, + }, nil) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + changes, err := gitRepo.CompareFiles(context.Background(), "", "feature") + + require.NoError(t, err) + require.NotNil(t, changes) + require.Len(t, changes, 1) + require.Equal(t, "new-file.yaml", changes[0].Path) + + // Verify CompareCommits was called with empty base hash and feature hash + require.Equal(t, 1, mockClient.CompareCommitsCallCount()) + _, baseHash, refHash := mockClient.CompareCommitsArgsForCall(0) + require.Nil(t, baseHash) // Empty hash for empty base + require.Equal(t, hash.Hash{4, 5, 6}, refHash) +} + +func TestGitRepository_EmptyRefHandling(t *testing.T) { + tests := []struct { + name string + method string + }{ + {"Create with empty ref", "Create"}, + {"Update with empty ref", "Update"}, + {"Delete with empty ref", "Delete"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.CreateBlobReturns(hash.Hash{}, nil) + mockWriter.UpdateBlobReturns(hash.Hash{}, nil) + mockWriter.DeleteBlobReturns(hash.Hash{}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + mockClient.NewStagedWriterReturns(mockWriter, nil) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + var err error + switch tt.method { + case "Create": + err = gitRepo.Create(context.Background(), "test.yaml", "", []byte("content"), "comment") + case "Update": + err = gitRepo.Update(context.Background(), "test.yaml", "", []byte("content"), "comment") + case "Delete": + err = gitRepo.Delete(context.Background(), "test.yaml", "", "comment") + } + + require.NoError(t, err) + }) + } +} + +func TestGitRepository_CompareFiles_ResolveErrors(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + base string + ref string + wantError string + }{ + { + name: "resolve base ref error", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{}, errors.New("base ref error")) + }, + base: "main", + ref: "feature", + wantError: "resolve base ref", + }, + { + name: "resolve ref error", + setupMock: func(mockClient *mocks.FakeClient) { + // First call succeeds for base + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + // Second call fails for ref + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{}, errors.New("ref error")) + }, + base: "main", + ref: "feature", + wantError: "resolve ref", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + changes, err := gitRepo.CompareFiles(context.Background(), tt.base, tt.ref) + + require.Error(t, err) + require.Nil(t, changes) + require.Contains(t, err.Error(), tt.wantError) + }) + } +} + +func TestGitRepository_Read_EmptyRef(t *testing.T) { + mockClient := &mocks.FakeClient{} + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash{4, 5, 6}, + }, nil) + mockClient.GetBlobByPathReturns(&nanogit.Blob{ + Content: []byte("file content"), + Hash: hash.Hash{7, 8, 9}, + }, nil) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + fileInfo, err := gitRepo.Read(context.Background(), "test.yaml", "") + + require.NoError(t, err) + require.NotNil(t, fileInfo) + require.Equal(t, "test.yaml", fileInfo.Path) + require.Equal(t, []byte("file content"), fileInfo.Data) +} + +func TestGitRepository_ReadTree_EmptyRef(t *testing.T) { + mockClient := &mocks.FakeClient{} + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetFlatTreeReturns(&nanogit.FlatTree{ + Entries: []nanogit.FlatTreeEntry{ + { + Path: "configs/test.yaml", + Hash: hash.Hash{4, 5, 6}, + Type: protocol.ObjectTypeBlob, + }, + }, + }, nil) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + entries, err := gitRepo.ReadTree(context.Background(), "") + + require.NoError(t, err) + require.NotNil(t, entries) + require.Len(t, entries, 1) + require.Equal(t, "test.yaml", entries[0].Path) +} + +func TestGitRepository_Update_EnsureBranchExistsError(t *testing.T) { + mockClient := &mocks.FakeClient{} + mockClient.GetRefReturns(nanogit.Ref{}, errors.New("branch error")) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + err := gitRepo.Update(context.Background(), "test.yaml", "feature", []byte("content"), "comment") + + require.Error(t, err) + require.Contains(t, err.Error(), "branch error") +} + +func TestGitRepository_Create_EnsureBranchExistsError(t *testing.T) { + mockClient := &mocks.FakeClient{} + mockClient.GetRefReturns(nanogit.Ref{}, errors.New("branch error")) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + err := gitRepo.Create(context.Background(), "test.yaml", "feature", []byte("content"), "comment") + + require.Error(t, err) + require.Contains(t, err.Error(), "branch error") +} + +func TestGitRepository_Delete_EnsureBranchExistsError(t *testing.T) { + mockClient := &mocks.FakeClient{} + mockClient.GetRefReturns(nanogit.Ref{}, errors.New("branch error")) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + err := gitRepo.Delete(context.Background(), "test.yaml", "feature", "comment") + + require.Error(t, err) + require.Contains(t, err.Error(), "branch error") +} + +func TestGitRepository_Update_DirectoryCheck(t *testing.T) { + gitRepo := &gitRepository{ + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + // Test the internal update function directly with directory path + mockWriter := &mocks.FakeStagedWriter{} + err := gitRepo.update(context.Background(), "directory/", []byte("content"), mockWriter) + + require.Error(t, err) + require.Contains(t, err.Error(), "cannot update a directory") +} + +func TestGitRepository_Write_DefaultRef(t *testing.T) { + mockClient := &mocks.FakeClient{} + // First call for Read check - file not found + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash{4, 5, 6}, + }, nil) + mockClient.GetBlobByPathReturns(&nanogit.Blob{}, nanogit.ErrObjectNotFound) + + // Second call for Create + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockWriter.CreateBlobReturns(hash.Hash{}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + mockClient.NewStagedWriterReturns(mockWriter, nil) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + // Test Write with empty ref to trigger default branch usage + err := gitRepo.Write(context.Background(), "test.yaml", "", []byte("content"), "message") + + require.NoError(t, err) +} + +func TestGitRepository_Read_RefInFileInfo(t *testing.T) { + mockClient := &mocks.FakeClient{} + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/feature", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash{4, 5, 6}, + }, nil) + mockClient.GetBlobByPathReturns(&nanogit.Blob{ + Content: []byte("file content"), + Hash: hash.Hash{7, 8, 9}, + }, nil) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + // Test Read with specific ref to ensure ref is preserved in FileInfo + fileInfo, err := gitRepo.Read(context.Background(), "test.yaml", "feature") + + require.NoError(t, err) + require.NotNil(t, fileInfo) + require.Equal(t, "test.yaml", fileInfo.Path) + require.Equal(t, "feature", fileInfo.Ref) // Should preserve original ref, not hash + require.Equal(t, []byte("file content"), fileInfo.Data) +} + +func TestGitRepository_Read_GetTreeByPath_NotFound(t *testing.T) { + mockClient := &mocks.FakeClient{} + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash([]byte("tree-hash")), + }, nil) + mockClient.GetTreeByPathReturns(nil, nanogit.ErrObjectNotFound) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + // Test reading a directory that doesn't exist + fileInfo, err := gitRepo.Read(context.Background(), "missing-dir/", "main") + + require.Error(t, err) + require.Nil(t, fileInfo) + require.ErrorIs(t, err, repository.ErrFileNotFound) +} + +func TestGitRepository_ReadTree_GetFlatTree_NotFound(t *testing.T) { + mockClient := &mocks.FakeClient{} + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetFlatTreeReturns(nil, nanogit.ErrObjectNotFound) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + // Test reading tree when the commit doesn't exist + entries, err := gitRepo.ReadTree(context.Background(), "main") + + require.Error(t, err) + require.Nil(t, entries) + require.ErrorIs(t, err, repository.ErrRefNotFound) +} + +func TestGitRepository_CompareFiles_FilesOutsideConfiguredPath_AllStatuses(t *testing.T) { + tests := []struct { + name string + status protocol.FileStatus + }{ + {"FileStatusAdded outside path", protocol.FileStatusAdded}, + {"FileStatusModified outside path", protocol.FileStatusModified}, + {"FileStatusDeleted outside path", protocol.FileStatusDeleted}, + {"FileStatusTypeChanged outside path", protocol.FileStatusTypeChanged}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + mockClient.GetRefReturnsOnCall(0, nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetRefReturnsOnCall(1, nanogit.Ref{ + Name: "refs/heads/feature", + Hash: hash.Hash{4, 5, 6}, + }, nil) + mockClient.CompareCommitsReturns([]nanogit.CommitFile{ + { + Path: "other/file.yaml", // File outside "configs/" path + Status: tt.status, + }, + { + Path: "configs/included.yaml", // File inside configured path + Status: tt.status, + }, + }, nil) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + } + + changes, err := gitRepo.CompareFiles(context.Background(), "main", "feature") + + require.NoError(t, err) + require.NotNil(t, changes) + // Should only include the file inside the configured path + require.Len(t, changes, 1) + require.Equal(t, "included.yaml", changes[0].Path) + require.Equal(t, "feature", changes[0].Ref) + + // Verify the action based on status + switch tt.status { + case protocol.FileStatusAdded: + require.Equal(t, repository.FileActionCreated, changes[0].Action) + case protocol.FileStatusModified: + require.Equal(t, repository.FileActionUpdated, changes[0].Action) + case protocol.FileStatusDeleted: + require.Equal(t, repository.FileActionDeleted, changes[0].Action) + require.Equal(t, "main", changes[0].PreviousRef) + require.Equal(t, "included.yaml", changes[0].PreviousPath) + case protocol.FileStatusTypeChanged: + require.Equal(t, repository.FileActionUpdated, changes[0].Action) + } + }) + } +} diff --git a/pkg/registry/apis/provisioning/repository/nanogit/github.go b/pkg/registry/apis/provisioning/repository/nanogit/github.go new file mode 100644 index 00000000000..1305a79a014 --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/nanogit/github.go @@ -0,0 +1,125 @@ +package nanogit + +import ( + "context" + "strings" + + "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" + pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" + "k8s.io/apimachinery/pkg/util/validation/field" + + provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" +) + +// githubRepository is a repository implementation that integrates both a GitHub API-backed repository and a nanogit-based repository. +// It combines the features of the GitHub API with those of a standard Git repository. +// This is an interim solution to support both backends within a single repository abstraction. +// Once nanogit is fully integrated, functionality from GithubRepository should be migrated here, and this type should extend the nanogit.GitRepository interface. +type githubRepository struct { + apiRepo repository.GithubRepository + nanogitRepo repository.GitRepository +} + +func NewGithubRepository( + apiRepo repository.GithubRepository, + nanogitRepo repository.GitRepository, +) repository.GithubRepository { + return &githubRepository{ + apiRepo: apiRepo, + nanogitRepo: nanogitRepo, + } +} + +func (r *githubRepository) Config() *provisioning.Repository { + return r.nanogitRepo.Config() +} + +func (r *githubRepository) Owner() string { + return r.apiRepo.Owner() +} + +func (r *githubRepository) Repo() string { + return r.apiRepo.Repo() +} + +func (r *githubRepository) Client() pgh.Client { + return r.apiRepo.Client() +} + +// Validate extends the nanogit repo validation with github specific validation +func (r *githubRepository) Validate() (list field.ErrorList) { + cfg := r.nanogitRepo.Config() + gh := cfg.Spec.GitHub + if gh == nil { + list = append(list, field.Required(field.NewPath("spec", "github"), "a github config is required")) + return list + } + if gh.URL == "" { + list = append(list, field.Required(field.NewPath("spec", "github", "url"), "a github url is required")) + } else { + _, _, err := repository.ParseOwnerRepoGithub(gh.URL) + if err != nil { + list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, err.Error())) + } else if !strings.HasPrefix(gh.URL, "https://github.com/") { + list = append(list, field.Invalid(field.NewPath("spec", "github", "url"), gh.URL, "URL must start with https://github.com/")) + } + } + + if len(list) > 0 { + return list + } + + return r.nanogitRepo.Validate() +} + +// Test implements provisioning.Repository. +func (r *githubRepository) Test(ctx context.Context) (*provisioning.TestResults, error) { + return r.apiRepo.Test(ctx) +} + +// ReadResource implements provisioning.Repository. +func (r *githubRepository) Read(ctx context.Context, filePath, ref string) (*repository.FileInfo, error) { + return r.nanogitRepo.Read(ctx, filePath, ref) +} + +func (r *githubRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) { + return r.nanogitRepo.ReadTree(ctx, ref) +} + +func (r *githubRepository) Create(ctx context.Context, path, ref string, data []byte, comment string) error { + return r.nanogitRepo.Create(ctx, path, ref, data, comment) +} + +func (r *githubRepository) Update(ctx context.Context, path, ref string, data []byte, comment string) error { + return r.nanogitRepo.Update(ctx, path, ref, data, comment) +} + +func (r *githubRepository) Write(ctx context.Context, path string, ref string, data []byte, message string) error { + return r.nanogitRepo.Write(ctx, path, ref, data, message) +} + +func (r *githubRepository) Delete(ctx context.Context, path, ref, comment string) error { + return r.nanogitRepo.Delete(ctx, path, ref, comment) +} + +func (r *githubRepository) History(ctx context.Context, path, ref string) ([]provisioning.HistoryItem, error) { + // Github API provides avatar URLs which nanogit does not, so we delegate to the github repo. + return r.apiRepo.History(ctx, path, ref) +} + +func (r *githubRepository) LatestRef(ctx context.Context) (string, error) { + return r.nanogitRepo.LatestRef(ctx) +} + +func (r *githubRepository) CompareFiles(ctx context.Context, base, ref string) ([]repository.VersionedFileChange, error) { + return r.nanogitRepo.CompareFiles(ctx, base, ref) +} + +// ResourceURLs implements RepositoryWithURLs. +func (r *githubRepository) ResourceURLs(ctx context.Context, file *repository.FileInfo) (*provisioning.ResourceURLs, error) { + return r.apiRepo.ResourceURLs(ctx, file) +} + +func (r *githubRepository) Clone(ctx context.Context, opts repository.CloneOptions) (repository.ClonedRepository, error) { + return r.nanogitRepo.Clone(ctx, opts) +} diff --git a/pkg/registry/apis/provisioning/repository/nanogit/github_test.go b/pkg/registry/apis/provisioning/repository/nanogit/github_test.go new file mode 100644 index 00000000000..ef878a27c5c --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/nanogit/github_test.go @@ -0,0 +1,356 @@ +package nanogit + +import ( + "context" + "testing" + + provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" + pgh "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" + "github.com/stretchr/testify/require" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func TestGithubRepository(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + // Create a proper config for testing + expectedConfig := &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/test/repo", + Branch: "main", + }, + }, + } + + // Set up mock expectations for the methods that exist + gitRepo.EXPECT().Config().Return(expectedConfig) + apiRepo.EXPECT().Owner().Return("test") + apiRepo.EXPECT().Repo().Return("repo") + mockClient := pgh.NewMockClient(t) + apiRepo.EXPECT().Client().Return(mockClient) + + repo := NewGithubRepository(apiRepo, gitRepo) + + t.Run("delegates config to nanogit repo", func(t *testing.T) { + result := repo.Config() + require.Equal(t, expectedConfig, result) + }) + + t.Run("delegates owner to api repo", func(t *testing.T) { + result := repo.Owner() + require.Equal(t, "test", result) + }) + + t.Run("delegates repo to api repo", func(t *testing.T) { + result := repo.Repo() + require.Equal(t, "repo", result) + }) + + t.Run("delegates client to api repo", func(t *testing.T) { + result := repo.Client() + require.Equal(t, mockClient, result) + }) +} + +func TestGithubRepositoryDelegation(t *testing.T) { + ctx := context.Background() + + t.Run("delegates test to api repo", func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + expectedResult := &provisioning.TestResults{ + Code: 200, + Success: true, + } + + apiRepo.EXPECT().Test(ctx).Return(expectedResult, nil) + + repo := NewGithubRepository(apiRepo, gitRepo) + result, err := repo.Test(ctx) + + require.NoError(t, err) + require.Equal(t, expectedResult, result) + }) + + t.Run("delegates read to nanogit repo", func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + expectedFileInfo := &repository.FileInfo{ + Path: "test.yaml", + Data: []byte("test data"), + Ref: "main", + Hash: "abc123", + } + + gitRepo.EXPECT().Read(ctx, "test.yaml", "main").Return(expectedFileInfo, nil) + + repo := NewGithubRepository(apiRepo, gitRepo) + result, err := repo.Read(ctx, "test.yaml", "main") + + require.NoError(t, err) + require.Equal(t, expectedFileInfo, result) + }) + + t.Run("delegates read tree to nanogit repo", func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + expectedEntries := []repository.FileTreeEntry{ + {Path: "file1.yaml", Size: 100, Hash: "hash1", Blob: true}, + {Path: "dir/", Size: 0, Hash: "hash2", Blob: false}, + } + + gitRepo.EXPECT().ReadTree(ctx, "main").Return(expectedEntries, nil) + + repo := NewGithubRepository(apiRepo, gitRepo) + result, err := repo.ReadTree(ctx, "main") + + require.NoError(t, err) + require.Equal(t, expectedEntries, result) + }) + + t.Run("delegates create to nanogit repo", func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + data := []byte("test content") + gitRepo.EXPECT().Create(ctx, "new-file.yaml", "main", data, "Create new file").Return(nil) + + repo := NewGithubRepository(apiRepo, gitRepo) + err := repo.Create(ctx, "new-file.yaml", "main", data, "Create new file") + + require.NoError(t, err) + }) + + t.Run("delegates update to nanogit repo", func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + data := []byte("updated content") + gitRepo.EXPECT().Update(ctx, "existing-file.yaml", "main", data, "Update file").Return(nil) + + repo := NewGithubRepository(apiRepo, gitRepo) + err := repo.Update(ctx, "existing-file.yaml", "main", data, "Update file") + + require.NoError(t, err) + }) + + t.Run("delegates write to nanogit repo", func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + data := []byte("file content") + gitRepo.EXPECT().Write(ctx, "file.yaml", "main", data, "Write file").Return(nil) + + repo := NewGithubRepository(apiRepo, gitRepo) + err := repo.Write(ctx, "file.yaml", "main", data, "Write file") + + require.NoError(t, err) + }) + + t.Run("delegates delete to nanogit repo", func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + gitRepo.EXPECT().Delete(ctx, "file.yaml", "main", "Delete file").Return(nil) + + repo := NewGithubRepository(apiRepo, gitRepo) + err := repo.Delete(ctx, "file.yaml", "main", "Delete file") + + require.NoError(t, err) + }) + + t.Run("delegates history to api repo", func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + expectedHistory := []provisioning.HistoryItem{ + { + Ref: "commit1", + Message: "First commit", + Authors: []provisioning.Author{{Name: "Test User"}}, + }, + } + + apiRepo.EXPECT().History(ctx, "file.yaml", "main").Return(expectedHistory, nil) + + repo := NewGithubRepository(apiRepo, gitRepo) + result, err := repo.History(ctx, "file.yaml", "main") + + require.NoError(t, err) + require.Equal(t, expectedHistory, result) + }) + + t.Run("delegates latest ref to nanogit repo", func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + expectedRef := "abc123def456" + gitRepo.EXPECT().LatestRef(ctx).Return(expectedRef, nil) + + repo := NewGithubRepository(apiRepo, gitRepo) + result, err := repo.LatestRef(ctx) + + require.NoError(t, err) + require.Equal(t, expectedRef, result) + }) + + t.Run("delegates compare files to nanogit repo", func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + expectedChanges := []repository.VersionedFileChange{ + { + Action: repository.FileActionCreated, + Path: "new-file.yaml", + Ref: "feature-branch", + }, + } + + gitRepo.EXPECT().CompareFiles(ctx, "main", "feature-branch").Return(expectedChanges, nil) + + repo := NewGithubRepository(apiRepo, gitRepo) + result, err := repo.CompareFiles(ctx, "main", "feature-branch") + + require.NoError(t, err) + require.Equal(t, expectedChanges, result) + }) + + t.Run("delegates resource URLs to api repo", func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + fileInfo := &repository.FileInfo{ + Path: "dashboard.json", + Ref: "main", + Hash: "hash123", + } + + expectedURLs := &provisioning.ResourceURLs{ + SourceURL: "https://github.com/test/repo/blob/main/dashboard.json", + RepositoryURL: "https://github.com/test/repo", + NewPullRequestURL: "https://github.com/test/repo/compare/main...feature", + } + + apiRepo.EXPECT().ResourceURLs(ctx, fileInfo).Return(expectedURLs, nil) + + repo := NewGithubRepository(apiRepo, gitRepo) + result, err := repo.ResourceURLs(ctx, fileInfo) + + require.NoError(t, err) + require.Equal(t, expectedURLs, result) + }) + + t.Run("delegates clone to nanogit repo", func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + mockClonedRepo := repository.NewMockClonedRepository(t) + + opts := repository.CloneOptions{ + CreateIfNotExists: true, + PushOnWrites: true, + } + + gitRepo.EXPECT().Clone(ctx, opts).Return(mockClonedRepo, nil) + + repo := NewGithubRepository(apiRepo, gitRepo) + result, err := repo.Clone(ctx, opts) + + require.NoError(t, err) + require.Equal(t, mockClonedRepo, result) + }) +} + +func TestGithubRepositoryValidation(t *testing.T) { + tests := []struct { + name string + config *provisioning.Repository + expectedErrors int + }{ + { + name: "missing github config", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + }, + }, + expectedErrors: 1, + }, + { + name: "missing github url", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + GitHub: &provisioning.GitHubRepositoryConfig{ + Branch: "main", + }, + }, + }, + expectedErrors: 1, + }, + { + name: "invalid github url", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "invalid-url", + Branch: "main", + }, + }, + }, + expectedErrors: 1, + }, + { + name: "non-github url", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://gitlab.com/test/repo", + Branch: "main", + }, + }, + }, + expectedErrors: 1, + }, + { + name: "valid github config", + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: provisioning.GitHubRepositoryType, + GitHub: &provisioning.GitHubRepositoryConfig{ + URL: "https://github.com/test/repo", + Branch: "main", + }, + }, + }, + expectedErrors: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + apiRepo := repository.NewMockGithubRepository(t) + gitRepo := repository.NewMockGitRepository(t) + + // Set up mock expectations + gitRepo.EXPECT().Config().Return(tt.config) + if tt.expectedErrors == 0 { + // If no validation errors expected, nanogit validation should be called + gitRepo.EXPECT().Validate().Return(field.ErrorList{}) + } + + repo := NewGithubRepository(apiRepo, gitRepo) + + result := repo.Validate() + require.Len(t, result, tt.expectedErrors) + }) + } +} diff --git a/pkg/registry/apis/provisioning/repository/nanogit/staged.go b/pkg/registry/apis/provisioning/repository/nanogit/staged.go new file mode 100644 index 00000000000..9c03ffd045b --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/nanogit/staged.go @@ -0,0 +1,193 @@ +package nanogit + +import ( + "context" + "errors" + "fmt" + + "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/safepath" + "github.com/grafana/nanogit" +) + +// stagedGitRepository implements repository.ClonedRepository by wrapping a gitRepository +// FIXME: this is a hack until we can delete the go-git cloned implementation +// once that happens we could do more magic here. +type stagedGitRepository struct { + *gitRepository + opts repository.CloneOptions + writer nanogit.StagedWriter +} + +func NewStagedGitRepository(ctx context.Context, repo *gitRepository, opts repository.CloneOptions) (repository.ClonedRepository, error) { + if opts.BeforeFn != nil { + if err := opts.BeforeFn(); err != nil { + return nil, err + } + } + + if opts.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, opts.Timeout) + defer cancel() + } + + ref, err := repo.client.GetRef(ctx, "refs/heads/"+repo.gitConfig.Branch) + if err != nil { + // TODO: opts.CreateIfNotExists doesn't make sense in the context of the staged repository + // because we only support the branch that is passed in. + // we should probably add branch to the repository.CloneOptions which should be repurposed + // as some kind of branch creation options. + return nil, err + } + + writer, err := repo.client.NewStagedWriter(ctx, ref) + if err != nil { + return nil, fmt.Errorf("build staged writer: %w", err) + } + + return &stagedGitRepository{ + gitRepository: repo, + opts: opts, + writer: writer, + }, nil +} + +func (r *stagedGitRepository) Read(ctx context.Context, path, ref string) (*repository.FileInfo, error) { + if ref != "" && ref != r.gitConfig.Branch { + return nil, errors.New("ref is not supported for staged repository") + } + + // TODO: the read in the cloned is simplied used to check if a folder exists, + // We should fix the usage and the interface so that it's not needed to load the entire blob + return r.gitRepository.Read(ctx, path, ref) +} + +func (r *stagedGitRepository) ReadTree(ctx context.Context, ref string) ([]repository.FileTreeEntry, error) { + if ref != "" && ref != r.gitConfig.Branch { + return nil, errors.New("ref is not supported for staged repository") + } + + ref = "" + // TODO: I think we don't need this for cloned repository currently. + // we should probably remove it from the interface or construct this tree from the writer itself + + return r.gitRepository.ReadTree(ctx, ref) +} + +func (r *stagedGitRepository) Create(ctx context.Context, path, ref string, data []byte, message string) error { + if ref != "" && ref != r.gitConfig.Branch { + return errors.New("ref is not supported for staged repository") + } + + if err := r.create(ctx, path, data, r.writer); err != nil { + return err + } + + if err := r.commit(ctx, r.writer, message); err != nil { + return err + } + + if r.opts.PushOnWrites { + return r.Push(ctx, repository.PushOptions{}) + } + + return nil +} + +func (r *stagedGitRepository) Write(ctx context.Context, path, ref string, data []byte, message string) error { + if ref != "" && ref != r.gitConfig.Branch { + return errors.New("ref is not supported for staged repository") + } + + ok, err := r.writer.BlobExists(ctx, path) + if err != nil { + return fmt.Errorf("check if file exists: %w", err) + } + + if !ok { + if err := r.create(ctx, path, data, r.writer); err != nil { + return err + } + } else { + if err := r.update(ctx, path, data, r.writer); err != nil { + return err + } + } + + if err := r.commit(ctx, r.writer, message); err != nil { + return err + } + + if r.opts.PushOnWrites { + return r.Push(ctx, repository.PushOptions{}) + } + + return nil +} + +func (r *stagedGitRepository) Update(ctx context.Context, path, ref string, data []byte, message string) error { + if ref != "" && ref != r.gitConfig.Branch { + return errors.New("ref is not supported for staged repository") + } + + if safepath.IsDir(path) { + return errors.New("cannot update a directory in a staged repository") + } + + if err := r.update(ctx, path, data, r.writer); err != nil { + return err + } + + if err := r.commit(ctx, r.writer, message); err != nil { + return err + } + + if r.opts.PushOnWrites { + return r.Push(ctx, repository.PushOptions{}) + } + + return nil +} + +func (r *stagedGitRepository) Delete(ctx context.Context, path, ref, message string) error { + if ref != "" && ref != r.gitConfig.Branch { + return errors.New("ref is not supported for staged repository") + } + + if err := r.delete(ctx, path, r.writer); err != nil { + return err + } + + if err := r.commit(ctx, r.writer, message); err != nil { + return err + } + + if r.opts.PushOnWrites { + return r.Push(ctx, repository.PushOptions{}) + } + + return nil +} + +func (r *stagedGitRepository) Push(ctx context.Context, opts repository.PushOptions) error { + if opts.BeforeFn != nil { + if err := opts.BeforeFn(); err != nil { + return err + } + } + + if opts.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, opts.Timeout) + defer cancel() + } + + return r.writer.Push(ctx) +} + +func (r *stagedGitRepository) Remove(_ context.Context) error { + // Since we're using nanogit which doesn't actually clone the repository, + // we don't need to do anything here as there's nothing to clean up + return nil +} diff --git a/pkg/registry/apis/provisioning/repository/nanogit/staged_test.go b/pkg/registry/apis/provisioning/repository/nanogit/staged_test.go new file mode 100644 index 00000000000..4bdff5e885f --- /dev/null +++ b/pkg/registry/apis/provisioning/repository/nanogit/staged_test.go @@ -0,0 +1,941 @@ +package nanogit + +import ( + "context" + "errors" + "testing" + "time" + + provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" + "github.com/grafana/nanogit" + "github.com/grafana/nanogit/mocks" + "github.com/grafana/nanogit/protocol/hash" + "github.com/stretchr/testify/require" +) + +func TestNewStagedGitRepository(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + opts repository.CloneOptions + wantError error + }{ + { + name: "succeeds with default options", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + opts: repository.CloneOptions{ + CreateIfNotExists: false, + PushOnWrites: false, + }, + wantError: nil, + }, + { + name: "succeeds with BeforeFn", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + opts: repository.CloneOptions{ + CreateIfNotExists: false, + PushOnWrites: false, + BeforeFn: func() error { + return nil + }, + }, + wantError: nil, + }, + { + name: "succeeds with timeout", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockWriter := &mocks.FakeStagedWriter{} + mockClient.NewStagedWriterReturns(mockWriter, nil) + }, + opts: repository.CloneOptions{ + CreateIfNotExists: false, + PushOnWrites: false, + Timeout: time.Second * 5, + }, + wantError: nil, + }, + { + name: "fails with BeforeFn error", + setupMock: func(mockClient *mocks.FakeClient) { + // No setup needed as BeforeFn fails first + }, + opts: repository.CloneOptions{ + BeforeFn: func() error { + return errors.New("before function failed") + }, + }, + wantError: errors.New("before function failed"), + }, + { + name: "fails with GetRef error", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{}, errors.New("ref not found")) + }, + opts: repository.CloneOptions{ + CreateIfNotExists: false, + PushOnWrites: false, + }, + wantError: errors.New("ref not found"), + }, + { + name: "fails with NewStagedWriter error", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.NewStagedWriterReturns(nil, errors.New("failed to create writer")) + }, + opts: repository.CloneOptions{ + CreateIfNotExists: false, + PushOnWrites: false, + }, + wantError: errors.New("build staged writer: failed to create writer"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + gitRepo := &gitRepository{ + client: mockClient, + gitConfig: RepositoryConfig{ + Branch: "main", + URL: "https://git.example.com/repo.git", + Token: "token123", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + } + + stagedRepo, err := NewStagedGitRepository(context.Background(), gitRepo, tt.opts) + if tt.wantError != nil { + require.EqualError(t, err, tt.wantError.Error()) + } else { + require.NoError(t, err) + require.NotNil(t, stagedRepo) + + // Compare opts fields individually since function pointers can't be compared directly + actualOpts := stagedRepo.(*stagedGitRepository).opts + require.Equal(t, tt.opts.CreateIfNotExists, actualOpts.CreateIfNotExists) + require.Equal(t, tt.opts.PushOnWrites, actualOpts.PushOnWrites) + require.Equal(t, tt.opts.MaxSize, actualOpts.MaxSize) + require.Equal(t, tt.opts.Timeout, actualOpts.Timeout) + require.Equal(t, tt.opts.Progress, actualOpts.Progress) + // BeforeFn is a function pointer, so we just check if both are nil or both are not nil + require.Equal(t, tt.opts.BeforeFn == nil, actualOpts.BeforeFn == nil) + } + }) + } +} + +func TestStagedGitRepository_Read(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + path string + ref string + wantError error + }{ + { + name: "succeeds with empty ref", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash{4, 5, 6}, + }, nil) + mockClient.GetBlobByPathReturns(&nanogit.Blob{ + Content: []byte("file content"), + Hash: hash.Hash{7, 8, 9}, + }, nil) + }, + path: "test.yaml", + ref: "", + wantError: nil, + }, + { + name: "succeeds with matching ref", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetCommitReturns(&nanogit.Commit{ + Tree: hash.Hash{4, 5, 6}, + }, nil) + mockClient.GetBlobByPathReturns(&nanogit.Blob{ + Content: []byte("file content"), + Hash: hash.Hash{7, 8, 9}, + }, nil) + }, + path: "test.yaml", + ref: "main", + wantError: nil, + }, + { + name: "fails with unsupported ref", + setupMock: func(_ *mocks.FakeClient) { + // No setup needed as error occurs before client calls + }, + path: "test.yaml", + ref: "feature-branch", + wantError: errors.New("ref is not supported for staged repository"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + stagedRepo := createTestStagedRepository(mockClient) + fileInfo, err := stagedRepo.Read(context.Background(), tt.path, tt.ref) + if tt.wantError != nil { + require.EqualError(t, err, tt.wantError.Error()) + } else { + require.NoError(t, err) + require.NotNil(t, fileInfo) + require.Equal(t, tt.path, fileInfo.Path) + } + }) + } +} + +func TestStagedGitRepository_ReadTree(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeClient) + ref string + wantError error + }{ + { + name: "succeeds with empty ref", + setupMock: func(mockClient *mocks.FakeClient) { + mockClient.GetRefReturns(nanogit.Ref{ + Name: "refs/heads/main", + Hash: hash.Hash{1, 2, 3}, + }, nil) + mockClient.GetFlatTreeReturns(&nanogit.FlatTree{ + Entries: []nanogit.FlatTreeEntry{ + { + Path: "configs/test.yaml", + Hash: hash.Hash{4, 5, 6}, + }, + }, + }, nil) + }, + ref: "", + wantError: nil, + }, + { + name: "fails with unsupported ref", + setupMock: func(mockClient *mocks.FakeClient) { + // No setup needed as error occurs before client calls + }, + ref: "feature-branch", + wantError: errors.New("ref is not supported for staged repository"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockClient := &mocks.FakeClient{} + tt.setupMock(mockClient) + + stagedRepo := createTestStagedRepository(mockClient) + + entries, err := stagedRepo.ReadTree(context.Background(), tt.ref) + + if tt.wantError != nil { + require.EqualError(t, err, tt.wantError.Error()) + } else { + require.NoError(t, err) + require.NotNil(t, entries) + } + }) + } +} + +func TestStagedGitRepository_Create(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeStagedWriter) + opts repository.CloneOptions + path string + ref string + data []byte + message string + wantError error + expectPush bool + }{ + { + name: "succeeds with empty ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + data: []byte("content"), + message: "Create test file", + wantError: nil, + expectPush: false, + }, + { + name: "succeeds with matching ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + }, + opts: repository.CloneOptions{ + PushOnWrites: true, + }, + path: "test.yaml", + ref: "main", + data: []byte("content"), + message: "Create test file", + wantError: nil, + expectPush: true, + }, + { + name: "fails with unsupported ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + // No setup needed as error occurs before writer calls + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "feature-branch", + data: []byte("content"), + message: "Create test file", + wantError: errors.New("ref is not supported for staged repository"), + }, + { + name: "fails with create blob error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CreateBlobReturns(hash.Hash{}, errors.New("create blob failed")) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + data: []byte("content"), + message: "Create test file", + wantError: errors.New("create blob: create blob failed"), + }, + { + name: "fails with commit error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed")) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + data: []byte("content"), + message: "Create test file", + wantError: errors.New("commit changes: commit failed"), + }, + { + name: "fails with push error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(errors.New("push failed")) + }, + opts: repository.CloneOptions{ + PushOnWrites: true, + }, + path: "test.yaml", + ref: "", + data: []byte("content"), + message: "Create test file", + wantError: errors.New("push failed"), + expectPush: true, // Push is still called even though it fails + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockWriter := &mocks.FakeStagedWriter{} + tt.setupMock(mockWriter) + + stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, tt.opts) + + err := stagedRepo.Create(context.Background(), tt.path, tt.ref, tt.data, tt.message) + + if tt.wantError != nil { + require.EqualError(t, err, tt.wantError.Error()) + } else { + require.NoError(t, err) + } + + // Verify push behavior + if tt.expectPush { + require.Equal(t, 1, mockWriter.PushCallCount()) + } else if tt.wantError == nil { + require.Equal(t, 0, mockWriter.PushCallCount()) + } + }) + } +} + +func TestStagedGitRepository_Write(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeStagedWriter) + opts repository.CloneOptions + path string + ref string + data []byte + message string + fileExists bool + wantError error + expectPush bool + }{ + { + name: "succeeds with empty ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.BlobExistsReturns(false, nil) + mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + data: []byte("content"), + message: "Write test file", + fileExists: false, + wantError: nil, + expectPush: false, + }, + { + name: "succeeds with matching ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.BlobExistsReturns(true, nil) + mockWriter.UpdateBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + }, + opts: repository.CloneOptions{ + PushOnWrites: true, + }, + path: "test.yaml", + ref: "main", + data: []byte("updated content"), + message: "Update test file", + fileExists: true, + wantError: nil, + expectPush: true, + }, + { + name: "fails with unsupported ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + // No setup needed as error occurs before writer calls + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "feature-branch", + data: []byte("content"), + message: "Write test file", + wantError: errors.New("ref is not supported for staged repository"), + }, + { + name: "fails with blob exists check error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.BlobExistsReturns(false, errors.New("blob exists check failed")) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + data: []byte("content"), + message: "Write test file", + wantError: errors.New("check if file exists: blob exists check failed"), + }, + { + name: "fails with create error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.BlobExistsReturns(false, nil) + mockWriter.CreateBlobReturns(hash.Hash{}, errors.New("create failed")) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + data: []byte("content"), + message: "Write test file", + wantError: errors.New("create blob: create failed"), + }, + { + name: "fails with update error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.BlobExistsReturns(true, nil) + mockWriter.UpdateBlobReturns(hash.Hash{}, errors.New("update failed")) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + data: []byte("content"), + message: "Write test file", + wantError: errors.New("update blob: update failed"), + }, + { + name: "fails with commit error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.BlobExistsReturns(false, nil) + mockWriter.CreateBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed")) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + data: []byte("content"), + message: "Write test file", + wantError: errors.New("commit changes: commit failed"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockWriter := &mocks.FakeStagedWriter{} + tt.setupMock(mockWriter) + + stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, tt.opts) + + err := stagedRepo.Write(context.Background(), tt.path, tt.ref, tt.data, tt.message) + + if tt.wantError != nil { + require.EqualError(t, err, tt.wantError.Error()) + } else { + require.NoError(t, err) + } + + // Verify push behavior + if tt.expectPush { + require.Equal(t, 1, mockWriter.PushCallCount()) + } else if tt.wantError == nil { + require.Equal(t, 0, mockWriter.PushCallCount()) + } + }) + } +} + +func TestStagedGitRepository_Update(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeStagedWriter) + opts repository.CloneOptions + path string + ref string + data []byte + message string + wantError error + expectPush bool + }{ + { + name: "succeeds with empty ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.UpdateBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + data: []byte("updated content"), + message: "Update test file", + wantError: nil, + expectPush: false, + }, + { + name: "succeeds with matching ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.UpdateBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + }, + opts: repository.CloneOptions{ + PushOnWrites: true, + }, + path: "test.yaml", + ref: "main", + data: []byte("updated content"), + message: "Update test file", + wantError: nil, + expectPush: true, + }, + { + name: "fails with unsupported ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + // No setup needed as error occurs before writer calls + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "feature-branch", + data: []byte("content"), + message: "Update test file", + wantError: errors.New("ref is not supported for staged repository"), + }, + { + name: "fails with directory update", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + // No setup needed as error occurs before writer calls + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "directory/", + ref: "", + data: []byte("content"), + message: "Update directory", + wantError: errors.New("cannot update a directory in a staged repository"), + }, + { + name: "fails with update blob error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.UpdateBlobReturns(hash.Hash{}, errors.New("update blob failed")) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + data: []byte("content"), + message: "Update test file", + wantError: errors.New("update blob: update blob failed"), + }, + { + name: "fails with commit error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.UpdateBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed")) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + data: []byte("content"), + message: "Update test file", + wantError: errors.New("commit changes: commit failed"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockWriter := &mocks.FakeStagedWriter{} + tt.setupMock(mockWriter) + + stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, tt.opts) + + err := stagedRepo.Update(context.Background(), tt.path, tt.ref, tt.data, tt.message) + + if tt.wantError != nil { + require.EqualError(t, err, tt.wantError.Error()) + } else { + require.NoError(t, err) + } + + // Verify push behavior + if tt.expectPush { + require.Equal(t, 1, mockWriter.PushCallCount()) + } else if tt.wantError == nil { + require.Equal(t, 0, mockWriter.PushCallCount()) + } + }) + } +} + +func TestStagedGitRepository_Delete(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeStagedWriter) + opts repository.CloneOptions + path string + ref string + message string + wantError error + expectPush bool + }{ + { + name: "succeeds with empty ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.DeleteBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + message: "Delete test file", + wantError: nil, + expectPush: false, + }, + { + name: "succeeds with matching ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.DeleteTreeReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, nil) + mockWriter.PushReturns(nil) + }, + opts: repository.CloneOptions{ + PushOnWrites: true, + }, + path: "testdir/", + ref: "main", + message: "Delete test directory", + wantError: nil, + expectPush: true, + }, + { + name: "fails with unsupported ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + // No setup needed as error occurs before writer calls + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "feature-branch", + message: "Delete test file", + wantError: errors.New("ref is not supported for staged repository"), + }, + { + name: "fails with delete blob error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.DeleteBlobReturns(hash.Hash{}, errors.New("delete blob failed")) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + message: "Delete test file", + wantError: errors.New("delete blob: delete blob failed"), + }, + { + name: "fails with commit error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.DeleteBlobReturns(hash.Hash{1, 2, 3}, nil) + mockWriter.CommitReturns(&nanogit.Commit{}, errors.New("commit failed")) + }, + opts: repository.CloneOptions{ + PushOnWrites: false, + }, + path: "test.yaml", + ref: "", + message: "Delete test file", + wantError: errors.New("commit changes: commit failed"), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockWriter := &mocks.FakeStagedWriter{} + tt.setupMock(mockWriter) + + stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, tt.opts) + + err := stagedRepo.Delete(context.Background(), tt.path, tt.ref, tt.message) + + if tt.wantError != nil { + require.EqualError(t, err, tt.wantError.Error()) + } else { + require.NoError(t, err) + } + + // Verify push behavior + if tt.expectPush { + require.Equal(t, 1, mockWriter.PushCallCount()) + } else if tt.wantError == nil { + require.Equal(t, 0, mockWriter.PushCallCount()) + } + }) + } +} + +func TestStagedGitRepository_Push(t *testing.T) { + tests := []struct { + name string + setupMock func(*mocks.FakeStagedWriter) + opts repository.PushOptions + wantError error + expectCalls int + }{ + { + name: "succeeds with empty ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.PushReturns(nil) + }, + opts: repository.PushOptions{}, + wantError: nil, + expectCalls: 1, + }, + { + name: "succeeds with matching ref", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.PushReturns(nil) + }, + opts: repository.PushOptions{ + BeforeFn: func() error { + return nil + }, + }, + wantError: nil, + expectCalls: 1, + }, + { + name: "succeeds with timeout", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.PushReturns(nil) + }, + opts: repository.PushOptions{ + Timeout: time.Second * 5, + }, + wantError: nil, + expectCalls: 1, + }, + { + name: "fails with before fn error", + setupMock: func(_ *mocks.FakeStagedWriter) { + // No setup needed as BeforeFn fails first + }, + opts: repository.PushOptions{ + BeforeFn: func() error { + return errors.New("before function failed") + }, + }, + wantError: errors.New("before function failed"), + expectCalls: 0, + }, + { + name: "fails with push error", + setupMock: func(mockWriter *mocks.FakeStagedWriter) { + mockWriter.PushReturns(errors.New("push failed")) + }, + opts: repository.PushOptions{}, + wantError: errors.New("push failed"), + expectCalls: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mockWriter := &mocks.FakeStagedWriter{} + tt.setupMock(mockWriter) + + stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, repository.CloneOptions{}) + + err := stagedRepo.Push(context.Background(), tt.opts) + + if tt.wantError != nil { + require.EqualError(t, err, tt.wantError.Error()) + } else { + require.NoError(t, err) + } + + require.Equal(t, tt.expectCalls, mockWriter.PushCallCount()) + }) + } +} + +func TestStagedGitRepository_Remove(t *testing.T) { + t.Run("succeeds with remove", func(t *testing.T) { + mockWriter := &mocks.FakeStagedWriter{} + stagedRepo := createTestStagedRepositoryWithWriter(mockWriter, repository.CloneOptions{}) + + err := stagedRepo.Remove(context.Background()) + + require.NoError(t, err) + // No mock calls expected since Remove is a no-op + }) +} + +// Helper functions for creating test instances + +func createTestStagedRepository(mockClient *mocks.FakeClient) *stagedGitRepository { + mockWriter := &mocks.FakeStagedWriter{} + return createTestStagedRepositoryWithWriter(mockWriter, repository.CloneOptions{}, mockClient) +} + +func createTestStagedRepositoryWithWriter(mockWriter *mocks.FakeStagedWriter, opts repository.CloneOptions, mockClient ...*mocks.FakeClient) *stagedGitRepository { + var client nanogit.Client + if len(mockClient) > 0 { + client = mockClient[0] + } else { + // Create a default mock client for tests that don't need specific behavior + defaultClient := &mocks.FakeClient{} + client = defaultClient + } + + gitRepo := &gitRepository{ + client: client, + gitConfig: RepositoryConfig{ + Branch: "main", + URL: "https://git.example.com/repo.git", + Token: "token123", + Path: "configs", + }, + config: &provisioning.Repository{ + Spec: provisioning.RepositorySpec{ + Type: "test_type", + }, + }, + } + + return &stagedGitRepository{ + gitRepository: gitRepo, + opts: opts, + writer: mockWriter, + } +} diff --git a/pkg/registry/apis/provisioning/repository/repository.go b/pkg/registry/apis/provisioning/repository/repository.go index e29539ed864..fcd40d5ba80 100644 --- a/pkg/registry/apis/provisioning/repository/repository.go +++ b/pkg/registry/apis/provisioning/repository/repository.go @@ -38,6 +38,20 @@ var ErrFileNotFound error = &apierrors.StatusError{ErrStatus: metav1.Status{ Message: "file not found", }} +var ErrRefNotFound error = &apierrors.StatusError{ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusNotFound, + Reason: metav1.StatusReasonNotFound, + Message: "ref not found", +}} + +var ErrFileAlreadyExists error = &apierrors.StatusError{ErrStatus: metav1.Status{ + Status: metav1.StatusFailure, + Code: http.StatusConflict, + Reason: metav1.StatusReasonAlreadyExists, + Message: "file already exists", +}} + type FileInfo struct { // Path to the file on disk. // No leading or trailing slashes will be contained within. diff --git a/pkg/registry/apis/provisioning/webhooks/register.go b/pkg/registry/apis/provisioning/webhooks/register.go index 53a6f36c3ea..ff73ad525d4 100644 --- a/pkg/registry/apis/provisioning/webhooks/register.go +++ b/pkg/registry/apis/provisioning/webhooks/register.go @@ -6,16 +6,19 @@ import ( "path/filepath" "strings" + "github.com/grafana/grafana-app-sdk/logging" provisioning "github.com/grafana/grafana/pkg/apis/provisioning/v0alpha1" provisioningapis "github.com/grafana/grafana/pkg/registry/apis/provisioning" "github.com/grafana/grafana/pkg/registry/apis/provisioning/jobs" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository" "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/github" gogit "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/go-git" + "github.com/grafana/grafana/pkg/registry/apis/provisioning/repository/nanogit" "github.com/grafana/grafana/pkg/registry/apis/provisioning/resources" "github.com/grafana/grafana/pkg/registry/apis/provisioning/secrets" "github.com/grafana/grafana/pkg/registry/apis/provisioning/webhooks/pullrequest" "github.com/grafana/grafana/pkg/services/apiserver" + "github.com/grafana/grafana/pkg/services/featuremgmt" "github.com/grafana/grafana/pkg/services/rendering" grafanasecrets "github.com/grafana/grafana/pkg/services/secrets" "github.com/grafana/grafana/pkg/setting" @@ -34,6 +37,7 @@ type WebhookExtraBuilder struct { func ProvideWebhooks( cfg *setting.Cfg, + features featuremgmt.FeatureToggles, // FIXME: use multi-tenant service when one exists. In this state, we can't make this a multi-tenant service! secretsSvc grafanasecrets.Service, ghFactory *github.Factory, @@ -64,6 +68,7 @@ func ProvideWebhooks( pullRequestWorker := pullrequest.NewPullRequestWorker(evaluator, commenter) return NewWebhookExtra( + features, render, webhook, urlProvider, @@ -80,6 +85,7 @@ func ProvideWebhooks( // WebhookExtra implements the Extra interface for webhooks // to wrap around type WebhookExtra struct { + features featuremgmt.FeatureToggles render *renderConnector webhook *webhookConnector urlProvider func(namespace string) string @@ -91,6 +97,7 @@ type WebhookExtra struct { } func NewWebhookExtra( + features featuremgmt.FeatureToggles, render *renderConnector, webhook *webhookConnector, urlProvider func(namespace string) string, @@ -101,6 +108,7 @@ func NewWebhookExtra( workers []jobs.Worker, ) *WebhookExtra { return &WebhookExtra{ + features: features, render: render, webhook: webhook, urlProvider: urlProvider, @@ -176,11 +184,39 @@ func (e *WebhookExtra) AsRepository(ctx context.Context, r *provisioning.Reposit return gogit.Clone(ctx, e.clonedir, r, opts, e.secrets) } - basicRepo, err := repository.NewGitHub(ctx, r, e.ghFactory, e.secrets, cloneFn) + apiRepo, err := repository.NewGitHub(ctx, r, e.ghFactory, e.secrets, cloneFn) if err != nil { - return nil, err + return nil, fmt.Errorf("create github API repository: %w", err) } + logger := logging.FromContext(ctx).With("url", r.Spec.GitHub.URL, "branch", r.Spec.GitHub.Branch, "path", r.Spec.GitHub.Path) + if !e.features.IsEnabledGlobally(featuremgmt.FlagNanoGit) { + logger.Debug("Instantiating Github repository with go-git and Github API") + return NewGithubWebhookRepository(apiRepo, webhookURL, e.secrets), nil + } + + logger.Info("Instantiating Github repository with nanogit") + + ghCfg := r.Spec.GitHub + if ghCfg == nil { + return nil, fmt.Errorf("github configuration is required for nano git") + } + + gitCfg := nanogit.RepositoryConfig{ + URL: ghCfg.URL, + Branch: ghCfg.Branch, + Path: ghCfg.Path, + Token: ghCfg.Token, + EncryptedToken: ghCfg.EncryptedToken, + } + + nanogitRepo, err := nanogit.NewGitRepository(ctx, e.secrets, r, gitCfg) + if err != nil { + return nil, fmt.Errorf("error creating nanogit repository: %w", err) + } + + basicRepo := nanogit.NewGithubRepository(apiRepo, nanogitRepo) + return NewGithubWebhookRepository(basicRepo, webhookURL, e.secrets), nil } diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index ece77ff06fa..8a4b2e56ccc 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -351,6 +351,13 @@ var ( RequiresRestart: true, Owner: grafanaAppPlatformSquad, }, + { + Name: "nanoGit", + Description: "Use experimental git library for provisioning", + Stage: FeatureStageExperimental, + RequiresRestart: true, + Owner: grafanaAppPlatformSquad, + }, { Name: "grafanaAPIServerEnsureKubectlAccess", Description: "Start an additional https handler and write kubectl options", diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index fde28104831..3afc47cac37 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -45,6 +45,7 @@ mlExpressions,experimental,@grafana/alerting-squad,false,false,false datasourceAPIServers,experimental,@grafana/grafana-app-platform-squad,false,true,false grafanaAPIServerWithExperimentalAPIs,experimental,@grafana/grafana-app-platform-squad,true,true,false provisioning,experimental,@grafana/grafana-app-platform-squad,false,true,false +nanoGit,experimental,@grafana/grafana-app-platform-squad,false,true,false grafanaAPIServerEnsureKubectlAccess,experimental,@grafana/grafana-app-platform-squad,true,true,false featureToggleAdminPage,experimental,@grafana/grafana-operator-experience-squad,false,true,false awsAsyncQueryCaching,GA,@grafana/aws-datasources,false,false,false diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index feb2f6dd71a..989dbdb0f0f 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -191,6 +191,10 @@ const ( // Next generation provisioning... and git FlagProvisioning = "provisioning" + // FlagNanoGit + // Use experimental git library for provisioning + FlagNanoGit = "nanoGit" + // FlagGrafanaAPIServerEnsureKubectlAccess // Start an additional https handler and write kubectl options FlagGrafanaAPIServerEnsureKubectlAccess = "grafanaAPIServerEnsureKubectlAccess" diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 40997549ea6..17ddf6f9ce3 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -2236,6 +2236,19 @@ "codeowner": "@grafana/search-and-storage" } }, + { + "metadata": { + "name": "nanoGit", + "resourceVersion": "1750087021356", + "creationTimestamp": "2025-06-16T15:17:01Z" + }, + "spec": { + "description": "Use experimental git library for provisioning", + "stage": "experimental", + "codeowner": "@grafana/grafana-app-platform-squad", + "requiresRestart": true + } + }, { "metadata": { "name": "nestedFolders",