diff --git a/go.mod b/go.mod index 0ec7795..79717c7 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( github.com/go-git/go-billy/v5 v5.5.0 github.com/go-git/go-git/v5 v5.12.0 github.com/google/go-cmp v0.6.0 + github.com/google/go-github/v62 v62.0.0 github.com/hashicorp/go-hclog v1.6.3 github.com/hashicorp/go-multierror v1.1.1 github.com/hashicorp/go-plugin v1.6.1 @@ -25,6 +26,7 @@ require ( github.com/sirupsen/logrus v1.9.3 github.com/urfave/cli/v2 v2.27.2 github.com/whilp/git-urls v1.0.0 + golang.org/x/oauth2 v0.20.0 golang.org/x/term v0.20.0 gopkg.in/yaml.v3 v3.0.1 gotest.tools/v3 v3.5.1 @@ -56,7 +58,6 @@ require ( github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/golang/protobuf v1.5.4 // indirect - github.com/google/go-github/v62 v62.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/css v1.0.1 // indirect @@ -101,7 +102,6 @@ require ( golang.org/x/crypto v0.23.0 // indirect golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 // indirect golang.org/x/net v0.25.0 // indirect - golang.org/x/oauth2 v0.20.0 // indirect golang.org/x/sys v0.20.0 // indirect golang.org/x/text v0.15.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect diff --git a/go.sum b/go.sum index c8ca7c9..a172bd8 100644 --- a/go.sum +++ b/go.sum @@ -84,8 +84,6 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.6.0 h1:n+5WquG0fcWoWp6xPWfHdbskMCQaFnG6PfBrh1Ky4HY= github.com/fsnotify/fsnotify v1.6.0/go.mod h1:sl3t1tCWJFWoRz9R8WJCbQihKKwmorjAbSClcnxKAGw= -github.com/getoutreach/gobox v1.90.3 h1:xyxoUUvczB9wA65lAljEQJU50QR48eNaKsLlGMaTJhs= -github.com/getoutreach/gobox v1.90.3/go.mod h1:1iL7CJnVoVbBUeWhJMbwEPeH9wflJ22OiO0F9Qtr/Aw= github.com/getoutreach/gobox v1.91.0 h1:tvCiVbPi/mKpuOA5vR3YAUkbDhSiRbdq1hLFOVSE/IA= github.com/getoutreach/gobox v1.91.0/go.mod h1:AYZr5hBbU33WDq0bybUxiVyn4Wl1quY+ndwEJcG74mY= github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= diff --git a/internal/cmd/stencil/stencil.go b/internal/cmd/stencil/stencil.go index 7f1182e..15ad4f9 100644 --- a/internal/cmd/stencil/stencil.go +++ b/internal/cmd/stencil/stencil.go @@ -17,10 +17,9 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/blang/semver/v4" "github.com/charmbracelet/glamour" - "github.com/getoutreach/gobox/pkg/cfg" - "github.com/getoutreach/gobox/pkg/cli/github" "github.com/pkg/errors" "go.rgst.io/stencil/internal/codegen" + "go.rgst.io/stencil/internal/git/vcs/github" "go.rgst.io/stencil/internal/modules" "go.rgst.io/stencil/internal/slogext" "go.rgst.io/stencil/pkg/configuration" @@ -54,7 +53,7 @@ type Command struct { allowMajorVersionUpgrades bool // token is the github token used for fetching modules - token cfg.SecretData + token string } // NewCommand creates a new stencil command @@ -64,7 +63,7 @@ func NewCommand(log slogext.Logger, s *configuration.Manifest, if err != nil && !errors.Is(err, os.ErrNotExist) { log.WithError(err).Warn("failed to load lockfile") } - token, err := github.GetToken() + token, err := github.Token() if err != nil { log.Warn("failed to get github token, using anonymous access") } @@ -272,7 +271,7 @@ func (c *Command) promptMajorVersion(ctx context.Context, m *modules.Module, las ) } - gh, err := github.NewClient(github.WithAllowUnauthenticated()) + gh, err := github.New() if err != nil { return errors.Wrap(err, "failed to fetch release notes (create github client)") } diff --git a/internal/git/vcs/github/errors.go b/internal/git/vcs/github/errors.go new file mode 100644 index 0000000..ac8fc78 --- /dev/null +++ b/internal/git/vcs/github/errors.go @@ -0,0 +1,33 @@ +// Copyright (C) 2024 stencil contributors +// +// 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 github + +import "errors" + +// ErrNoToken is returned when no token is found in the configured +// credential providers. +type ErrNoToken struct { + errs []error +} + +// Unwrap returns the errors that caused the ErrNoToken error. +func (e ErrNoToken) Unwrap() []error { + return e.errs +} + +// Error returns the error message for ErrNoToken. +func (e ErrNoToken) Error() string { + return errors.Join(e.errs...).Error() +} diff --git a/internal/git/vcs/github/github.go b/internal/git/vcs/github/github.go new file mode 100644 index 0000000..98f1b37 --- /dev/null +++ b/internal/git/vcs/github/github.go @@ -0,0 +1,78 @@ +// Copyright (C) 2024 stencil contributors +// +// 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 github provides methods for creating a Github client for the +// purposes of interacting with the API. Provides support for retrieving +// authentication tokens from the following sources: +// +// - Environment variables ($GITHUB_TOKEN) +// - Github CLI +package github + +import ( + "context" + "net/http" + + "github.com/google/go-github/v62/github" + "golang.org/x/oauth2" +) + +// defaultProviders is a list of credential providers that are used to +// retrieve a token by default. +var defaultProviders = []provider{ + &envProvider{}, + &ghProvider{}, +} + +// Token returns a valid token from one of the configured credential +// providers. If no token is found, ErrNoToken is returned. +func Token() (string, error) { + token := "" + errors := []error{} + for _, p := range defaultProviders { + var err error + token, err = p.Token() + if err != nil { + errors = append(errors, err) + continue + } + + // Got a token, break out of the loop. + if token != "" { + break + } + } + if token == "" { + return "", ErrNoToken{errors} + } + return token, nil +} + +// New returns a new [github.Client] using credentials from one of the +// configured credential providers. If no token is found, an +// unauthenticated client is returned. +func New() (*github.Client, error) { + token, err := Token() + if err != nil { + return github.NewClient(http.DefaultClient), nil + } + + // Note: background ctx is used here because we don't want the oauth2 + // client to pick up credentials from a provided context. + return github.NewClient(oauth2.NewClient(context.Background(), + oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + )), + ), nil +} diff --git a/internal/git/vcs/github/github_test.go b/internal/git/vcs/github/github_test.go new file mode 100644 index 0000000..7e53e8d --- /dev/null +++ b/internal/git/vcs/github/github_test.go @@ -0,0 +1,67 @@ +// Copyright (C) 2024 stencil contributors +// +// 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 github + +import ( + "errors" + "fmt" + "testing" + + "go.rgst.io/stencil/internal/testing/cmdexec" + "golang.org/x/oauth2" + "gotest.tools/v3/assert" +) + +// TestEnvOverGH ensures that the GITHUB_TOKEN environment variable +// takes precedence over the token returned by the gh CLI. +func TestEnvOverGH(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "github_token") + + cmdexec.UseMockExecutor(t, cmdexec.NewMockExecutor(&cmdexec.MockCommand{ + Name: "gh", + Args: []string{"auth", "token"}, + Stdout: []byte("gh_token\n"), + })) + + cli, err := New() + assert.NilError(t, err) + token, err := cli.Client().Transport.(*oauth2.Transport).Source.Token() + assert.NilError(t, err) + assert.Equal(t, "github_token", token.AccessToken) +} + +// TestReturnsErrors ensures that New returns underlying provider errors +// and that they can be found. +func TestReturnsErrors(t *testing.T) { + cmdexec.UseMockExecutor(t, cmdexec.NewMockExecutor(&cmdexec.MockCommand{ + Name: "gh", + Args: []string{"auth", "token"}, + Err: fmt.Errorf("bad things happened"), + })) + + token, err := Token() + var tokenErr ErrNoToken + assert.Assert(t, errors.As(err, &tokenErr)) + assert.Assert(t, token == "", "expected token to be empty") + + // Find a GH cli error + var found bool + for _, e := range tokenErr.errs { + if e.Error() == "gh failed: bad things happened (no stderr)" { + found = true + } + } + assert.Assert(t, found, "expected error not found") +} diff --git a/internal/git/vcs/github/provider_env.go b/internal/git/vcs/github/provider_env.go new file mode 100644 index 0000000..a900678 --- /dev/null +++ b/internal/git/vcs/github/provider_env.go @@ -0,0 +1,36 @@ +// Copyright (C) 2024 stencil contributors +// +// 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 github + +import ( + "fmt" + "os" +) + +// envProvider implements the provider interface using the environment +// variables to retrieve a token. +type envProvider struct{} + +// Token returns a valid token or an error if no token is found. +func (p *envProvider) Token() (string, error) { + envVars := []string{"GITHUB_TOKEN"} + for _, env := range envVars { + if token := os.Getenv(env); token != "" { + return token, nil + } + } + + return "", fmt.Errorf("no token found in environment variables: %v", envVars) +} diff --git a/internal/git/vcs/github/provider_env_test.go b/internal/git/vcs/github/provider_env_test.go new file mode 100644 index 0000000..a398761 --- /dev/null +++ b/internal/git/vcs/github/provider_env_test.go @@ -0,0 +1,30 @@ +// Copyright (C) 2024 stencil contributors +// +// 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 github + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestEnvProviderReadsCorrectEnvVar(t *testing.T) { + t.Setenv("GITHUB_TOKEN", "token") + + p := &envProvider{} + token, err := p.Token() + assert.NilError(t, err) + assert.Equal(t, "token", token) +} diff --git a/internal/git/vcs/github/provider_gh.go b/internal/git/vcs/github/provider_gh.go new file mode 100644 index 0000000..79dea66 --- /dev/null +++ b/internal/git/vcs/github/provider_gh.go @@ -0,0 +1,44 @@ +// Copyright (C) 2024 stencil contributors +// +// 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 github + +import ( + "errors" + "fmt" + "os/exec" + "strings" + + "go.rgst.io/stencil/internal/testing/cmdexec" +) + +// ghProvider implements the provider interface using the Github CLI to +// retrieve a token. +type ghProvider struct{} + +// Token returns a valid token or an error if no token is found. +func (p *ghProvider) Token() (string, error) { + cmd := cmdexec.Command("gh", "auth", "token") + token, err := cmd.Output() + if err != nil { + var execErr *exec.ExitError + if errors.As(err, &execErr) { + return "", fmt.Errorf("gh failed: %s (%w)", string(execErr.Stderr), execErr) + } + + return "", fmt.Errorf("gh failed: %w (no stderr)", err) + } + + return strings.TrimSpace(string(token)), nil +} diff --git a/internal/git/vcs/github/provider_gh_test.go b/internal/git/vcs/github/provider_gh_test.go new file mode 100644 index 0000000..abe5a1c --- /dev/null +++ b/internal/git/vcs/github/provider_gh_test.go @@ -0,0 +1,40 @@ +// Copyright (C) 2024 stencil contributors +// +// 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 github + +import ( + "testing" + + "go.rgst.io/stencil/internal/testing/cmdexec" + "gotest.tools/v3/assert" +) + +// TestGhProviderTrimsSpace ensures that the token returned by the +// ghProvider is trimmed of any leading or trailing whitespace. +func TestGhProviderTrimsSpace(t *testing.T) { + t.Parallel() + + p := &ghProvider{} + + cmdexec.UseMockExecutor(t, cmdexec.NewMockExecutor(&cmdexec.MockCommand{ + Name: "gh", + Args: []string{"auth", "token"}, + Stdout: []byte(" token\n"), + })) + + token, err := p.Token() + assert.NilError(t, err) + assert.Equal(t, "token", token) +} diff --git a/internal/git/vcs/github/providers.go b/internal/git/vcs/github/providers.go new file mode 100644 index 0000000..c956303 --- /dev/null +++ b/internal/git/vcs/github/providers.go @@ -0,0 +1,21 @@ +// Copyright (C) 2024 stencil contributors +// +// 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 github + +// provider is a valid credential provider that can return a token. +type provider interface { + // Token returns a valid token or an error if no token is found. + Token() (string, error) +} diff --git a/internal/gitauth/gitauth.go b/internal/gitauth/gitauth.go deleted file mode 100644 index ec086c7..0000000 --- a/internal/gitauth/gitauth.go +++ /dev/null @@ -1,69 +0,0 @@ -// Copyright 2022 Outreach Corporation. All Rights Reserved. - -// Description: See package description. - -// Package gitauth provides helpers for setting up auth -package gitauth - -import ( - "github.com/getoutreach/gobox/pkg/cfg" - "github.com/go-git/go-git/v5" - - githttp "github.com/go-git/go-git/v5/plumbing/transport/http" - - giturls "github.com/whilp/git-urls" -) - -// protocol is a protocol for Git -type protocol string - -// This block contains valid protocols -const ( - // protocolSSH is for accessing over SSH - protocolSSH protocol = "SSH" - - // protocolHTTPS is for accessing over HTTPS - protocolHTTPS protocol = "HTTPS" -) - -// ensureURLIsValidForProtocol ensures that a provided gitUrl is valid for the given -// protocol by parsing it into a URL and then returning a valid URL for the provided -// protocol -func ensureURLIsValidForProtocol(opts *git.CloneOptions, expectedProtocol protocol) error { - u, err := giturls.Parse(opts.URL) - if err != nil { - return err - } - - switch expectedProtocol { - case protocolSSH: - u.Scheme = "ssh" - case protocolHTTPS: - u.Scheme = "https" - } - - opts.URL = u.String() - - return nil -} - -// configureAccessTokenAuth sets up Github access token authentication -func configureAccessTokenAuth(token cfg.SecretData, opts *git.CloneOptions) error { - opts.Auth = &githttp.BasicAuth{ - Username: "x-access-token", - Password: string(token), - } - - return ensureURLIsValidForProtocol(opts, protocolHTTPS) -} - -// ConfigureAuth configures the provided git.CloneOptions to be authenticated for -// Github repository clones -func ConfigureAuth(accessToken cfg.SecretData, opts *git.CloneOptions) error { - // Don't setup auth if no auth token is set - if accessToken == "" { - return nil - } - - return configureAccessTokenAuth(accessToken, opts) -} diff --git a/internal/modules/modules.go b/internal/modules/modules.go index c043e3f..ddd1952 100644 --- a/internal/modules/modules.go +++ b/internal/modules/modules.go @@ -69,7 +69,7 @@ type resolution struct { // ModuleResolveOptions contains options for resolving modules type ModuleResolveOptions struct { // Token is the token to use to resolve modules - Token cfg.SecretData + Token string // Log is the logger to use Log slogext.Logger @@ -263,7 +263,7 @@ func GetModulesForProject(ctx context.Context, opts *ModuleResolveOptions) ([]*M } // getLatestModuleForConstraints returns the latest module that satisfies the provided constraints -func getLatestModuleForConstraints(ctx context.Context, uri string, token cfg.SecretData, +func getLatestModuleForConstraints(ctx context.Context, uri, token string, m *resolveModule, resolved map[string]*resolvedModule) (*resolver.Version, error) { constraints := make([]string, 0) for _, r := range resolved[m.conf.Name].history { @@ -299,7 +299,7 @@ func getLatestModuleForConstraints(ctx context.Context, uri string, token cfg.Se } } - v, err := resolver.Resolve(ctx, token, &resolver.Criteria{ + v, err := resolver.Resolve(ctx, cfg.SecretData(token), &resolver.Criteria{ URL: uri, Channel: channel, Constraints: constraints, diff --git a/internal/testing/cmdexec/cmdexec.go b/internal/testing/cmdexec/cmdexec.go new file mode 100755 index 0000000..03b8ea4 --- /dev/null +++ b/internal/testing/cmdexec/cmdexec.go @@ -0,0 +1,98 @@ +// Copyright (C) 2024 stencil contributors +// +// 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 cmdexec provides a way to execute commands using the exec +// package while supporting mocking for testing purposes. The default +// behaviour of the package is to simply wrap [exec.Command] and it's +// context accepting counterpart, [exec.CommandContext]. However, when +// running in tests, the package can be configured to use a mock +// executor that allows for controlling the output and behaviour of the +// commands executed for testing purposes. +package cmdexec + +import ( + "context" + "testing" +) + +// Cmd is an interface to be used instead of [*exec.Cmd] for mocking +// purposes. +type Cmd interface { + Output() ([]byte, error) + CombinedOutput() ([]byte, error) +} + +// Command returns a new Cmd that will call the given command with the +// given arguments. See [exec.Command] for more information. +func Command(name string, arg ...string) Cmd { + return CommandContext(context.Background(), name, arg...) +} + +// CommandContext returns a new Cmd that will call the given command with +// the given arguments and the given context. See [exec.CommandContext] +// for more information. +func CommandContext(ctx context.Context, name string, arg ...string) Cmd { + executorRLock.Lock() + defer executorRLock.Unlock() + + return executor(ctx, name, arg...) +} + +// UseMockExecutor replaces the executor used by exectest with a mock +// executor that can be used to control the output of all commands +// created after this function is called. A cleanup function is added +// to the test to ensure that the original executor is restored after +// the test has finished. +// +// Note: This function can only ever be called once per test. If it's +// called again it will deadlock the test. +// +// Usage: +// +// func TestSomething(t *testing.T) { +// mock := exectest.NewMockExecutor() +// mock.AddCommand(&exectest.MockCommand{ +// Name: "echo", +// Args: []string{"hello", "world"}, +// Stdout: []byte("hello world\n"), +// }) +// +// exectest.UseMockExecutor(t, mock) +// +// // Your test code here. +// } +func UseMockExecutor(t *testing.T, mock *MockExecutor) { + // Prevent new mock executors from being used until this test has finished. + executorWLock.Lock() + + // Lock the reader to prevent new commands from being created while we + // swap out the executor. + executorRLock.Lock() + originalExecutor := executor + executor = mock.executor + executorRLock.Unlock() + + t.Cleanup(func() { + // Lock the reader again to prevent new commands from being created + // while we restore the original executor. + executorRLock.Lock() + + // Unlock the reader and writer once we're done. + defer executorRLock.Unlock() + defer executorWLock.Unlock() + + // Restore the original executor. + executor = originalExecutor + }) +} diff --git a/internal/testing/cmdexec/executor.go b/internal/testing/cmdexec/executor.go new file mode 100755 index 0000000..d8557a6 --- /dev/null +++ b/internal/testing/cmdexec/executor.go @@ -0,0 +1,46 @@ +// Copyright (C) 2024 stencil contributors +// +// 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 cmdexec + +import ( + "context" + "os/exec" + "sync" +) + +// Contains package globals to control which executor is used by the +// package as well as locks to ensure this package is thread-safe. +var ( + // executor is the function used to create new commands. By default, + // this is set to [stdExecutor], but can be replaced with a mock + // executor using [UseMockExecutor]. + executor executorFn = stdExecutor + + // Locks to control the accessing of the executor variable. We don't + // use a [sync.RWMutex] here because we want to be able to lock the + // read and write operations separately. + executorRLock = new(sync.Mutex) + executorWLock = new(sync.Mutex) +) + +// stdExecutor is the default executor used by exectest. It's a simple +// wrapper around [exec.CommandContext] to return the Cmd interface. +func stdExecutor(ctx context.Context, name string, arg ...string) Cmd { + return exec.CommandContext(ctx, name, arg...) +} + +// executorFn is a function that returns a new Cmd based on the given +// arguments. +type executorFn func(context.Context, string, ...string) Cmd diff --git a/internal/testing/cmdexec/mock.go b/internal/testing/cmdexec/mock.go new file mode 100755 index 0000000..c0aae5d --- /dev/null +++ b/internal/testing/cmdexec/mock.go @@ -0,0 +1,85 @@ +// Copyright (C) 2024 stencil contributors +// +// 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 cmdexec + +import ( + "context" + "fmt" +) + +// MockExecutor provides an executor that returns mock data. +type MockExecutor struct { + cmds []*MockCommand +} + +// MockCommand is a command that can be executed by the MockExecutor. +type MockCommand struct { + Name string + Args []string + Stdout []byte + Stderr []byte + Err error +} + +func (c *MockCommand) Output() ([]byte, error) { + return c.Stdout, c.Err +} + +func (c *MockCommand) CombinedOutput() ([]byte, error) { + return append(c.Stdout, c.Stderr...), c.Err +} + +// NewMockExecutor returns a new MockExecutor with the given commands. +func NewMockExecutor(cmds ...*MockCommand) *MockExecutor { + return &MockExecutor{cmds} +} + +// AddCommand adds a command to the executor. +// +// Note: This is not thread-safe. +func (e *MockExecutor) AddCommand(cmd *MockCommand) { + e.cmds = append(e.cmds, cmd) +} + +// executor implements the [executorFn] type, returning a Cmd based on +// the provided arguments. If no commands are available based on the +// provided input, this function will panic. +func (e *MockExecutor) executor(_ context.Context, name string, arg ...string) Cmd { + if len(e.cmds) == 0 { + panic("no commands to execute") + } + + // argsEqual checks if two slices of strings are equal. + var argsEqual = func(a, b []string) bool { + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true + } + + // Check if we have a command that matches the input name and args. + for i := range e.cmds { + cmd := e.cmds[i] + if cmd.Name == name && argsEqual(cmd.Args, arg) { + return cmd + } + } + + // Did you forget to call [AddCommand]? + panic(fmt.Errorf("no mocked output registered for %s %v", name, arg)) +} diff --git a/pkg/stenciltest/stenciltest.go b/pkg/stenciltest/stenciltest.go index cd6be7f..8bdfb8a 100644 --- a/pkg/stenciltest/stenciltest.go +++ b/pkg/stenciltest/stenciltest.go @@ -131,7 +131,7 @@ func (t *Template) getModuleDependencies(ctx context.Context, m *modules.Module) } mods, err := modules.GetModulesForProject(ctx, &modules.ModuleResolveOptions{ - Token: token, + Token: string(token), Module: m, Log: t.log, })