From 063f9559061ab27fad926b9234b3f5e61d08ff07 Mon Sep 17 00:00:00 2001 From: haitham911 <haitham911eg@gmail.com> Date: Tue, 21 Jan 2025 00:50:34 +0200 Subject: [PATCH] push reset to git reset --soft 15bad9a7f6409cf5abf3762643ae2cd65c026007 --- examples/tests/test-vendor/atmos.yaml | 40 ++++++ .../test-vendor/test-components/README.md | 13 ++ .../tests/test-vendor/test-components/main.tf | 7 + .../test-vendor/test-components/outputs.tf | 4 + .../test-vendor/test-components/providers.tf | 1 + .../test-vendor/test-components/variables.tf | 5 + .../test-vendor/test-components/versions.tf | 5 + examples/tests/test-vendor/vendor.yaml | 43 ++++++ internal/exec/vendor_component_utils.go | 9 ++ internal/exec/vendor_model.go | 2 +- internal/exec/vendor_utils.go | 127 +++++++++++++++++- tests/cli_test.go | 99 +++++++++++++- tests/fixtures/scenarios/complete/vendor.yaml | 2 +- tests/test-cases/demo-stacks.yaml | 60 +++++++++ 14 files changed, 408 insertions(+), 9 deletions(-) create mode 100644 examples/tests/test-vendor/atmos.yaml create mode 100644 examples/tests/test-vendor/test-components/README.md create mode 100644 examples/tests/test-vendor/test-components/main.tf create mode 100644 examples/tests/test-vendor/test-components/outputs.tf create mode 100644 examples/tests/test-vendor/test-components/providers.tf create mode 100644 examples/tests/test-vendor/test-components/variables.tf create mode 100644 examples/tests/test-vendor/test-components/versions.tf create mode 100644 examples/tests/test-vendor/vendor.yaml diff --git a/examples/tests/test-vendor/atmos.yaml b/examples/tests/test-vendor/atmos.yaml new file mode 100644 index 000000000..0f0506e81 --- /dev/null +++ b/examples/tests/test-vendor/atmos.yaml @@ -0,0 +1,40 @@ +base_path: "./" + +components: + terraform: + base_path: "components/terraform" + apply_auto_approve: false + deploy_run_init: true + init_run_reconfigure: true + auto_generate_backend_file: false + +stacks: + base_path: "stacks" + included_paths: + - "deploy/**/*" + excluded_paths: + - "**/_defaults.yaml" + name_pattern: "{stage}" + +vendor: + # Single file + base_path: "./vendor.yaml" + + # Directory with multiple files + #base_path: "./vendor" + + # Absolute path + #base_path: "vendor.d/vendor1.yaml" + +logs: + file: "/dev/stderr" + level: Info + +# Custom CLI commands + +# No arguments or flags are required +commands: +- name: "test" + description: "Run all tests" + steps: + - atmos vendor pull --everything diff --git a/examples/tests/test-vendor/test-components/README.md b/examples/tests/test-vendor/test-components/README.md new file mode 100644 index 000000000..a1b28ebfd --- /dev/null +++ b/examples/tests/test-vendor/test-components/README.md @@ -0,0 +1,13 @@ +# Example Terraform IPinfo Component + +This Terraform module retrieves data from the IPinfo API for a specified IP address. If no IP address is specified, it retrieves data for the requester's IP address. + +## Usage + +### Inputs + +- `ip_address` (optional): The IP address to retrieve information for. If not specified, the requester's IP address will be used. The default value is an empty string. + +### Outputs + +- `metadata`: The data retrieved from IPinfo for the specified IP address, in JSON format. diff --git a/examples/tests/test-vendor/test-components/main.tf b/examples/tests/test-vendor/test-components/main.tf new file mode 100644 index 000000000..0e3e9d0b0 --- /dev/null +++ b/examples/tests/test-vendor/test-components/main.tf @@ -0,0 +1,7 @@ +data "http" "ipinfo" { + url = var.ip_address != "" ? "https://ipinfo.io/${var.ip_address}" : "https://ipinfo.io" + + request_headers = { + Accept = "application/json" + } +} diff --git a/examples/tests/test-vendor/test-components/outputs.tf b/examples/tests/test-vendor/test-components/outputs.tf new file mode 100644 index 000000000..12bb2bb8d --- /dev/null +++ b/examples/tests/test-vendor/test-components/outputs.tf @@ -0,0 +1,4 @@ +output "metadata" { + description = "The data retrieved from IPinfo for the specified IP address" + value = jsondecode(data.http.ipinfo.response_body) +} diff --git a/examples/tests/test-vendor/test-components/providers.tf b/examples/tests/test-vendor/test-components/providers.tf new file mode 100644 index 000000000..0b91fe2aa --- /dev/null +++ b/examples/tests/test-vendor/test-components/providers.tf @@ -0,0 +1 @@ +provider "http" {} diff --git a/examples/tests/test-vendor/test-components/variables.tf b/examples/tests/test-vendor/test-components/variables.tf new file mode 100644 index 000000000..85f32544d --- /dev/null +++ b/examples/tests/test-vendor/test-components/variables.tf @@ -0,0 +1,5 @@ +variable "ip_address" { + description = "The IP address to retrieve information for (optional)" + type = string + default = "" +} diff --git a/examples/tests/test-vendor/test-components/versions.tf b/examples/tests/test-vendor/test-components/versions.tf new file mode 100644 index 000000000..e2a3d732d --- /dev/null +++ b/examples/tests/test-vendor/test-components/versions.tf @@ -0,0 +1,5 @@ +terraform { + required_version = ">= 1.0.0" + + required_providers {} +} diff --git a/examples/tests/test-vendor/vendor.yaml b/examples/tests/test-vendor/vendor.yaml new file mode 100644 index 000000000..258a28e81 --- /dev/null +++ b/examples/tests/test-vendor/vendor.yaml @@ -0,0 +1,43 @@ +apiVersion: atmos/v1 +kind: AtmosVendorConfig +metadata: + name: demo-vendoring + description: Atmos vendoring manifest for Atmos demo component library +spec: + # Import other vendor manifests, if necessary + imports: [] + + sources: + - component: "github/stargazers" + source: "github.com/cloudposse/atmos.git//examples/demo-library/{{ .Component }}?ref={{.Version}}" + version: "main" + targets: + - "components/terraform/{{ .Component }}/{{.Version}}" + included_paths: + - "**/*.tf" + - "**/*.tfvars" + - "**/*.md" + tags: + - demo + - github + + - component: "test-components" + source: "file:///./test-components" + version: "main" + targets: + - "components/terraform/{{ .Component }}/{{.Version}}" + tags: + - demo + + - component: "weather" + source: "git::https://github.com/cloudposse/atmos.git//examples/demo-library/{{ .Component }}?ref={{.Version}}" + version: "main" + targets: + - "components/terraform/{{ .Component }}/{{.Version}}" + tags: + - demo + - component: "my-vpc1" + source: "oci://public.ecr.aws/cloudposse/components/terraform/stable/aws/vpc:{{.Version}}" + version: "latest" + targets: + - "components/terraform/infra/my-vpc1" diff --git a/internal/exec/vendor_component_utils.go b/internal/exec/vendor_component_utils.go index 5ca0f4c38..94088bdf1 100644 --- a/internal/exec/vendor_component_utils.go +++ b/internal/exec/vendor_component_utils.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "net/url" "os" "path/filepath" "strings" @@ -248,6 +249,14 @@ func ExecuteComponentVendorInternal( sourceIsLocalFile = true } } + u, err := url.Parse(uri) + if err == nil && u.Scheme != "" { + if u.Scheme == "file" { + trimmedPath := strings.TrimPrefix(filepath.ToSlash(u.Path), "/") + uri = filepath.Clean(trimmedPath) + useLocalFileSystem = true + } + } } var pType pkgType diff --git a/internal/exec/vendor_model.go b/internal/exec/vendor_model.go index 20e873f24..6ddf14e71 100644 --- a/internal/exec/vendor_model.go +++ b/internal/exec/vendor_model.go @@ -248,7 +248,7 @@ func downloadAndInstall(p *pkgAtmosVendor, dryRun bool, atmosConfig schema.Atmos } } // Create temp directory - tempDir, err := os.MkdirTemp("", fmt.Sprintf("atmos-vendor-%d-*", time.Now().Unix())) + tempDir, err := os.MkdirTemp("", "atmos-vendor") if err != nil { return installedPkgMsg{ err: fmt.Errorf("failed to create temp directory: %w", err), diff --git a/internal/exec/vendor_utils.go b/internal/exec/vendor_utils.go index ee6c6520b..6d130931b 100644 --- a/internal/exec/vendor_utils.go +++ b/internal/exec/vendor_utils.go @@ -2,6 +2,7 @@ package exec import ( "fmt" + "net/url" "os" "path/filepath" "sort" @@ -9,6 +10,7 @@ import ( "github.com/bmatcuk/doublestar/v4" tea "github.com/charmbracelet/bubbletea" + "github.com/hashicorp/go-getter" cp "github.com/otiai10/copy" "github.com/samber/lo" "github.com/spf13/cobra" @@ -384,7 +386,7 @@ func ExecuteAtmosVendorInternal( if err != nil { return err } - targetPath := filepath.Join(vendorConfigFilePath, target) + targetPath := filepath.Join(filepath.ToSlash(vendorConfigFilePath), filepath.ToSlash(target)) pkgName := s.Component if pkgName == "" { pkgName = uri @@ -514,12 +516,22 @@ func determineSourceType(uri *string, vendorConfigFilePath string) (bool, bool, useLocalFileSystem := false sourceIsLocalFile := false if !useOciScheme { - if absPath, err := u.JoinAbsolutePathWithPath(vendorConfigFilePath, *uri); err == nil { + if absPath, err := u.JoinAbsolutePathWithPath(filepath.ToSlash(vendorConfigFilePath), *uri); err == nil { uri = &absPath useLocalFileSystem = true sourceIsLocalFile = u.FileExists(*uri) } + u, err := url.Parse(*uri) + if err == nil && u.Scheme != "" { + if u.Scheme == "file" { + trimmedPath := strings.TrimPrefix(filepath.ToSlash(u.Path), "/") + *uri = filepath.Clean(trimmedPath) + useLocalFileSystem = true + } + } + } + return useOciScheme, useLocalFileSystem, sourceIsLocalFile } @@ -556,6 +568,8 @@ func generateSkipFunction(atmosConfig schema.AtmosConfiguration, tempDir string, if filepath.Base(src) == ".git" { return true, nil } + tempDir = filepath.ToSlash(tempDir) + src = filepath.ToSlash(src) trimmedSrc := u.TrimBasePathFromPath(tempDir+"/", src) @@ -608,3 +622,112 @@ func generateSkipFunction(atmosConfig schema.AtmosConfiguration, tempDir string, return false, nil } } + +func validateURI(uri string) error { + if uri == "" { + return fmt.Errorf("URI cannot be empty") + } + if strings.Contains(uri, " ") { + return fmt.Errorf("URI cannot contain spaces") + } + // Validate scheme-specific format + if strings.HasPrefix(uri, "oci://") { + if !strings.Contains(uri[6:], "/") { + return fmt.Errorf("invalid OCI URI format") + } + } + return nil +} +func isValidScheme(scheme string) bool { + validSchemes := map[string]bool{ + "http": true, + "https": true, + "git": true, + "ssh": true, + "git::https": true, + } + return validSchemes[scheme] +} + +// CustomGitHubDetector intercepts GitHub URLs and transforms them +// into something like git::https://<token>@github.com/... so we can +// do a git-based clone with a token. +type CustomGitHubDetector struct { + AtmosConfig schema.AtmosConfiguration +} + +// Detect implements the getter.Detector interface for go-getter v1. +func (d *CustomGitHubDetector) Detect(src, _ string) (string, bool, error) { + if len(src) == 0 { + return "", false, nil + } + + if !strings.Contains(src, "://") { + src = "https://" + src + } + + parsedURL, err := url.Parse(src) + if err != nil { + u.LogDebug(d.AtmosConfig, fmt.Sprintf("Failed to parse URL %q: %v\n", src, err)) + return "", false, fmt.Errorf("failed to parse URL %q: %w", src, err) + } + + if strings.ToLower(parsedURL.Host) != "github.com" { + u.LogDebug(d.AtmosConfig, fmt.Sprintf("Host is %q, not 'github.com', skipping token injection\n", parsedURL.Host)) + return "", false, nil + } + + parts := strings.SplitN(parsedURL.Path, "/", 4) + if len(parts) < 3 { + u.LogDebug(d.AtmosConfig, fmt.Sprintf("URL path %q doesn't look like /owner/repo\n", parsedURL.Path)) + return "", false, fmt.Errorf("invalid GitHub URL %q", parsedURL.Path) + } + + atmosGitHubToken := os.Getenv("ATMOS_GITHUB_TOKEN") + gitHubToken := os.Getenv("GITHUB_TOKEN") + + var usedToken string + var tokenSource string + + // 1. If ATMOS_GITHUB_TOKEN is set, always use that + if atmosGitHubToken != "" { + usedToken = atmosGitHubToken + tokenSource = "ATMOS_GITHUB_TOKEN" + u.LogDebug(d.AtmosConfig, "ATMOS_GITHUB_TOKEN is set\n") + } else { + // 2. Otherwise, only inject GITHUB_TOKEN if cfg.Settings.InjectGithubToken == true + if d.AtmosConfig.Settings.InjectGithubToken && gitHubToken != "" { + usedToken = gitHubToken + tokenSource = "GITHUB_TOKEN" + u.LogTrace(d.AtmosConfig, "InjectGithubToken=true and GITHUB_TOKEN is set, using it\n") + } else { + u.LogTrace(d.AtmosConfig, "No ATMOS_GITHUB_TOKEN or GITHUB_TOKEN found\n") + } + } + + if usedToken != "" { + user := parsedURL.User.Username() + pass, _ := parsedURL.User.Password() + if user == "" && pass == "" { + u.LogDebug(d.AtmosConfig, fmt.Sprintf("Injecting token from %s for %s\n", tokenSource, src)) + parsedURL.User = url.UserPassword("x-access-token", usedToken) + } else { + u.LogDebug(d.AtmosConfig, "Credentials found, skipping token injection\n") + } + } + + finalURL := "git::" + parsedURL.String() + + return finalURL, true, nil +} + +// RegisterCustomDetectors prepends the custom detector so it runs before +// the built-in ones. Any code that calls go-getter should invoke this. +func RegisterCustomDetectors(atmosConfig schema.AtmosConfiguration) { + getter.Detectors = append( + []getter.Detector{ + &CustomGitHubDetector{AtmosConfig: atmosConfig}, + }, + getter.Detectors..., + ) +} diff --git a/tests/cli_test.go b/tests/cli_test.go index 79ed0a494..77c0e2661 100644 --- a/tests/cli_test.go +++ b/tests/cli_test.go @@ -5,6 +5,8 @@ import ( "errors" "flag" "fmt" + "io" + "log" "os" "os/exec" "path/filepath" // For resolving absolute paths @@ -84,7 +86,13 @@ func (m *MatchPattern) UnmarshalYAML(value *yaml.Node) error { } func loadTestSuite(filePath string) (*TestSuite, error) { - data, err := os.ReadFile(filePath) + // Open the file + f, err := os.Open(filePath) + if err != nil { + log.Fatalf("Failed to open file: %v", err) + } + defer f.Close() + data, err := io.ReadAll(f) if err != nil { return nil, err } @@ -449,6 +457,76 @@ func TestCLICommands(t *testing.T) { // Run with `t.Run` for non-TTY tests t.Run(tc.Name, func(t *testing.T) { runCLICommandTest(t, tc) + defer func() { + // Change back to the original working directory after the test + if err := os.Chdir(startingDir); err != nil { + t.Fatalf("Failed to change back to the starting directory: %v", err) + } + }() + + // Change to the specified working directory + if tc.Workdir != "" { + err := os.Chdir(tc.Workdir) + if err != nil { + t.Fatalf("Failed to change directory to %q: %v", tc.Workdir, err) + } + } + + // Check if the binary exists + binaryPath, err := exec.LookPath(tc.Command) + if err != nil { + t.Fatalf("Binary not found: %s. Current PATH: %s", tc.Command, pathManager.GetPath()) + } + + // Prepare the command + cmd := exec.Command(binaryPath, tc.Args...) + + // Set environment variables + envVars := os.Environ() + for key, value := range tc.Env { + envVars = append(envVars, fmt.Sprintf("%s=%s", key, value)) + } + cmd.Env = envVars + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + // Run the command + err = cmd.Run() + + // Validate exit code + exitCode := 0 + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + exitCode = exitErr.ExitCode() + } + } + if exitCode != tc.Expect.ExitCode { + t.Errorf("Description: %s", tc.Description) + t.Errorf("Reason: Expected exit code %d, got %d", tc.Expect.ExitCode, exitCode) + t.Errorf("error: %v", cmd.Stderr) + } + + // Validate stdout + if !verifyOutput(t, "stdout", stdout.String(), tc.Expect.Stdout) { + t.Errorf("Description: %s", tc.Description) + } + + // Validate stderr + if !verifyOutput(t, "stderr", stderr.String(), tc.Expect.Stderr) { + t.Errorf("Description: %s", tc.Description) + } + + // Validate file existence + if !verifyFileExists(t, tc.Expect.FileExists) { + t.Errorf("Description: %s", tc.Description) + } + + // Validate file contents + if !verifyFileContains(t, tc.Expect.FileContains) { + t.Errorf("Description: %s", tc.Description) + } }) } } @@ -512,12 +590,17 @@ func verifyOutput(t *testing.T, outputType, output string, patterns []MatchPatte } return success } - func verifyFileExists(t *testing.T, files []string) bool { success := true for _, file := range files { - if _, err := os.Stat(file); errors.Is(err, os.ErrNotExist) { - t.Errorf("Reason: Expected file does not exist: %q", file) + fileAbs, err := filepath.Abs(file) + if err != nil { + log.Println(err) + return false + } + + if _, err := os.Stat(fileAbs); errors.Is(err, os.ErrNotExist) { + t.Errorf("Reason: Expected file does not exist: %q", fileAbs) success = false } } @@ -527,7 +610,13 @@ func verifyFileExists(t *testing.T, files []string) bool { func verifyFileContains(t *testing.T, filePatterns map[string][]MatchPattern) bool { success := true for file, patterns := range filePatterns { - content, err := os.ReadFile(file) + // Open the file + f, err := os.Open(file) + if err != nil { + log.Fatalf("Failed to open file: %v", err) + } + defer f.Close() + content, err := io.ReadAll(f) if err != nil { t.Errorf("Reason: Failed to read file %q: %v", file, err) success = false diff --git a/tests/fixtures/scenarios/complete/vendor.yaml b/tests/fixtures/scenarios/complete/vendor.yaml index 17bba24fc..ab64e00df 100644 --- a/tests/fixtures/scenarios/complete/vendor.yaml +++ b/tests/fixtures/scenarios/complete/vendor.yaml @@ -45,7 +45,7 @@ spec: - test - networking - component: "vpc-flow-logs-bucket" - source: "github.com/cloudposse/terraform-aws-components.git//modules/vpc-flow-logs-bucket?ref={{.Version}}" + source: "git::https://github.com/cloudposse/terraform-aws-components.git//modules/vpc-flow-logs-bucket?ref={{.Version}}" version: "1.323.0" targets: - "components/terraform/infra/vpc-flow-logs-bucket/{{.Version}}" diff --git a/tests/test-cases/demo-stacks.yaml b/tests/test-cases/demo-stacks.yaml index f2c7867cc..18a4876ab 100644 --- a/tests/test-cases/demo-stacks.yaml +++ b/tests/test-cases/demo-stacks.yaml @@ -130,3 +130,63 @@ tests: stderr: - "^$" exit_code: 0 + + - name: atmos greet with args + enabled: true + description: "Validate atmos custom command greet runs with argument provided." + workdir: "../examples/demo-custom-command/" + command: "atmos" + args: + - "greet" + - "Andrey" + expect: + stdout: + - "Hello, Andrey\n" + stderr: + - "^$" + exit_code: 0 + + - name: atmos greet without args + enabled: true + description: "Validate atmos custom command greet runs without argument provided." + workdir: "../examples/demo-custom-command/" + command: "atmos" + args: + - "greet" + expect: + stdout: + - "Hello, John Doe\n" + stderr: + - "^$" + exit_code: 0 + - name: atmos vendor pull + enabled: true + description: "Ensure atmos vendor pull command executes without errors and files are present." + workdir: "../examples/tests/test-vendor/" + command: "atmos" + args: + - "vendor" + - "pull" + expect: + file_exists: + - "./components/terraform/github/stargazers/main/main.tf" + - "./components/terraform/github/stargazers/main/outputs.tf" + - "./components/terraform/github/stargazers/main/providers.tf" + - "./components/terraform/github/stargazers/main/variables.tf" + - "./components/terraform/github/stargazers/main/versions.tf" + - "./components/terraform/infra/my-vpc1/main.tf" + - "./components/terraform/infra/my-vpc1/outputs.tf" + - "./components/terraform/infra/my-vpc1/providers.tf" + - "./components/terraform/infra/my-vpc1/variables.tf" + - "./components/terraform/infra/my-vpc1/versions.tf" + - "./components/terraform/test-components/main/main.tf" + - "./components/terraform/test-components/main/outputs.tf" + - "./components/terraform/test-components/main/providers.tf" + - "./components/terraform/test-components/main/variables.tf" + - "./components/terraform/test-components/main/versions.tf" + - "./components/terraform/weather/main/main.tf" + - "./components/terraform/weather/main/outputs.tf" + - "./components/terraform/weather/main/providers.tf" + - "./components/terraform/weather/main/variables.tf" + - "./components/terraform/weather/main/versions.tf" + exit_code: 0