Skip to content

Commit c930bbb

Browse files
samtholiyaostermanautofix-ci[bot]
authored
Refactor gogetter utility for better testability (#1146)
* Update atmos schema init * updated schema * refactor schema name * fixed test case * processSchemas updated * fix golangci lint * fix the downloadSchemaFromURL lint * lint fix validate stacks * lint fix * fixed lints * fix lint * fix lint * fix lint * fixed the processCommandLineArgs lint * fix lint * fix lint * Update internal/exec/validate_stacks.go Co-authored-by: Erik Osterman (CEO @ Cloud Posse) <[email protected]> * Update pkg/config/utils.go Co-authored-by: Erik Osterman (CEO @ Cloud Posse) <[email protected]> * Update pkg/config/utils.go Co-authored-by: Erik Osterman (CEO @ Cloud Posse) <[email protected]> * Update pkg/config/utils.go Co-authored-by: Erik Osterman (CEO @ Cloud Posse) <[email protected]> * Update pkg/config/utils.go Co-authored-by: Erik Osterman (CEO @ Cloud Posse) <[email protected]> * Update pkg/config/utils.go Co-authored-by: Erik Osterman (CEO @ Cloud Posse) <[email protected]> * Update pkg/config/utils.go Co-authored-by: Erik Osterman (CEO @ Cloud Posse) <[email protected]> * Update pkg/config/utils.go Co-authored-by: Erik Osterman (CEO @ Cloud Posse) <[email protected]> * fix tests * fixed log messages * add new interface based downloader package * update the usage and remove old gogetter usage * [autofix.ci] apply automated fixes * add unit test cases * [autofix.ci] apply automated fixes * fix golangci lint * fix golangci-lint * fixed logging * fix vendor test case * [autofix.ci] apply automated fixes * added unit test cases to have more coverage * Update pkg/downloader/custom_github_detector.go Co-authored-by: Erik Osterman (CEO @ Cloud Posse) <[email protected]> * fix test * [autofix.ci] apply automated fixes * golangci-lint fix * fix golangci-lint * ignore mock files in the test * fix rebase * lint test * [autofix.ci] apply automated fixes * remove old unwanted test * fix merge issues * update file name * custom git detector * fix golangci lint * updated parsing logic * fix test case * fix test case * reduce complexity * updated describe_config test * fix snapshots * fix test case * updated snapshot * fix tests * config inject bitbucket and inject gitlab * fix tests --------- Co-authored-by: Erik Osterman (CEO @ Cloud Posse) <[email protected]> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
1 parent ded205f commit c930bbb

33 files changed

+1307
-839
lines changed

codecov.yml

+2
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ coverage:
88
target: 80% # Require at least 80% test coverage on new/changed lines
99
threshold: 2% # Allow a small drop in coverage
1010
base: auto
11+
ignore:
12+
- "mock_*.go" # Adjust this pattern based on your project structure
1113

1214
comment:
1315
layout: "reach,diff,flags,tree" # Display different coverage views

internal/exec/utils.go

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/spf13/pflag"
1313

1414
cfg "github.com/cloudposse/atmos/pkg/config"
15+
"github.com/cloudposse/atmos/pkg/filetype"
1516
"github.com/cloudposse/atmos/pkg/schema"
1617
u "github.com/cloudposse/atmos/pkg/utils"
1718
)
@@ -1206,7 +1207,7 @@ func getCliVars(args []string) (map[string]any, error) {
12061207
varName := parts[0]
12071208
part2 := parts[1]
12081209
var varValue any
1209-
if u.IsJSON(part2) {
1210+
if filetype.IsJSON(part2) {
12101211
v, err := u.ConvertFromJSON(part2)
12111212
if err != nil {
12121213
return nil, err

internal/exec/validate_stacks.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ import (
1212
"time"
1313

1414
log "github.com/charmbracelet/log"
15-
"github.com/hashicorp/go-getter"
1615
"github.com/pkg/errors"
1716
"github.com/spf13/cobra"
1817

1918
cfg "github.com/cloudposse/atmos/pkg/config"
19+
"github.com/cloudposse/atmos/pkg/downloader"
2020
"github.com/cloudposse/atmos/pkg/schema"
2121
u "github.com/cloudposse/atmos/pkg/utils"
2222
)
@@ -408,7 +408,7 @@ func downloadSchemaFromURL(atmosConfig *schema.AtmosConfiguration) (string, erro
408408

409409
atmosManifestJsonSchemaFilePath := filepath.Join(tempDir, fileName)
410410

411-
if err = u.GoGetterGet(*atmosConfig, manifestURL, atmosManifestJsonSchemaFilePath, getter.ClientModeFile, time.Second*30); err != nil {
411+
if err = downloader.NewGoGetterDownloader(atmosConfig).Fetch(manifestURL, atmosManifestJsonSchemaFilePath, downloader.ClientModeFile, 30*time.Second); err != nil {
412412
return "", fmt.Errorf("failed to download the Atmos JSON Schema file '%s' from the URL '%s': %w", fileName, manifestURL, err)
413413
}
414414

internal/exec/vendor_component_utils.go

+4-4
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@ import (
1515
"github.com/Masterminds/sprig/v3"
1616
tea "github.com/charmbracelet/bubbletea"
1717
"github.com/hairyhenderson/gomplate/v3"
18-
"github.com/hashicorp/go-getter"
1918
"github.com/jfrog/jfrog-client-go/utils/log"
2019
cp "github.com/otiai10/copy"
2120

2221
cfg "github.com/cloudposse/atmos/pkg/config"
22+
"github.com/cloudposse/atmos/pkg/downloader"
2323
"github.com/cloudposse/atmos/pkg/schema"
2424
u "github.com/cloudposse/atmos/pkg/utils"
2525
)
@@ -388,7 +388,7 @@ func downloadComponentAndInstall(p *pkgComponentVendor, dryRun bool, atmosConfig
388388
if dryRun {
389389
if needsCustomDetection(p.uri) {
390390
log.Debug("Dry-run mode: custom detection required for component (or mixin) URI", "component", p.name, "uri", p.uri)
391-
detector := &u.CustomGitDetector{AtmosConfig: *atmosConfig, Source: ""}
391+
detector := downloader.NewCustomGitDetector(atmosConfig, "")
392392
_, _, err := detector.Detect(p.uri, "")
393393
if err != nil {
394394
return installedPkgMsg{
@@ -455,7 +455,7 @@ func installComponent(p *pkgComponentVendor, atmosConfig *schema.AtmosConfigurat
455455
case pkgTypeRemote:
456456
tempDir = filepath.Join(tempDir, SanitizeFileName(p.uri))
457457

458-
if err := u.GoGetterGet(*atmosConfig, p.uri, tempDir, getter.ClientModeAny, 10*time.Minute); err != nil {
458+
if err := downloader.NewGoGetterDownloader(atmosConfig).Fetch(p.uri, tempDir, downloader.ClientModeAny, 10*time.Minute); err != nil {
459459
return fmt.Errorf("failed to download package %s error %w", p.name, err)
460460
}
461461

@@ -511,7 +511,7 @@ func installMixin(p *pkgComponentVendor, atmosConfig *schema.AtmosConfiguration)
511511

512512
switch p.pkgType {
513513
case pkgTypeRemote:
514-
if err = u.GoGetterGet(*atmosConfig, p.uri, filepath.Join(tempDir, p.mixinFilename), getter.ClientModeFile, 10*time.Minute); err != nil {
514+
if err = downloader.NewGoGetterDownloader(atmosConfig).Fetch(p.uri, filepath.Join(tempDir, p.mixinFilename), downloader.ClientModeFile, 10*time.Minute); err != nil {
515515
return fmt.Errorf("failed to download package %s error %w", p.name, err)
516516
}
517517

internal/exec/vendor_model.go

+4-2
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
cp "github.com/otiai10/copy"
1818

1919
"github.com/cloudposse/atmos/internal/tui/templates/term"
20+
"github.com/cloudposse/atmos/pkg/downloader"
2021
"github.com/cloudposse/atmos/pkg/schema"
2122
"github.com/cloudposse/atmos/pkg/ui/theme"
2223
u "github.com/cloudposse/atmos/pkg/utils"
@@ -359,7 +360,8 @@ func (p *pkgAtmosVendor) installer(tempDir *string, atmosConfig *schema.AtmosCon
359360
switch p.pkgType {
360361
case pkgTypeRemote:
361362
// Use go-getter to download remote packages
362-
if err := u.GoGetterGet(*atmosConfig, p.uri, *tempDir, getter.ClientModeAny, 10*time.Minute); err != nil {
363+
364+
if err := downloader.NewGoGetterDownloader(atmosConfig).Fetch(p.uri, *tempDir, downloader.ClientModeAny, 10*time.Minute); err != nil {
363365
return fmt.Errorf("failed to download package: %w", err)
364366
}
365367

@@ -393,7 +395,7 @@ func handleDryRunInstall(p *pkgAtmosVendor, atmosConfig *schema.AtmosConfigurati
393395

394396
if needsCustomDetection(p.uri) {
395397
log.Debug("Custom detection required for URI", "uri", p.uri)
396-
detector := &u.CustomGitDetector{AtmosConfig: *atmosConfig, Source: ""}
398+
detector := downloader.NewCustomGitDetector(atmosConfig, "")
397399
_, _, err := detector.Detect(p.uri, "")
398400
if err != nil {
399401
return installedPkgMsg{

pkg/config/load.go

+22
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ func LoadConfig(configAndStacksInfo *schema.ConfigAndStacksInfo) (schema.AtmosCo
5858
atmosConfig.CliConfigPath = absPath
5959
}
6060
}
61+
setEnv(v)
6162
// We want the editorconfig color by default to be true
6263
atmosConfig.Validate.EditorConfig.Color = true
6364
// https://gist.github.com/chazcheadle/45bf85b793dea2b71bd05ebaa3c28644
@@ -69,6 +70,27 @@ func LoadConfig(configAndStacksInfo *schema.ConfigAndStacksInfo) (schema.AtmosCo
6970
return atmosConfig, nil
7071
}
7172

73+
func setEnv(v *viper.Viper) {
74+
bindEnv(v, "settings.github_token", "GITHUB_TOKEN")
75+
bindEnv(v, "settings.inject_github_token", "ATMOS_INJECT_GITHUB_TOKEN")
76+
bindEnv(v, "settings.atmos_github_token", "ATMOS_GITHUB_TOKEN")
77+
78+
bindEnv(v, "settings.bitbucket_token", "BITBUCKET_TOKEN")
79+
bindEnv(v, "settings.atmos_bitbucket_token", "ATMOS_BITBUCKET_TOKEN")
80+
bindEnv(v, "settings.inject_bitbucket_token", "ATMOS_INJECT_BITBUCKET_TOKEN")
81+
bindEnv(v, "settings.bitbucket_username", "BITBUCKET_USERNAME")
82+
83+
bindEnv(v, "settings.gitlab_token", "GITLAB_TOKEN")
84+
bindEnv(v, "settings.inject_gitlab_token", "ATMOS_INJECT_GITLAB_TOKEN")
85+
bindEnv(v, "settings.atmos_gitlab_token", "ATMOS_GITLAB_TOKEN")
86+
}
87+
88+
func bindEnv(v *viper.Viper, key ...string) {
89+
if err := v.BindEnv(key...); err != nil {
90+
panic(err)
91+
}
92+
}
93+
7294
// setDefaultConfiguration set default configuration for the viper instance.
7395
func setDefaultConfiguration(v *viper.Viper) {
7496
v.SetDefault("components.helmfile.use_eks", true)

pkg/downloader/custom_git_detector.go

+229
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
package downloader
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
"path/filepath"
7+
"regexp"
8+
"strings"
9+
10+
log "github.com/charmbracelet/log"
11+
"github.com/cloudposse/atmos/pkg/schema"
12+
)
13+
14+
var ErrInvalidURL = fmt.Errorf("invalid URL")
15+
16+
const schemeSeparator = "://"
17+
18+
// CustomGitDetector intercepts Git URLs (for GitHub, Bitbucket, GitLab, etc.)
19+
// and transforms them into a proper URL for cloning, optionally injecting tokens.
20+
type CustomGitDetector struct {
21+
atmosConfig *schema.AtmosConfiguration
22+
source string
23+
}
24+
25+
func NewCustomGitDetector(atmosConfig *schema.AtmosConfiguration, source string) *CustomGitDetector {
26+
return &CustomGitDetector{
27+
atmosConfig: atmosConfig,
28+
source: source,
29+
}
30+
}
31+
32+
// Detect implements the getter.Detector interface for go-getter v1.
33+
func (d *CustomGitDetector) Detect(src, _ string) (string, bool, error) {
34+
log.Debug("CustomGitDetector.Detect called")
35+
36+
if len(src) == 0 {
37+
return "", false, nil
38+
}
39+
40+
// Ensure the URL has an explicit scheme.
41+
src = d.ensureScheme(src)
42+
43+
// Parse the URL to extract the host and path.
44+
parsedURL, err := url.Parse(src)
45+
if err != nil {
46+
maskedSrc, _ := maskBasicAuth(src)
47+
log.Debug("Failed to parse URL", keyURL, maskedSrc, "error", err)
48+
return "", false, fmt.Errorf("failed to parse URL %q: %w", maskedSrc, err)
49+
}
50+
51+
// If no host is detected, this is likely a local file path.
52+
// Skip custom processing so that go getter handles it as is.
53+
if parsedURL.Host == "" {
54+
log.Debug("No host detected in URL, skipping custom git detection", keyURL, src)
55+
return "", false, nil
56+
}
57+
58+
// Normalize the path.
59+
d.normalizePath(parsedURL)
60+
61+
// Adjust host check to support GitHub, Bitbucket, GitLab, etc.
62+
host := strings.ToLower(parsedURL.Host)
63+
if host != hostGitHub && host != hostBitbucket && host != hostGitLab {
64+
log.Debug("Skipping token injection for an unsupported host", "host", parsedURL.Host)
65+
return "", false, nil
66+
}
67+
68+
log.Debug("Reading config param", "InjectGithubToken", d.atmosConfig.Settings.InjectGithubToken)
69+
// Inject token if available.
70+
d.injectToken(parsedURL, host)
71+
72+
// Adjust subdirectory if needed.
73+
d.adjustSubdir(parsedURL, d.source)
74+
75+
// Set "depth=1" for a shallow clone if not specified.
76+
q := parsedURL.Query()
77+
if _, exists := q["depth"]; !exists {
78+
q.Set("depth", "1")
79+
}
80+
parsedURL.RawQuery = q.Encode()
81+
82+
finalURL := "git::" + parsedURL.String()
83+
maskedFinal, err := maskBasicAuth(strings.TrimPrefix(finalURL, "git::"))
84+
if err != nil {
85+
log.Debug("Masking failed", "error", err)
86+
} else {
87+
log.Debug("Final transformation", "url", "git::"+maskedFinal)
88+
}
89+
90+
return finalURL, true, nil
91+
}
92+
93+
const (
94+
// Named constants for regex match indices.
95+
matchIndexUser = 1
96+
matchIndexHost = 3
97+
matchIndexPath = 4
98+
matchIndexSuffix = 5
99+
matchIndexExtra = 6
100+
101+
keyURL = "url"
102+
103+
hostGitHub = "github.com"
104+
hostGitLab = "gitlab.com"
105+
hostBitbucket = "bitbucket.org"
106+
)
107+
108+
const GitPrefix = "git::"
109+
110+
// ensureScheme checks for an explicit scheme and rewrites SCP-style URLs if needed.
111+
// Also removes any existing "git::" prefix (required for the dry-run mode to operate correctly).
112+
func (d *CustomGitDetector) ensureScheme(src string) string {
113+
// Strip any existing "git::" prefix
114+
src = strings.TrimPrefix(src, GitPrefix)
115+
116+
if !strings.Contains(src, schemeSeparator) {
117+
if newSrc, rewritten := rewriteSCPURL(src); rewritten {
118+
maskedOld, _ := maskBasicAuth(src)
119+
maskedNew, _ := maskBasicAuth(newSrc)
120+
log.Debug("Rewriting SCP-style SSH URL", "old_url", maskedOld, "new_url", maskedNew)
121+
return newSrc
122+
}
123+
src = "https://" + src
124+
maskedSrc, _ := maskBasicAuth(src)
125+
log.Debug("Defaulting to https scheme", keyURL, maskedSrc)
126+
}
127+
return src
128+
}
129+
130+
func rewriteSCPURL(src string) (string, bool) {
131+
scpPattern := regexp.MustCompile(`^(([\w.-]+)@)?([\w.-]+\.[\w.-]+):([\w./-]+)(\.git)?(.*)$`)
132+
if scpPattern.MatchString(src) {
133+
matches := scpPattern.FindStringSubmatch(src)
134+
newSrc := "ssh://"
135+
user := matches[matchIndexUser] // This includes the "@" if present.
136+
host := matches[matchIndexHost]
137+
// Only for SSH vendoring (i.e. when rewriting an SCP URL), inject default username (git) for known hosts.
138+
if user == "" && (strings.EqualFold(host, hostGitHub) ||
139+
strings.EqualFold(host, hostGitLab) ||
140+
strings.EqualFold(host, hostBitbucket)) {
141+
user = "git@"
142+
}
143+
newSrc += user + host + "/" + matches[matchIndexPath]
144+
if matches[matchIndexSuffix] != "" {
145+
newSrc += matches[matchIndexSuffix]
146+
}
147+
if matches[matchIndexExtra] != "" {
148+
newSrc += matches[matchIndexExtra]
149+
}
150+
return newSrc, true
151+
}
152+
return "", false
153+
}
154+
155+
// normalizePath converts the URL path to use forward slashes.
156+
func (d *CustomGitDetector) normalizePath(parsedURL *url.URL) {
157+
unescapedPath, err := url.PathUnescape(parsedURL.Path)
158+
if err == nil {
159+
parsedURL.Path = filepath.ToSlash(unescapedPath)
160+
} else {
161+
parsedURL.Path = filepath.ToSlash(parsedURL.Path)
162+
}
163+
}
164+
165+
// injectToken injects a token into the URL if available.
166+
func (d *CustomGitDetector) injectToken(parsedURL *url.URL, host string) {
167+
token, tokenSource := d.resolveToken(host)
168+
if token != "" {
169+
defaultUsername := d.getDefaultUsername(host)
170+
parsedURL.User = url.UserPassword(defaultUsername, token)
171+
maskedURL, _ := maskBasicAuth(parsedURL.String())
172+
log.Debug("Injected token", "env", tokenSource, keyURL, maskedURL)
173+
} else {
174+
log.Debug("No token found for injection")
175+
}
176+
}
177+
178+
// resolveToken returns the token and its source based on the host.
179+
func (d *CustomGitDetector) resolveToken(host string) (string, string) {
180+
switch host {
181+
case hostGitHub:
182+
if d.atmosConfig.Settings.InjectGithubToken {
183+
return d.atmosConfig.Settings.AtmosGithubToken, "ATMOS_GITHUB_TOKEN"
184+
}
185+
return d.atmosConfig.Settings.GithubToken, "GITHUB_TOKEN"
186+
case hostBitbucket:
187+
if d.atmosConfig.Settings.InjectBitbucketToken {
188+
return d.atmosConfig.Settings.AtmosBitbucketToken, "ATMOS_BITBUCKET_TOKEN"
189+
}
190+
return d.atmosConfig.Settings.BitbucketToken, "BITBUCKET_TOKEN"
191+
case hostGitLab:
192+
if d.atmosConfig.Settings.InjectGitlabToken {
193+
return d.atmosConfig.Settings.AtmosGitlabToken, "ATMOS_GITLAB_TOKEN"
194+
}
195+
return d.atmosConfig.Settings.GitlabToken, "GITLAB_TOKEN"
196+
}
197+
return "", ""
198+
}
199+
200+
// getDefaultUsername returns the default username for token injection based on the host.
201+
func (d *CustomGitDetector) getDefaultUsername(host string) string {
202+
switch host {
203+
case hostGitHub:
204+
return "x-access-token"
205+
case hostGitLab:
206+
return "oauth2"
207+
case hostBitbucket:
208+
defaultUsername := d.atmosConfig.Settings.BitbucketUsername
209+
if defaultUsername == "" {
210+
return "x-token-auth"
211+
}
212+
return defaultUsername
213+
default:
214+
return "x-access-token"
215+
}
216+
}
217+
218+
// adjustSubdir appends "//." to the path if no subdirectory is specified.
219+
func (d *CustomGitDetector) adjustSubdir(parsedURL *url.URL, source string) {
220+
normalizedSource := filepath.ToSlash(source)
221+
if normalizedSource != "" && !strings.Contains(normalizedSource, "//") {
222+
parts := strings.SplitN(parsedURL.Path, "/", 4)
223+
if strings.HasSuffix(parsedURL.Path, ".git") || len(parts) == 3 {
224+
maskedSrc, _ := maskBasicAuth(source)
225+
log.Debug("Detected top-level repo with no subdir: appending '//.'", keyURL, maskedSrc)
226+
parsedURL.Path += "//."
227+
}
228+
}
229+
}

0 commit comments

Comments
 (0)