mirror of https://github.com/helm/helm
Merge 89f3a87e0c into d31cd6992f
commit
b449ed0fa3
@ -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
|
||||
}
|
||||
@ -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") != ""
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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"`
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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 <file>')")
|
||||
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
|
||||
}
|
||||
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Reference in new issue