From 89f3a87e0c7afc0b2f000f05e7b1f84a008b7822 Mon Sep 17 00:00:00 2001 From: jansure Date: Thu, 17 Sep 2026 20:49:29 +0800 Subject: [PATCH] feat: add remote Git file update command Signed-off-by: jansure --- go.mod | 23 +- go.sum | 63 ++++ internal/gitupdate/api.go | 173 +++++++++++ internal/gitupdate/auth.go | 212 ++++++++++++++ internal/gitupdate/auth_test.go | 128 ++++++++ internal/gitupdate/edit.go | 356 ++++++++++++++++++++++ internal/gitupdate/edit_test.go | 364 +++++++++++++++++++++++ internal/gitupdate/git.go | 307 +++++++++++++++++++ internal/gitupdate/git_test.go | 205 +++++++++++++ internal/gitupdate/gitlab_api.go | 227 ++++++++++++++ internal/gitupdate/gitlab_api_test.go | 306 +++++++++++++++++++ internal/gitupdate/json.go | 337 +++++++++++++++++++++ internal/gitupdate/pointer.go | 59 ++++ internal/gitupdate/yaml.go | 390 ++++++++++++++++++++++++ pkg/cmd/git.go | 407 ++++++++++++++++++++++++++ pkg/cmd/git_test.go | 116 ++++++++ pkg/cmd/root.go | 1 + 17 files changed, 3673 insertions(+), 1 deletion(-) create mode 100644 internal/gitupdate/api.go create mode 100644 internal/gitupdate/auth.go create mode 100644 internal/gitupdate/auth_test.go create mode 100644 internal/gitupdate/edit.go create mode 100644 internal/gitupdate/edit_test.go create mode 100644 internal/gitupdate/git.go create mode 100644 internal/gitupdate/git_test.go create mode 100644 internal/gitupdate/gitlab_api.go create mode 100644 internal/gitupdate/gitlab_api_test.go create mode 100644 internal/gitupdate/json.go create mode 100644 internal/gitupdate/pointer.go create mode 100644 internal/gitupdate/yaml.go create mode 100644 pkg/cmd/git.go create mode 100644 pkg/cmd/git_test.go diff --git a/go.mod b/go.mod index 9bf87cc87..a699556b6 100644 --- a/go.mod +++ b/go.mod @@ -52,6 +52,25 @@ require ( sigs.k8s.io/yaml v1.6.0 ) +require ( + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-git/go-billy/v5 v5.9.0 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/google/go-querystring v1.2.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/pjbgf/sha1cd v0.6.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect +) + require ( dario.cat/mergo v1.0.1 // indirect github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect @@ -78,6 +97,7 @@ require ( github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fxamacker/cbor/v2 v2.9.3 // indirect github.com/go-errors/errors v1.5.1 // indirect + github.com/go-git/go-git/v5 v5.19.1 github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-logr/logr v1.4.4 // indirect @@ -142,6 +162,7 @@ require ( github.com/tetratelabs/wabin v0.0.0-20230304001439-f6f874872834 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xlab/treeprint v1.2.0 // indirect + gitlab.com/gitlab-org/api/client-go/v2 v2.51.0 go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 // indirect @@ -168,7 +189,7 @@ require ( go.yaml.in/yaml/v2 v2.4.4 // indirect golang.org/x/mod v0.41.0 // indirect golang.org/x/net v0.58.0 // indirect - golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/oauth2 v0.36.0 golang.org/x/sync v0.23.0 // indirect golang.org/x/sys v0.48.0 // indirect golang.org/x/time v0.15.0 // indirect diff --git a/go.sum b/go.sum index 76878be07..a709c9c78 100644 --- a/go.sum +++ b/go.sum @@ -22,12 +22,19 @@ github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8 github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Masterminds/vcs v1.13.3 h1:IIA2aBdXvfbIM+yl/eTnL4hb1XwdpvuQLglAix1gweE= github.com/Masterminds/vcs v1.13.3/go.mod h1:TiE7xuEjl1N4j016moRd6vezp6e6Lz23gypeXfzXeW8= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/ProtonMail/go-crypto v1.4.1 h1:9RfcZHqEQUvP8RzecWEUafnZVtEvrBVL9BiF67IQOfM= github.com/ProtonMail/go-crypto v1.4.1/go.mod h1:e1OaTyu5SYVrO9gKOEhTc+5UcXtTUa+P3uLudwcgPqo= github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc= github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRFXyAe+OPACYpWeL0wqObRcbAqCMya13uyzqw0= github.com/alicebob/miniredis/v2 v2.35.0 h1:QwLphYqCEAo1eu1TqPRN2jgVMPBweeQcR21jeqDCONI= github.com/alicebob/miniredis/v2 v2.35.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= @@ -81,8 +88,12 @@ github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQ github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a h1:UwSIFv5g5lIvbGgtf3tVwC7Ky9rmMFBp0RMs+6f6YqE= github.com/dylibso/observe-sdk/go v0.0.0-20240819160327-2d926c5d788a/go.mod h1:C8DzXehI4zAbrdlbtOByKX6pfivJTBiV9Jjqv56Yd9Q= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= @@ -101,8 +112,18 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fxamacker/cbor/v2 v2.9.3 h1:oQBnFATpNdY8gJHTndDDv5Xl4QqNaz51G5LLEPhng3Q= github.com/fxamacker/cbor/v2 v2.9.3/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.9.0 h1:jItGXszUDRtR/AlferWPTMN4j38BQ88XnXKbilmmBPA= +github.com/go-git/go-billy/v5 v5.9.0/go.mod h1:jCnQMLj9eUgGU7+ludSTYoZL/GGmii14RxKFj7ROgHw= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.19.1 h1:nX27AnaU43/K5bKktKwgBmR9lawoYVe1Ckg0rgzzN00= +github.com/go-git/go-git/v5 v5.19.1/go.mod h1:Pb1v0c7/g8aGQJwx9Us09W85yGoyvSwuhEGMH7zjDKQ= github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= @@ -161,6 +182,8 @@ github.com/gobwas/glob v1.0.0/go.mod h1:oWCdo522i2P1n/hMXGNWs7yoV4wy/ciZuUIbvKj5 github.com/gofrs/flock v0.13.1 h1:jjREztyBeSKBZYAC+mgc1laB+xsgy4kYMf3FbKF2UBo= github.com/gofrs/flock v0.13.1/go.mod h1:sf4BFiHwnvgxa25DlQoDqXQnwRMEOwqxRq37P6MzzmE= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= @@ -171,8 +194,11 @@ github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl76 github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 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/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI= @@ -186,6 +212,12 @@ github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= github.com/hashicorp/golang-lru/v2 v2.0.5 h1:wW7h1TG88eUIJ2i69gaE3uNVtEPIagzhGvHgwfx2Vm4= @@ -196,6 +228,8 @@ github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca h1:T54Ema1 github.com/ianlancetaylor/demangle v0.0.0-20240805132620-81f5be970eca/go.mod h1:gx7rwoVhcfuVKG5uya9Hs3Sxj7EIvldVofAWIUtGouw= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU= @@ -203,13 +237,20 @@ github.com/json-iterator/go v1.1.7/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/u github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE= github.com/klauspost/compress v1.19.1 h1:VsB4HPswih7mmZ8WleSFQ75c/Ui1M4trX5oAsJnhSlk= github.com/klauspost/compress v1.19.1/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= @@ -267,7 +308,11 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/pjbgf/sha1cd v0.6.0 h1:3WJ8Wz8gvDz29quX1OcEmkAlUg9diU4GxJHqs0/XiwU= +github.com/pjbgf/sha1cd v0.6.0/go.mod h1:lhpGlyHLpQZoxMv8HcgXvZEhcGs0PG/vsZnEJ7H0iCM= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= @@ -313,9 +358,12 @@ github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepq github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -329,6 +377,7 @@ github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4= github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= @@ -339,11 +388,15 @@ github.com/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZ github.com/tetratelabs/wazero v1.12.0/go.mod h1:LvKtzl2RqO4gyF27BiXU+nKAjcV8f38U+kP/q2vgxh0= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M= github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw= +gitlab.com/gitlab-org/api/client-go/v2 v2.51.0 h1:jP3bsuS3WpiEqhxhG58GJtqCyOnjyACQ8u8oyUg7ru8= +gitlab.com/gitlab-org/api/client-go/v2 v2.51.0/go.mod h1:P0sRPwCAGIek6HIU0JH5W9Ic5+z3eWvCWwXeg7Pwr7o= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= @@ -408,6 +461,7 @@ go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg= golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= @@ -423,6 +477,7 @@ golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73r golang.org/x/net v0.0.0-20190613194153-d28f0bde5980/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= @@ -449,9 +504,12 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190801041406-cbf593c0f2f3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -472,6 +530,7 @@ golang.org/x/term v0.46.0 h1:3+OXuTbaKDgwk8jTi3aSLHRlmWqHEUDUtxnbFigO4YE= golang.org/x/term v0.46.0/go.mod h1:+K02xbkittuwc0Am4abfA3Fc+XRGXkvBXNO88NCXPoc= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= @@ -502,13 +561,17 @@ google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fw google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/gitupdate/api.go b/internal/gitupdate/api.go new file mode 100644 index 000000000..7d5756222 --- /dev/null +++ b/internal/gitupdate/api.go @@ -0,0 +1,173 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitupdate + +import ( + "context" + "errors" + "fmt" + "net/url" + "slices" + "strings" +) + +const ( + APIProviderAuto = "auto" + APIProviderGitLab = "gitlab" + + APITokenAuto = "auto" + APITokenPrivate = "private" + APITokenOAuth = "oauth" + APITokenJob = "job" +) + +// APIOptions configures direct repository-provider API access. +type APIOptions struct { + Provider string + BaseURL string + Project string + TokenType string +} + +// ValidAPIProviders returns the repository APIs supported by TransportAPI. +func ValidAPIProviders() []string { + return []string{APIProviderAuto, APIProviderGitLab} +} + +// ValidAPITokenTypes returns the authentication token types supported by API +// backends. Individual providers may support only a subset. +func ValidAPITokenTypes() []string { + return []string{APITokenAuto, APITokenPrivate, APITokenOAuth, APITokenJob} +} + +func validateAPIOptions(opts RepositoryOptions) error { + provider, err := resolveAPIProvider(opts.API) + if err != nil { + return err + } + if _, err := resolveAPITokenType(opts.API.TokenType, opts.Auth.Type); err != nil { + return err + } + if opts.Auth.Token == "" { + return errors.New("API transport requires --token, --token-file, or HELM_GIT_TOKEN") + } + switch opts.Auth.Type { + case "", AuthAuto, AuthToken, AuthBearer: + default: + return fmt.Errorf("authentication mode %q is not supported by the %s API transport", opts.Auth.Type, provider) + } + if opts.Auth.Password != "" || opts.Auth.SSHPrivateKey != "" || opts.Auth.SSHKeyPassphrase != "" || + opts.Auth.KnownHostsFile != "" || opts.Auth.InsecureHostKey { + return fmt.Errorf("password and SSH authentication options are not supported by the %s API transport", provider) + } + if !opts.Push && !opts.DryRun { + return errors.New("API transport writes commits remotely and requires --push=true (use --dry-run to validate without writing)") + } + + _, _, err = resolveGitLabCoordinates(opts.RepositoryURL, opts.API) + return err +} + +func updateViaAPI(ctx context.Context, opts RepositoryOptions, result Result) (Result, error) { + if _, err := resolveAPIProvider(opts.API); err != nil { + return result, err + } + return updateViaGitLabAPI(ctx, opts, result) +} + +func resolveAPIProvider(apiOpts APIOptions) (string, error) { + provider := apiOpts.Provider + if provider == "" { + provider = APIProviderAuto + } + if provider == APIProviderAuto || provider == APIProviderGitLab { + return APIProviderGitLab, nil + } + return "", fmt.Errorf("unknown API provider %q (supported: %s)", provider, strings.Join(ValidAPIProviders(), ", ")) +} + +func resolveAPITokenType(tokenType, authType string) (string, error) { + if tokenType == "" || tokenType == APITokenAuto { + if authType == AuthBearer { + return APITokenOAuth, nil + } + return APITokenPrivate, nil + } + if slices.Contains(ValidAPITokenTypes(), tokenType) { + return tokenType, nil + } + return "", fmt.Errorf("unknown API token type %q (supported: %s)", tokenType, strings.Join(ValidAPITokenTypes(), ", ")) +} + +func resolveAPICoordinates(repositoryURL string, apiOpts APIOptions, apiPath, providerName string) (string, string, error) { + inferredHost, inferredProject, inferredScheme, err := inferRepositoryCoordinates(repositoryURL) + if err != nil && (apiOpts.BaseURL == "" || apiOpts.Project == "") { + return "", "", err + } + + baseURL := apiOpts.BaseURL + if baseURL == "" { + baseURL = fmt.Sprintf("%s://%s/%s", inferredScheme, inferredHost, strings.Trim(apiPath, "/")) + } + project := apiOpts.Project + if project == "" { + project = inferredProject + } + if project == "" { + return "", "", fmt.Errorf("%s API project is empty (set --api-project)", providerName) + } + + parsed, err := url.Parse(baseURL) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "", "", fmt.Errorf("invalid %s API base URL %q", providerName, baseURL) + } + return strings.TrimRight(baseURL, "/") + "/", project, nil +} + +func inferRepositoryCoordinates(repositoryURL string) (host, project, scheme string, err error) { + if strings.Contains(repositoryURL, "://") { + parsed, parseErr := url.Parse(repositoryURL) + if parseErr != nil { + return "", "", "", fmt.Errorf("parse repository URL for API access: %w", parseErr) + } + host = parsed.Host + project = strings.Trim(parsed.Path, "/") + switch parsed.Scheme { + case "http", "https": + scheme = parsed.Scheme + case "ssh", "git+ssh": + scheme = "https" + default: + return "", "", "", fmt.Errorf("cannot infer API URL from repository scheme %q", parsed.Scheme) + } + } else { + at := strings.IndexByte(repositoryURL, '@') + colon := strings.IndexByte(repositoryURL, ':') + if at <= 0 || colon <= at+1 { + return "", "", "", errors.New("cannot infer API coordinates from repository URL (set --api-base-url, --api-project, and --api-provider)") + } + scheme = "https" + host = repositoryURL[at+1 : colon] + project = repositoryURL[colon+1:] + } + + project = strings.TrimSuffix(strings.Trim(project, "/"), ".git") + if host == "" || project == "" { + return "", "", "", errors.New("repository URL does not contain a host and project path") + } + return host, project, scheme, nil +} diff --git a/internal/gitupdate/auth.go b/internal/gitupdate/auth.go new file mode 100644 index 000000000..7aaacc25d --- /dev/null +++ b/internal/gitupdate/auth.go @@ -0,0 +1,212 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitupdate + +import ( + "errors" + "fmt" + "os" + "strings" + + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + gitssh "github.com/go-git/go-git/v5/plumbing/transport/ssh" + "golang.org/x/crypto/ssh" + + "github.com/go-git/go-git/v5/plumbing/transport" +) + +const ( + AuthAuto = "auto" + AuthNone = "none" + AuthBasic = "basic" + AuthToken = "token" + AuthBearer = "bearer" + AuthSSHKey = "ssh-key" + AuthSSHAgent = "ssh-agent" + AuthSSHPassword = "ssh-password" +) + +// ValidAuthTypes returns the authentication modes understood by BuildAuth. +func ValidAuthTypes() []string { + return []string{ + AuthAuto, + AuthNone, + AuthBasic, + AuthToken, + AuthBearer, + AuthSSHKey, + AuthSSHAgent, + AuthSSHPassword, + } +} + +// AuthOptions contains credentials for Git HTTP(S) and SSH transports. +type AuthOptions struct { + Type string + Username string + Password string + Token string + SSHPrivateKey string + SSHKeyPassphrase string + KnownHostsFile string + InsecureHostKey bool + SSHAgentSocketIsSet bool +} + +// BuildAuth constructs a go-git authentication method. Automatic mode chooses +// an explicit credential first, then an SSH agent for SSH URLs. +func BuildAuth(repositoryURL string, opts AuthOptions) (transport.AuthMethod, error) { + authType := opts.Type + if authType == "" { + authType = AuthAuto + } + if authType == AuthAuto { + switch { + case opts.SSHPrivateKey != "": + authType = AuthSSHKey + case opts.Token != "": + authType = AuthToken + case opts.Password != "" && isSSHURL(repositoryURL): + authType = AuthSSHPassword + case opts.Password != "" || opts.Username != "": + authType = AuthBasic + case isSSHURL(repositoryURL) && opts.SSHAgentSocketIsSet: + authType = AuthSSHAgent + default: + authType = AuthNone + } + } + + sshTransport := isSSHURL(repositoryURL) + httpTransport := strings.HasPrefix(repositoryURL, "http://") || strings.HasPrefix(repositoryURL, "https://") + switch authType { + case AuthBasic, AuthToken, AuthBearer: + if !httpTransport { + return nil, fmt.Errorf("authentication mode %q requires an HTTP(S) repository URL", authType) + } + case AuthSSHKey, AuthSSHAgent, AuthSSHPassword: + if !sshTransport { + return nil, fmt.Errorf("authentication mode %q requires an SSH repository URL", authType) + } + } + + switch authType { + case AuthNone: + if opts.Username != "" || opts.Password != "" || opts.Token != "" || opts.SSHPrivateKey != "" || opts.SSHKeyPassphrase != "" { + return nil, errors.New("authentication mode \"none\" cannot be combined with credentials") + } + if opts.KnownHostsFile != "" || opts.InsecureHostKey { + return nil, errors.New("SSH host-key options require ssh-key, ssh-agent, or ssh-password authentication") + } + return nil, nil + case AuthBasic: + if opts.Username == "" { + return nil, errors.New("basic authentication requires --username") + } + if opts.Password == "" { + return nil, errors.New("basic authentication requires a password") + } + return &githttp.BasicAuth{Username: opts.Username, Password: opts.Password}, nil + case AuthToken: + if opts.Token == "" { + return nil, errors.New("token authentication requires a token") + } + username := opts.Username + if username == "" { + username = "git" + } + return &githttp.BasicAuth{Username: username, Password: opts.Token}, nil + case AuthBearer: + if opts.Token == "" { + return nil, errors.New("bearer authentication requires a token") + } + return &githttp.TokenAuth{Token: opts.Token}, nil + case AuthSSHKey: + if opts.SSHPrivateKey == "" { + return nil, errors.New("ssh-key authentication requires --ssh-private-key") + } + username := sshUsername(opts.Username) + key, err := gitssh.NewPublicKeysFromFile(username, opts.SSHPrivateKey, opts.SSHKeyPassphrase) + if err != nil { + return nil, fmt.Errorf("load SSH private key: %w", err) + } + if err := configureHostKeyCallback(&key.HostKeyCallbackHelper, opts); err != nil { + return nil, err + } + return key, nil + case AuthSSHAgent: + username := sshUsername(opts.Username) + agent, err := gitssh.NewSSHAgentAuth(username) + if err != nil { + return nil, fmt.Errorf("connect to SSH agent: %w", err) + } + if err := configureHostKeyCallback(&agent.HostKeyCallbackHelper, opts); err != nil { + return nil, err + } + return agent, nil + case AuthSSHPassword: + if opts.Password == "" { + return nil, errors.New("ssh-password authentication requires a password") + } + password := &gitssh.Password{User: sshUsername(opts.Username), Password: opts.Password} + if err := configureHostKeyCallback(&password.HostKeyCallbackHelper, opts); err != nil { + return nil, err + } + return password, nil + default: + return nil, fmt.Errorf("unknown authentication mode %q (supported: %s)", authType, strings.Join(ValidAuthTypes(), ", ")) + } +} + +func configureHostKeyCallback(helper *gitssh.HostKeyCallbackHelper, opts AuthOptions) error { + if opts.InsecureHostKey && opts.KnownHostsFile != "" { + return errors.New("--insecure-skip-ssh-host-key cannot be combined with --ssh-known-hosts") + } + if opts.InsecureHostKey { + helper.HostKeyCallback = ssh.InsecureIgnoreHostKey() + return nil + } + if opts.KnownHostsFile != "" { + callback, err := gitssh.NewKnownHostsCallback(opts.KnownHostsFile) + if err != nil { + return fmt.Errorf("load SSH known-hosts file: %w", err) + } + helper.HostKeyCallback = callback + } + return nil +} + +func isSSHURL(repositoryURL string) bool { + if strings.HasPrefix(repositoryURL, "ssh://") || strings.HasPrefix(repositoryURL, "git+ssh://") { + return true + } + at := strings.IndexByte(repositoryURL, '@') + colon := strings.IndexByte(repositoryURL, ':') + return at > 0 && colon > at+1 && !strings.Contains(repositoryURL[:colon], "://") +} + +func sshUsername(username string) string { + if username == "" { + return gitssh.DefaultUsername + } + return username +} + +// AgentAvailable reports whether an SSH agent socket is configured. +func AgentAvailable() bool { + return os.Getenv("SSH_AUTH_SOCK") != "" +} diff --git a/internal/gitupdate/auth_test.go b/internal/gitupdate/auth_test.go new file mode 100644 index 000000000..9a3323b47 --- /dev/null +++ b/internal/gitupdate/auth_test.go @@ -0,0 +1,128 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitupdate + +import ( + "testing" + + githttp "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildAuth(t *testing.T) { + tests := []struct { + name string + url string + options AuthOptions + expected any + expectError string + }{ + { + name: "none", + url: "https://example.com/repo.git", + options: AuthOptions{Type: AuthNone}, + expected: nil, + }, + { + name: "basic", + url: "https://example.com/repo.git", + options: AuthOptions{ + Type: AuthBasic, + Username: "alice", + Password: "secret", + }, + expected: &githttp.BasicAuth{}, + }, + { + name: "token defaults username", + url: "https://example.com/repo.git", + options: AuthOptions{ + Type: AuthToken, + Token: "secret", + }, + expected: &githttp.BasicAuth{}, + }, + { + name: "bearer", + url: "https://example.com/repo.git", + options: AuthOptions{ + Type: AuthBearer, + Token: "secret", + }, + expected: &githttp.TokenAuth{}, + }, + { + name: "auto selects token", + url: "https://example.com/repo.git", + options: AuthOptions{ + Type: AuthAuto, + Token: "secret", + }, + expected: &githttp.BasicAuth{}, + }, + { + name: "basic requires username", + url: "https://example.com/repo.git", + options: AuthOptions{Type: AuthBasic, Password: "secret"}, + expectError: "requires --username", + }, + { + name: "unknown mode", + url: "https://example.com/repo.git", + options: AuthOptions{Type: "magic"}, + expectError: "unknown authentication mode", + }, + { + name: "HTTP authentication rejects SSH URL", + url: "git@example.com:group/repo.git", + options: AuthOptions{Type: AuthToken, Token: "secret"}, + expectError: "requires an HTTP(S) repository URL", + }, + { + name: "SSH authentication rejects HTTP URL", + url: "https://example.com/repo.git", + options: AuthOptions{Type: AuthSSHAgent}, + expectError: "requires an SSH repository URL", + }, + { + name: "host-key option requires explicit SSH authentication", + url: "git@example.com:group/repo.git", + options: AuthOptions{Type: AuthNone, InsecureHostKey: true}, + expectError: "SSH host-key options require", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, err := BuildAuth(test.url, test.options) + if test.expectError != "" { + require.ErrorContains(t, err, test.expectError) + return + } + require.NoError(t, err) + if test.expected == nil { + assert.Nil(t, actual) + return + } + assert.IsType(t, test.expected, actual) + if token, ok := actual.(*githttp.BasicAuth); ok && test.name == "token defaults username" { + assert.Equal(t, "git", token.Username) + } + }) + } +} diff --git a/internal/gitupdate/edit.go b/internal/gitupdate/edit.go new file mode 100644 index 000000000..2fe32d481 --- /dev/null +++ b/internal/gitupdate/edit.go @@ -0,0 +1,356 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitupdate + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "regexp" + "slices" + "strings" +) + +// Method identifies how content is applied to the target file. +type Method string + +const ( + MethodOverwrite Method = "overwrite" + MethodReplace Method = "replace" + MethodInsertBefore Method = "insert-before" + MethodInsertAfter Method = "insert-after" + MethodAppend Method = "append" + MethodPrepend Method = "prepend" + MethodYAMLSet Method = "yaml-set" + MethodYAMLMerge Method = "yaml-merge" + MethodYAMLDelete Method = "yaml-delete" + MethodJSONSet Method = "json-set" + MethodJSONMerge Method = "json-merge" + MethodJSONDelete Method = "json-delete" + MethodJSONPatch Method = "json-patch" + MethodJSONMergePatch Method = "json-merge-patch" +) + +// ValidMethods returns the supported file editing methods. +func ValidMethods() []string { + return []string{ + string(MethodOverwrite), + string(MethodReplace), + string(MethodInsertBefore), + string(MethodInsertAfter), + string(MethodAppend), + string(MethodPrepend), + string(MethodYAMLSet), + string(MethodYAMLMerge), + string(MethodYAMLDelete), + string(MethodJSONSet), + string(MethodJSONMerge), + string(MethodJSONDelete), + string(MethodJSONPatch), + string(MethodJSONMergePatch), + } +} + +// EditOptions describes one deterministic edit to a file. +type EditOptions struct { + Method Method + Content []byte + ContentProvided bool + LiteralMatch string + RegexMatch string + ExpectedMatches int + AllMatches bool + Expand bool + Pointer string + CreatePath bool +} + +// ApplyFile applies an edit to targetPath underneath worktreeRoot. It rejects +// paths that escape the worktree, paths inside .git, and symbolic links. +func ApplyFile(worktreeRoot, targetPath string, opts EditOptions, createFile bool) (bool, error) { + targetPath, err := validateTargetPath(targetPath) + if err != nil { + return false, err + } + + root, err := os.OpenRoot(worktreeRoot) + if err != nil { + return false, fmt.Errorf("open worktree: %w", err) + } + defer root.Close() + + localPath := filepath.FromSlash(targetPath) + if err := rejectSymlinks(root, localPath); err != nil { + return false, err + } + + existed := true + original, err := root.ReadFile(localPath) + if err != nil { + if !errors.Is(err, fs.ErrNotExist) { + return false, fmt.Errorf("read target file %q: %w", targetPath, err) + } + if !createFile { + return false, fmt.Errorf("target file %q does not exist (use --create-file to create it)", targetPath) + } + existed = false + original = initialContent(opts.Method) + } + + updated, err := Apply(original, opts) + if err != nil { + return false, fmt.Errorf("edit %q: %w", targetPath, err) + } + if existed && bytes.Equal(original, updated) { + return false, nil + } + + parent := filepath.Dir(localPath) + if parent != "." { + if err := root.MkdirAll(parent, 0o755); err != nil { + return false, fmt.Errorf("create parent directories for %q: %w", targetPath, err) + } + } + + mode := fs.FileMode(0o644) + if info, statErr := root.Stat(localPath); statErr == nil { + if !info.Mode().IsRegular() { + return false, fmt.Errorf("target path %q is not a regular file", targetPath) + } + mode = info.Mode().Perm() + } else if !errors.Is(statErr, fs.ErrNotExist) { + return false, fmt.Errorf("inspect target file %q: %w", targetPath, statErr) + } + + if err := root.WriteFile(localPath, updated, mode); err != nil { + return false, fmt.Errorf("write target file %q: %w", targetPath, err) + } + return true, nil +} + +func validateTargetPath(targetPath string) (string, error) { + if targetPath == "" { + return "", errors.New("target file is required") + } + if filepath.IsAbs(targetPath) || path.IsAbs(filepath.ToSlash(targetPath)) { + return "", fmt.Errorf("target file %q must be relative to the repository root", targetPath) + } + + clean := path.Clean(filepath.ToSlash(targetPath)) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", fmt.Errorf("target file %q escapes the repository root", targetPath) + } + for segment := range strings.SplitSeq(clean, "/") { + if strings.EqualFold(segment, ".git") { + return "", fmt.Errorf("target file %q must not be inside .git", targetPath) + } + } + return clean, nil +} + +func rejectSymlinks(root *os.Root, targetPath string) error { + current := "" + for segment := range strings.SplitSeq(filepath.Clean(targetPath), string(filepath.Separator)) { + if current == "" { + current = segment + } else { + current = filepath.Join(current, segment) + } + + info, err := root.Lstat(current) + if errors.Is(err, fs.ErrNotExist) { + return nil + } + if err != nil { + return fmt.Errorf("inspect target path %q: %w", filepath.ToSlash(current), err) + } + if info.Mode()&fs.ModeSymlink != 0 { + return fmt.Errorf("target path %q contains symbolic link %q", filepath.ToSlash(targetPath), filepath.ToSlash(current)) + } + } + return nil +} + +func initialContent(method Method) []byte { + switch method { + case MethodYAMLSet, MethodYAMLMerge, MethodYAMLDelete: + return []byte("{}\n") + case MethodJSONSet, MethodJSONMerge, MethodJSONDelete, MethodJSONPatch, MethodJSONMergePatch: + return []byte("{}\n") + default: + return nil + } +} + +// Apply returns the edited file contents without writing to disk. +func Apply(original []byte, opts EditOptions) ([]byte, error) { + if err := validateEditOptions(opts); err != nil { + return nil, err + } + + switch opts.Method { + case MethodOverwrite: + return append([]byte(nil), opts.Content...), nil + case MethodAppend: + return append(append([]byte(nil), original...), opts.Content...), nil + case MethodPrepend: + return append(append([]byte(nil), opts.Content...), original...), nil + case MethodReplace, MethodInsertBefore, MethodInsertAfter: + return applySelectedText(original, opts) + case MethodYAMLSet, MethodYAMLMerge, MethodYAMLDelete: + return applyYAML(original, opts) + case MethodJSONSet, MethodJSONMerge, MethodJSONDelete: + return applyJSON(original, opts) + case MethodJSONPatch, MethodJSONMergePatch: + return applyJSONPatch(original, opts) + default: + return nil, fmt.Errorf("unknown edit method %q", opts.Method) + } +} + +func validateEditOptions(opts EditOptions) error { + if !slices.Contains(ValidMethods(), string(opts.Method)) { + return fmt.Errorf("unknown edit method %q (supported: %s)", opts.Method, strings.Join(ValidMethods(), ", ")) + } + if opts.ExpectedMatches < 0 { + return errors.New("expected matches must be zero or greater") + } + + needsContent := opts.Method != MethodYAMLDelete && opts.Method != MethodJSONDelete + if needsContent && !opts.ContentProvided { + return fmt.Errorf("method %q requires content", opts.Method) + } + if !needsContent && opts.ContentProvided { + return fmt.Errorf("method %q does not accept content", opts.Method) + } + + isSelectedText := opts.Method == MethodReplace || opts.Method == MethodInsertBefore || opts.Method == MethodInsertAfter + hasLiteral := opts.LiteralMatch != "" + hasRegex := opts.RegexMatch != "" + if isSelectedText { + if hasLiteral == hasRegex { + return fmt.Errorf("method %q requires exactly one of a non-empty literal match or regular expression", opts.Method) + } + } else if hasLiteral || hasRegex { + return fmt.Errorf("method %q does not accept a text selector", opts.Method) + } + if opts.AllMatches && !isSelectedText { + return fmt.Errorf("method %q does not accept --all", opts.Method) + } + + isStructuredPath := opts.Method == MethodYAMLSet || opts.Method == MethodYAMLMerge || opts.Method == MethodYAMLDelete || + opts.Method == MethodJSONSet || opts.Method == MethodJSONMerge || opts.Method == MethodJSONDelete + if isStructuredPath { + if _, err := parsePointer(opts.Pointer); err != nil { + return err + } + } else if opts.Pointer != "" { + return fmt.Errorf("method %q does not accept a JSON pointer", opts.Method) + } + canCreatePath := opts.Method == MethodYAMLSet || opts.Method == MethodYAMLMerge || + opts.Method == MethodJSONSet || opts.Method == MethodJSONMerge + if opts.CreatePath && !canCreatePath { + return fmt.Errorf("method %q does not accept --create-path", opts.Method) + } + + if opts.Expand && opts.RegexMatch == "" { + return errors.New("capture expansion requires a regular expression selector") + } + return nil +} + +func applySelectedText(original []byte, opts EditOptions) ([]byte, error) { + if opts.LiteralMatch != "" { + return applyLiteralSelector(original, opts) + } + return applyRegexSelector(original, opts) +} + +func applyLiteralSelector(original []byte, opts EditOptions) ([]byte, error) { + match := []byte(opts.LiteralMatch) + actual := bytes.Count(original, match) + if err := validateMatchCount(actual, opts.ExpectedMatches); err != nil { + return nil, err + } + + replacement := opts.Content + switch opts.Method { + case MethodInsertBefore: + replacement = append(append([]byte(nil), opts.Content...), match...) + case MethodInsertAfter: + replacement = append(append([]byte(nil), match...), opts.Content...) + case MethodReplace: + default: + return nil, fmt.Errorf("unsupported literal text method %q", opts.Method) + } + + limit := 1 + if opts.AllMatches { + limit = -1 + } + return bytes.Replace(original, match, replacement, limit), nil +} + +func applyRegexSelector(original []byte, opts EditOptions) ([]byte, error) { + expression, err := regexp.Compile(opts.RegexMatch) + if err != nil { + return nil, fmt.Errorf("compile regular expression: %w", err) + } + + matches := expression.FindAllSubmatchIndex(original, -1) + if err := validateMatchCount(len(matches), opts.ExpectedMatches); err != nil { + return nil, err + } + if !opts.AllMatches { + matches = matches[:1] + } + + var result bytes.Buffer + last := 0 + for _, indexes := range matches { + start, end := indexes[0], indexes[1] + result.Write(original[last:start]) + if opts.Method == MethodInsertAfter { + result.Write(original[start:end]) + } + if opts.Expand { + result.Write(expression.Expand(nil, opts.Content, original, indexes)) + } else { + result.Write(opts.Content) + } + if opts.Method == MethodInsertBefore { + result.Write(original[start:end]) + } + last = end + } + result.Write(original[last:]) + return result.Bytes(), nil +} + +func validateMatchCount(actual, expected int) error { + if actual == 0 { + return errors.New("selector did not match the target file") + } + if expected > 0 && actual != expected { + return fmt.Errorf("selector matched %d times; expected %d", actual, expected) + } + return nil +} diff --git a/internal/gitupdate/edit_test.go b/internal/gitupdate/edit_test.go new file mode 100644 index 000000000..41abd3b46 --- /dev/null +++ b/internal/gitupdate/edit_test.go @@ -0,0 +1,364 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitupdate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestApplyText(t *testing.T) { + tests := []struct { + name string + original string + options EditOptions + expected string + errorText string + }{ + { + name: "overwrite", + original: "old\n", + options: textOptions(MethodOverwrite, "new\n"), + expected: "new\n", + }, + { + name: "literal replacement", + original: "replicas: 2\n", + options: EditOptions{ + Method: MethodReplace, + Content: []byte("replicas: 3"), + ContentProvided: true, + LiteralMatch: "replicas: 2", + ExpectedMatches: 1, + }, + expected: "replicas: 3\n", + }, + { + name: "insert after", + original: "first\nlast\n", + options: EditOptions{ + Method: MethodInsertAfter, + Content: []byte("middle\n"), + ContentProvided: true, + LiteralMatch: "first\n", + ExpectedMatches: 1, + }, + expected: "first\nmiddle\nlast\n", + }, + { + name: "regular expression capture expansion", + original: "image: app:v1\n", + options: EditOptions{ + Method: MethodReplace, + Content: []byte("${1}v2"), + ContentProvided: true, + RegexMatch: `(image: app:)v1`, + ExpectedMatches: 1, + Expand: true, + }, + expected: "image: app:v2\n", + }, + { + name: "replace all", + original: "dev dev dev", + options: EditOptions{ + Method: MethodReplace, + Content: []byte("prod"), + ContentProvided: true, + LiteralMatch: "dev", + ExpectedMatches: 3, + AllMatches: true, + }, + expected: "prod prod prod", + }, + { + name: "safe count rejects an ambiguous selector", + original: "dev dev", + options: EditOptions{ + Method: MethodReplace, + Content: []byte("prod"), + ContentProvided: true, + LiteralMatch: "dev", + ExpectedMatches: 1, + }, + errorText: "selector matched 2 times; expected 1", + }, + { + name: "selector must match", + original: "dev", + options: EditOptions{ + Method: MethodReplace, + Content: []byte("prod"), + ContentProvided: true, + LiteralMatch: "staging", + ExpectedMatches: 0, + }, + errorText: "selector did not match", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, err := Apply([]byte(test.original), test.options) + if test.errorText != "" { + require.ErrorContains(t, err, test.errorText) + return + } + require.NoError(t, err) + assert.Equal(t, test.expected, string(actual)) + }) + } +} + +func TestApplyYAML(t *testing.T) { + tests := []struct { + name string + original string + options EditOptions + expected string + }{ + { + name: "set nested value and preserve comment", + original: `image: + repository: example/app + tag: v1 # deployed version +`, + options: EditOptions{ + Method: MethodYAMLSet, + Content: []byte("v2"), + ContentProvided: true, + Pointer: "/image/tag", + }, + expected: `image: + repository: example/app + tag: v2 # deployed version +`, + }, + { + name: "create mapping path", + original: `image: {} +`, + options: EditOptions{ + Method: MethodYAMLSet, + Content: []byte("v2"), + ContentProvided: true, + Pointer: "/image/tag", + CreatePath: true, + }, + expected: `image: {tag: v2} +`, + }, + { + name: "deep merge", + original: `image: + repository: example/app +resources: + limits: + cpu: 100m +`, + options: EditOptions{ + Method: MethodYAMLMerge, + Content: []byte(`limits: + memory: 128Mi +requests: + cpu: 50m +`), + ContentProvided: true, + Pointer: "/resources", + }, + expected: `image: + repository: example/app +resources: + limits: + cpu: 100m + memory: 128Mi + requests: + cpu: 50m +`, + }, + { + name: "delete", + original: `image: + repository: example/app + tag: v1 +`, + options: EditOptions{ + Method: MethodYAMLDelete, + Pointer: "/image/tag", + }, + expected: `image: + repository: example/app +`, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, err := Apply([]byte(test.original), test.options) + require.NoError(t, err) + assert.Equal(t, test.expected, string(actual)) + }) + } +} + +func TestApplyYAMLNoSemanticChangePreservesBytes(t *testing.T) { + original := []byte("image: {tag: v1} # keep compact style\n") + actual, err := Apply(original, EditOptions{ + Method: MethodYAMLSet, + Content: []byte("v1"), + ContentProvided: true, + Pointer: "/image/tag", + }) + require.NoError(t, err) + assert.Equal(t, original, actual) +} + +func TestApplyYAMLRejectsDuplicateKeys(t *testing.T) { + _, err := Apply([]byte("image:\n tag: v1\n tag: v2\n"), EditOptions{ + Method: MethodYAMLSet, + Content: []byte("v3"), + ContentProvided: true, + Pointer: "/image/tag", + }) + require.ErrorContains(t, err, `duplicate YAML mapping key "tag"`) +} + +func TestApplyJSON(t *testing.T) { + tests := []struct { + name string + original string + options EditOptions + expected string + }{ + { + name: "set array value", + original: `{"containers":[{"image":"app:v1"}]}`, + options: EditOptions{ + Method: MethodJSONSet, + Content: []byte(`"app:v2"`), + ContentProvided: true, + Pointer: "/containers/0/image", + }, + expected: "{\n \"containers\": [\n {\n \"image\": \"app:v2\"\n }\n ]\n}\n", + }, + { + name: "append array value", + original: `{"items":["a"]}`, + options: EditOptions{ + Method: MethodJSONSet, + Content: []byte(`"b"`), + ContentProvided: true, + Pointer: "/items/-", + }, + expected: "{\n \"items\": [\n \"a\",\n \"b\"\n ]\n}\n", + }, + { + name: "merge object", + original: `{"image":{"repository":"app"},"replicas":1}`, + options: EditOptions{ + Method: MethodJSONMerge, + Content: []byte(`{"tag":"v2"}`), + ContentProvided: true, + Pointer: "/image", + }, + expected: "{\n \"image\": {\n \"repository\": \"app\",\n \"tag\": \"v2\"\n },\n \"replicas\": 1\n}\n", + }, + { + name: "RFC 6902 patch", + original: "{\"replicas\":1}\n", + options: EditOptions{ + Method: MethodJSONPatch, + Content: []byte(`[{"op":"replace","path":"/replicas","value":2}]`), + ContentProvided: true, + }, + expected: "{\"replicas\":2}\n", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, err := Apply([]byte(test.original), test.options) + require.NoError(t, err) + assert.Equal(t, test.expected, string(actual)) + }) + } +} + +func TestApplyFilePathSafety(t *testing.T) { + worktree := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(worktree, "values.yaml"), []byte("old"), 0o644)) + + tests := []struct { + name string + target string + prepare func(*testing.T) + errorText string + }{ + { + name: "parent traversal", + target: "../outside", + errorText: "escapes the repository root", + }, + { + name: "git internals", + target: ".git/config", + errorText: "must not be inside .git", + }, + { + name: "symbolic link", + target: "link", + prepare: func(t *testing.T) { + t.Helper() + require.NoError(t, os.Symlink("values.yaml", filepath.Join(worktree, "link"))) + }, + errorText: "contains symbolic link", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if test.prepare != nil { + test.prepare(t) + } + _, err := ApplyFile(worktree, test.target, textOptions(MethodOverwrite, "new"), false) + require.ErrorContains(t, err, test.errorText) + }) + } +} + +func TestApplyFileCreatesEmptyFile(t *testing.T) { + worktree := t.TempDir() + changed, err := ApplyFile(worktree, "empty.txt", textOptions(MethodOverwrite, ""), true) + require.NoError(t, err) + assert.True(t, changed) + content, err := os.ReadFile(filepath.Join(worktree, "empty.txt")) + require.NoError(t, err) + assert.Empty(t, content) +} + +func textOptions(method Method, content string) EditOptions { + return EditOptions{ + Method: method, + Content: []byte(content), + ContentProvided: true, + ExpectedMatches: 1, + } +} diff --git a/internal/gitupdate/git.go b/internal/gitupdate/git.go new file mode 100644 index 000000000..0d4aea880 --- /dev/null +++ b/internal/gitupdate/git.go @@ -0,0 +1,307 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitupdate + +import ( + "context" + "errors" + "fmt" + "io" + "log/slog" + "os" + "slices" + "strings" + "time" + + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" +) + +const ( + // TransportClone uses the Git smart protocol to clone, commit, and push. + TransportClone = "clone" + // TransportAPI uses the repository provider's HTTP API without cloning. + TransportAPI = "api" +) + +// ValidTransports returns the supported remote update backends. +func ValidTransports() []string { + return []string{TransportClone, TransportAPI} +} + +// TLSOptions controls HTTPS server and client certificate handling. +type TLSOptions struct { + CAFile string + ClientCertificate string + ClientKey string + InsecureSkipTLS bool +} + +type loadedTLSOptions struct { + caBundle []byte + clientCertificate []byte + clientKey []byte + insecureSkipTLS bool +} + +// RepositoryOptions describes a remote edit-commit transaction. +type RepositoryOptions struct { + RepositoryURL string + Transport string + Branch string + BaseBranch string + CreateBranch bool + TargetFile string + CreateFile bool + Edit EditOptions + Auth AuthOptions + TLS TLSOptions + API APIOptions + AuthorName string + AuthorEmail string + CommitMessage string + Depth int + DryRun bool + Push bool + Progress io.Writer + Now func() time.Time +} + +// Result summarizes a repository update. +type Result struct { + Changed bool + Committed bool + Pushed bool + CommitHash string + TargetFile string + Branch string + Transport string +} + +// Update applies exactly one file edit and commits it through either a private +// temporary clone or the repository provider API. It never invokes the git +// executable. +func Update(ctx context.Context, opts RepositoryOptions) (Result, error) { + if opts.Transport == "" { + opts.Transport = TransportClone + } + result := Result{TargetFile: opts.TargetFile, Branch: opts.Branch, Transport: opts.Transport} + if err := validateRepositoryOptions(opts); err != nil { + return result, err + } + + targetFile, err := validateTargetPath(opts.TargetFile) + if err != nil { + return result, err + } + result.TargetFile = targetFile + + if opts.Transport == TransportAPI { + return updateViaAPI(ctx, opts, result) + } + + auth, err := BuildAuth(opts.RepositoryURL, opts.Auth) + if err != nil { + return result, fmt.Errorf("configure Git authentication: %w", err) + } + tlsOptions, err := opts.TLS.load() + if err != nil { + return result, err + } + + cloneBranch := opts.Branch + if opts.CreateBranch { + cloneBranch = opts.BaseBranch + } + cloneReference := plumbing.NewBranchReferenceName(cloneBranch) + + worktreeDir, err := os.MkdirTemp("", "helm-git-update-*") + if err != nil { + return result, fmt.Errorf("create temporary worktree: %w", err) + } + slog.Debug("created temporary Git worktree", "path", worktreeDir) + defer os.RemoveAll(worktreeDir) + + repository, err := git.PlainCloneContext(ctx, worktreeDir, false, &git.CloneOptions{ + URL: opts.RepositoryURL, + Auth: auth, + ReferenceName: cloneReference, + SingleBranch: !opts.CreateBranch, + Depth: opts.Depth, + Progress: opts.Progress, + InsecureSkipTLS: tlsOptions.insecureSkipTLS, + ClientCert: tlsOptions.clientCertificate, + ClientKey: tlsOptions.clientKey, + CABundle: tlsOptions.caBundle, + }) + if err != nil { + return result, fmt.Errorf("clone branch %q: %w", cloneBranch, err) + } + + worktree, err := repository.Worktree() + if err != nil { + return result, fmt.Errorf("open cloned worktree: %w", err) + } + if opts.CreateBranch { + remoteTarget := plumbing.NewRemoteReferenceName("origin", opts.Branch) + if _, err := repository.Reference(remoteTarget, true); err == nil { + return result, fmt.Errorf("cannot create branch %q: it already exists on the remote", opts.Branch) + } else if !errors.Is(err, plumbing.ErrReferenceNotFound) { + return result, fmt.Errorf("check remote branch %q: %w", opts.Branch, err) + } + + targetReference := plumbing.NewBranchReferenceName(opts.Branch) + if err := worktree.Checkout(&git.CheckoutOptions{Branch: targetReference, Create: true}); err != nil { + return result, fmt.Errorf("create branch %q from %q: %w", opts.Branch, opts.BaseBranch, err) + } + } + + changed, err := ApplyFile(worktreeDir, targetFile, opts.Edit, opts.CreateFile) + if err != nil { + return result, err + } + result.Changed = changed + if !changed || opts.DryRun { + return result, nil + } + + if _, err := worktree.Add(targetFile); err != nil { + return result, fmt.Errorf("stage target file %q: %w", targetFile, err) + } + + now := time.Now + if opts.Now != nil { + now = opts.Now + } + signature := &object.Signature{ + Name: opts.AuthorName, + Email: opts.AuthorEmail, + When: now(), + } + hash, err := worktree.Commit(opts.CommitMessage, &git.CommitOptions{ + Author: signature, + Committer: signature, + }) + if err != nil { + return result, fmt.Errorf("commit target file %q: %w", targetFile, err) + } + result.Committed = true + result.CommitHash = hash.String() + + if !opts.Push { + return result, nil + } + targetReference := plumbing.NewBranchReferenceName(opts.Branch) + refspec := config.RefSpec(fmt.Sprintf("%s:%s", targetReference, targetReference)) + if err := repository.PushContext(ctx, &git.PushOptions{ + Auth: auth, + RefSpecs: []config.RefSpec{refspec}, + Progress: opts.Progress, + InsecureSkipTLS: tlsOptions.insecureSkipTLS, + ClientCert: tlsOptions.clientCertificate, + ClientKey: tlsOptions.clientKey, + CABundle: tlsOptions.caBundle, + }); err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { + return result, fmt.Errorf("push branch %q: %w", opts.Branch, err) + } + result.Pushed = true + return result, nil +} + +func validateRepositoryOptions(opts RepositoryOptions) error { + if opts.RepositoryURL == "" { + return errors.New("repository URL is required") + } + transport := opts.Transport + if transport == "" { + transport = TransportClone + } + if !slices.Contains(ValidTransports(), transport) { + return fmt.Errorf("unknown transport %q (supported: %s)", transport, strings.Join(ValidTransports(), ", ")) + } + if _, err := validateTargetPath(opts.TargetFile); err != nil { + return err + } + if err := validateEditOptions(opts.Edit); err != nil { + return fmt.Errorf("invalid edit: %w", err) + } + if opts.Branch == "" { + return errors.New("target branch is required") + } + if err := plumbing.NewBranchReferenceName(opts.Branch).Validate(); err != nil { + return fmt.Errorf("invalid target branch %q: %w", opts.Branch, err) + } + if opts.CreateBranch { + if opts.BaseBranch == "" { + return errors.New("--base-branch is required with --create-branch") + } + if opts.BaseBranch == opts.Branch { + return errors.New("target branch and base branch must differ when creating a branch") + } + if err := plumbing.NewBranchReferenceName(opts.BaseBranch).Validate(); err != nil { + return fmt.Errorf("invalid base branch %q: %w", opts.BaseBranch, err) + } + } else if opts.BaseBranch != "" { + return errors.New("--base-branch requires --create-branch") + } + if opts.AuthorName == "" { + return errors.New("commit author name is required") + } + if opts.AuthorEmail == "" { + return errors.New("commit author email is required") + } + if opts.CommitMessage == "" { + return errors.New("commit message is required") + } + if opts.Depth < 0 { + return errors.New("clone depth must be zero or greater") + } + if transport == TransportAPI { + if err := validateAPIOptions(opts); err != nil { + return err + } + } + return nil +} + +func (opts TLSOptions) load() (loadedTLSOptions, error) { + loaded := loadedTLSOptions{insecureSkipTLS: opts.InsecureSkipTLS} + if (opts.ClientCertificate == "") != (opts.ClientKey == "") { + return loaded, errors.New("--git-cert-file and --git-key-file must be set together") + } + + var err error + if opts.CAFile != "" { + loaded.caBundle, err = os.ReadFile(opts.CAFile) + if err != nil { + return loaded, fmt.Errorf("read Git CA file: %w", err) + } + } + if opts.ClientCertificate != "" { + loaded.clientCertificate, err = os.ReadFile(opts.ClientCertificate) + if err != nil { + return loaded, fmt.Errorf("read Git client certificate: %w", err) + } + loaded.clientKey, err = os.ReadFile(opts.ClientKey) + if err != nil { + return loaded, fmt.Errorf("read Git client key: %w", err) + } + } + return loaded, nil +} diff --git a/internal/gitupdate/git_test.go b/internal/gitupdate/git_test.go new file mode 100644 index 000000000..af6713802 --- /dev/null +++ b/internal/gitupdate/git_test.go @@ -0,0 +1,205 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitupdate + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + git "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdatePushesExistingBranch(t *testing.T) { + remotePath, remote := createRemoteRepository(t) + + result, err := Update(context.Background(), repositoryFixture(remotePath, EditOptions{ + Method: MethodYAMLSet, + Content: []byte("v2"), + ContentProvided: true, + Pointer: "/image/tag", + })) + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, result.Committed) + assert.True(t, result.Pushed) + assert.Len(t, result.CommitHash, 40) + + commit := branchCommit(t, remote, "main") + assert.Equal(t, "update image tag", commit.Message) + assert.Equal(t, "Automation", commit.Author.Name) + assert.Equal(t, "automation@example.com", commit.Author.Email) + assert.Equal(t, "image:\n repository: example/app\n tag: v2\n", commitFile(t, commit, "values.yaml")) +} + +func TestUpdateDryRunDoesNotPush(t *testing.T) { + remotePath, remote := createRemoteRepository(t) + before := branchCommit(t, remote, "main").Hash + + options := repositoryFixture(remotePath, textOptions(MethodOverwrite, "changed\n")) + options.DryRun = true + result, err := Update(context.Background(), options) + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Committed) + assert.False(t, result.Pushed) + assert.Equal(t, before, branchCommit(t, remote, "main").Hash) +} + +func TestUpdateReportsNoChange(t *testing.T) { + remotePath, remote := createRemoteRepository(t) + before := branchCommit(t, remote, "main").Hash + + result, err := Update(context.Background(), repositoryFixture(remotePath, EditOptions{ + Method: MethodYAMLSet, + Content: []byte("v1"), + ContentProvided: true, + Pointer: "/image/tag", + })) + require.NoError(t, err) + assert.False(t, result.Changed) + assert.False(t, result.Committed) + assert.False(t, result.Pushed) + assert.Equal(t, before, branchCommit(t, remote, "main").Hash) +} + +func TestUpdateCreatesBranch(t *testing.T) { + remotePath, remote := createRemoteRepository(t) + options := repositoryFixture(remotePath, textOptions(MethodOverwrite, "new branch\n")) + options.Branch = "automation/image-update" + options.BaseBranch = "main" + options.CreateBranch = true + + result, err := Update(context.Background(), options) + require.NoError(t, err) + assert.True(t, result.Pushed) + assert.Equal(t, "new branch\n", commitFile(t, branchCommit(t, remote, "automation/image-update"), "values.yaml")) + assert.Equal(t, "image:\n repository: example/app\n tag: v1\n", commitFile(t, branchCommit(t, remote, "main"), "values.yaml")) +} + +func TestUpdateRefusesToCreateExistingBranch(t *testing.T) { + remotePath, _ := createRemoteRepository(t) + options := repositoryFixture(remotePath, textOptions(MethodOverwrite, "changed\n")) + options.Branch = "main" + options.BaseBranch = "other" + options.CreateBranch = true + + // Add an independent base branch so the command can clone it before checking + // whether the target already exists. + seedPath := filepath.Join(t.TempDir(), "other-seed") + seed, err := git.PlainClone(seedPath, false, &git.CloneOptions{ + URL: remotePath, + ReferenceName: plumbing.NewBranchReferenceName("main"), + SingleBranch: true, + }) + require.NoError(t, err) + worktree, err := seed.Worktree() + require.NoError(t, err) + require.NoError(t, worktree.Checkout(&git.CheckoutOptions{ + Branch: plumbing.NewBranchReferenceName("other"), + Create: true, + })) + require.NoError(t, seed.Push(&git.PushOptions{ + RefSpecs: []config.RefSpec{"refs/heads/other:refs/heads/other"}, + })) + + _, err = Update(context.Background(), options) + require.ErrorContains(t, err, `branch "main": it already exists on the remote`) +} + +func createRemoteRepository(t *testing.T) (string, *git.Repository) { + t.Helper() + root := t.TempDir() + seedPath := filepath.Join(root, "seed") + remotePath := filepath.Join(root, "remote.git") + + seed, err := git.PlainInit(seedPath, false) + require.NoError(t, err) + require.NoError(t, seed.Storer.SetReference(plumbing.NewSymbolicReference( + plumbing.HEAD, + plumbing.NewBranchReferenceName("main"), + ))) + require.NoError(t, os.WriteFile( + filepath.Join(seedPath, "values.yaml"), + []byte("image:\n repository: example/app\n tag: v1\n"), + 0o644, + )) + worktree, err := seed.Worktree() + require.NoError(t, err) + _, err = worktree.Add("values.yaml") + require.NoError(t, err) + _, err = worktree.Commit("initial", &git.CommitOptions{ + Author: &object.Signature{ + Name: "Initial Author", + Email: "initial@example.com", + When: time.Unix(1_700_000_000, 0).UTC(), + }, + }) + require.NoError(t, err) + + remote, err := git.PlainInit(remotePath, true) + require.NoError(t, err) + _, err = seed.CreateRemote(&config.RemoteConfig{Name: "origin", URLs: []string{remotePath}}) + require.NoError(t, err) + require.NoError(t, seed.Push(&git.PushOptions{ + RefSpecs: []config.RefSpec{"refs/heads/main:refs/heads/main"}, + })) + return remotePath, remote +} + +func repositoryFixture(repositoryURL string, edit EditOptions) RepositoryOptions { + return RepositoryOptions{ + RepositoryURL: repositoryURL, + Branch: "main", + TargetFile: "values.yaml", + Edit: edit, + Auth: AuthOptions{Type: AuthNone}, + AuthorName: "Automation", + AuthorEmail: "automation@example.com", + CommitMessage: "update image tag", + Depth: 1, + Push: true, + Now: func() time.Time { + return time.Unix(1_700_000_100, 0).UTC() + }, + } +} + +func branchCommit(t *testing.T, repository *git.Repository, branch string) *object.Commit { + t.Helper() + reference, err := repository.Reference(plumbing.NewBranchReferenceName(branch), true) + require.NoError(t, err) + commit, err := repository.CommitObject(reference.Hash()) + require.NoError(t, err) + return commit +} + +func commitFile(t *testing.T, commit *object.Commit, filename string) string { + t.Helper() + file, err := commit.File(filename) + require.NoError(t, err) + content, err := file.Contents() + require.NoError(t, err) + return content +} diff --git a/internal/gitupdate/gitlab_api.go b/internal/gitupdate/gitlab_api.go new file mode 100644 index 000000000..d029b0616 --- /dev/null +++ b/internal/gitupdate/gitlab_api.go @@ -0,0 +1,227 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitupdate + +import ( + "bytes" + "context" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "errors" + "fmt" + "net/http" + + gitlab "gitlab.com/gitlab-org/api/client-go/v2" + "golang.org/x/oauth2" +) + +func updateViaGitLabAPI(ctx context.Context, opts RepositoryOptions, result Result) (Result, error) { + baseURL, project, err := resolveGitLabCoordinates(opts.RepositoryURL, opts.API) + if err != nil { + return result, err + } + client, err := newGitLabClient(opts, baseURL) + if err != nil { + return result, err + } + + readBranch := opts.Branch + if opts.CreateBranch { + if err := requireGitLabBranch(ctx, client, project, opts.Branch, false); err != nil { + return result, err + } + if err := requireGitLabBranch(ctx, client, project, opts.BaseBranch, true); err != nil { + return result, err + } + readBranch = opts.BaseBranch + } else if err := requireGitLabBranch(ctx, client, project, opts.Branch, true); err != nil { + return result, err + } + + ref := readBranch + file, response, getErr := client.RepositoryFiles.GetFile( + project, + result.TargetFile, + &gitlab.GetFileOptions{Ref: &ref}, + gitlab.WithContext(ctx), + ) + + existed := true + var original []byte + var lastCommitID string + switch { + case getErr == nil: + original, err = decodeGitLabFile(file) + if err != nil { + return result, fmt.Errorf("decode GitLab file %q: %w", result.TargetFile, err) + } + lastCommitID = file.LastCommitID + case isGitLabStatus(response, http.StatusNotFound): + if !opts.CreateFile { + return result, fmt.Errorf("target file %q does not exist on branch %q (use --create-file to create it)", result.TargetFile, readBranch) + } + existed = false + original = initialContent(opts.Edit.Method) + default: + return result, fmt.Errorf("read target file %q through GitLab API: %w", result.TargetFile, getErr) + } + + updated, err := Apply(original, opts.Edit) + if err != nil { + return result, fmt.Errorf("edit %q: %w", result.TargetFile, err) + } + result.Changed = !existed || !bytes.Equal(original, updated) + if !result.Changed || opts.DryRun { + return result, nil + } + + action := gitlab.FileUpdate + if !existed { + action = gitlab.FileCreate + } + encoding := "base64" + content := base64.StdEncoding.EncodeToString(updated) + commitOptions := &gitlab.CreateCommitOptions{ + Branch: &opts.Branch, + CommitMessage: &opts.CommitMessage, + AuthorName: &opts.AuthorName, + AuthorEmail: &opts.AuthorEmail, + Actions: []*gitlab.CommitActionOptions{{ + Action: &action, + FilePath: &result.TargetFile, + Content: &content, + Encoding: &encoding, + }}, + } + if existed { + commitOptions.Actions[0].LastCommitID = &lastCommitID + } + if opts.CreateBranch { + commitOptions.StartBranch = &opts.BaseBranch + } + + commit, _, err := client.Commits.CreateCommit(project, commitOptions, gitlab.WithContext(ctx)) + if err != nil { + return result, fmt.Errorf("commit target file %q through GitLab API: %w", result.TargetFile, err) + } + result.Committed = true + result.Pushed = true + result.CommitHash = commit.ID + return result, nil +} + +func requireGitLabBranch(ctx context.Context, client *gitlab.Client, project, branch string, mustExist bool) error { + _, response, err := client.Branches.GetBranch(project, branch, gitlab.WithContext(ctx)) + exists := err == nil + if err != nil && !isGitLabStatus(response, http.StatusNotFound) { + return fmt.Errorf("check GitLab branch %q: %w", branch, err) + } + if mustExist && !exists { + return fmt.Errorf("GitLab branch %q does not exist", branch) + } + if !mustExist && exists { + return fmt.Errorf("cannot create branch %q: it already exists on GitLab", branch) + } + return nil +} + +func newGitLabClient(opts RepositoryOptions, baseURL string) (*gitlab.Client, error) { + httpClient, err := opts.TLS.httpClient() + if err != nil { + return nil, err + } + clientOptions := []gitlab.ClientOptionFunc{ + gitlab.WithBaseURL(baseURL), + gitlab.WithHTTPClient(httpClient), + gitlab.WithOnlyIdempotentRetries(), + } + + tokenType, err := resolveAPITokenType(opts.API.TokenType, opts.Auth.Type) + if err != nil { + return nil, err + } + switch tokenType { + case APITokenPrivate: + return gitlab.NewClient(opts.Auth.Token, clientOptions...) + case APITokenOAuth: + source := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: opts.Auth.Token}) + return gitlab.NewAuthSourceClient(gitlab.OAuthTokenSource{TokenSource: source}, clientOptions...) + case APITokenJob: + return gitlab.NewJobClient(opts.Auth.Token, clientOptions...) + default: + return nil, fmt.Errorf("unsupported GitLab API token type %q", tokenType) + } +} + +func resolveGitLabCoordinates(repositoryURL string, apiOpts APIOptions) (string, string, error) { + return resolveAPICoordinates(repositoryURL, apiOpts, "/api/v4/", "GitLab") +} + +func inferGitLabCoordinates(repositoryURL string) (string, string, error) { + host, project, scheme, err := inferRepositoryCoordinates(repositoryURL) + if err != nil { + return "", "", err + } + return fmt.Sprintf("%s://%s/api/v4/", scheme, host), project, nil +} + +func decodeGitLabFile(file *gitlab.File) ([]byte, error) { + switch file.Encoding { + case "", "base64": + return base64.StdEncoding.DecodeString(file.Content) + case "text": + return []byte(file.Content), nil + default: + return nil, fmt.Errorf("unsupported content encoding %q", file.Encoding) + } +} + +func isGitLabStatus(response *gitlab.Response, status int) bool { + return response != nil && response.StatusCode == status +} + +func (opts TLSOptions) httpClient() (*http.Client, error) { + loaded, err := opts.load() + if err != nil { + return nil, err + } + transport := http.DefaultTransport.(*http.Transport).Clone() + tlsConfig := &tls.Config{ + MinVersion: tls.VersionTLS12, + InsecureSkipVerify: loaded.insecureSkipTLS, //nolint:gosec + } + if len(loaded.caBundle) != 0 { + roots, err := x509.SystemCertPool() + if err != nil { + roots = x509.NewCertPool() + } + if !roots.AppendCertsFromPEM(loaded.caBundle) { + return nil, errors.New("git CA file does not contain a valid certificate") + } + tlsConfig.RootCAs = roots + } + if len(loaded.clientCertificate) != 0 { + certificate, err := tls.X509KeyPair(loaded.clientCertificate, loaded.clientKey) + if err != nil { + return nil, fmt.Errorf("load Git client certificate and key: %w", err) + } + tlsConfig.Certificates = []tls.Certificate{certificate} + } + transport.TLSClientConfig = tlsConfig + return &http.Client{Transport: transport}, nil +} diff --git a/internal/gitupdate/gitlab_api_test.go b/internal/gitupdate/gitlab_api_test.go new file mode 100644 index 000000000..368f78d23 --- /dev/null +++ b/internal/gitupdate/gitlab_api_test.go @@ -0,0 +1,306 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitupdate + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestUpdateViaGitLabAPI(t *testing.T) { + original := "image:\n tag: v1\n" + var commitRequest gitLabCommitRequest + requests := []string{} + + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requests = append(requests, request.Method+" "+request.URL.EscapedPath()) + assert.Equal(t, "test-token", request.Header.Get("PRIVATE-TOKEN")) + writer.Header().Set("Content-Type", "application/json") + + switch len(requests) { + case 1: + assert.Contains(t, request.URL.EscapedPath(), "/repository/branches/main") + fmt.Fprint(writer, `{"name":"main","can_push":true}`) + case 2: + assert.Contains(t, request.URL.EscapedPath(), "/repository/files/") + assert.Equal(t, "main", request.URL.Query().Get("ref")) + writeGitLabFileResponse(t, writer, "values.yaml", original, "file-commit-1") + case 3: + assert.Equal(t, http.MethodPost, request.Method) + assert.Contains(t, request.URL.EscapedPath(), "/repository/commits") + if !assert.NoError(t, json.NewDecoder(request.Body).Decode(&commitRequest)) { + http.Error(writer, "invalid request body", http.StatusBadRequest) + return + } + fmt.Fprint(writer, `{"id":"0123456789abcdef","short_id":"01234567"}`) + default: + t.Errorf("unexpected request: %s %s", request.Method, request.URL) + http.Error(writer, "unexpected request", http.StatusInternalServerError) + } + })) + defer server.Close() + + options := apiRepositoryFixture(server.URL) + result, err := Update(context.Background(), options) + require.NoError(t, err) + assert.True(t, result.Changed) + assert.True(t, result.Committed) + assert.True(t, result.Pushed) + assert.Equal(t, TransportAPI, result.Transport) + assert.Equal(t, "0123456789abcdef", result.CommitHash) + require.Len(t, commitRequest.Actions, 1) + assert.Equal(t, "main", commitRequest.Branch) + assert.Equal(t, "update", commitRequest.Actions[0].Action) + assert.Equal(t, "values.yaml", commitRequest.Actions[0].FilePath) + assert.Equal(t, "file-commit-1", commitRequest.Actions[0].LastCommitID) + assert.Equal(t, "base64", commitRequest.Actions[0].Encoding) + updated, err := base64.StdEncoding.DecodeString(commitRequest.Actions[0].Content) + require.NoError(t, err) + assert.Equal(t, "image:\n tag: v2\n", string(updated)) +} + +func TestUpdateViaGitLabAPIDryRunDoesNotCommit(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requestCount++ + writer.Header().Set("Content-Type", "application/json") + switch requestCount { + case 1: + fmt.Fprint(writer, `{"name":"main","can_push":true}`) + case 2: + writeGitLabFileResponse(t, writer, "values.yaml", "image:\n tag: v1\n", "file-commit-1") + default: + t.Errorf("dry run made unexpected request: %s %s", request.Method, request.URL) + http.Error(writer, "unexpected request", http.StatusInternalServerError) + } + })) + defer server.Close() + + options := apiRepositoryFixture(server.URL) + options.DryRun = true + result, err := Update(context.Background(), options) + require.NoError(t, err) + assert.True(t, result.Changed) + assert.False(t, result.Committed) + assert.False(t, result.Pushed) + assert.Equal(t, 2, requestCount) +} + +func TestUpdateViaGitLabAPICreatesBranchAndFile(t *testing.T) { + requestCount := 0 + var commitRequest gitLabCommitRequest + server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + requestCount++ + writer.Header().Set("Content-Type", "application/json") + switch requestCount { + case 1: + http.Error(writer, `{"message":"404 Branch Not Found"}`, http.StatusNotFound) + case 2: + fmt.Fprint(writer, `{"name":"main","can_push":true}`) + case 3: + assert.Equal(t, "main", request.URL.Query().Get("ref")) + http.Error(writer, `{"message":"404 File Not Found"}`, http.StatusNotFound) + case 4: + if !assert.NoError(t, json.NewDecoder(request.Body).Decode(&commitRequest)) { + http.Error(writer, "invalid request body", http.StatusBadRequest) + return + } + fmt.Fprint(writer, `{"id":"new-branch-commit"}`) + default: + t.Errorf("unexpected request: %s %s", request.Method, request.URL) + } + })) + defer server.Close() + + options := apiRepositoryFixture(server.URL) + options.Branch = "feature/demo" + options.BaseBranch = "main" + options.CreateBranch = true + options.TargetFile = "config/new.yaml" + options.CreateFile = true + options.Edit = EditOptions{ + Method: MethodYAMLSet, + Content: []byte("true"), + ContentProvided: true, + Pointer: "/enabled", + } + + result, err := Update(context.Background(), options) + require.NoError(t, err) + assert.True(t, result.Pushed) + assert.Equal(t, "feature/demo", commitRequest.Branch) + assert.Equal(t, "main", commitRequest.StartBranch) + require.Len(t, commitRequest.Actions, 1) + assert.Equal(t, "create", commitRequest.Actions[0].Action) + assert.Empty(t, commitRequest.Actions[0].LastCommitID) + updated, err := base64.StdEncoding.DecodeString(commitRequest.Actions[0].Content) + require.NoError(t, err) + assert.Equal(t, "{enabled: true}\n", string(updated)) +} + +func TestInferGitLabCoordinates(t *testing.T) { + tests := []struct { + name string + repository string + expectedURL string + project string + }{ + { + name: "HTTPS", + repository: "https://gitlab.example.com/acme/charts.git", + expectedURL: "https://gitlab.example.com/api/v4/", + project: "acme/charts", + }, + { + name: "SSH URL", + repository: "ssh://git@gitlab.example.com/acme/charts.git", + expectedURL: "https://gitlab.example.com/api/v4/", + project: "acme/charts", + }, + { + name: "SCP syntax", + repository: "git@gitlab.example.com:acme/charts.git", + expectedURL: "https://gitlab.example.com/api/v4/", + project: "acme/charts", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + baseURL, project, err := inferGitLabCoordinates(test.repository) + require.NoError(t, err) + assert.Equal(t, test.expectedURL, baseURL) + assert.Equal(t, test.project, project) + }) + } +} + +func TestValidateAPIOptions(t *testing.T) { + options := apiRepositoryFixture("https://gitlab.example.com") + + tests := []struct { + name string + mutate func(*RepositoryOptions) + errorText string + }{ + { + name: "missing token", + mutate: func(options *RepositoryOptions) { + options.Auth.Token = "" + }, + errorText: "requires --token", + }, + { + name: "SSH auth is rejected", + mutate: func(options *RepositoryOptions) { + options.Auth.Type = AuthSSHKey + }, + errorText: "not supported by the gitlab API transport", + }, + { + name: "push false is rejected", + mutate: func(options *RepositoryOptions) { + options.Push = false + }, + errorText: "requires --push=true", + }, + { + name: "unknown provider", + mutate: func(options *RepositoryOptions) { + options.API.Provider = "github" + }, + errorText: "unknown API provider", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + candidate := options + test.mutate(&candidate) + err := validateRepositoryOptions(candidate) + require.ErrorContains(t, err, test.errorText) + }) + } +} + +func apiRepositoryFixture(serverURL string) RepositoryOptions { + return RepositoryOptions{ + RepositoryURL: serverURL + "/group/repo.git", + Transport: TransportAPI, + Branch: "main", + TargetFile: "values.yaml", + Edit: EditOptions{ + Method: MethodYAMLSet, + Content: []byte("v2"), + ContentProvided: true, + Pointer: "/image/tag", + }, + Auth: AuthOptions{ + Type: AuthToken, + Token: "test-token", + }, + API: APIOptions{ + Provider: APIProviderGitLab, + BaseURL: serverURL + "/api/v4/", + Project: "group/repo", + TokenType: APITokenPrivate, + }, + AuthorName: "Automation", + AuthorEmail: "automation@example.com", + CommitMessage: "update through API", + Depth: 1, + Push: true, + } +} + +func writeGitLabFileResponse(t *testing.T, writer http.ResponseWriter, path, content, lastCommitID string) { + t.Helper() + response := map[string]any{ + "file_name": path[strings.LastIndex(path, "/")+1:], + "file_path": path, + "encoding": "base64", + "content": base64.StdEncoding.EncodeToString([]byte(content)), + "last_commit_id": lastCommitID, + } + require.NoError(t, json.NewEncoder(writer).Encode(response)) +} + +type gitLabCommitRequest struct { + Branch string `json:"branch"` + StartBranch string `json:"start_branch"` + CommitMessage string `json:"commit_message"` + AuthorName string `json:"author_name"` + AuthorEmail string `json:"author_email"` + Actions []gitLabCommitActionRequest `json:"actions"` +} + +type gitLabCommitActionRequest struct { + Action string `json:"action"` + FilePath string `json:"file_path"` + Content string `json:"content"` + Encoding string `json:"encoding"` + LastCommitID string `json:"last_commit_id"` +} diff --git a/internal/gitupdate/json.go b/internal/gitupdate/json.go new file mode 100644 index 000000000..8d8be637e --- /dev/null +++ b/internal/gitupdate/json.go @@ -0,0 +1,337 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitupdate + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "reflect" + "strconv" + + jsonpatch "github.com/evanphx/json-patch/v5" +) + +func applyJSON(original []byte, opts EditOptions) ([]byte, error) { + document, err := decodeJSON(original) + if err != nil { + return nil, fmt.Errorf("decode target JSON: %w", err) + } + segments, err := parsePointer(opts.Pointer) + if err != nil { + return nil, err + } + + var changed bool + switch opts.Method { + case MethodJSONSet: + value, valueErr := decodeJSON(opts.Content) + if valueErr != nil { + return nil, fmt.Errorf("decode JSON content: %w", valueErr) + } + document, changed, err = setJSONValue(document, segments, value, opts.CreatePath) + case MethodJSONMerge: + value, valueErr := decodeJSON(opts.Content) + if valueErr != nil { + return nil, fmt.Errorf("decode JSON content: %w", valueErr) + } + document, changed, err = mergeJSONValue(document, segments, value, opts.CreatePath) + case MethodJSONDelete: + document, changed, err = deleteJSONValue(document, segments) + default: + return nil, fmt.Errorf("unsupported JSON method %q", opts.Method) + } + if err != nil { + return nil, err + } + if !changed { + return original, nil + } + + updated, err := json.MarshalIndent(document, "", " ") + if err != nil { + return nil, fmt.Errorf("encode target JSON: %w", err) + } + return append(updated, '\n'), nil +} + +func applyJSONPatch(original []byte, opts EditOptions) ([]byte, error) { + originalValue, err := decodeJSON(original) + if err != nil { + return nil, fmt.Errorf("decode target JSON: %w", err) + } + + var updated []byte + switch opts.Method { + case MethodJSONPatch: + patch, err := jsonpatch.DecodePatch(opts.Content) + if err != nil { + return nil, fmt.Errorf("decode RFC 6902 JSON patch: %w", err) + } + updated, err = patch.Apply(original) + if err != nil { + return nil, fmt.Errorf("apply RFC 6902 JSON patch: %w", err) + } + case MethodJSONMergePatch: + if _, err := decodeJSON(opts.Content); err != nil { + return nil, fmt.Errorf("decode RFC 7386 JSON merge patch: %w", err) + } + updated, err = jsonpatch.MergePatch(original, opts.Content) + if err != nil { + return nil, fmt.Errorf("apply RFC 7386 JSON merge patch: %w", err) + } + default: + return nil, fmt.Errorf("unsupported JSON patch method %q", opts.Method) + } + + updatedValue, err := decodeJSON(updated) + if err != nil { + return nil, fmt.Errorf("decode patched JSON: %w", err) + } + if reflect.DeepEqual(originalValue, updatedValue) { + return original, nil + } + return preserveJSONNewline(original, updated), nil +} + +func decodeJSON(data []byte) (any, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + if err := ensureJSONEOF(decoder); err != nil { + return nil, err + } + return value, nil +} + +func ensureJSONEOF(decoder *json.Decoder) error { + var extra any + err := decoder.Decode(&extra) + if errors.Is(err, io.EOF) { + return nil + } + if err == nil { + return errors.New("multiple JSON values are not allowed") + } + return err +} + +func setJSONValue(current any, segments []string, value any, createPath bool) (any, bool, error) { + if len(segments) == 0 { + return value, !reflect.DeepEqual(current, value), nil + } + + segment := segments[0] + switch typed := current.(type) { + case map[string]any: + child, exists := typed[segment] + if !exists { + if len(segments) == 1 { + typed[segment] = value + return typed, true, nil + } + if !createPath { + return current, false, fmt.Errorf("path component %q does not exist", segment) + } + child = map[string]any{} + } + updated, changed, err := setJSONValue(child, segments[1:], value, createPath) + if err != nil { + return current, false, err + } + if changed { + typed[segment] = updated + } + return typed, changed, nil + case []any: + if segment == "-" { + if len(segments) != 1 { + return current, false, errors.New("array append token '-' is only valid at the final path component") + } + return append(typed, value), true, nil + } + index, err := parseArrayIndex(segment, len(typed)) + if err != nil { + return current, false, err + } + updated, changed, err := setJSONValue(typed[index], segments[1:], value, createPath) + if err != nil { + return current, false, err + } + if changed { + typed[index] = updated + } + return typed, changed, nil + default: + return current, false, fmt.Errorf("cannot traverse path component %q through %T", segment, current) + } +} + +func mergeJSONValue(current any, segments []string, value any, createPath bool) (any, bool, error) { + if len(segments) != 0 { + target, err := getJSONValue(current, segments) + if err != nil { + if !createPath { + return current, false, err + } + valueMap, ok := value.(map[string]any) + if !ok { + return current, false, errors.New("json-merge content must be an object") + } + return setJSONValue(current, segments, valueMap, true) + } + merged, changed, err := deepMergeJSON(target, value) + if err != nil || !changed { + return current, changed, err + } + updated, _, err := setJSONValue(current, segments, merged, createPath) + return updated, true, err + } + return deepMergeJSON(current, value) +} + +func deepMergeJSON(target, source any) (any, bool, error) { + targetMap, ok := target.(map[string]any) + if !ok { + return target, false, errors.New("json-merge target must be an object") + } + sourceMap, ok := source.(map[string]any) + if !ok { + return target, false, errors.New("json-merge content must be an object") + } + + changed := false + for key, sourceValue := range sourceMap { + targetValue, exists := targetMap[key] + if !exists { + targetMap[key] = sourceValue + changed = true + continue + } + if _, sourceIsMap := sourceValue.(map[string]any); sourceIsMap { + if _, targetIsMap := targetValue.(map[string]any); targetIsMap { + merged, nestedChanged, err := deepMergeJSON(targetValue, sourceValue) + if err != nil { + return target, false, err + } + targetMap[key] = merged + changed = changed || nestedChanged + continue + } + } + if !reflect.DeepEqual(targetValue, sourceValue) { + targetMap[key] = sourceValue + changed = true + } + } + return targetMap, changed, nil +} + +func getJSONValue(current any, segments []string) (any, error) { + for _, segment := range segments { + switch typed := current.(type) { + case map[string]any: + next, ok := typed[segment] + if !ok { + return nil, fmt.Errorf("path component %q does not exist", segment) + } + current = next + case []any: + index, err := parseArrayIndex(segment, len(typed)) + if err != nil { + return nil, err + } + current = typed[index] + default: + return nil, fmt.Errorf("cannot traverse path component %q through %T", segment, current) + } + } + return current, nil +} + +func deleteJSONValue(current any, segments []string) (any, bool, error) { + if len(segments) == 0 { + return current, false, errors.New("deleting the document root is not supported") + } + if len(segments) == 1 { + switch typed := current.(type) { + case map[string]any: + if _, ok := typed[segments[0]]; !ok { + return current, false, fmt.Errorf("path component %q does not exist", segments[0]) + } + delete(typed, segments[0]) + return typed, true, nil + case []any: + index, err := parseArrayIndex(segments[0], len(typed)) + if err != nil { + return current, false, err + } + return append(typed[:index], typed[index+1:]...), true, nil + default: + return current, false, fmt.Errorf("cannot delete path component %q from %T", segments[0], current) + } + } + + segment := segments[0] + switch typed := current.(type) { + case map[string]any: + child, ok := typed[segment] + if !ok { + return current, false, fmt.Errorf("path component %q does not exist", segment) + } + updated, changed, err := deleteJSONValue(child, segments[1:]) + if changed { + typed[segment] = updated + } + return typed, changed, err + case []any: + index, err := parseArrayIndex(segment, len(typed)) + if err != nil { + return current, false, err + } + updated, changed, err := deleteJSONValue(typed[index], segments[1:]) + if changed { + typed[index] = updated + } + return typed, changed, err + default: + return current, false, fmt.Errorf("cannot traverse path component %q through %T", segment, current) + } +} + +func parseArrayIndex(segment string, length int) (int, error) { + index, err := strconv.Atoi(segment) + if err != nil || index < 0 { + return 0, fmt.Errorf("array path component %q is not a non-negative index", segment) + } + if index >= length { + return 0, fmt.Errorf("array index %d is out of bounds (length %d)", index, length) + } + return index, nil +} + +func preserveJSONNewline(original, updated []byte) []byte { + if len(original) > 0 && original[len(original)-1] == '\n' && (len(updated) == 0 || updated[len(updated)-1] != '\n') { + return append(updated, '\n') + } + return updated +} diff --git a/internal/gitupdate/pointer.go b/internal/gitupdate/pointer.go new file mode 100644 index 000000000..15aa88a58 --- /dev/null +++ b/internal/gitupdate/pointer.go @@ -0,0 +1,59 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitupdate + +import ( + "fmt" + "strings" +) + +// parsePointer implements the token decoding rules from RFC 6901. An empty +// pointer addresses the document root. +func parsePointer(pointer string) ([]string, error) { + if pointer == "" { + return nil, nil + } + if !strings.HasPrefix(pointer, "/") { + return nil, fmt.Errorf("path %q must be an RFC 6901 JSON pointer beginning with '/'", pointer) + } + + raw := strings.Split(pointer[1:], "/") + segments := make([]string, len(raw)) + for i, segment := range raw { + var decoded strings.Builder + for j := 0; j < len(segment); j++ { + if segment[j] != '~' { + decoded.WriteByte(segment[j]) + continue + } + if j+1 >= len(segment) { + return nil, fmt.Errorf("path %q contains an invalid '~' escape", pointer) + } + j++ + switch segment[j] { + case '0': + decoded.WriteByte('~') + case '1': + decoded.WriteByte('/') + default: + return nil, fmt.Errorf("path %q contains invalid escape ~%c", pointer, segment[j]) + } + } + segments[i] = decoded.String() + } + return segments, nil +} diff --git a/internal/gitupdate/yaml.go b/internal/gitupdate/yaml.go new file mode 100644 index 000000000..c6d026296 --- /dev/null +++ b/internal/gitupdate/yaml.go @@ -0,0 +1,390 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package gitupdate + +import ( + "bytes" + "errors" + "fmt" + "io" + "reflect" + + "go.yaml.in/yaml/v3" +) + +func applyYAML(original []byte, opts EditOptions) ([]byte, error) { + document, err := decodeYAMLDocument(original) + if err != nil { + return nil, fmt.Errorf("decode target YAML: %w", err) + } + segments, err := parsePointer(opts.Pointer) + if err != nil { + return nil, err + } + + root := document.Content[0] + var changed bool + switch opts.Method { + case MethodYAMLSet: + value, valueErr := decodeYAMLValue(opts.Content) + if valueErr != nil { + return nil, fmt.Errorf("decode YAML content: %w", valueErr) + } + changed, err = setYAMLValue(root, segments, value, opts.CreatePath) + case MethodYAMLMerge: + value, valueErr := decodeYAMLValue(opts.Content) + if valueErr != nil { + return nil, fmt.Errorf("decode YAML content: %w", valueErr) + } + changed, err = mergeYAMLAt(root, segments, value, opts.CreatePath) + case MethodYAMLDelete: + changed, err = deleteYAMLValue(root, segments) + default: + return nil, fmt.Errorf("unsupported YAML method %q", opts.Method) + } + if err != nil { + return nil, err + } + if !changed { + return original, nil + } + + var output bytes.Buffer + encoder := yaml.NewEncoder(&output) + encoder.SetIndent(2) + if err := encoder.Encode(document); err != nil { + return nil, fmt.Errorf("encode target YAML: %w", err) + } + if err := encoder.Close(); err != nil { + return nil, fmt.Errorf("finish encoding target YAML: %w", err) + } + return output.Bytes(), nil +} + +func decodeYAMLDocument(data []byte) (*yaml.Node, error) { + document, err := decodeOneYAML(data) + if err != nil { + return nil, err + } + if document.Kind != yaml.DocumentNode || len(document.Content) != 1 { + return nil, errors.New("expected one YAML document with a root value") + } + return document, nil +} + +func decodeYAMLValue(data []byte) (*yaml.Node, error) { + document, err := decodeOneYAML(data) + if err != nil { + return nil, err + } + if document.Kind != yaml.DocumentNode || len(document.Content) != 1 { + return nil, errors.New("expected one YAML value") + } + return cloneYAMLNode(document.Content[0]), nil +} + +func decodeOneYAML(data []byte) (*yaml.Node, error) { + decoder := yaml.NewDecoder(bytes.NewReader(data)) + var document yaml.Node + if err := decoder.Decode(&document); err != nil { + return nil, err + } + if len(document.Content) == 0 { + return nil, errors.New("empty YAML is not a value") + } + + var extra yaml.Node + err := decoder.Decode(&extra) + if err == nil { + return nil, errors.New("multiple YAML documents are not supported") + } + if !errors.Is(err, io.EOF) { + return nil, err + } + if err := validateYAMLUniqueKeys(document.Content[0]); err != nil { + return nil, err + } + return &document, nil +} + +func validateYAMLUniqueKeys(node *yaml.Node) error { + if node.Kind == yaml.MappingNode { + seen := map[string]struct{}{} + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + encoded, err := yaml.Marshal(key) + if err != nil { + return fmt.Errorf("encode YAML mapping key: %w", err) + } + identity := string(encoded) + if _, exists := seen[identity]; exists { + return fmt.Errorf("duplicate YAML mapping key %q", key.Value) + } + seen[identity] = struct{}{} + } + } + for _, child := range node.Content { + if err := validateYAMLUniqueKeys(child); err != nil { + return err + } + } + return nil +} + +func setYAMLValue(current *yaml.Node, segments []string, value *yaml.Node, createPath bool) (bool, error) { + if len(segments) == 0 { + if equalYAMLValue(current, value) { + return false, nil + } + replacement := cloneYAMLNode(value) + preserveYAMLComments(replacement, current) + *current = *replacement + return true, nil + } + + segment := segments[0] + switch current.Kind { + case yaml.MappingNode: + index := yamlMapValueIndex(current, segment) + if index < 0 { + if len(segments) == 1 { + current.Content = append(current.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: segment}, + cloneYAMLNode(value), + ) + return true, nil + } + if !createPath { + return false, fmt.Errorf("path component %q does not exist", segment) + } + child := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + current.Content = append(current.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: segment}, + child, + ) + return setYAMLValue(child, segments[1:], value, createPath) + } + return setYAMLValue(current.Content[index], segments[1:], value, createPath) + case yaml.SequenceNode: + if segment == "-" { + if len(segments) != 1 { + return false, errors.New("array append token '-' is only valid at the final path component") + } + current.Content = append(current.Content, cloneYAMLNode(value)) + return true, nil + } + index, err := parseArrayIndex(segment, len(current.Content)) + if err != nil { + return false, err + } + return setYAMLValue(current.Content[index], segments[1:], value, createPath) + case yaml.AliasNode: + return false, fmt.Errorf("cannot traverse path component %q through a YAML alias", segment) + default: + return false, fmt.Errorf("cannot traverse path component %q through YAML kind %d", segment, current.Kind) + } +} + +func mergeYAMLAt(current *yaml.Node, segments []string, value *yaml.Node, createPath bool) (bool, error) { + if value.Kind != yaml.MappingNode { + return false, errors.New("yaml-merge content must be a mapping") + } + if len(segments) == 0 { + return deepMergeYAML(current, value) + } + + target, err := getYAMLValue(current, segments) + if err != nil { + if !createPath { + return false, err + } + return setYAMLValue(current, segments, value, true) + } + return deepMergeYAML(target, value) +} + +func deepMergeYAML(target, source *yaml.Node) (bool, error) { + if target.Kind != yaml.MappingNode { + return false, errors.New("yaml-merge target must be a mapping") + } + if source.Kind != yaml.MappingNode { + return false, errors.New("yaml-merge content must be a mapping") + } + + changed := false + for i := 0; i < len(source.Content); i += 2 { + sourceKey := source.Content[i] + sourceValue := source.Content[i+1] + if sourceKey.Kind != yaml.ScalarNode { + return false, errors.New("yaml-merge only supports scalar mapping keys") + } + + targetIndex := yamlMapValueIndex(target, sourceKey.Value) + if targetIndex < 0 { + target.Content = append(target.Content, cloneYAMLNode(sourceKey), cloneYAMLNode(sourceValue)) + changed = true + continue + } + + targetValue := target.Content[targetIndex] + if targetValue.Kind == yaml.MappingNode && sourceValue.Kind == yaml.MappingNode { + nestedChanged, err := deepMergeYAML(targetValue, sourceValue) + if err != nil { + return false, err + } + changed = changed || nestedChanged + continue + } + if equalYAMLValue(targetValue, sourceValue) { + continue + } + replacement := cloneYAMLNode(sourceValue) + preserveYAMLComments(replacement, targetValue) + target.Content[targetIndex] = replacement + changed = true + } + return changed, nil +} + +func getYAMLValue(current *yaml.Node, segments []string) (*yaml.Node, error) { + for _, segment := range segments { + switch current.Kind { + case yaml.MappingNode: + index := yamlMapValueIndex(current, segment) + if index < 0 { + return nil, fmt.Errorf("path component %q does not exist", segment) + } + current = current.Content[index] + case yaml.SequenceNode: + index, err := parseArrayIndex(segment, len(current.Content)) + if err != nil { + return nil, err + } + current = current.Content[index] + case yaml.AliasNode: + return nil, fmt.Errorf("cannot traverse path component %q through a YAML alias", segment) + default: + return nil, fmt.Errorf("cannot traverse path component %q through YAML kind %d", segment, current.Kind) + } + } + return current, nil +} + +func deleteYAMLValue(current *yaml.Node, segments []string) (bool, error) { + if len(segments) == 0 { + return false, errors.New("deleting the document root is not supported") + } + if len(segments) == 1 { + segment := segments[0] + switch current.Kind { + case yaml.MappingNode: + valueIndex := yamlMapValueIndex(current, segment) + if valueIndex < 0 { + return false, fmt.Errorf("path component %q does not exist", segment) + } + keyIndex := valueIndex - 1 + current.Content = append(current.Content[:keyIndex], current.Content[valueIndex+1:]...) + return true, nil + case yaml.SequenceNode: + index, err := parseArrayIndex(segment, len(current.Content)) + if err != nil { + return false, err + } + current.Content = append(current.Content[:index], current.Content[index+1:]...) + return true, nil + default: + return false, fmt.Errorf("cannot delete path component %q from YAML kind %d", segment, current.Kind) + } + } + + segment := segments[0] + switch current.Kind { + case yaml.MappingNode: + index := yamlMapValueIndex(current, segment) + if index < 0 { + return false, fmt.Errorf("path component %q does not exist", segment) + } + return deleteYAMLValue(current.Content[index], segments[1:]) + case yaml.SequenceNode: + index, err := parseArrayIndex(segment, len(current.Content)) + if err != nil { + return false, err + } + return deleteYAMLValue(current.Content[index], segments[1:]) + case yaml.AliasNode: + return false, fmt.Errorf("cannot traverse path component %q through a YAML alias", segment) + default: + return false, fmt.Errorf("cannot traverse path component %q through YAML kind %d", segment, current.Kind) + } +} + +func yamlMapValueIndex(mapping *yaml.Node, key string) int { + for i := 0; i+1 < len(mapping.Content); i += 2 { + keyNode := mapping.Content[i] + if keyNode.Kind == yaml.ScalarNode && keyNode.Value == key { + return i + 1 + } + } + return -1 +} + +func equalYAMLValue(left, right *yaml.Node) bool { + var leftValue any + if err := left.Decode(&leftValue); err != nil { + return false + } + var rightValue any + if err := right.Decode(&rightValue); err != nil { + return false + } + return reflect.DeepEqual(leftValue, rightValue) +} + +func preserveYAMLComments(target, source *yaml.Node) { + if target.HeadComment == "" { + target.HeadComment = source.HeadComment + } + if target.LineComment == "" { + target.LineComment = source.LineComment + } + if target.FootComment == "" { + target.FootComment = source.FootComment + } +} + +func cloneYAMLNode(node *yaml.Node) *yaml.Node { + clones := map[*yaml.Node]*yaml.Node{} + var clone func(*yaml.Node) *yaml.Node + clone = func(source *yaml.Node) *yaml.Node { + if source == nil { + return nil + } + if existing, ok := clones[source]; ok { + return existing + } + target := *source + target.Content = nil + target.Alias = nil + clones[source] = &target + for _, child := range source.Content { + target.Content = append(target.Content, clone(child)) + } + target.Alias = clone(source.Alias) + return &target + } + return clone(node) +} diff --git a/pkg/cmd/git.go b/pkg/cmd/git.go new file mode 100644 index 000000000..3de022207 --- /dev/null +++ b/pkg/cmd/git.go @@ -0,0 +1,407 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "errors" + "fmt" + "io" + "os" + "strings" + + "github.com/spf13/cobra" + + "helm.sh/helm/v4/internal/gitupdate" + "helm.sh/helm/v4/pkg/cmd/require" +) + +const gitDesc = ` +Work with files stored in Git repositories. + +Git operations are implemented inside the Helm binary and do not require a +separate git executable. +` + +const gitUpdateDesc = ` +Modify one file in a Git repository, commit the change, and push it. + +The edit is deterministic: text selectors fail unless they match the expected +number of locations, and structured edits use RFC 6901 JSON pointers. An empty +pointer addresses the document root. For example: + + helm git update https://example.com/platform/config.git \ + --branch main --file charts/api/values.yaml \ + --method yaml-set --path /image/tag --content 1.4.2 + + helm git update git@example.com:platform/config.git \ + --branch main --file deploy/app.yaml \ + --method replace --match 'replicas: 2' --content 'replicas: 3' \ + --auth ssh-agent + +The default --transport=clone backend uses the Git smart protocol and a +temporary worktree. The --transport=api backend uses a repository service API +to read and commit the file without cloning. GitLab is the default API +provider: + + helm git update https://gitlab.example.com/platform/config.git \ + --transport api --api-provider gitlab --token-file /run/secrets/git-token \ + --branch main --file charts/api/values.yaml \ + --method yaml-set --path /image/tag --content 1.4.2 + +Content can be supplied with --content, --content-env, --content-file, or +--content-stdin. Secret flags also read HELM_GIT_PASSWORD, HELM_GIT_TOKEN, and +HELM_GIT_SSH_KEY_PASSPHRASE when their command-line and file forms are absent. +` + +type gitUpdateOptions struct { + repositoryURL string + transport string + branch string + baseBranch string + createBranch bool + targetFile string + createFile bool + method string + + content string + contentEnv string + contentFile string + contentStdin bool + match string + matchFile string + regex string + pointer string + createPath bool + expected int + all bool + expand bool + + authType string + username string + password string + passwordFile string + token string + tokenFile string + sshPrivateKey string + sshKeyPassphrase string + sshPassphraseFile string + sshKnownHosts string + insecureSSHHostKey bool + caFile string + certFile string + keyFile string + insecureSkipTLS bool + apiProvider string + apiBaseURL string + apiProject string + apiTokenType string + authorName string + authorEmail string + commitMessage string + depth int + dryRun bool + push bool + resolvedContent []byte + contentWasProvided bool + resolvedLiteralMatch string +} + +func newGitCmd(out io.Writer) *cobra.Command { + cmd := &cobra.Command{ + Use: "git", + Short: "edit files in Git repositories", + Long: gitDesc, + Args: require.NoArgs, + ValidArgsFunction: noMoreArgsCompFunc, + } + cmd.AddCommand(newGitUpdateCmd(out)) + return cmd +} + +func newGitUpdateCmd(out io.Writer) *cobra.Command { + o := &gitUpdateOptions{} + if value := os.Getenv("GIT_AUTHOR_NAME"); value != "" { + o.authorName = value + } else { + o.authorName = "Helm Git Update" + } + if value := os.Getenv("GIT_AUTHOR_EMAIL"); value != "" { + o.authorEmail = value + } else { + o.authorEmail = "helm-git@localhost" + } + + cmd := &cobra.Command{ + Use: "update REPOSITORY", + Short: "edit, commit, and push a file in a Git repository", + Long: gitUpdateDesc, + Args: require.ExactArgs(1), + ValidArgsFunction: func(_ *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return nil, cobra.ShellCompDirectiveNoFileComp + } + return noMoreArgsComp() + }, + PreRunE: func(cmd *cobra.Command, args []string) error { + return o.complete(cmd, args) + }, + RunE: func(cmd *cobra.Command, _ []string) error { + return o.run(cmd, out) + }, + } + + flags := cmd.Flags() + flags.StringVar(&o.transport, "transport", gitupdate.TransportClone, "remote update backend ("+strings.Join(gitupdate.ValidTransports(), ", ")+")") + flags.StringVar(&o.branch, "branch", "", "target branch to update") + flags.StringVar(&o.baseBranch, "base-branch", "", "branch from which a new target branch is created") + flags.BoolVar(&o.createBranch, "create-branch", false, "create the target branch from --base-branch") + flags.StringVar(&o.targetFile, "file", "", "file to edit, relative to the repository root") + flags.BoolVar(&o.createFile, "create-file", false, "create the target file and parent directories when absent") + flags.StringVar(&o.method, "method", string(gitupdate.MethodOverwrite), "edit method ("+strings.Join(gitupdate.ValidMethods(), ", ")+")") + + flags.StringVar(&o.content, "content", "", "new content or structured value") + flags.StringVar(&o.contentEnv, "content-env", "", "read new content or structured value from the named environment variable") + flags.StringVar(&o.contentFile, "content-file", "", "read new content or structured value from a file") + flags.BoolVar(&o.contentStdin, "content-stdin", false, "read new content or structured value from standard input") + flags.StringVar(&o.match, "match", "", "literal text selector for replace or insert methods") + flags.StringVar(&o.matchFile, "match-file", "", "read the literal text selector from a file") + flags.StringVar(&o.regex, "regex", "", "Go regular expression selector for replace or insert methods") + flags.IntVar(&o.expected, "expected-matches", 1, "required selector match count; 0 accepts any non-zero count") + flags.BoolVar(&o.all, "all", false, "edit all selector matches instead of only the first") + flags.BoolVar(&o.expand, "expand", false, "expand regular expression captures such as $1 in content") + flags.StringVar(&o.pointer, "path", "", "RFC 6901 path used by YAML and JSON set, merge, and delete methods") + flags.BoolVar(&o.createPath, "create-path", false, "create absent mapping components for structured set or merge") + + flags.StringVar(&o.authType, "auth", gitupdate.AuthAuto, "authentication mode ("+strings.Join(gitupdate.ValidAuthTypes(), ", ")+")") + flags.StringVar(&o.username, "username", os.Getenv("HELM_GIT_USERNAME"), "HTTP or SSH username") + flags.StringVar(&o.password, "password", "", "HTTP or SSH password (prefer --password-file or HELM_GIT_PASSWORD)") + flags.StringVar(&o.passwordFile, "password-file", "", "read the HTTP or SSH password from a file") + flags.StringVar(&o.token, "token", "", "HTTP token (prefer --token-file or HELM_GIT_TOKEN)") + flags.StringVar(&o.tokenFile, "token-file", "", "read the HTTP token from a file") + flags.StringVar(&o.sshPrivateKey, "ssh-private-key", "", "SSH private key file") + flags.StringVar(&o.sshKeyPassphrase, "ssh-key-passphrase", "", "SSH key passphrase (prefer its file or environment form)") + flags.StringVar(&o.sshPassphraseFile, "ssh-key-passphrase-file", "", "read the SSH key passphrase from a file") + flags.StringVar(&o.sshKnownHosts, "ssh-known-hosts", "", "SSH known_hosts file (defaults to standard SSH known_hosts files)") + flags.BoolVar(&o.insecureSSHHostKey, "insecure-skip-ssh-host-key", false, "skip SSH host-key verification (insecure)") + + flags.StringVar(&o.caFile, "ca-file", "", "CA bundle used to verify the Git HTTPS server") + flags.StringVar(&o.certFile, "cert-file", "", "client certificate used for Git mutual TLS") + flags.StringVar(&o.keyFile, "key-file", "", "client key used for Git mutual TLS") + flags.BoolVar(&o.insecureSkipTLS, "insecure-skip-tls-verify", false, "skip Git HTTPS certificate verification (insecure)") + + flags.StringVar(&o.apiProvider, "api-provider", gitupdate.APIProviderAuto, "repository API provider ("+strings.Join(gitupdate.ValidAPIProviders(), ", ")+")") + flags.StringVar(&o.apiBaseURL, "api-base-url", "", "repository API base URL (inferred from REPOSITORY)") + flags.StringVar(&o.apiProject, "api-project", "", "repository API project ID or path (inferred from REPOSITORY)") + flags.StringVar(&o.apiTokenType, "api-token-type", gitupdate.APITokenAuto, "repository API token type ("+strings.Join(gitupdate.ValidAPITokenTypes(), ", ")+")") + + flags.StringVar(&o.authorName, "author-name", o.authorName, "commit author name") + flags.StringVar(&o.authorEmail, "author-email", o.authorEmail, "commit author email") + flags.StringVarP(&o.commitMessage, "message", "m", "", "commit message (defaults to 'helm git update ')") + flags.IntVar(&o.depth, "depth", 1, "clone depth; 0 clones the complete history") + flags.BoolVar(&o.dryRun, "dry-run", false, "read and validate the edit without committing or pushing") + flags.BoolVar(&o.push, "push", true, "push the commit to the remote repository") + + _ = cmd.MarkFlagRequired("branch") + _ = cmd.MarkFlagRequired("file") + _ = cmd.RegisterFlagCompletionFunc("method", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return gitupdate.ValidMethods(), cobra.ShellCompDirectiveNoFileComp + }) + _ = cmd.RegisterFlagCompletionFunc("auth", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return gitupdate.ValidAuthTypes(), cobra.ShellCompDirectiveNoFileComp + }) + _ = cmd.RegisterFlagCompletionFunc("transport", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return gitupdate.ValidTransports(), cobra.ShellCompDirectiveNoFileComp + }) + _ = cmd.RegisterFlagCompletionFunc("api-provider", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return gitupdate.ValidAPIProviders(), cobra.ShellCompDirectiveNoFileComp + }) + _ = cmd.RegisterFlagCompletionFunc("api-token-type", func(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return gitupdate.ValidAPITokenTypes(), cobra.ShellCompDirectiveNoFileComp + }) + + return cmd +} + +func (o *gitUpdateOptions) complete(cmd *cobra.Command, args []string) error { + o.repositoryURL = args[0] + if o.commitMessage == "" { + o.commitMessage = "helm git update " + o.targetFile + } + + contentSources := 0 + if cmd.Flags().Changed("content") { + contentSources++ + o.resolvedContent = []byte(o.content) + o.contentWasProvided = true + } + if o.contentEnv != "" { + contentSources++ + content, exists := os.LookupEnv(o.contentEnv) + if !exists { + return fmt.Errorf("content environment variable %q is not set", o.contentEnv) + } + o.resolvedContent = []byte(content) + o.contentWasProvided = true + } + if o.contentFile != "" { + contentSources++ + content, err := os.ReadFile(o.contentFile) + if err != nil { + return fmt.Errorf("read content file: %w", err) + } + o.resolvedContent = content + o.contentWasProvided = true + } + if o.contentStdin { + contentSources++ + content, err := io.ReadAll(cmd.InOrStdin()) + if err != nil { + return fmt.Errorf("read content from standard input: %w", err) + } + o.resolvedContent = content + o.contentWasProvided = true + } + if contentSources > 1 { + return errors.New("use only one of --content, --content-env, --content-file, or --content-stdin") + } + + o.resolvedLiteralMatch = o.match + if o.match != "" && o.matchFile != "" { + return errors.New("use only one of --match or --match-file") + } + if o.matchFile != "" { + match, err := os.ReadFile(o.matchFile) + if err != nil { + return fmt.Errorf("read match file: %w", err) + } + o.resolvedLiteralMatch = string(match) + } + + password, err := resolveCredential(o.password, o.passwordFile, "HELM_GIT_PASSWORD") + if err != nil { + return fmt.Errorf("resolve password: %w", err) + } + o.password = password + token, err := resolveCredential(o.token, o.tokenFile, "HELM_GIT_TOKEN") + if err != nil { + return fmt.Errorf("resolve token: %w", err) + } + o.token = token + passphrase, err := resolveCredential(o.sshKeyPassphrase, o.sshPassphraseFile, "HELM_GIT_SSH_KEY_PASSPHRASE") + if err != nil { + return fmt.Errorf("resolve SSH key passphrase: %w", err) + } + o.sshKeyPassphrase = passphrase + return nil +} + +func (o *gitUpdateOptions) run(cmd *cobra.Command, out io.Writer) error { + var progress io.Writer + if settings.Debug { + progress = cmd.ErrOrStderr() + } + + result, err := gitupdate.Update(cmd.Context(), gitupdate.RepositoryOptions{ + RepositoryURL: o.repositoryURL, + Transport: o.transport, + Branch: o.branch, + BaseBranch: o.baseBranch, + CreateBranch: o.createBranch, + TargetFile: o.targetFile, + CreateFile: o.createFile, + Edit: gitupdate.EditOptions{ + Method: gitupdate.Method(o.method), + Content: o.resolvedContent, + ContentProvided: o.contentWasProvided, + LiteralMatch: o.resolvedLiteralMatch, + RegexMatch: o.regex, + ExpectedMatches: o.expected, + AllMatches: o.all, + Expand: o.expand, + Pointer: o.pointer, + CreatePath: o.createPath, + }, + Auth: gitupdate.AuthOptions{ + Type: o.authType, + Username: o.username, + Password: o.password, + Token: o.token, + SSHPrivateKey: o.sshPrivateKey, + SSHKeyPassphrase: o.sshKeyPassphrase, + KnownHostsFile: o.sshKnownHosts, + InsecureHostKey: o.insecureSSHHostKey, + SSHAgentSocketIsSet: gitupdate.AgentAvailable(), + }, + TLS: gitupdate.TLSOptions{ + CAFile: o.caFile, + ClientCertificate: o.certFile, + ClientKey: o.keyFile, + InsecureSkipTLS: o.insecureSkipTLS, + }, + API: gitupdate.APIOptions{ + Provider: o.apiProvider, + BaseURL: o.apiBaseURL, + Project: o.apiProject, + TokenType: o.apiTokenType, + }, + AuthorName: o.authorName, + AuthorEmail: o.authorEmail, + CommitMessage: o.commitMessage, + Depth: o.depth, + DryRun: o.dryRun, + Push: o.push, + Progress: progress, + }) + if err != nil { + return err + } + + switch { + case !result.Changed: + fmt.Fprintf(out, "No changes needed for %s on branch %s\n", result.TargetFile, result.Branch) + case o.dryRun: + fmt.Fprintf(out, "Would update %s on branch %s using %s transport (dry run; no remote commit)\n", result.TargetFile, result.Branch, result.Transport) + case result.Pushed: + fmt.Fprintf(out, "Updated %s on branch %s using %s transport in commit %s\n", result.TargetFile, result.Branch, result.Transport, result.CommitHash) + case result.Committed: + fmt.Fprintf(out, "Updated %s on branch %s in commit %s (not pushed)\n", result.TargetFile, result.Branch, result.CommitHash) + } + return nil +} + +func resolveCredential(direct, filename, environment string) (string, error) { + if direct != "" && filename != "" { + return "", errors.New("a direct value and file cannot both be set") + } + if filename != "" { + value, err := os.ReadFile(filename) + if err != nil { + return "", err + } + return trimOneLineEnding(string(value)), nil + } + if direct != "" { + return direct, nil + } + return os.Getenv(environment), nil +} + +func trimOneLineEnding(value string) string { + value = strings.TrimSuffix(value, "\n") + value = strings.TrimSuffix(value, "\r") + return value +} diff --git a/pkg/cmd/git_test.go b/pkg/cmd/git_test.go new file mode 100644 index 000000000..f68c8c3c6 --- /dev/null +++ b/pkg/cmd/git_test.go @@ -0,0 +1,116 @@ +/* +Copyright The Helm Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package cmd + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGitCommandIsRegistered(t *testing.T) { + root, _, err := executeActionCommand("help git") + require.NoError(t, err) + command, _, err := root.Root().Find([]string{"git", "update"}) + require.NoError(t, err) + assert.Equal(t, "update", command.Name()) + apiProvider := command.Flag("api-provider") + require.NotNil(t, apiProvider) + assert.Equal(t, "auto", apiProvider.DefValue) +} + +func TestGitUpdateValidatesEditBeforeClone(t *testing.T) { + _, _, err := executeActionCommand("git update https://example.invalid/repo.git --branch main --file values.yaml") + require.ErrorContains(t, err, `method "overwrite" requires content`) +} + +func TestGitUpdateRejectsMultipleContentSources(t *testing.T) { + contentFile := filepath.Join(t.TempDir(), "content") + require.NoError(t, os.WriteFile(contentFile, []byte("value"), 0o600)) + + command := newGitUpdateCmd(os.Stdout) + command.SetArgs([]string{ + "https://example.invalid/repo.git", + "--branch", "main", + "--file", "values.yaml", + "--content", "inline", + "--content-file", contentFile, + }) + err := command.Execute() + require.ErrorContains(t, err, "use only one of --content, --content-env, --content-file, or --content-stdin") +} + +func TestGitUpdateRejectsContentAndContentEnvironment(t *testing.T) { + t.Setenv("HELM_GIT_TEST_CONTENT", "from-environment") + + command := newGitUpdateCmd(os.Stdout) + command.SetArgs([]string{ + "https://example.invalid/repo.git", + "--branch", "main", + "--file", "values.yaml", + "--content", "inline", + "--content-env", "HELM_GIT_TEST_CONTENT", + }) + err := command.Execute() + require.ErrorContains(t, err, "use only one of --content, --content-env, --content-file, or --content-stdin") +} + +func TestGitUpdateRejectsUnsetContentEnvironment(t *testing.T) { + command := newGitUpdateCmd(os.Stdout) + command.SetArgs([]string{ + "https://example.invalid/repo.git", + "--branch", "main", + "--file", "values.yaml", + "--content-env", "HELM_GIT_TEST_UNSET_CONTENT", + }) + err := command.Execute() + require.ErrorContains(t, err, `content environment variable "HELM_GIT_TEST_UNSET_CONTENT" is not set`) +} + +func TestResolveCredential(t *testing.T) { + t.Setenv("HELM_GIT_TEST_SECRET", "from-env") + secretFile := filepath.Join(t.TempDir(), "secret") + require.NoError(t, os.WriteFile(secretFile, []byte("from-file\r\n"), 0o600)) + + tests := []struct { + name string + direct string + filename string + expected string + errorText string + }{ + {name: "environment", expected: "from-env"}, + {name: "direct", direct: "from-flag", expected: "from-flag"}, + {name: "file", filename: secretFile, expected: "from-file"}, + {name: "conflict", direct: "from-flag", filename: secretFile, errorText: "cannot both be set"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + actual, err := resolveCredential(test.direct, test.filename, "HELM_GIT_TEST_SECRET") + if test.errorText != "" { + require.ErrorContains(t, err, test.errorText) + return + } + require.NoError(t, err) + assert.Equal(t, test.expected, actual) + }) + } +} diff --git a/pkg/cmd/root.go b/pkg/cmd/root.go index dbedca026..ac6e91b83 100644 --- a/pkg/cmd/root.go +++ b/pkg/cmd/root.go @@ -289,6 +289,7 @@ func newRootCmdWithConfig(actionConfig *action.Configuration, out io.Writer, arg newCompletionCmd(out), newEnvCmd(out), + newGitCmd(out), newPluginCmd(out), newVersionCmd(out),