diff --git a/cmd/root.go b/cmd/root.go
index 81a9df8d2e..38c2876cde 100644
--- a/cmd/root.go
+++ b/cmd/root.go
@@ -53,6 +53,7 @@ import (
_ "github.com/cloudposse/atmos/cmd/list"
_ "github.com/cloudposse/atmos/cmd/profile"
themeCmd "github.com/cloudposse/atmos/cmd/theme"
+ _ "github.com/cloudposse/atmos/cmd/vendor"
"github.com/cloudposse/atmos/cmd/version"
)
diff --git a/cmd/vendor.go b/cmd/vendor.go
deleted file mode 100644
index 9ff2d4da6d..0000000000
--- a/cmd/vendor.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package cmd
-
-import (
- "github.com/spf13/cobra"
-)
-
-// vendorCmd executes 'atmos vendor' CLI commands
-var vendorCmd = &cobra.Command{
- Use: "vendor",
- Short: "Manage external dependencies for components or stacks",
- Long: `This command manages external dependencies for Atmos components or stacks by vendoring them. Vendoring involves copying and locking required dependencies locally, ensuring consistency, reliability, and alignment with the principles of immutable infrastructure.`,
- FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false},
- Args: cobra.NoArgs,
-}
-
-func init() {
- RootCmd.AddCommand(vendorCmd)
-}
diff --git a/cmd/vendor/diff.go b/cmd/vendor/diff.go
new file mode 100644
index 0000000000..8251c5768a
--- /dev/null
+++ b/cmd/vendor/diff.go
@@ -0,0 +1,138 @@
+package vendor
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/pkg/flags"
+ "github.com/cloudposse/atmos/pkg/flags/global"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+ vendor "github.com/cloudposse/atmos/pkg/vendoring"
+)
+
+// DiffOptions contains all options for vendor diff command.
+type DiffOptions struct {
+ global.Flags // Embedded global flags (chdir, logs-level, etc.).
+ AtmosConfig *schema.AtmosConfiguration // Populated after config init.
+ Component string // --component, -c (required).
+ ComponentType string // --type, -t (default: "terraform").
+ From string // --from (source version/ref).
+ To string // --to (target version/ref).
+ File string // --file (specific file to diff).
+ Context int // --context (lines of context, default: 3).
+ Unified bool // --unified (unified diff format, default: true).
+}
+
+// SetAtmosConfig implements AtmosConfigSetter for DiffOptions.
+func (o *DiffOptions) SetAtmosConfig(cfg *schema.AtmosConfiguration) {
+ o.AtmosConfig = cfg
+}
+
+// Validate checks that diff options are consistent.
+func (o *DiffOptions) Validate() error {
+ defer perf.Track(nil, "vendor.DiffOptions.Validate")()
+
+ if o.Component == "" {
+ return errUtils.ErrComponentFlagRequired
+ }
+ return nil
+}
+
+var diffParser *flags.StandardParser
+
+var diffCmd = &cobra.Command{
+ Use: "diff",
+ Short: "Show differences between vendor component versions",
+ Long: "Compare vendor component versions and display the differences between the current version and a target version or between two specified versions.",
+ FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false},
+ Args: cobra.NoArgs,
+ RunE: runDiff,
+}
+
+func init() {
+ // Create parser with all diff-specific flags.
+ diffParser = flags.NewStandardParser(
+ // String flags.
+ flags.WithStringFlag("component", "c", "", "Component to diff (required)"),
+ flags.WithStringFlag("type", "t", "terraform", "Component type (terraform or helmfile)"),
+ flags.WithStringFlag("from", "", "", "Source version/ref to compare from (default: current version)"),
+ flags.WithStringFlag("to", "", "", "Target version/ref to compare to (default: latest)"),
+ flags.WithStringFlag("file", "", "", "Specific file to diff within the component"),
+
+ // Int flags.
+ flags.WithIntFlag("context", "", 3, "Number of context lines to show in diff"),
+
+ // Bool flags.
+ flags.WithBoolFlag("unified", "", true, "Use unified diff format"),
+
+ // Environment variable bindings.
+ flags.WithEnvVars("component", "ATMOS_VENDOR_COMPONENT"),
+ flags.WithEnvVars("type", "ATMOS_VENDOR_TYPE"),
+ flags.WithEnvVars("from", "ATMOS_VENDOR_FROM"),
+ flags.WithEnvVars("to", "ATMOS_VENDOR_TO"),
+ flags.WithEnvVars("context", "ATMOS_VENDOR_CONTEXT"),
+ )
+
+ // Register flags with cobra command.
+ diffParser.RegisterFlags(diffCmd)
+
+ // Shell completions.
+ _ = diffCmd.RegisterFlagCompletionFunc("component", componentsArgCompletion)
+}
+
+func runDiff(cmd *cobra.Command, args []string) error {
+ defer perf.Track(nil, "vendor.runDiff")()
+
+ // Parse options with Viper precedence (CLI > ENV > config).
+ opts, err := parseDiffOptions(cmd)
+ if err != nil {
+ return err
+ }
+
+ // Validate options.
+ if err := opts.Validate(); err != nil {
+ return err
+ }
+
+ // Initialize Atmos config.
+ // Vendor diff doesn't use stack flag, so skip stack validation.
+ if err := initAtmosConfig(opts, true); err != nil {
+ return err
+ }
+
+ // Execute via pkg/vendoring.
+ return vendor.Diff(opts.AtmosConfig, &vendor.DiffParams{
+ Component: opts.Component,
+ ComponentType: opts.ComponentType,
+ From: opts.From,
+ To: opts.To,
+ File: opts.File,
+ Context: opts.Context,
+ Unified: opts.Unified,
+ NoColor: false, // This would come from global flags if needed.
+ })
+}
+
+func parseDiffOptions(cmd *cobra.Command) (*DiffOptions, error) {
+ defer perf.Track(nil, "vendor.parseDiffOptions")()
+
+ v := viper.GetViper()
+ if err := diffParser.BindFlagsToViper(cmd, v); err != nil {
+ return nil, fmt.Errorf("%w: %w", errUtils.ErrParseFlag, err)
+ }
+
+ return &DiffOptions{
+ // global.Flags embedded - would be populated from root persistent flags.
+ Component: v.GetString(flagComponent),
+ ComponentType: v.GetString(flagType),
+ From: v.GetString("from"),
+ To: v.GetString("to"),
+ File: v.GetString("file"),
+ Context: v.GetInt("context"),
+ Unified: v.GetBool("unified"),
+ }, nil
+}
diff --git a/cmd/vendor/pull.go b/cmd/vendor/pull.go
new file mode 100644
index 0000000000..86afe25d78
--- /dev/null
+++ b/cmd/vendor/pull.go
@@ -0,0 +1,138 @@
+package vendor
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/pkg/flags"
+ "github.com/cloudposse/atmos/pkg/flags/global"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+ vendor "github.com/cloudposse/atmos/pkg/vendoring"
+)
+
+// PullOptions contains all options for vendor pull command.
+type PullOptions struct {
+ global.Flags // Embedded global flags (chdir, logs-level, etc.).
+ AtmosConfig *schema.AtmosConfiguration // Populated after config init.
+ Component string // --component, -c.
+ Stack string // --stack, -s.
+ ComponentType string // --type, -t (default: "terraform").
+ Tags string // --tags (comma-separated).
+ DryRun bool // --dry-run.
+ Everything bool // --everything.
+}
+
+// SetAtmosConfig implements AtmosConfigSetter for PullOptions.
+func (o *PullOptions) SetAtmosConfig(cfg *schema.AtmosConfiguration) {
+ o.AtmosConfig = cfg
+}
+
+// Validate checks that pull options are consistent.
+func (o *PullOptions) Validate() error {
+ defer perf.Track(nil, "vendor.PullOptions.Validate")()
+
+ if o.Component != "" && o.Stack != "" {
+ return fmt.Errorf("%w: --component and --stack cannot be used together", errUtils.ErrMutuallyExclusiveFlags)
+ }
+ if o.Component != "" && o.Tags != "" {
+ return fmt.Errorf("%w: --component and --tags cannot be used together", errUtils.ErrMutuallyExclusiveFlags)
+ }
+ if o.Everything && (o.Component != "" || o.Stack != "" || o.Tags != "") {
+ return fmt.Errorf("%w: --everything cannot be combined with --component, --stack, or --tags", errUtils.ErrMutuallyExclusiveFlags)
+ }
+ return nil
+}
+
+var pullParser *flags.StandardParser
+
+var pullCmd = &cobra.Command{
+ Use: "pull",
+ Short: "Pull the latest vendor configurations or dependencies",
+ Long: "Pull and update vendor-specific configurations or dependencies to ensure the project has the latest required resources.",
+ FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false},
+ Args: cobra.NoArgs,
+ RunE: runPull,
+}
+
+func init() {
+ // Create parser with all pull-specific flags.
+ pullParser = flags.NewStandardParser(
+ // String flags.
+ flags.WithStringFlag("component", "c", "", "Only vendor the specified component"),
+ flags.WithStringFlag("stack", "s", "", "Only vendor the specified stack"),
+ flags.WithStringFlag("type", "t", "terraform", "Component type (terraform or helmfile)"),
+ flags.WithStringFlag("tags", "", "", "Only vendor components with specified tags (comma-separated)"),
+
+ // Bool flags.
+ flags.WithBoolFlag("dry-run", "", false, "Simulate without making changes"),
+ flags.WithBoolFlag("everything", "", false, "Vendor all components"),
+
+ // Environment variable bindings.
+ flags.WithEnvVars("component", "ATMOS_VENDOR_COMPONENT"),
+ flags.WithEnvVars("stack", "ATMOS_VENDOR_STACK", "ATMOS_STACK"),
+ flags.WithEnvVars("type", "ATMOS_VENDOR_TYPE"),
+ flags.WithEnvVars("tags", "ATMOS_VENDOR_TAGS"),
+ flags.WithEnvVars("dry-run", "ATMOS_VENDOR_DRY_RUN"),
+ )
+
+ // Register flags with cobra command.
+ pullParser.RegisterFlags(pullCmd)
+
+ // Shell completions.
+ _ = pullCmd.RegisterFlagCompletionFunc("component", componentsArgCompletion)
+ addStackCompletion(pullCmd)
+}
+
+func runPull(cmd *cobra.Command, args []string) error {
+ defer perf.Track(nil, "vendor.runPull")()
+
+ // Parse options with Viper precedence (CLI > ENV > config).
+ opts, err := parsePullOptions(cmd)
+ if err != nil {
+ return err
+ }
+
+ // Validate options.
+ if err := opts.Validate(); err != nil {
+ return err
+ }
+
+ // Initialize Atmos config.
+ skipStackValidation := opts.Stack == ""
+ if err := initAtmosConfig(opts, skipStackValidation); err != nil {
+ return err
+ }
+
+ // Execute via pkg/vendoring.
+ return vendor.Pull(opts.AtmosConfig, &vendor.PullParams{
+ Component: opts.Component,
+ Stack: opts.Stack,
+ ComponentType: opts.ComponentType,
+ DryRun: opts.DryRun,
+ Tags: opts.Tags,
+ Everything: opts.Everything,
+ })
+}
+
+func parsePullOptions(cmd *cobra.Command) (*PullOptions, error) {
+ defer perf.Track(nil, "vendor.parsePullOptions")()
+
+ v := viper.GetViper()
+ if err := pullParser.BindFlagsToViper(cmd, v); err != nil {
+ return nil, fmt.Errorf("%w: %w", errUtils.ErrParseFlag, err)
+ }
+
+ return &PullOptions{
+ // global.Flags embedded - would be populated from root persistent flags.
+ Component: v.GetString(flagComponent),
+ Stack: v.GetString("stack"),
+ ComponentType: v.GetString(flagType),
+ Tags: v.GetString("tags"),
+ DryRun: v.GetBool("dry-run"),
+ Everything: v.GetBool("everything"),
+ }, nil
+}
diff --git a/cmd/vendor/update.go b/cmd/vendor/update.go
new file mode 100644
index 0000000000..144bd2bd23
--- /dev/null
+++ b/cmd/vendor/update.go
@@ -0,0 +1,132 @@
+package vendor
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+ "github.com/spf13/viper"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/pkg/flags"
+ "github.com/cloudposse/atmos/pkg/flags/global"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+ vendor "github.com/cloudposse/atmos/pkg/vendoring"
+)
+
+// UpdateOptions contains all options for vendor update command.
+type UpdateOptions struct {
+ global.Flags // Embedded global flags (chdir, logs-level, etc.).
+ AtmosConfig *schema.AtmosConfiguration // Populated after config init.
+ Component string // --component, -c.
+ ComponentType string // --type, -t (default: "terraform").
+ Tags string // --tags (comma-separated).
+ Check bool // --check (check only, don't update).
+ Pull bool // --pull (pull after updating).
+ Outdated bool // --outdated (show only outdated components).
+}
+
+// SetAtmosConfig implements AtmosConfigSetter for UpdateOptions.
+func (o *UpdateOptions) SetAtmosConfig(cfg *schema.AtmosConfiguration) {
+ o.AtmosConfig = cfg
+}
+
+// Validate checks that update options are consistent.
+func (o *UpdateOptions) Validate() error {
+ defer perf.Track(nil, "vendor.UpdateOptions.Validate")()
+
+ if o.Check && o.Pull {
+ return fmt.Errorf("%w: --check and --pull cannot be used together", errUtils.ErrMutuallyExclusiveFlags)
+ }
+ return nil
+}
+
+var updateParser *flags.StandardParser
+
+var updateCmd = &cobra.Command{
+ Use: "update",
+ Short: "Check for and apply vendor component updates",
+ Long: "Check for available updates to vendored components and optionally update the vendor configuration to use newer versions.",
+ FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false},
+ Args: cobra.NoArgs,
+ RunE: runUpdate,
+}
+
+func init() {
+ // Create parser with all update-specific flags.
+ updateParser = flags.NewStandardParser(
+ // String flags.
+ flags.WithStringFlag("component", "c", "", "Only check/update the specified component"),
+ flags.WithStringFlag("type", "t", "terraform", "Component type (terraform or helmfile)"),
+ flags.WithStringFlag("tags", "", "", "Only check/update components with specified tags (comma-separated)"),
+
+ // Bool flags.
+ flags.WithBoolFlag("check", "", false, "Check for updates without modifying files"),
+ flags.WithBoolFlag("pull", "", false, "Pull components after updating vendor config"),
+ flags.WithBoolFlag("outdated", "", false, "Show only outdated components"),
+
+ // Environment variable bindings.
+ flags.WithEnvVars("component", "ATMOS_VENDOR_COMPONENT"),
+ flags.WithEnvVars("type", "ATMOS_VENDOR_TYPE"),
+ flags.WithEnvVars("tags", "ATMOS_VENDOR_TAGS"),
+ flags.WithEnvVars("check", "ATMOS_VENDOR_CHECK"),
+ flags.WithEnvVars("pull", "ATMOS_VENDOR_PULL"),
+ flags.WithEnvVars("outdated", "ATMOS_VENDOR_OUTDATED"),
+ )
+
+ // Register flags with cobra command.
+ updateParser.RegisterFlags(updateCmd)
+
+ // Shell completions.
+ _ = updateCmd.RegisterFlagCompletionFunc("component", componentsArgCompletion)
+}
+
+func runUpdate(cmd *cobra.Command, args []string) error {
+ defer perf.Track(nil, "vendor.runUpdate")()
+
+ // Parse options with Viper precedence (CLI > ENV > config).
+ opts, err := parseUpdateOptions(cmd)
+ if err != nil {
+ return err
+ }
+
+ // Validate options.
+ if err := opts.Validate(); err != nil {
+ return err
+ }
+
+ // Initialize Atmos config.
+ // Vendor update doesn't use stack flag, so skip stack validation.
+ if err := initAtmosConfig(opts, true); err != nil {
+ return err
+ }
+
+ // Execute via pkg/vendoring.
+ return vendor.Update(opts.AtmosConfig, &vendor.UpdateParams{
+ Component: opts.Component,
+ ComponentType: opts.ComponentType,
+ Tags: opts.Tags,
+ Check: opts.Check,
+ Pull: opts.Pull,
+ Outdated: opts.Outdated,
+ })
+}
+
+func parseUpdateOptions(cmd *cobra.Command) (*UpdateOptions, error) {
+ defer perf.Track(nil, "vendor.parseUpdateOptions")()
+
+ v := viper.GetViper()
+ if err := updateParser.BindFlagsToViper(cmd, v); err != nil {
+ return nil, fmt.Errorf("%w: %w", errUtils.ErrParseFlag, err)
+ }
+
+ return &UpdateOptions{
+ // global.Flags embedded - would be populated from root persistent flags.
+ Component: v.GetString(flagComponent),
+ ComponentType: v.GetString(flagType),
+ Tags: v.GetString("tags"),
+ Check: v.GetBool("check"),
+ Pull: v.GetBool("pull"),
+ Outdated: v.GetBool("outdated"),
+ }, nil
+}
diff --git a/cmd/vendor/utils.go b/cmd/vendor/utils.go
new file mode 100644
index 0000000000..94f559bd10
--- /dev/null
+++ b/cmd/vendor/utils.go
@@ -0,0 +1,89 @@
+package vendor
+
+import (
+ "fmt"
+
+ "github.com/spf13/cobra"
+
+ e "github.com/cloudposse/atmos/internal/exec"
+ cfg "github.com/cloudposse/atmos/pkg/config"
+ l "github.com/cloudposse/atmos/pkg/list"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+)
+
+// Flag name constants used across vendor commands.
+const (
+ flagComponent = "component"
+ flagType = "type"
+)
+
+// AtmosConfigSetter is an interface for options that need AtmosConfig.
+type AtmosConfigSetter interface {
+ SetAtmosConfig(cfg *schema.AtmosConfiguration)
+}
+
+// initAtmosConfig initializes Atmos configuration and stores it in opts.
+func initAtmosConfig[T AtmosConfigSetter](opts T, skipStackValidation bool) error {
+ defer perf.Track(nil, "vendor.initAtmosConfig")()
+
+ configAndStacksInfo := schema.ConfigAndStacksInfo{}
+ atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, skipStackValidation)
+ if err != nil {
+ return fmt.Errorf("failed to initialize Atmos config: %w", err)
+ }
+
+ opts.SetAtmosConfig(&atmosConfig)
+ return nil
+}
+
+// componentsArgCompletion provides shell completion for --component flag.
+func componentsArgCompletion(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ defer perf.Track(nil, "vendor.componentsArgCompletion")()
+
+ configAndStacksInfo := schema.ConfigAndStacksInfo{}
+ atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true)
+ if err != nil {
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+
+ // Get all stacks to extract components from them.
+ stacksMap, err := e.ExecuteDescribeStacks(&atmosConfig, "", nil, nil, nil, false, false, false, false, nil, nil)
+ if err != nil {
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+
+ // Extract components from all stacks.
+ components, err := l.FilterAndListComponents("", stacksMap)
+ if err != nil {
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+
+ return components, cobra.ShellCompDirectiveNoFileComp
+}
+
+// addStackCompletion adds --stack flag completion to a command.
+func addStackCompletion(cmd *cobra.Command) {
+ defer perf.Track(nil, "vendor.addStackCompletion")()
+
+ _ = cmd.RegisterFlagCompletionFunc("stack", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
+ configAndStacksInfo := schema.ConfigAndStacksInfo{}
+ atmosConfig, err := cfg.InitCliConfig(configAndStacksInfo, true)
+ if err != nil {
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+
+ // Get all stacks.
+ stacksMap, err := e.ExecuteDescribeStacks(&atmosConfig, "", nil, nil, nil, false, false, false, false, nil, nil)
+ if err != nil {
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+
+ stacks, err := l.FilterAndListStacks(stacksMap, "")
+ if err != nil {
+ return nil, cobra.ShellCompDirectiveNoFileComp
+ }
+
+ return stacks, cobra.ShellCompDirectiveNoFileComp
+ })
+}
diff --git a/cmd/vendor/vendor.go b/cmd/vendor/vendor.go
new file mode 100644
index 0000000000..255d9f1e29
--- /dev/null
+++ b/cmd/vendor/vendor.go
@@ -0,0 +1,70 @@
+package vendor
+
+import (
+ "github.com/spf13/cobra"
+
+ "github.com/cloudposse/atmos/cmd/internal"
+ "github.com/cloudposse/atmos/pkg/flags"
+ "github.com/cloudposse/atmos/pkg/flags/compat"
+)
+
+// vendorCmd is the parent command for all vendor subcommands.
+var vendorCmd = &cobra.Command{
+ Use: "vendor",
+ Short: "Manage vendored components and dependencies",
+ Long: `Pull, diff, and update vendored components from remote sources.`,
+ FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false},
+ Args: cobra.NoArgs,
+}
+
+func init() {
+ // Attach all subcommands.
+ vendorCmd.AddCommand(pullCmd)
+ vendorCmd.AddCommand(diffCmd)
+ vendorCmd.AddCommand(updateCmd)
+
+ // Register with registry.
+ internal.Register(&VendorCommandProvider{})
+}
+
+// VendorCommandProvider implements the CommandProvider interface.
+type VendorCommandProvider struct{}
+
+// GetCommand returns the vendor command with all subcommands attached.
+func (v *VendorCommandProvider) GetCommand() *cobra.Command {
+ return vendorCmd
+}
+
+// GetName returns the command name.
+func (v *VendorCommandProvider) GetName() string {
+ return "vendor"
+}
+
+// GetGroup returns the command group for help organization.
+func (v *VendorCommandProvider) GetGroup() string {
+ return "Configuration Management"
+}
+
+// GetFlagsBuilder returns the flags builder for this command.
+// Vendor parent command has no flags.
+func (v *VendorCommandProvider) GetFlagsBuilder() flags.Builder {
+ return nil
+}
+
+// GetPositionalArgsBuilder returns the positional args builder for this command.
+// Vendor parent command has no positional arguments.
+func (v *VendorCommandProvider) GetPositionalArgsBuilder() *flags.PositionalArgsBuilder {
+ return nil
+}
+
+// GetCompatibilityFlags returns compatibility flags for this command.
+// Vendor command has no compatibility flags.
+func (v *VendorCommandProvider) GetCompatibilityFlags() map[string]compat.CompatibilityFlag {
+ return nil
+}
+
+// GetAliases returns command aliases.
+// Vendor command has no aliases.
+func (v *VendorCommandProvider) GetAliases() []internal.CommandAlias {
+ return nil
+}
diff --git a/cmd/vendor_diff.go b/cmd/vendor_diff.go
deleted file mode 100644
index ea31dc1b7d..0000000000
--- a/cmd/vendor_diff.go
+++ /dev/null
@@ -1,35 +0,0 @@
-package cmd
-
-import (
- "github.com/spf13/cobra"
-
- e "github.com/cloudposse/atmos/internal/exec"
-)
-
-// vendorDiffCmd executes 'vendor diff' CLI commands.
-var vendorDiffCmd = &cobra.Command{
- Use: "diff",
- Short: "Show differences in vendor configurations or dependencies",
- Long: "This command compares and displays the differences in vendor-specific configurations or dependencies.",
- FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false},
- RunE: func(cmd *cobra.Command, args []string) error {
- handleHelpRequest(cmd, args)
- // TODO: There was no documentation here:https://atmos.tools/cli/commands/vendor we need to know what this command requires to check if we should add usage help
-
- // Check Atmos configuration
- checkAtmosConfig()
-
- err := e.ExecuteVendorDiffCmd(cmd, args)
- return err
- },
-}
-
-func init() {
- vendorDiffCmd.PersistentFlags().StringP("component", "c", "", "Compare the differences between the local and vendored versions of the specified component.")
- AddStackCompletion(vendorDiffCmd)
- vendorDiffCmd.PersistentFlags().StringP("type", "t", "terraform", "Compare the differences between the local and vendored versions of the specified component, filtering by type (terraform or helmfile).")
- vendorDiffCmd.PersistentFlags().Bool("dry-run", false, "Simulate the comparison of differences between the local and vendored versions of the specified component without making any changes.")
-
- // Since this command is not implemented yet, exclude it from `atmos help`
- // vendorCmd.AddCommand(vendorDiffCmd)
-}
diff --git a/cmd/vendor_pull.go b/cmd/vendor_pull.go
deleted file mode 100644
index bd8a68e3a8..0000000000
--- a/cmd/vendor_pull.go
+++ /dev/null
@@ -1,36 +0,0 @@
-package cmd
-
-import (
- "github.com/spf13/cobra"
-
- e "github.com/cloudposse/atmos/internal/exec"
-)
-
-// vendorPullCmd executes 'vendor pull' CLI commands.
-var vendorPullCmd = &cobra.Command{
- Use: "pull",
- Short: "Pull the latest vendor configurations or dependencies",
- Long: "Pull and update vendor-specific configurations or dependencies to ensure the project has the latest required resources.",
- FParseErrWhitelist: struct{ UnknownFlags bool }{UnknownFlags: false},
- Args: cobra.NoArgs,
- RunE: func(cmd *cobra.Command, args []string) error {
- // WithStackValidation is a functional option that enables/disables stack configuration validation
- // based on whether the --stack flag is provided
- checkAtmosConfig(WithStackValidation(cmd.Flag("stack").Changed))
-
- err := e.ExecuteVendorPullCmd(cmd, args)
- return err
- },
-}
-
-func init() {
- vendorPullCmd.PersistentFlags().StringP("component", "c", "", "Only vendor the specified component")
- vendorPullCmd.RegisterFlagCompletionFunc("component", ComponentsArgCompletion)
- vendorPullCmd.PersistentFlags().StringP("stack", "s", "", "Only vendor the specified stack")
- AddStackCompletion(vendorPullCmd)
- vendorPullCmd.PersistentFlags().StringP("type", "t", "terraform", "The type of the vendor (terraform or helmfile).")
- vendorPullCmd.PersistentFlags().Bool("dry-run", false, "Simulate pulling the latest version of the specified component from the remote repository without making any changes.")
- vendorPullCmd.PersistentFlags().String("tags", "", "Only vendor the components that have the specified tags")
- vendorPullCmd.PersistentFlags().Bool("everything", false, "Vendor all components")
- vendorCmd.AddCommand(vendorPullCmd)
-}
diff --git a/cmd/vendor_test.go b/cmd/vendor_test.go
deleted file mode 100644
index e917333f25..0000000000
--- a/cmd/vendor_test.go
+++ /dev/null
@@ -1,18 +0,0 @@
-package cmd
-
-import (
- "testing"
-
- "github.com/stretchr/testify/assert"
-)
-
-// setupTestCommand creates a test command with the necessary flags.
-func TestVendorCommands_Error(t *testing.T) {
- stacksPath := "../tests/fixtures/scenarios/terraform-apply-affected"
-
- t.Setenv("ATMOS_CLI_CONFIG_PATH", stacksPath)
- t.Setenv("ATMOS_BASE_PATH", stacksPath)
-
- err := vendorPullCmd.RunE(vendorPullCmd, []string{"--invalid-flag"})
- assert.Error(t, err, "vendor pull command should return an error when called with invalid flags")
-}
diff --git a/docs/prd/vendor-refactoring-plan.md b/docs/prd/vendor-refactoring-plan.md
new file mode 100644
index 0000000000..44f8168576
--- /dev/null
+++ b/docs/prd/vendor-refactoring-plan.md
@@ -0,0 +1,194 @@
+# Vendor Package Refactoring Plan
+
+## Current State
+- **30 files** in `pkg/vendor/`
+- **51.5% test coverage** (target: 80-90%)
+- Files range from 1K to 25K bytes
+- No internal import cycles (all files in flat structure)
+
+## Goals
+1. Split `pkg/vendor/` into focused sub-packages
+2. Increase test coverage to 80%+
+3. Eliminate circular dependency risk
+4. Make code more maintainable and testable
+
+---
+
+## Phase 1: Create Sub-Package Structure (No Breaking Changes)
+
+### Step 1.1: Create `pkg/vendor/uri` package
+**Files to move:**
+- `uri_helpers.go` → `pkg/vendor/uri/helpers.go`
+- `uri_helpers_test.go` → `pkg/vendor/uri/helpers_test.go`
+
+**Exports:** All URI helper functions (already 100% covered)
+
+**Why first:** Leaf package with no internal dependencies, already well-tested.
+
+### Step 1.2: Create `pkg/vendor/version` package
+**Files to move:**
+- `version_check.go` → `pkg/vendor/version/check.go`
+- `version_check_test.go` → `pkg/vendor/version/check_test.go`
+- `version_constraints.go` → `pkg/vendor/version/constraints.go`
+- `version_constraints_test.go` → `pkg/vendor/version/constraints_test.go`
+
+**Exports:** Version checking and constraint functions
+
+**Why second:** Leaf package, mostly well-tested (100% on constraints).
+
+### Step 1.3: Create `pkg/vendor/gitops` package
+**Files to move:**
+- `git_interface.go` → `pkg/vendor/gitops/interface.go`
+- `git_diff.go` → `pkg/vendor/gitops/diff.go`
+- `git_diff_test.go` → `pkg/vendor/gitops/diff_test.go`
+- `mock_git_interface.go` → `pkg/vendor/gitops/mock.go`
+
+**Exports:** GitOperations interface, diff helpers
+
+**Why:** Clean separation of git operations, enables better mocking.
+
+### Step 1.4: Create `pkg/vendor/source` package
+**Files to move:**
+- `source_provider.go` → `pkg/vendor/source/provider.go`
+- `source_provider_git.go` → `pkg/vendor/source/git.go`
+- `source_provider_github.go` → `pkg/vendor/source/github.go`
+- `source_provider_unsupported.go` → `pkg/vendor/source/unsupported.go`
+- `source_provider_test.go` → `pkg/vendor/source/provider_test.go`
+
+**Depends on:** `pkg/vendor/gitops`, `pkg/vendor/version`
+
+**Exports:** VendorSourceProvider interface, GetProviderForSource
+
+### Step 1.5: Create `pkg/vendor/yaml` package
+**Files to move:**
+- `yaml_updater.go` → `pkg/vendor/yaml/updater.go`
+- `yaml_updater_test.go` → `pkg/vendor/yaml/updater_test.go`
+
+**Exports:** YAML version update functions
+
+---
+
+## Phase 2: Refactor Core Files (Careful Dependencies)
+
+### Step 2.1: Keep in `pkg/vendor/` (main package)
+These files stay as the public API and orchestration layer:
+- `pull.go` - Public Pull() function
+- `diff.go` - Public Diff() function
+- `update.go` - Public Update() function
+- `params.go` - Public param structs
+- `utils.go` - Internal utilities (may split further)
+- `component_utils.go` - Component vendor logic
+- `model.go` - TUI model (may move to `pkg/vendor/tui/`)
+
+### Step 2.2: Create backward-compatible re-exports
+In `pkg/vendor/vendor.go`, re-export moved functions to avoid breaking external consumers:
+```go
+// Re-exports for backward compatibility
+var (
+ GetProviderForSource = source.GetProviderForSource
+ // etc.
+)
+```
+
+---
+
+## Phase 3: Increase Test Coverage
+
+### Priority 1: Zero-coverage functions (immediate impact)
+| Function | File | Action |
+|----------|------|--------|
+| `Update` | update.go | Add unit tests with mocked config |
+| `Diff` | diff.go | Add unit tests with mocked git ops |
+| `executeVendorUpdate` | update.go | Add unit tests |
+| `executeComponentVendorUpdate` | update.go | Add unit tests |
+| `handleComponentVendor` | pull.go | Add unit tests |
+| `ExecuteStackVendorInternal` | component_utils.go | Add stub test (returns ErrNotSupported) |
+
+### Priority 2: Low-coverage functions (38-70%)
+| Function | Coverage | Action |
+|----------|----------|--------|
+| `handleVendorConfig` | 38.5% | Add edge case tests |
+| `validateVendorFlags` | 57.1% | Add all flag combination tests |
+| `getConfigFiles` | 21.1% | Add directory/permission tests |
+| `processVendorImports` | 15.8% | Add import chain tests |
+
+### Priority 3: TUI model testing
+- Mock `downloadAndInstall` for unit tests
+- Test state transitions in `Update` method
+- Test `View` rendering
+
+---
+
+## Phase 4: File Moves Summary
+
+```
+pkg/vendor/
+├── uri/
+│ ├── helpers.go (from uri_helpers.go)
+│ └── helpers_test.go (from uri_helpers_test.go)
+├── version/
+│ ├── check.go (from version_check.go)
+│ ├── check_test.go (from version_check_test.go)
+│ ├── constraints.go (from version_constraints.go)
+│ └── constraints_test.go (from version_constraints_test.go)
+├── gitops/
+│ ├── interface.go (from git_interface.go)
+│ ├── diff.go (from git_diff.go)
+│ ├── diff_test.go (from git_diff_test.go)
+│ └── mock.go (from mock_git_interface.go)
+├── source/
+│ ├── provider.go (from source_provider.go)
+│ ├── git.go (from source_provider_git.go)
+│ ├── github.go (from source_provider_github.go)
+│ ├── unsupported.go (from source_provider_unsupported.go)
+│ └── provider_test.go (from source_provider_test.go)
+├── yaml/
+│ ├── updater.go (from yaml_updater.go)
+│ └── updater_test.go (from yaml_updater_test.go)
+├── pull.go (stays - public API)
+├── diff.go (stays - public API)
+├── update.go (stays - public API)
+├── params.go (stays - public param types)
+├── utils.go (stays - internal utilities)
+├── component_utils.go (stays - component logic)
+├── model.go (stays - TUI)
+└── *_test.go (integration tests stay)
+```
+
+---
+
+## Dependency Graph (No Cycles)
+
+```
+pkg/vendor (main)
+ ├── pkg/vendor/uri (leaf - no deps)
+ ├── pkg/vendor/version (leaf - no deps)
+ ├── pkg/vendor/gitops (leaf - no deps)
+ ├── pkg/vendor/yaml (leaf - no deps)
+ └── pkg/vendor/source
+ ├── pkg/vendor/gitops
+ └── pkg/vendor/version
+```
+
+---
+
+## Implementation Order
+
+1. **Phase 1.1**: Move `uri/` - verify tests pass
+2. **Phase 1.2**: Move `version/` - verify tests pass
+3. **Phase 1.3**: Move `gitops/` - verify tests pass
+4. **Phase 1.4**: Move `source/` - update imports, verify tests
+5. **Phase 1.5**: Move `yaml/` - verify tests pass
+6. **Phase 2**: Add re-exports, update main package imports
+7. **Phase 3**: Add tests for zero-coverage functions
+8. **Build & Test**: `go build ./... && go test ./pkg/vendor/...`
+
+---
+
+## Success Criteria
+
+- [ ] All tests pass after each phase
+- [ ] No import cycles (`go build ./...` succeeds)
+- [ ] Coverage increases to 70%+ after Phase 3.1
+- [ ] Coverage reaches 80%+ after Phase 3.2
+- [ ] Public API unchanged (Pull, Diff, Update functions)
diff --git a/docs/prd/vendor-update.md b/docs/prd/vendor-update.md
new file mode 100644
index 0000000000..c04749930f
--- /dev/null
+++ b/docs/prd/vendor-update.md
@@ -0,0 +1,1486 @@
+# Vendor Update Product Requirements Document
+
+## Executive Summary
+
+This PRD covers two related commands for vendor management:
+
+1. **`atmos vendor update`** - Automated version management for vendored components. Checks upstream Git sources for newer versions and updates version references in vendor configuration files while **strictly preserving** YAML structure, comments, anchors, aliases, and formatting.
+
+2. **`atmos vendor diff`** - Shows Git diffs between two versions of a component from a remote repository. Git-only feature that displays actual code/file changes between versions without requiring local checkout.
+
+## Problem Statement
+
+Currently, maintaining up-to-date versions of vendored components requires:
+- Manual checking of upstream Git repositories for new releases and commits
+- Manual editing of `vendor.yaml` and `component.yaml` files
+- High risk of breaking complex YAML structures (anchors, aliases, merge keys)
+- Loss of comments and documentation during updates
+- No visibility into available updates across multiple components
+- Tedious, error-prone process when managing many components with imports
+- No way to see what's changed between current and available versions
+
+## Goals
+
+### Primary Goals
+1. **Automated Version Checking**: Check Git repositories for newer tags and commits
+2. **YAML Structure Preservation**: Maintain ALL YAML features during updates:
+ - YAML anchors (`&anchor`)
+ - YAML aliases (`*anchor`)
+ - Merge keys (`<<: *anchor`)
+ - Comments (inline and block)
+ - Indentation and formatting
+ - Quote styles (single, double, unquoted)
+3. **Multi-File Support**: Handle `vendor.yaml`, `component.yaml`, and imported files
+4. **Import Chain Processing**: Follow `imports:` directives recursively
+5. **Filtering**: Filter by component name and tags
+6. **Dry-Run Mode**: See what would change without modifying files
+7. **Progressive UI**: Use Charm Bracelet TUI with progress indicators
+8. **GitHub Rate Limit Respect**: Handle rate limits gracefully
+9. **Test Coverage**: Achieve 80-90% test coverage
+10. **Complete Documentation**: CLI docs, blog post, usage examples
+11. **Version Constraints**: Support semver constraints and version exclusions via `constraints` field
+
+### Non-Goals (Future Scope)
+- OCI registry version checking
+- S3/GCS bucket version checking
+- HTTP/HTTPS direct file version checking (not applicable)
+- Local file system version checking (not applicable)
+- Automatic major version upgrades without user confirmation
+- Version pinning with different folder names (complexity)
+- Rollback capabilities
+- Breaking change detection
+
+## User Stories
+
+### US-1: Check for Updates (Dry-Run)
+**As a** platform engineer
+**I want to** see what component updates are available
+**So that** I can decide which updates to apply
+
+```bash
+atmos vendor update --check
+```
+
+**Output:**
+```
+Checking for vendor updates...
+
+[=========>] 9/10 Checking terraform-aws-ecs...
+
+✓ terraform-aws-vpc (1.323.0 → 1.372.0)
+✓ terraform-aws-s3-bucket (4.1.0 → 4.2.0)
+✓ terraform-aws-eks (2.1.0 - up to date)
+⚠ custom-module (skipped - templated version {{.Version}})
+⚠ terraform-aws-ecs (skipped - OCI registry not yet supported)
+✓ terraform-aws-rds (5.0.0 → 5.1.0)
+
+Found 3 updates available.
+```
+
+### US-2: Update Version References
+**As a** platform engineer
+**I want to** update version strings in my config files
+**So that** vendor pull will fetch the latest versions
+
+```bash
+atmos vendor update
+```
+
+**Expected behavior:**
+- Updates only the `version:` fields in YAML files
+- Preserves all YAML anchors, aliases, merge keys
+- Preserves all comments
+- Preserves indentation and quote styles
+- Updates the correct file (where the source was defined, not where it was imported)
+
+### US-3: Update and Pull in One Command
+**As a** platform engineer
+**I want to** update versions and download components in one command
+**So that** I can quickly get the latest components
+
+```bash
+atmos vendor update --pull
+```
+
+**Expected behavior:**
+1. Update version references in config files
+2. Execute `atmos vendor pull` automatically
+3. Show combined progress UI
+
+### US-4: Update Specific Component
+**As a** platform engineer
+**I want to** update only one component
+**So that** I can test updates incrementally
+
+```bash
+atmos vendor update --component vpc
+atmos vendor update --component vpc --pull
+```
+
+### US-5: Update Components by Tags
+**As a** platform engineer
+**I want to** update components with specific tags
+**So that** I can update related components together
+
+```bash
+atmos vendor update --tags terraform,networking
+atmos vendor update --tags production --check
+```
+
+### US-6: Work with Imports
+**As a** platform engineer
+**I want to** updates to work correctly with vendor config imports
+**So that** the right files get updated
+
+**Example:**
+```yaml
+# vendor.yaml
+spec:
+ imports:
+ - vendor/terraform.yaml
+ - vendor/helmfile.yaml
+```
+
+```yaml
+# vendor/terraform.yaml
+spec:
+ sources:
+ - component: vpc
+ version: 1.0.0
+```
+
+**Expected:** When vpc is updated, `vendor/terraform.yaml` gets modified (not `vendor.yaml`)
+
+### US-7: View Changes Between Versions
+**As a** platform engineer
+**I want to** see what changed between two versions of a component
+**So that** I can assess the impact before updating
+
+```bash
+atmos vendor diff --component vpc
+atmos vendor diff --component vpc --from 1.0.0 --to 2.0.0
+```
+
+**Output:**
+```diff
+Showing diff for component 'vpc' (1.0.0 → 2.0.0)
+Source: github.com/cloudposse/terraform-aws-vpc.git
+
+diff --git a/main.tf b/main.tf
+...
++ enable_dns_support = var.enable_dns_support
+...
+```
+
+**Expected behavior:**
+- Show Git diff between two versions without local clone
+- Support comparing current version to latest
+- Support comparing any two specific versions
+- Work with Git repositories only (not OCI, local, HTTP)
+
+## Supported Upstream Sources
+
+| Source Type | Version Detection | Priority | Notes |
+|-------------|------------------|----------|-------|
+| **Git Repositories** (GitHub, GitLab, Bitbucket, self-hosted) | Tags & commits via `git ls-remote` | P0 - MUST | Primary use case |
+| **OCI Registries** | Registry API | P2 - Future | Complex, different API per registry |
+| **HTTP/HTTPS Direct Files** | N/A | N/A | No versioning concept |
+| **Local File System** | N/A | N/A | No versioning concept |
+| **Amazon S3** | Object metadata | P3 - Future | Requires AWS SDK |
+| **Google GCS** | Object metadata | P3 - Future | Requires GCP SDK |
+
+### Git Repository Version Detection
+
+**Tag-based versions:**
+```bash
+# Get all tags from remote
+git ls-remote --tags https://github.com/cloudposse/terraform-aws-vpc.git
+
+# Parse and sort semantic versions
+# Filter out pre-release versions (alpha, beta, rc)
+# Compare with current version
+# Return latest stable version
+```
+
+**Commit-based versions:**
+```bash
+# Get HEAD commit hash
+git ls-remote https://github.com/cloudposse/terraform-aws-vpc.git HEAD
+
+# Compare with current commit hash (7+ chars)
+# Return if different
+```
+
+**Templated versions (skip):**
+```yaml
+version: "{{.Version}}" # Skip - contains template syntax
+version: "{{ atmos.Component }}" # Skip - contains template syntax
+```
+
+## Version Constraints
+
+### Overview
+
+Version constraints allow users to control which versions are allowed when updating vendored components. This provides:
+- **Safety**: Prevent breaking changes by constraining to compatible version ranges
+- **Security**: Explicitly exclude versions with known vulnerabilities
+- **Flexibility**: Use semver constraints familiar from npm, cargo, composer
+
+### Configuration Schema
+
+Constraints are specified in the vendor configuration using a `constraints` field:
+
+```yaml
+sources:
+ - component: "vpc"
+ source: "github.com/cloudposse/terraform-aws-components"
+ version: "1.323.0" # Current pinned version
+ constraints:
+ version: "^1.0.0" # Semver constraint (allow 1.x)
+ excluded_versions: # Versions to skip
+ - "1.2.3" # Has critical bug
+ - "1.5.*" # Entire patch series broken
+ no_prereleases: true # Skip alpha/beta/rc versions
+```
+
+### Constraint Field Definitions
+
+#### `constraints.version` (string, optional)
+
+Semantic version constraint using [Masterminds/semver](https://github.com/Masterminds/semver) syntax:
+
+| Constraint | Meaning | Example |
+|------------|---------|---------|
+| `^1.2.3` | Compatible with 1.2.3 (allows >=1.2.3 <2.0.0) | 1.2.3, 1.5.0, 1.999.0 ✓
2.0.0 ✗ |
+| `~1.2.3` | Patch updates only (allows >=1.2.3 <1.3.0) | 1.2.3, 1.2.9 ✓
1.3.0 ✗ |
+| `>=1.0.0 <2.0.0` | Range constraint | 1.x.x ✓
2.0.0 ✗ |
+| `1.2.x` | Any patch version in 1.2 | 1.2.0, 1.2.99 ✓
1.3.0 ✗ |
+| `*` or empty | Any version (no constraint) | All versions ✓ |
+
+If not specified, defaults to no constraint (any version allowed).
+
+#### `constraints.excluded_versions` (list of strings, optional)
+
+List of specific versions to exclude from consideration. Supports:
+- **Exact versions**: `"1.2.3"` - exclude this specific version
+- **Wildcard patterns**: `"1.5.*"` - exclude all 1.5.x versions
+- **Multiple entries**: Can exclude any number of versions
+
+**Use cases:**
+- Known security vulnerabilities (CVEs)
+- Versions with critical bugs
+- Breaking changes or incompatibilities
+- Deprecated/yanked versions
+
+#### `constraints.no_prereleases` (boolean, optional, default: false)
+
+When `true`, exclude pre-release versions (alpha, beta, rc, pre, etc.).
+
+**Examples of excluded versions when `true`:**
+- `1.0.0-alpha`
+- `2.3.0-beta.1`
+- `1.5.0-rc.2`
+- `3.0.0-pre`
+
+**Production use case:** Ensure only stable releases are used in production environments.
+
+### Constraint Resolution Logic
+
+When `atmos vendor update` checks for updates:
+
+1. **Fetch available versions** from Git repository (tags)
+2. **Parse semantic versions** - skip non-semver tags
+3. **Apply `no_prereleases` filter** - remove alpha/beta/rc if enabled
+4. **Apply `excluded_versions` filter** - remove blacklisted versions
+5. **Apply `constraints.version` filter** - keep only versions matching constraint
+6. **Select latest version** from remaining candidates
+7. **Update `version` field** in YAML while preserving structure
+
+### Example Configurations
+
+#### Example 1: Conservative Updates (Patch Only)
+```yaml
+sources:
+ - component: "vpc"
+ source: "github.com/cloudposse/terraform-aws-components"
+ version: "1.323.0"
+ constraints:
+ version: "~1.323.0" # Only allow 1.323.x patches
+ no_prereleases: true
+```
+
+#### Example 2: Minor Updates with Exclusions
+```yaml
+sources:
+ - component: "eks"
+ source: "github.com/cloudposse/terraform-aws-components"
+ version: "2.1.0"
+ constraints:
+ version: "^2.0.0" # Allow 2.x minor/patch updates
+ excluded_versions:
+ - "2.3.0" # Has CVE-2024-12345
+ - "2.5.*" # Entire 2.5.x series is broken
+ no_prereleases: true
+```
+
+#### Example 3: Production-Safe Range
+```yaml
+sources:
+ - component: "rds"
+ source: "github.com/cloudposse/terraform-aws-components"
+ version: "5.0.0"
+ constraints:
+ version: ">=5.0.0 <6.0.0" # Allow 5.x only
+ no_prereleases: true # No beta/rc versions
+```
+
+#### Example 4: No Constraints (Always Latest)
+```yaml
+sources:
+ - component: "test-module"
+ source: "github.com/example/terraform-modules"
+ version: "1.0.0"
+ # No constraints - will update to absolute latest tag
+```
+
+### Schema Changes Required
+
+Update `pkg/schema/schema.go` to add `Constraints` field to `AtmosVendorSource`:
+
+```go
+type AtmosVendorSource struct {
+ Component string `yaml:"component,omitempty" json:"component,omitempty"`
+ Source string `yaml:"source" json:"source"`
+ Version string `yaml:"version,omitempty" json:"version,omitempty"`
+ Constraints *VendorConstraints `yaml:"constraints,omitempty" json:"constraints,omitempty"`
+ Targets []string `yaml:"targets,omitempty" json:"targets,omitempty"`
+ IncludedPaths []string `yaml:"included_paths,omitempty" json:"included_paths,omitempty"`
+ ExcludedPaths []string `yaml:"excluded_paths,omitempty" json:"excluded_paths,omitempty"`
+ Tags []string `yaml:"tags,omitempty" json:"tags,omitempty"`
+}
+
+type VendorConstraints struct {
+ Version string `yaml:"version,omitempty" json:"version,omitempty"`
+ ExcludedVersions []string `yaml:"excluded_versions,omitempty" json:"excluded_versions,omitempty"`
+ NoPrereleases bool `yaml:"no_prereleases,omitempty" json:"no_prereleases,omitempty"`
+}
+```
+
+### JSON Schema Update
+
+Update `/pkg/datafetcher/schema/vendor/package/1.0.json` to include constraints:
+
+```json
+{
+ "constraints": {
+ "type": "object",
+ "properties": {
+ "version": {
+ "type": "string",
+ "description": "Semantic version constraint (e.g., ^1.0.0, ~1.2.3)"
+ },
+ "excluded_versions": {
+ "type": "array",
+ "items": {
+ "type": "string"
+ },
+ "description": "List of versions to exclude (supports wildcards)"
+ },
+ "no_prereleases": {
+ "type": "boolean",
+ "default": false,
+ "description": "Exclude pre-release versions (alpha, beta, rc)"
+ }
+ }
+ }
+}
+```
+
+## YAML Structure Preservation Requirements
+
+### Critical Requirements
+
+**MUST preserve:**
+1. **YAML Anchors**: `&anchor-name`
+2. **YAML Aliases**: `*anchor-name`
+3. **Merge Keys**: `<<: *anchor`
+4. **Comments**: Both inline (`# comment`) and block comments
+5. **Indentation**: Exact whitespace (spaces, not tabs)
+6. **Quote Styles**: Single (`'`), double (`"`), or unquoted
+7. **Line Ordering**: Maintain order of keys and list items
+8. **Empty Lines**: Preserve spacing between sections
+
+### YAML Preservation Strategy
+
+**Use Established YAML Libraries - DO NOT implement custom parser**
+
+**Recommended Approach:**
+
+**Option 1: `gopkg.in/yaml.v3` (Preferred)**
+- Industry-standard Go YAML library
+- Full support for YAML 1.2
+- Preserves comments via `yaml.Node` API
+- Handles anchors and aliases correctly
+- Used extensively in the Go ecosystem
+- Battle-tested and maintained
+
+**Implementation:**
+```go
+import "gopkg.in/yaml.v3"
+
+// Parse YAML preserving structure
+var node yaml.Node
+err := yaml.Unmarshal(content, &node)
+
+// Navigate and update version nodes
+// node.Content contains the document structure
+
+// Marshal back to YAML with comments preserved
+output, err := yaml.Marshal(&node)
+```
+
+**Option 2: `goccy/go-yaml` (Alternative)**
+- Better comment preservation in some cases
+- AST-based approach
+- May have limitations with complex anchors
+
+**Implementation Decision:**
+- **Use `gopkg.in/yaml.v3` for v1** - proven, stable, well-documented
+- **Leverage `yaml.Node` API** for structure-preserving updates
+- **Do NOT write custom YAML parser** - use established libraries
+- **Test extensively** with real vendor.yaml files including anchors, aliases, merge keys
+- **Document any discovered limitations** and file issues upstream if needed
+
+**Key Requirements:**
+1. Use `yaml.Node` for low-level node manipulation
+2. Preserve `yaml.Node.Style` for quote preservation
+3. Preserve `yaml.Node.HeadComment` and `yaml.Node.LineComment`
+4. Maintain node ordering
+5. Test with complex anchor/alias scenarios
+
+### Test Cases for YAML Preservation
+
+```yaml
+# Test 1: Simple version update
+spec:
+ sources:
+ - component: vpc
+ version: 1.0.0 # Should update to 2.0.0
+
+# Test 2: Anchors and aliases
+spec:
+ bases:
+ - &defaults
+ source: github.com/example
+ version: 1.0.0 # Should update anchor definition
+ sources:
+ - <<: *defaults # Should inherit updated version
+ component: vpc
+
+# Test 3: Comments preservation
+spec:
+ sources:
+ # VPC component from CloudPosse
+ - component: vpc
+ version: 1.0.0 # Latest stable - Should update
+
+# Test 4: Quote styles
+spec:
+ sources:
+ - version: "1.0.0" # Double quotes - preserve
+ - version: '1.0.0' # Single quotes - preserve
+ - version: 1.0.0 # Unquoted - preserve
+
+# Test 5: Multiple components
+spec:
+ sources:
+ - component: vpc
+ version: 1.0.0 # Update to 2.0.0
+ - component: s3
+ version: 3.0.0 # Update to 3.1.0
+
+# Test 6: Indentation
+spec:
+ sources:
+ - component: vpc
+ version: 1.0.0
+ targets:
+ - target: components/vpc # Nested - preserve indent
+```
+
+## Command Structure
+
+### `atmos vendor update`
+
+```bash
+atmos vendor update [flags]
+```
+
+**Flags:**
+- `--check` - Dry-run mode, show what would be updated without making changes
+- `--pull` - Update version references AND pull the new components
+- `--component ` / `-c ` - Update specific component only
+- `--tags ` - Update components with specific tags (comma-separated)
+- `--type ` / `-t ` - Component type: `terraform` or `helmfile` (default: `terraform`)
+- `--outdated` - Show only components with available updates (combined with `--check`)
+
+**Examples:**
+```bash
+# Check for updates
+atmos vendor update --check
+
+# Update all components
+atmos vendor update
+
+# Update and pull
+atmos vendor update --pull
+
+# Update specific component
+atmos vendor update --component vpc
+
+# Update by tags
+atmos vendor update --tags terraform,aws
+
+# Show only outdated
+atmos vendor update --check --outdated
+```
+
+### `atmos vendor diff`
+
+Shows Git diffs between two versions of a vendored component from the remote repository.
+
+```bash
+atmos vendor diff [flags]
+```
+
+**Flags:**
+- `--component ` / `-c ` - Component to diff (required)
+- `--from ` - Starting version/tag/commit (defaults to current version in vendor.yaml)
+- `--to ` - Ending version/tag/commit (defaults to latest)
+- `--file ` - Show diff for specific file within component
+- `--context ` - Number of context lines (default: 3)
+- `--unified` - Show unified diff format (default: true)
+- `--no-color` - Disable color output (overrides auto-detection)
+
+**Examples:**
+```bash
+# Show diff between current version and latest (colorized if TTY)
+atmos vendor diff --component vpc
+
+# Show diff between two specific versions
+atmos vendor diff --component vpc --from 1.0.0 --to 2.0.0
+
+# Show diff for a specific file
+atmos vendor diff --component vpc --from 1.0.0 --to 2.0.0 --file main.tf
+
+# Show diff between current and a specific commit
+atmos vendor diff --component vpc --to abc1234
+
+# Disable colors (for piping or scripts)
+atmos vendor diff --component vpc --no-color
+
+# Pipe to file (colors auto-disabled)
+atmos vendor diff --component vpc > changes.diff
+```
+
+**How It Works:**
+1. Read vendor configuration to get component source URL
+2. Determine versions to compare (from vendor.yaml or flags)
+3. Use `git diff` with remote refs to show changes
+4. Apply color formatting based on output context
+
+**Color Handling:**
+Diff output is colorized **automatically** when:
+- ✅ Output is to a terminal (TTY detected via `isatty()`)
+- ✅ Terminal is not `TERM=dumb`
+- ✅ `--no-color` flag is not set
+- ✅ Global `--no-color` flag is not set (from root command)
+
+Diff output is **NOT** colorized when:
+- ❌ Output is being piped (`| less`, `> file.diff`)
+- ❌ `--no-color` flag is explicitly set
+- ❌ `TERM=dumb` environment variable
+- ❌ Not a TTY (scripting, CI/CD)
+
+**Implementation:**
+```go
+func shouldColorize(cmd *cobra.Command) bool {
+ // Check --no-color flag (command-specific or global)
+ if noColor, _ := cmd.Flags().GetBool("no-color"); noColor {
+ return false
+ }
+
+ // Check if stdout is a terminal
+ if !isatty.IsTerminal(os.Stdout.Fd()) {
+ return false
+ }
+
+ // Check for TERM=dumb
+ if os.Getenv("TERM") == "dumb" {
+ return false
+ }
+
+ return true
+}
+```
+
+**Scope:**
+- **GitHub repositories only** - leverages GitHub's native compare API
+- For other Git sources (GitLab, Bitbucket, self-hosted), returns "not implemented"
+- Not applicable to OCI registries, local files, or HTTP sources
+- Shows actual code/file changes between Git refs
+- No local clone required - uses provider's diff capabilities
+
+**Technical Implementation:**
+```bash
+# For GitHub sources, uses GitHub Compare API:
+# https://api.github.com/repos/{owner}/{repo}/compare/{from}...{to}
+
+# For non-GitHub Git sources, a temporary bare repository workflow would be used:
+# 1. Create temporary bare repository
+# 2. Fetch both refs into the temporary repo
+# 3. Run git diff between the two refs
+# Example:
+tmpdir=$(mktemp -d)
+git init --bare "$tmpdir"
+git -C "$tmpdir" fetch : :
+git -C "$tmpdir" diff [-- ]
+rm -rf "$tmpdir"
+```
+
+**Output Format:**
+```diff
+Showing diff for component 'vpc' (1.0.0 → 2.0.0)
+Source: github.com/cloudposse/terraform-aws-vpc.git
+
+diff --git a/main.tf b/main.tf
+index abc1234..def5678 100644
+--- a/main.tf
++++ b/main.tf
+@@ -10,7 +10,7 @@ resource "aws_vpc" "default" {
+ cidr_block = var.cidr_block
+- enable_dns_support = true
++ enable_dns_support = var.enable_dns_support
+ enable_dns_hostnames = true
+
+ tags = merge(
+```
+
+## Configuration File Support
+
+### vendor.yaml Format
+
+```yaml
+apiVersion: atmos/v1
+kind: AtmosVendorConfig
+metadata:
+ name: example-vendor-config
+ description: Vendor configuration
+spec:
+ # Import other vendor configs
+ imports:
+ - vendor/terraform.yaml
+ - vendor/helmfile.yaml
+
+ # Vendor sources
+ sources:
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-vpc.git
+ version: 1.323.0 # Will be updated
+ targets:
+ - components/terraform/vpc
+```
+
+### component.yaml Format
+
+```yaml
+# components/terraform/vpc/component.yaml
+version: 1.323.0 # Will be updated
+source: github.com/cloudposse/terraform-aws-vpc.git
+targets:
+ - .
+```
+
+### Version Specification Format
+
+Atmos vendor configurations support multiple version specification formats:
+
+**1. Semantic Versions (Tags)**
+```yaml
+version: 1.323.0 # Specific semver tag
+version: v1.323.0 # With 'v' prefix (normalized)
+version: 2.0.0-beta.1 # Pre-release version
+```
+
+**2. Git Commit Hashes**
+```yaml
+version: abc1234 # Short hash (7+ chars)
+version: abc1234567890 # Full hash
+```
+
+**3. Git Branches**
+```yaml
+version: main # Branch name
+version: develop # Branch name
+```
+
+**4. Templated Versions (Skipped)**
+```yaml
+version: "{{.Version}}" # Template syntax - skipped
+version: "{{ atmos.Component }}" # Template syntax - skipped
+```
+
+**Version Detection Logic:**
+1. If version contains `{{` or `}}` → Skip (templated)
+2. If version matches semver pattern → Compare as semantic version
+3. If version is 7-40 hex chars → Treat as Git commit hash
+4. Otherwise → Treat as Git branch/tag name
+
+**Semantic Version Comparison:**
+- Uses [Masterminds/semver](https://github.com/Masterminds/semver) library
+- Supports standard semver format: `MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]`
+- Normalizes 'v' prefix: `v1.0.0` → `1.0.0`
+- Pre-release versions (alpha, beta, rc) are filtered by default
+- Invalid semver falls back to string comparison
+
+**Examples:**
+```yaml
+# These are considered newer than 1.0.0:
+version: 1.0.1 # Patch bump
+version: 1.1.0 # Minor bump
+version: 2.0.0 # Major bump
+
+# These are NOT considered newer (pre-release):
+version: 1.0.0-alpha.1 # Filtered out by default
+version: 1.0.0-beta # Filtered out by default
+version: 1.0.0-rc.1 # Filtered out by default
+
+# Commit hashes - always considered "newer" if different:
+version: abc1234 # Different hash = update available
+```
+
+### Import Chain Processing
+
+**Requirement:** Must track which file defines each source to update the correct file.
+
+**Example:**
+```
+vendor.yaml
+ imports:
+ - vendor/terraform.yaml
+ - vendor/helmfile.yaml
+
+vendor/terraform.yaml
+ sources:
+ - component: vpc
+ version: 1.0.0 # Defined here
+
+vendor/helmfile.yaml
+ sources:
+ - component: app
+ version: 2.0.0 # Defined here
+```
+
+**Expected behavior:**
+- When updating `vpc`, modify `vendor/terraform.yaml`
+- When updating `app`, modify `vendor/helmfile.yaml`
+- Track source file mapping during import processing
+
+**Implementation:**
+```go
+type SourceFileMapping struct {
+ Component string
+ SourceFile string // File where source was defined
+ LineNumber int // Optional: for precise updates
+}
+```
+
+## GitHub Rate Limit Handling
+
+### Requirements
+
+1. **Detect rate limits** from `git ls-remote` errors
+2. **Show clear messages** to users when rate limited
+3. **Suggest solutions**: Use SSH authentication, wait, or use GitHub token
+4. **Don't fail completely** - show what was checked before hitting limit
+
+### Rate Limit Scenarios
+
+**Unauthenticated HTTPS:**
+- 60 requests per hour per IP
+- Most restrictive
+
+**Authenticated HTTPS (with token):**
+- 5,000 requests per hour
+- Recommended approach
+
+**SSH with keys:**
+- No rate limits from GitHub API
+- Requires SSH setup
+
+### Error Handling
+
+```bash
+# Rate limit error message
+⚠ GitHub rate limit exceeded after checking 45/100 components
+
+Checked successfully:
+✓ vpc (1.0.0 → 2.0.0)
+✓ s3 (3.0.0 - up to date)
+...
+
+Remaining: 55 components not checked
+
+To avoid rate limits:
+1. Use SSH authentication: git@github.com:owner/repo.git
+2. Set GITHUB_TOKEN environment variable
+3. Wait 37 minutes for rate limit reset
+```
+
+## Terminal UI (TUI) Design
+
+### Progress Indicator
+
+Use existing Charm Bracelet (`bubbletea`) TUI pattern from `vendor pull`:
+
+```go
+type modelVendor struct {
+ packages []pkgVendor
+ index int
+ done bool
+ spinner spinner.Model
+ progress progress.Model
+ width int
+ height int
+ dryRun bool
+ atmosConfig *schema.AtmosConfiguration
+ isTTY bool
+}
+```
+
+### TUI States
+
+**1. Checking State:**
+```
+Checking for vendor updates...
+
+[=========> ] 45/100 Checking terraform-aws-ecs...
+```
+
+**2. Results State:**
+```
+✓ terraform-aws-vpc (1.323.0 → 1.372.0)
+✓ terraform-aws-s3-bucket (4.1.0 → 4.2.0)
+✓ terraform-aws-eks (2.1.0 - up to date)
+⚠ custom-module (skipped - templated version)
+```
+
+**3. Updating State (when not using --check):**
+```
+Updating version references...
+
+[=========> ] 2/3 Updating terraform-aws-s3-bucket...
+```
+
+**4. Pulling State (when using --pull):**
+```
+Pulling updated components...
+
+[=========> ] 2/3 Pulling terraform-aws-s3-bucket...
+```
+
+### Status Icons
+
+- `✓` - Update available or operation succeeded
+- `⚠` - Skipped (templated, unsupported source type)
+- `✗` - Error occurred
+- `—` - Up to date (no update available)
+
+## Technical Architecture
+
+### Package Structure
+
+```
+cmd/
+ vendor_update.go # Cobra command definition for update
+ vendor_diff.go # Cobra command definition for diff
+
+internal/exec/
+ vendor_update.go # Update command execution logic
+ vendor_diff.go # Diff command execution logic
+ vendor_version_check.go # Version checking via git ls-remote
+ vendor_yaml_updater.go # YAML update with preservation
+ vendor_filter.go # Component/tag filtering
+ vendor_git_diff.go # Git diff operations
+ vendor_model.go # TUI model (shared with pull)
+ vendor_model_helpers.go # TUI helper functions
+ vendor_interfaces.go # Interfaces for testability
+
+errors/
+ errors.go
+ - ErrVendorConfigNotFound
+ - ErrVersionCheckingNotSupported
+ - ErrNoValidCommitsFound
+ - ErrNoTagsFound
+ - ErrNoStableReleaseTags
+ - ErrCheckingForUpdates
+ - ErrComponentNotFound
+ - ErrGitDiffFailed
+ - ErrInvalidGitRef
+```
+
+### Core Functions
+
+```go
+// Vendor Update
+func ExecuteVendorUpdateCmd(cmd *cobra.Command, args []string) error
+
+// Vendor Diff
+func ExecuteVendorDiffCmd(cmd *cobra.Command, args []string) error
+func GetGitDiff(source *schema.AtmosVendorSource, fromRef, toRef string) (string, error)
+func GetGitDiffForFile(source *schema.AtmosVendorSource, fromRef, toRef, filePath string) (string, error)
+func ColorizeGitDiff(diff string, isTTY bool) string
+
+// Version checking
+func CheckForVendorUpdates(source *schema.AtmosVendorSource) (hasUpdate bool, latestVersion string, err error)
+func GetLatestGitTag(repoURL string) (string, error)
+func GetLatestGitCommit(repoURL string) (string, error)
+func CompareVersions(current, latest string) (isNewer bool, err error)
+
+// YAML updating using gopkg.in/yaml.v3
+type YAMLVersionUpdater interface {
+ UpdateVersionsInFile(filePath string, updates map[string]string) error
+ UpdateVersionsInContent(content []byte, updates map[string]string) ([]byte, error)
+}
+
+// Implementation using yaml.Node API from gopkg.in/yaml.v3
+type YAMLNodeVersionUpdater struct{}
+
+// Key methods for node traversal and updates
+func (u *YAMLNodeVersionUpdater) findComponentNodes(node *yaml.Node) map[string][]*yaml.Node
+func (u *YAMLNodeVersionUpdater) updateVersionNode(node *yaml.Node, newVersion string)
+func (u *YAMLNodeVersionUpdater) preserveNodeStyle(node *yaml.Node) // Preserve quotes, etc.
+
+// Filtering
+func FilterSources(sources []schema.AtmosVendorSource, component string, tags []string) []schema.AtmosVendorSource
+
+// Import processing with file tracking
+func ProcessVendorImportsWithFileTracking(
+ atmosConfig *schema.AtmosConfiguration,
+ vendorConfigFile string,
+ imports []string,
+ sources []schema.AtmosVendorSource,
+ visitedFiles []string,
+) ([]schema.AtmosVendorSource, map[string]string, error)
+```
+
+### Data Flow
+
+```
+1. Parse CLI flags
+ ↓
+2. Initialize Atmos config
+ ↓
+3. Read vendor.yaml (or component.yaml)
+ ↓
+4. Process imports recursively → Build source-to-file mapping
+ ↓
+5. Filter sources by component/tags
+ ↓
+6. For each source:
+ - Check if version is templated → Skip
+ - Determine source type (Git, OCI, etc.)
+ - If Git: Call git ls-remote
+ - Compare versions
+ - Record updates available
+ ↓
+7. Display results (TUI)
+ ↓
+8. If not --check:
+ - Group updates by file
+ - For each file:
+ - Load content
+ - Update versions (preserve YAML)
+ - Write back
+ ↓
+9. If --pull:
+ - Execute vendor pull command
+```
+
+## Testing Strategy
+
+### Unit Tests (80-90% coverage target)
+
+**Version Checking Tests:**
+```go
+func TestCheckForVendorUpdates(t *testing.T)
+func TestGetLatestGitTag(t *testing.T)
+func TestGetLatestGitCommit(t *testing.T)
+func TestCompareVersions(t *testing.T)
+func TestIsTemplatedVersion(t *testing.T)
+```
+
+**YAML Preservation Tests:**
+```go
+func TestYAMLNodeVersionUpdater_PreserveComments(t *testing.T)
+func TestYAMLNodeVersionUpdater_PreserveAnchors(t *testing.T)
+func TestYAMLNodeVersionUpdater_PreserveAliases(t *testing.T)
+func TestYAMLNodeVersionUpdater_PreserveMergeKeys(t *testing.T)
+func TestYAMLNodeVersionUpdater_PreserveQuotes(t *testing.T)
+func TestYAMLNodeVersionUpdater_PreserveIndentation(t *testing.T)
+func TestYAMLNodeVersionUpdater_MultipleComponents(t *testing.T)
+func TestYAMLNodeVersionUpdater_ComplexAnchors(t *testing.T)
+```
+
+**Import Processing Tests:**
+```go
+func TestProcessVendorImportsWithFileTracking(t *testing.T)
+func TestSourceFileMapping(t *testing.T)
+func TestCircularImportDetection(t *testing.T)
+```
+
+**Filtering Tests:**
+```go
+func TestFilterSources_ByComponent(t *testing.T)
+func TestFilterSources_ByTags(t *testing.T)
+func TestFilterSources_Combined(t *testing.T)
+```
+
+### Integration Tests
+
+```go
+func TestVendorUpdate_EndToEnd(t *testing.T)
+func TestVendorUpdate_WithImports(t *testing.T)
+func TestVendorUpdate_WithPull(t *testing.T)
+func TestVendorUpdate_GitHubRateLimit(t *testing.T)
+```
+
+### Test Fixtures
+
+```
+tests/test-cases/vendor-update/
+ vendor.yaml # Main config
+ vendor/terraform.yaml # Imported config
+ vendor/helmfile.yaml # Imported config
+ component.yaml # Component-level config
+ expected/
+ vendor-updated.yaml # Expected result after update
+```
+
+### Mock Strategy
+
+**Mock Git operations for unit tests:**
+```go
+type GitVersionChecker interface {
+ GetLatestTag(repoURL string) (string, error)
+ GetLatestCommit(repoURL string) (string, error)
+}
+
+// Use gomock for mocking
+//go:generate mockgen -source=vendor_version_check.go -destination=mock_version_check_test.go
+```
+
+**Use real git ls-remote for integration tests:**
+- Test against public repos (cloudposse/terraform-aws-vpc)
+- Skip if GitHub rate limit hit
+- Use test preconditions pattern
+
+## Error Handling
+
+### Error Types
+
+```go
+// In errors/errors.go
+var (
+ ErrVendorConfigNotFound = errors.New("vendor config file not found")
+ ErrVersionCheckingNotSupported = errors.New("version checking not supported for this source type")
+ ErrNoValidCommitsFound = errors.New("no valid commits found")
+ ErrNoTagsFound = errors.New("no tags found in repository")
+ ErrNoStableReleaseTags = errors.New("no stable release tags found")
+ ErrCheckingForUpdates = errors.New("error checking for updates")
+ ErrGitHubRateLimitExceeded = errors.New("GitHub API rate limit exceeded")
+ ErrInvalidGitLsRemoteOutput = errors.New("invalid git ls-remote output")
+)
+```
+
+### Error Scenarios
+
+| Scenario | Error | User Message | Recovery |
+|----------|-------|--------------|----------|
+| No vendor.yaml | `ErrVendorConfigNotFound` | "Vendor config file not found: vendor.yaml" | Check file path |
+| Git ls-remote fails | `ErrCheckingForUpdates` | "Failed to check updates for vpc: connection timeout" | Check network, try again |
+| No tags in repo | `ErrNoTagsFound` | "No version tags found in repository" | Use commit hash versioning |
+| Rate limit hit | `ErrGitHubRateLimitExceeded` | "GitHub rate limit exceeded. Use SSH or set GITHUB_TOKEN." | Wait or authenticate |
+| Invalid version format | `errUtils.ErrInvalidVersion` | "Invalid version format: 'abc'. Expected semver or commit hash." | Fix version string |
+| Templated version | (skip silently) | "⚠ vpc (skipped - templated version)" | No action needed |
+| YAML parse error | `yaml.ParseError` | "Failed to parse vendor.yaml: line 5, invalid syntax" | Fix YAML syntax |
+
+## Documentation Requirements
+
+### 1. CLI Documentation (Docusaurus)
+
+**File:** `website/docs/cli/commands/vendor/vendor-update.mdx`
+
+**Sections:**
+- Purpose note
+- Usage syntax
+- Examples (all flag combinations)
+- Arguments (none)
+- Flags (detailed descriptions)
+- Supported upstream sources table
+- How it works (step-by-step)
+- YAML preservation explanation
+- Troubleshooting
+
+**File:** `website/docs/cli/commands/vendor/vendor-diff.mdx`
+
+**Sections:**
+- Purpose note (distinct command for viewing Git diffs between component versions)
+- Usage syntax with command-specific flags (--from-version, --to-version, --context-lines, --output)
+- Examples showing diff output between vendor versions
+- See also: link to vendor-update.mdx for related version management context
+
+### 2. Blog Post
+
+**File:** `website/blog/YYYY-MM-DD-vendor-update-command.md`
+
+**Frontmatter:**
+```yaml
+---
+slug: vendor-update-command
+title: "Introducing atmos vendor update: Automated Component Version Management"
+authors: [atmos]
+tags: [feature, terraform, helmfile, vendor, automation]
+---
+```
+
+**Sections:**
+- Introduction: The problem of manual version management
+- What's New: Overview of vendor update command
+- How It Works: Step-by-step with examples
+- YAML Structure Preservation: Why this matters
+- Supported Sources: What works today, what's coming
+- Examples: Real-world use cases
+- Get Started: Try it today
+- Roadmap: Future enhancements
+
+### 3. Usage Examples
+
+**File:** `cmd/markdown/atmos_vendor_update_usage.md`
+
+```markdown
+- Check for updates without making changes
+
+\`\`\`bash
+atmos vendor update --check
+\`\`\`
+
+- Update version references in configuration files
+
+\`\`\`bash
+atmos vendor update
+\`\`\`
+
+- Update and pull new components in one command
+
+\`\`\`bash
+atmos vendor update --pull
+\`\`\`
+
+- Update specific component
+
+\`\`\`bash
+atmos vendor update --component vpc
+\`\`\`
+
+- Update components with specific tags
+
+\`\`\`bash
+atmos vendor update --tags terraform,networking
+\`\`\`
+
+- Show only components with available updates
+
+\`\`\`bash
+atmos vendor update --check --outdated
+\`\`\`
+```
+
+## Implementation Phases
+
+### Phase 1: Core Functionality (v1 - MVP)
+**Goal:** Complete vendor management with update and diff commands
+
+**Vendor Update:**
+- [ ] Command structure and flags
+- [ ] Git repository version checking (tags and commits)
+- [ ] YAML updater using `gopkg.in/yaml.v3` (preserves comments, anchors, formatting)
+- [ ] Import chain processing with file tracking
+- [ ] Component/tag filtering
+- [ ] TUI progress indicators
+- [ ] Dry-run mode (--check)
+- [ ] Update mode (modify files)
+- [ ] Pull mode (--pull)
+- [ ] Semantic version comparison with `Masterminds/semver`
+
+**Vendor Diff:**
+- [ ] Command structure and flags
+- [ ] Git diff between remote refs (no local clone needed)
+- [ ] Version to Git ref resolution
+- [ ] File-specific diff support
+- [ ] Diff colorization with TTY detection
+- [ ] Context lines configuration
+
+**Shared:**
+- [ ] Error handling for all scenarios
+- [ ] Unit tests (80-90% coverage)
+- [ ] Integration tests
+- [ ] CLI documentation for both commands
+- [ ] Blog post
+
+**Deliverables:**
+- Working `atmos vendor update` command with all flags
+- Working `atmos vendor diff` command with all flags
+- Full documentation for both commands
+- Blog post announcement
+
+### Phase 2: Enhanced Features (v1.1)
+**Goal:** Improved user experience and edge case handling
+
+- [ ] SSH to HTTPS fallback for Git operations
+- [ ] GitHub API integration for better rate limit handling
+- [ ] Pre-release tag filtering options (--include-prerelease flag)
+- [ ] Enhanced error messages with recovery suggestions
+- [ ] Diff format options (side-by-side, stats)
+- [ ] More test coverage (90%)
+
+### Phase 3: Additional Sources (v2.0)
+**Goal:** Support more source types
+
+- [ ] OCI registry support
+- [ ] S3 bucket support
+- [ ] GCS bucket support
+- [ ] GitLab-specific optimizations
+- [ ] Bitbucket-specific optimizations
+
+### Phase 4: Advanced Features (v2.1+)
+**Goal:** Power user features
+
+- [ ] Semantic version ranges (`~> 1.2.0`)
+- [ ] Lock file generation
+- [ ] Update policies (major/minor/patch)
+- [ ] Rollback capabilities
+- [ ] Breaking change detection
+- [ ] Webhook notifications
+
+## Success Metrics
+
+### Functional Metrics
+- ✅ Zero YAML corruption (anchors, comments preserved)
+- ✅ Version checking accuracy > 99%
+- ✅ 80-90% test coverage
+- ✅ All supported source types work correctly
+
+### Performance Metrics
+- Version check: < 2 seconds per component (network dependent)
+- YAML update: < 100ms per file (local operation)
+- Full workflow: < 30 seconds for 50 components
+
+### User Experience Metrics
+- Clear progress indicators during operation
+- Helpful error messages with recovery steps
+- Intuitive command structure
+- Comprehensive documentation
+
+## Security Considerations
+
+1. **No Credentials in Configs**: Never store tokens in YAML files
+2. **Use Existing Auth**: Leverage SSH keys and git credentials
+3. **No Auto Major Bumps**: Require user confirmation for major versions
+4. **Audit Trail**: All updates tracked via Git commits
+5. **Input Validation**: Validate all version strings and URLs
+6. **No Command Injection**: Sanitize all inputs to git commands
+
+## Backward Compatibility
+
+1. **Existing Configs**: All existing vendor.yaml files work unchanged
+2. **Templated Versions**: Automatically skipped, no errors
+3. **Import Chains**: Fully supported
+4. **vendor pull**: No changes to existing pull command
+5. **vendor diff**: Implemented for GitHub sources, displays file-level diffs between component versions using GitHub's Compare API; returns "not implemented" for non-GitHub sources
+
+## Migration Path
+
+### For Users Currently Running vendor pull Manually
+
+**Before:**
+```bash
+# 1. Check GitHub for new releases manually
+# 2. Edit vendor.yaml by hand
+# 3. Run vendor pull
+atmos vendor pull
+```
+
+**After:**
+```bash
+# One command does it all
+atmos vendor update --pull
+```
+
+### For Users Wanting to Check for Updates
+
+**Use:**
+```bash
+atmos vendor update --check # Check what updates are available
+```
+
+**Note:**
+- `atmos vendor diff` shows Git code changes between versions (Git-only feature)
+- For checking if updates are available, use `atmos vendor update --check`
+- For viewing code changes between versions, use `atmos vendor diff --component `
+
+## Open Questions & Decisions
+
+### Q1: What to do with complex YAML anchors the simple updater can't handle?
+
+**Decision:**
+- Use simple updater for v1 (handles 95% of cases)
+- Document limitations for complex anchor structures
+- Add AST updater in Phase 2 if user feedback requires it
+- Test extensively with real-world vendor.yaml files
+
+### Q2: Should we auto-update patch versions but require confirmation for major?
+
+**Decision:**
+- v1: All updates require explicit action (`atmos vendor update`)
+- v2: Add update policies (auto-patch, manual-major)
+- Keep it simple for initial release
+
+### Q3: How to handle version pinning with folder names?
+
+**Example:**
+```yaml
+source: github.com/example/repo.git
+version: 1.0.0
+targets:
+ - components/terraform/vpc-1.0.0 # Folder name includes version
+```
+
+**Decision:**
+- Out of scope for v1 (complexity)
+- Users manually rename folders if needed
+- Consider for v2 with folder template support
+- Document this limitation
+
+### Q4: Should --check and regular update show the same output?
+
+**Decision:**
+- Yes, same output format
+- `--check` shows what would be updated
+- Regular update shows what was updated
+- Use same TUI, different past/future tense messages
+
+### Q5: What about components with no version field?
+
+**Decision:**
+- Skip components without explicit version field
+- Show warning: "⚠ vpc (skipped - no version field)"
+- Don't add version field automatically
+- User must add version: field manually if they want updates
+
+## Implementation Checklist
+
+### Code
+
+**Vendor Update:**
+- [ ] Create `cmd/vendor_update.go` with command structure
+- [ ] Create `internal/exec/vendor_update.go` with main logic
+- [ ] Create `internal/exec/vendor_version_check.go` for Git operations using `git ls-remote`
+- [ ] Create `internal/exec/vendor_yaml_updater.go` using `gopkg.in/yaml.v3` with `yaml.Node` API
+- [ ] Create `internal/exec/vendor_filter.go` for component/tag filtering
+
+**Vendor Diff:**
+- [ ] Create `cmd/vendor_diff.go` with command structure
+- [ ] Create `internal/exec/vendor_diff.go` with main logic
+- [ ] Create `internal/exec/vendor_git_diff.go` for Git diff operations
+
+**Shared:**
+- [ ] Add error types to `errors/errors.go` (update and diff errors)
+- [ ] Update `internal/exec/vendor_model.go` to support update operations
+- [ ] Add helper functions to `internal/exec/vendor_model_helpers.go`
+- [ ] Add `Masterminds/semver` dependency for version comparison
+
+**Key Implementation Requirements:**
+- Use `gopkg.in/yaml.v3` for YAML parsing (DO NOT write custom parser)
+- Use `yaml.Node` API for structure-preserving updates
+- Preserve `yaml.Node.Style`, `yaml.Node.HeadComment`, `yaml.Node.LineComment`
+- Use `Masterminds/semver` library for semantic version comparison
+- Use `git diff` command for comparing remote refs
+- Use `mattn/go-isatty` or equivalent for TTY detection
+- Respect `--no-color` flag (command-specific and global)
+- Auto-disable colors when piping (TTY check)
+- Auto-disable colors when `TERM=dumb`
+- Colorize diff output only when appropriate (see color handling rules)
+
+### Tests
+
+**Vendor Update Tests:**
+- [ ] Unit tests for version checking
+- [ ] Unit tests for YAML preservation (all node types)
+- [ ] Unit tests for import processing
+- [ ] Unit tests for filtering
+- [ ] Integration tests for update workflows
+
+**Vendor Diff Tests:**
+- [ ] Unit tests for Git diff operations
+- [ ] Unit tests for ref resolution (version → Git ref)
+- [ ] Unit tests for diff colorization
+- [ ] Integration tests for diff workflows
+
+**Shared:**
+- [ ] Test fixtures in `tests/test-cases/vendor-update/`
+- [ ] Achieve 80-90% test coverage
+
+### Documentation
+
+**Vendor Update:**
+- [ ] CLI docs: `website/docs/cli/commands/vendor/vendor-update.mdx`
+- [ ] Usage markdown: `cmd/markdown/atmos_vendor_update_usage.md`
+
+**Vendor Diff:**
+- [ ] CLI docs: `website/docs/cli/commands/vendor/vendor-diff.mdx`
+- [ ] Usage markdown: `cmd/markdown/atmos_vendor_diff_usage.md`
+
+**Shared:**
+- [ ] Blog post: `website/blog/YYYY-MM-DD-vendor-management-commands.md`
+- [ ] Update existing vendor docs to mention new commands
+- [ ] Build website to verify no broken links
+
+### Release
+- [ ] Follow PR template (what/why/references)
+- [ ] Label PR as `minor` (new feature)
+- [ ] Ensure blog post is included (required for minor releases)
+- [ ] All CI checks pass (tests, lint, coverage)
+- [ ] Get approval from maintainers
+- [ ] Merge to main
+
+## Conclusion
+
+This PRD defines two complementary commands that together provide comprehensive vendor management:
+
+1. **`atmos vendor update`** - Automates version checking and YAML updates while preserving file integrity
+2. **`atmos vendor diff`** - Shows actual code changes between versions to assess impact
+
+By implementing both commands in Phase 1, users get a complete workflow:
+- Check what updates are available (`atmos vendor update --check`)
+- Review what changed between versions (`atmos vendor diff --component vpc`)
+- Apply updates with confidence (`atmos vendor update`)
+
+**Key Technical Decisions:**
+- Use `gopkg.in/yaml.v3` for YAML parsing (DO NOT write custom parser)
+- Use `yaml.Node` API for structure preservation
+- Use `Masterminds/semver` for version comparison
+- Use `git diff` with remote refs for showing changes
+- Git repositories only (not OCI, local, or HTTP sources)
+
+The comprehensive testing strategy (80-90% coverage) and documentation requirements ensure high quality and excellent user experience from day one. By focusing exclusively on Git repository support, we deliver a focused, robust solution for the primary use case.
diff --git a/errors/errors.go b/errors/errors.go
index d084a6715f..67aeb4673d 100644
--- a/errors/errors.go
+++ b/errors/errors.go
@@ -615,6 +615,59 @@ var (
ErrProviderNotInConfig = errors.New("provider not found in configuration")
ErrInvalidLogoutOption = errors.New("invalid logout option")
+ // Vendor update/diff errors.
+ ErrComponentNotFound = errors.New("component not found in vendor config")
+ ErrComponentFlagRequired = errors.New("--component flag is required")
+ ErrVendorConfigNotFound = errors.New("vendor config file not found")
+ ErrGitDiffFailed = errors.New("failed to execute git diff")
+ ErrInvalidGitRef = errors.New("invalid git reference")
+ ErrNoUpdatesAvailable = errors.New("no updates available")
+ ErrUnsupportedVendorSource = errors.New("unsupported vendor source type")
+ ErrGitLsRemoteFailed = errors.New("failed to execute git ls-remote")
+ ErrInvalidVersionSpec = errors.New("invalid version specification")
+ ErrVersionNotFound = errors.New("specified version not found in repository")
+ ErrYAMLUpdateFailed = errors.New("failed to update YAML file")
+ ErrYAMLPreservationFailed = errors.New("failed to preserve YAML structure")
+ ErrMultipleComponentMatches = errors.New("multiple components match the specified name")
+ ErrGitRefNotFound = errors.New("git reference not found in remote repository")
+ ErrInvalidSemanticVersion = errors.New("invalid semantic version")
+ ErrNoTagsFound = errors.New("no tags found in repository")
+ ErrNoVersionsAvailable = errors.New("no versions available")
+ ErrNoVersionsMatchConstraints = errors.New("no versions match the specified constraints")
+ ErrInvalidSemverConstraint = errors.New("invalid semantic version constraint")
+
+ // Vendor pull errors.
+ ErrVendorConfigNotExist = errors.New("the '--everything' flag is set, but vendor config file does not exist")
+ ErrValidateComponentFlag = errors.New("either '--component' or '--tags' flag can be provided, but not both")
+ ErrValidateComponentStackFlag = errors.New("either '--component' or '--stack' flag can be provided, but not both")
+ ErrValidateEverythingFlag = errors.New("'--everything' flag cannot be combined with '--component', '--stack', or '--tags' flags")
+ ErrVendorMissingComponent = errors.New("to vendor a component, the '--component' (shorthand '-c') flag needs to be specified")
+ ErrVendorComponents = errors.New("failed to vendor components")
+ ErrSourceMissing = errors.New("'source' must be specified in 'sources' in the vendor config file")
+ ErrTargetsMissing = errors.New("'targets' must be specified for the source in the vendor config file")
+ ErrVendorConfigSelfImport = errors.New("vendor config file imports itself in 'spec.imports'")
+ ErrMissingVendorConfigDefinition = errors.New("either 'spec.sources' or 'spec.imports' (or both) must be defined in the vendor config file")
+ ErrVendoringNotConfigured = errors.New("vendoring is not configured")
+ ErrPermissionDenied = errors.New("permission denied when accessing")
+ ErrEmptySources = errors.New("'spec.sources' is empty in the vendor config file and the imports")
+ ErrNoComponentsWithTags = errors.New("there are no components in the vendor config file")
+ ErrNoYAMLConfigFiles = errors.New("no YAML configuration files found in directory")
+ ErrDuplicateComponents = errors.New("duplicate component names")
+ ErrDuplicateImport = errors.New("duplicate import")
+ ErrDuplicateComponentsFound = errors.New("duplicate component")
+ ErrVendorComponentNotDefinedInConfig = errors.New("the flag '--component' is passed, but the component is not defined in any of the 'sources' in the vendor config file and the imports")
+
+ // Vendor component config errors.
+ ErrMissingMixinURI = errors.New("'uri' must be specified for each 'mixin' in the 'component.yaml' file")
+ ErrMissingMixinFilename = errors.New("'filename' must be specified for each 'mixin' in the 'component.yaml' file")
+ ErrMixinEmpty = errors.New("mixin URI cannot be empty")
+ ErrMixinNotImplemented = errors.New("local mixin installation not implemented")
+ ErrStackPullNotSupported = errors.New("command 'atmos vendor pull --stack ' is not supported yet")
+ ErrComponentConfigFileNotFound = errors.New("component vendoring config file does not exist in the folder")
+ ErrFolderNotFound = errors.New("folder does not exist")
+ ErrInvalidComponentKind = errors.New("invalid 'kind' in the component vendoring config file. Supported kinds: 'ComponentVendorConfig'")
+ ErrURIMustBeSpecified = errors.New("'uri' must be specified in 'source.uri' in the component vendoring config file")
+
// Component path resolution errors.
ErrPathNotInComponentDir = errors.New("path is not within Atmos component directories")
ErrComponentTypeMismatch = errors.New("path component type does not match command")
diff --git a/go.mod b/go.mod
index 4613d661b5..5fcac3693b 100644
--- a/go.mod
+++ b/go.mod
@@ -14,6 +14,7 @@ require (
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.3
github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0
github.com/HdrHistogram/hdrhistogram-go v1.2.0
+ github.com/Masterminds/semver/v3 v3.4.0
github.com/Masterminds/sprig/v3 v3.3.0
github.com/adrg/xdg v0.5.3
github.com/agiledragon/gomonkey/v2 v2.13.0
@@ -135,7 +136,6 @@ require (
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.54.0 // indirect
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.54.0 // indirect
github.com/Masterminds/goutils v1.1.1 // indirect
- github.com/Masterminds/semver/v3 v3.4.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/PuerkitoBio/goquery v1.9.2 // indirect
diff --git a/internal/exec/copy_glob.go b/internal/exec/copy_glob.go
index 4db7cb47b3..2a5f4eaf96 100644
--- a/internal/exec/copy_glob.go
+++ b/internal/exec/copy_glob.go
@@ -365,7 +365,7 @@ func isShallowPattern(pattern string) bool {
return strings.HasSuffix(pattern, shallowCopySuffix) && !strings.HasSuffix(pattern, "/**")
}
-// processMatch handles a single file/directory match for copyToTargetWithPatterns.
+// processMatch handles a single file/directory match for CopyToTargetWithPatterns.
func processMatch(sourceDir, targetPath, file string, shallow bool, excluded []string) error {
info, err := os.Stat(file)
if err != nil {
@@ -454,12 +454,14 @@ func handleLocalFileSource(sourceDir, finalTarget string) error {
return ComponentOrMixinsCopy(sourceDir, finalTarget)
}
-// copyToTargetWithPatterns copies the contents from sourceDir to targetPath, applying inclusion and exclusion patterns.
-func copyToTargetWithPatterns(
+// CopyToTargetWithPatterns copies the contents from sourceDir to targetPath, applying inclusion and exclusion patterns.
+func CopyToTargetWithPatterns(
sourceDir, targetPath string,
s *schema.AtmosVendorSource,
sourceIsLocalFile bool,
) error {
+ defer perf.Track(nil, "exec.CopyToTargetWithPatterns")()
+
finalTarget, err := initFinalTarget(sourceDir, targetPath, sourceIsLocalFile)
if err != nil {
return err
diff --git a/internal/exec/copy_glob_error_paths_test.go b/internal/exec/copy_glob_error_paths_test.go
index 1b63944b0e..d8faf1a26d 100644
--- a/internal/exec/copy_glob_error_paths_test.go
+++ b/internal/exec/copy_glob_error_paths_test.go
@@ -594,7 +594,7 @@ func TestCopyToTargetWithPatterns_InitFinalTargetError(t *testing.T) {
s := &schema.AtmosVendorSource{}
- err = copyToTargetWithPatterns(tempDir, targetPath, s, false)
+ err = CopyToTargetWithPatterns(tempDir, targetPath, s, false)
assert.Error(t, err)
assert.Contains(t, err.Error(), "creating target directory")
}
@@ -609,7 +609,7 @@ func TestCopyToTargetWithPatterns_ProcessIncludedPatternError(t *testing.T) {
}
// Should succeed even with no matches
- err := copyToTargetWithPatterns(tempDir, dstDir, s, false)
+ err := CopyToTargetWithPatterns(tempDir, dstDir, s, false)
assert.NoError(t, err)
}
@@ -632,7 +632,7 @@ func TestCopyToTargetWithPatterns_CopyDirRecursiveError(t *testing.T) {
require.NoError(t, err)
dstDir := filepath.Join(tempDir, "dst2")
- err = copyToTargetWithPatterns(srcDir, dstDir, s, false)
+ err = CopyToTargetWithPatterns(srcDir, dstDir, s, false)
assert.NoError(t, err)
}
diff --git a/internal/exec/copy_glob_test.go b/internal/exec/copy_glob_test.go
index 89c60d92ac..77c27eb82a 100644
--- a/internal/exec/copy_glob_test.go
+++ b/internal/exec/copy_glob_test.go
@@ -432,8 +432,8 @@ func TestCopyToTargetWithPatterns(t *testing.T) {
IncludedPaths: []string{"**/*.test"},
ExcludedPaths: []string{"**/skip.test"},
}
- if err := copyToTargetWithPatterns(srcDir, dstDir, dummy, false); err != nil {
- t.Fatalf("copyToTargetWithPatterns failed: %v", err)
+ if err := CopyToTargetWithPatterns(srcDir, dstDir, dummy, false); err != nil {
+ t.Fatalf("CopyToTargetWithPatterns failed: %v", err)
}
if _, err := os.Stat(filepath.Join(dstDir, "sub", "keep.test")); os.IsNotExist(err) {
t.Errorf("Expected keep.test to exist")
@@ -455,8 +455,8 @@ func TestCopyToTargetWithPatterns_NoPatterns(t *testing.T) {
IncludedPaths: []string{},
ExcludedPaths: []string{},
}
- if err := copyToTargetWithPatterns(srcDir, dstDir, dummy, false); err != nil {
- t.Fatalf("copyToTargetWithPatterns failed: %v", err)
+ if err := CopyToTargetWithPatterns(srcDir, dstDir, dummy, false); err != nil {
+ t.Fatalf("CopyToTargetWithPatterns failed: %v", err)
}
if _, err := os.Stat(filepath.Join(dstDir, "file.txt")); os.IsNotExist(err) {
t.Errorf("Expected file.txt to exist in destination")
@@ -479,8 +479,8 @@ func TestCopyToTargetWithPatterns_LocalFileBranch(t *testing.T) {
IncludedPaths: []string{"**/*.txt"},
ExcludedPaths: []string{},
}
- if err := copyToTargetWithPatterns(srcDir, targetFile, dummy, true); err != nil {
- t.Fatalf("copyToTargetWithPatterns failed: %v", err)
+ if err := CopyToTargetWithPatterns(srcDir, targetFile, dummy, true); err != nil {
+ t.Fatalf("CopyToTargetWithPatterns failed: %v", err)
}
if _, err := os.Stat(targetFile); os.IsNotExist(err) {
t.Errorf("Expected %q to exist in destination", targetFile)
@@ -705,8 +705,8 @@ func TestCopyToTargetWithPatterns_UseCpCopy(t *testing.T) {
IncludedPaths: []string{},
ExcludedPaths: []string{},
}
- if err := copyToTargetWithPatterns(srcDir, dstDir, dummy, false); err != nil {
- t.Fatalf("copyToTargetWithPatterns failed: %v", err)
+ if err := CopyToTargetWithPatterns(srcDir, dstDir, dummy, false); err != nil {
+ t.Fatalf("CopyToTargetWithPatterns failed: %v", err)
}
if !called {
t.Errorf("Expected cp.Copy to be called, but it was not")
@@ -983,8 +983,8 @@ func TestCopyToTargetWithPatterns_InclusionOnly(t *testing.T) {
ExcludedPaths: []string{}, // No exclusions
}
- if err := copyToTargetWithPatterns(srcDir, dstDir, dummy, false); err != nil {
- t.Fatalf("copyToTargetWithPatterns failed: %v", err)
+ if err := CopyToTargetWithPatterns(srcDir, dstDir, dummy, false); err != nil {
+ t.Fatalf("CopyToTargetWithPatterns failed: %v", err)
}
// Only .md file should be copied.
diff --git a/internal/exec/describe_affected_helpers.go b/internal/exec/describe_affected_helpers.go
index fb79e7b0cc..636ee1acba 100644
--- a/internal/exec/describe_affected_helpers.go
+++ b/internal/exec/describe_affected_helpers.go
@@ -169,15 +169,15 @@ func ExecuteDescribeAffectedWithTargetRefClone(
}
/*
- Do not use `defer removeTempDir(tempDir)` right after the temp dir is created, instead call `removeTempDir(tempDir)` at the end of the main function:
+ Do not use `defer RemoveTempDir(tempDir)` right after the temp dir is created, instead call `RemoveTempDir(tempDir)` at the end of the main function:
- On Windows, there are race conditions when using `defer` and goroutines
- - We defer removeTempDir(tempDir) right after creating the temp dir
+ - We defer RemoveTempDir(tempDir) right after creating the temp dir
- We `git clone` a repo into it
- We then start goroutines that read files from the temp dir
- - Meanwhile, when the main function exits, defer removeTempDir(...) runs
+ - Meanwhile, when the main function exits, defer RemoveTempDir(...) runs
- On Windows, open file handles in goroutines make directory deletion flaky or fail entirely (and possibly prematurely delete files while goroutines are mid-read)
*/
- removeTempDir(tempDir)
+ RemoveTempDir(tempDir)
return affected, localRepoHead, remoteRepoHead, localRepoInfo.RepoUrl, nil
}
@@ -333,15 +333,15 @@ func ExecuteDescribeAffectedWithTargetRefCheckout(
}
/*
- Do not use `defer removeTempDir(tempDir)` right after the temp dir is created, instead call `removeTempDir(tempDir)` at the end of the main function:
+ Do not use `defer RemoveTempDir(tempDir)` right after the temp dir is created, instead call `RemoveTempDir(tempDir)` at the end of the main function:
- On Windows, there are race conditions when using `defer` and goroutines
- - We defer removeTempDir(tempDir) right after creating the temp dir
+ - We defer RemoveTempDir(tempDir) right after creating the temp dir
- We `git clone` a repo into it
- We then start goroutines that read files from the temp dir
- - Meanwhile, when the main function exits, defer removeTempDir(...) runs
+ - Meanwhile, when the main function exits, defer RemoveTempDir(...) runs
- On Windows, open file handles in goroutines make directory deletion flaky or fail entirely (and possibly prematurely delete files while goroutines are mid-read)
*/
- removeTempDir(tempDir)
+ RemoveTempDir(tempDir)
return affected, localRepoHead, remoteRepoHead, localRepoInfo.RepoUrl, nil
}
diff --git a/internal/exec/docs_generate.go b/internal/exec/docs_generate.go
index 6552e1256f..d337c48d2b 100644
--- a/internal/exec/docs_generate.go
+++ b/internal/exec/docs_generate.go
@@ -139,7 +139,7 @@ func getTemplateContent(atmosConfig *schema.AtmosConfiguration, templateURL, dir
if err != nil {
return "", err
}
- defer removeTempDir(tempDir)
+ defer RemoveTempDir(tempDir)
body, err := os.ReadFile(templateFile)
if err != nil {
return "", err
@@ -241,7 +241,7 @@ func fetchAndParseYAML(atmosConfig *schema.AtmosConfiguration, pathOrURL string,
if err != nil {
return nil, err
}
- defer removeTempDir(tempDir)
+ defer RemoveTempDir(tempDir)
return parseYAML(localPath)
}
diff --git a/internal/exec/file_utils.go b/internal/exec/file_utils.go
index 691360334e..4bfb51f44b 100644
--- a/internal/exec/file_utils.go
+++ b/internal/exec/file_utils.go
@@ -21,7 +21,10 @@ const (
uncPathPrefix = "//" // UNC paths after filepath.ToSlash on Windows
)
-func removeTempDir(path string) {
+// RemoveTempDir removes a temporary directory and logs a warning on error.
+func RemoveTempDir(path string) {
+ defer perf.Track(nil, "exec.RemoveTempDir")()
+
err := os.RemoveAll(path)
if err != nil {
log.Warn(err.Error())
diff --git a/internal/exec/file_utils_test.go b/internal/exec/file_utils_test.go
index d3c0af1e98..caab1f01e4 100644
--- a/internal/exec/file_utils_test.go
+++ b/internal/exec/file_utils_test.go
@@ -346,8 +346,8 @@ func TestRemoveTempDir(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
path := tt.setup()
- // Call removeTempDir - it doesn't return anything
- removeTempDir(path)
+ // Call RemoveTempDir - it doesn't return anything
+ RemoveTempDir(path)
// Verify directory was removed
_, err := os.Stat(path)
diff --git a/internal/exec/oci_utils.go b/internal/exec/oci_utils.go
index d7ad601d18..7212ac9145 100644
--- a/internal/exec/oci_utils.go
+++ b/internal/exec/oci_utils.go
@@ -18,6 +18,7 @@ import (
errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/pkg/filesystem"
log "github.com/cloudposse/atmos/pkg/logger" // Charmbracelet structured logger
+ "github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/schema"
)
@@ -29,8 +30,10 @@ const (
var defaultOCIFileSystem = filesystem.NewOSFileSystem()
-// processOciImage processes an OCI image and extracts its layers to the specified destination directory.
-func processOciImage(atmosConfig *schema.AtmosConfiguration, imageName string, destDir string) error {
+// ProcessOciImage processes an OCI image and extracts its layers to the specified destination directory.
+func ProcessOciImage(atmosConfig *schema.AtmosConfiguration, imageName string, destDir string) error {
+ defer perf.Track(atmosConfig, "exec.ProcessOciImage")()
+
return processOciImageWithFS(atmosConfig, imageName, destDir, defaultOCIFileSystem)
}
diff --git a/internal/exec/oci_utils_test.go b/internal/exec/oci_utils_test.go
index cdab3caacd..37ba3effae 100644
--- a/internal/exec/oci_utils_test.go
+++ b/internal/exec/oci_utils_test.go
@@ -59,7 +59,7 @@ func TestProcessOciImage_InvalidReference(t *testing.T) {
atmosConfig := &schema.AtmosConfiguration{}
// Test with invalid image reference.
- err := processOciImage(atmosConfig, "invalid::image//name", "/tmp/dest")
+ err := ProcessOciImage(atmosConfig, "invalid::image//name", "/tmp/dest")
assert.Error(t, err)
assert.True(t, errors.Is(err, errUtils.ErrInvalidImageReference), "Expected ErrInvalidImageReference, got: %v", err)
@@ -168,24 +168,24 @@ func TestRemoveTempDir_OCIUtils(t *testing.T) {
assert.NoError(t, err)
// Remove the directory.
- removeTempDir(tempDir)
+ RemoveTempDir(tempDir)
// Verify directory was removed.
_, err = os.Stat(tempDir)
assert.True(t, os.IsNotExist(err))
}
-// TestRemoveTempDir_NonExistent tests removeTempDir with non-existent directory.
+// TestRemoveTempDir_NonExistent tests RemoveTempDir with non-existent directory.
func TestRemoveTempDir_NonExistent(t *testing.T) {
// This should not panic when removing a non-existent directory.
// Use defer/recover to verify no panic occurs.
defer func() {
if r := recover(); r != nil {
- t.Errorf("removeTempDir panicked on non-existent directory: %v", r)
+ t.Errorf("RemoveTempDir panicked on non-existent directory: %v", r)
}
}()
- removeTempDir("/nonexistent/directory/path")
+ RemoveTempDir("/nonexistent/directory/path")
// Test passes if no panic occurs.
assert.True(t, true, "Function executed without panic on non-existent directory")
diff --git a/internal/exec/template_processing_test.go b/internal/exec/template_processing_test.go
index 631c8eda5b..99d09e78e7 100644
--- a/internal/exec/template_processing_test.go
+++ b/internal/exec/template_processing_test.go
@@ -319,16 +319,16 @@ timestamp: {{ now | date "2006-01-02" }}`
// TestGetSprigFuncMap_CachingBehavior tests that Sprig function map caching works correctly.
// This validates P7.7.2 optimization: cached Sprig function maps produce consistent results.
func TestGetSprigFuncMap_CachingBehavior(t *testing.T) {
- // Call getSprigFuncMap multiple times
- funcMap1 := getSprigFuncMap()
+ // Call GetSprigFuncMap multiple times
+ funcMap1 := GetSprigFuncMap()
require.NotNil(t, funcMap1)
require.NotEmpty(t, funcMap1)
- funcMap2 := getSprigFuncMap()
+ funcMap2 := GetSprigFuncMap()
require.NotNil(t, funcMap2)
require.NotEmpty(t, funcMap2)
- funcMap3 := getSprigFuncMap()
+ funcMap3 := GetSprigFuncMap()
require.NotNil(t, funcMap3)
require.NotEmpty(t, funcMap3)
@@ -376,7 +376,7 @@ func TestGetSprigFuncMap_Concurrent(t *testing.T) {
<-start
// Get cached function map
- funcMap := getSprigFuncMap()
+ funcMap := GetSprigFuncMap()
if funcMap == nil {
errors <- assert.AnError
return
@@ -470,11 +470,11 @@ func TestGetSprigFuncMap_ConcurrentTemplateProcessing(t *testing.T) {
// This demonstrates P7.7.2 optimization: after first call, subsequent calls have zero overhead.
func BenchmarkGetSprigFuncMap(b *testing.B) {
// First call to initialize cache
- _ = getSprigFuncMap()
+ _ = GetSprigFuncMap()
b.ResetTimer()
for i := 0; i < b.N; i++ {
- _ = getSprigFuncMap()
+ _ = GetSprigFuncMap()
}
}
diff --git a/internal/exec/template_utils.go b/internal/exec/template_utils.go
index 45f75bbe03..fe90e4fcc9 100644
--- a/internal/exec/template_utils.go
+++ b/internal/exec/template_utils.go
@@ -38,11 +38,13 @@ const (
logKeyTemplate = "template"
)
-// getSprigFuncMap returns a cached copy of the sprig function map.
+// GetSprigFuncMap returns a cached copy of the sprig function map.
// Sprig function maps are expensive to create (173MB+ allocations) and immutable,
// so we cache and reuse them across template operations.
// This optimization reduces heap allocations by ~3.76% (173MB) per profile run.
-func getSprigFuncMap() template.FuncMap {
+func GetSprigFuncMap() template.FuncMap {
+ defer perf.Track(nil, "exec.GetSprigFuncMap")()
+
sprigFuncMapCacheOnce.Do(func() {
sprigFuncMapCache = sprig.FuncMap()
})
@@ -67,7 +69,7 @@ func ProcessTmpl(
if cfg == nil {
cfg = &schema.AtmosConfiguration{}
}
- funcs := lo.Assign(gomplate.CreateFuncs(ctx, &d), getSprigFuncMap(), FuncMap(cfg, &schema.ConfigAndStacksInfo{}, ctx, &d))
+ funcs := lo.Assign(gomplate.CreateFuncs(ctx, &d), GetSprigFuncMap(), FuncMap(cfg, &schema.ConfigAndStacksInfo{}, ctx, &d))
t, err := template.New(tmplName).Funcs(funcs).Parse(tmplValue)
if err != nil {
@@ -180,7 +182,7 @@ func ProcessTmplWithDatasources(
// Sprig functions
if atmosConfig.Templates.Settings.Sprig.Enabled {
- funcs = lo.Assign(funcs, getSprigFuncMap())
+ funcs = lo.Assign(funcs, GetSprigFuncMap())
}
// Atmos functions
diff --git a/internal/exec/vendor.go b/internal/exec/vendor.go
deleted file mode 100644
index 1cf0d27afe..0000000000
--- a/internal/exec/vendor.go
+++ /dev/null
@@ -1,201 +0,0 @@
-package exec
-
-import (
- "fmt"
- "strings"
-
- "github.com/pkg/errors"
- "github.com/spf13/cobra"
- "github.com/spf13/pflag"
-
- cfg "github.com/cloudposse/atmos/pkg/config"
- "github.com/cloudposse/atmos/pkg/perf"
- "github.com/cloudposse/atmos/pkg/schema"
-)
-
-var (
- ErrVendorConfigNotExist = errors.New("the '--everything' flag is set, but vendor config file does not exist")
- ErrExecuteVendorDiffCmd = errors.New("'atmos vendor diff' is not implemented yet")
- ErrValidateComponentFlag = errors.New("either '--component' or '--tags' flag can be provided, but not both")
- ErrValidateComponentStackFlag = errors.New("either '--component' or '--stack' flag can be provided, but not both")
- ErrValidateEverythingFlag = errors.New("'--everything' flag cannot be combined with '--component', '--stack', or '--tags' flags")
- ErrMissingComponent = errors.New("to vendor a component, the '--component' (shorthand '-c') flag needs to be specified.\n" +
- "Example: atmos vendor pull -c ")
-)
-
-// ExecuteVendorPullCmd executes `vendor pull` commands.
-func ExecuteVendorPullCmd(cmd *cobra.Command, args []string) error {
- defer perf.Track(nil, "exec.ExecuteVendorPullCmd")()
-
- return ExecuteVendorPullCommand(cmd, args)
-}
-
-// ExecuteVendorDiffCmd executes `vendor diff` commands.
-func ExecuteVendorDiffCmd(cmd *cobra.Command, args []string) error {
- return ErrExecuteVendorDiffCmd
-}
-
-type VendorFlags struct {
- DryRun bool
- Component string
- Stack string
- Tags []string
- Everything bool
- ComponentType string
-}
-
-// ExecuteVendorPullCommand executes `atmos vendor` commands.
-func ExecuteVendorPullCommand(cmd *cobra.Command, args []string) error {
- defer perf.Track(nil, "exec.ExecuteVendorPullCommand")()
-
- info, err := ProcessCommandLineArgs("terraform", cmd, args, nil)
- if err != nil {
- return err
- }
-
- flags := cmd.Flags()
- processStacks := flags.Changed("stack")
-
- atmosConfig, err := cfg.InitCliConfig(info, processStacks)
- if err != nil {
- return fmt.Errorf("failed to initialize CLI config: %w", err)
- }
-
- vendorFlags, err := parseVendorFlags(flags)
- if err != nil {
- return err
- }
-
- if err := validateVendorFlags(&vendorFlags); err != nil {
- return err
- }
-
- if vendorFlags.Stack != "" {
- return ExecuteStackVendorInternal(vendorFlags.Stack, vendorFlags.DryRun)
- }
-
- return handleVendorConfig(&atmosConfig, &vendorFlags, args)
-}
-
-func parseVendorFlags(flags *pflag.FlagSet) (VendorFlags, error) {
- vendorFlags := VendorFlags{}
- var err error
-
- if vendorFlags.DryRun, err = flags.GetBool("dry-run"); err != nil {
- return vendorFlags, err
- }
-
- if vendorFlags.Component, err = flags.GetString("component"); err != nil {
- return vendorFlags, err
- }
-
- if vendorFlags.Stack, err = flags.GetString("stack"); err != nil {
- return vendorFlags, err
- }
-
- tagsCsv, err := flags.GetString("tags")
- if err != nil {
- return vendorFlags, err
- }
- if tagsCsv != "" {
- vendorFlags.Tags = strings.Split(tagsCsv, ",")
- }
-
- if vendorFlags.Everything, err = flags.GetBool("everything"); err != nil {
- return vendorFlags, err
- }
-
- // Set default for 'everything' if no specific flags are provided
- setDefaultEverythingFlag(flags, &vendorFlags)
-
- // Handle 'type' flag only if it exists
- if flags.Lookup("type") != nil {
- if vendorFlags.ComponentType, err = flags.GetString("type"); err != nil {
- return vendorFlags, err
- }
- }
-
- return vendorFlags, nil
-}
-
-// Helper function to set the default for 'everything' if no specific flags are provided.
-func setDefaultEverythingFlag(flags *pflag.FlagSet, vendorFlags *VendorFlags) {
- if !vendorFlags.Everything && !flags.Changed("everything") &&
- vendorFlags.Component == "" && vendorFlags.Stack == "" && len(vendorFlags.Tags) == 0 {
- vendorFlags.Everything = true
- }
-}
-
-func validateVendorFlags(flg *VendorFlags) error {
- if flg.Component != "" && flg.Stack != "" {
- return ErrValidateComponentStackFlag
- }
-
- if flg.Component != "" && len(flg.Tags) > 0 {
- return ErrValidateComponentFlag
- }
-
- if flg.Everything && (flg.Component != "" || flg.Stack != "" || len(flg.Tags) > 0) {
- return ErrValidateEverythingFlag
- }
-
- return nil
-}
-
-func handleVendorConfig(atmosConfig *schema.AtmosConfiguration, flg *VendorFlags, args []string) error {
- vendorConfig, vendorConfigExists, foundVendorConfigFile, err := ReadAndProcessVendorConfigFile(
- atmosConfig,
- cfg.AtmosVendorConfigFileName,
- true,
- )
- if err != nil {
- return err
- }
- if !vendorConfigExists && flg.Everything {
- return fmt.Errorf("%w: %s", ErrVendorConfigNotExist, cfg.AtmosVendorConfigFileName)
- }
- if vendorConfigExists {
- return ExecuteAtmosVendorInternal(&executeVendorOptions{
- vendorConfigFileName: foundVendorConfigFile,
- dryRun: flg.DryRun,
- atmosConfig: atmosConfig,
- atmosVendorSpec: vendorConfig.Spec,
- component: flg.Component,
- tags: flg.Tags,
- })
- }
-
- if flg.Component != "" {
- return handleComponentVendor(atmosConfig, flg)
- }
-
- if len(args) > 0 {
- q := fmt.Sprintf("Did you mean 'atmos vendor pull -c %s'?", args[0])
- return fmt.Errorf("%w\n%s", ErrMissingComponent, q)
- }
- return ErrMissingComponent
-}
-
-func handleComponentVendor(atmosConfig *schema.AtmosConfiguration, flg *VendorFlags) error {
- componentType := flg.ComponentType
- if componentType == "" {
- componentType = "terraform"
- }
-
- config, path, err := ReadAndProcessComponentVendorConfigFile(
- atmosConfig,
- flg.Component,
- componentType,
- )
- if err != nil {
- return err
- }
-
- return ExecuteComponentVendorInternal(
- atmosConfig,
- &config.Spec,
- flg.Component,
- path,
- flg.DryRun,
- )
-}
diff --git a/pkg/datafetcher/schema/vendor/package/1.0.json b/pkg/datafetcher/schema/vendor/package/1.0.json
index 809056d7b4..20da7b5a56 100644
--- a/pkg/datafetcher/schema/vendor/package/1.0.json
+++ b/pkg/datafetcher/schema/vendor/package/1.0.json
@@ -66,6 +66,27 @@
"type": "string",
"description": "Version of the component to fetch"
},
+ "constraints": {
+ "type": "object",
+ "description": "Version constraints for vendor updates",
+ "properties": {
+ "version": {
+ "type": "string",
+ "description": "Semantic version constraint (e.g., ^1.0.0, ~1.2.0, >=1.0.0 <2.0.0)"
+ },
+ "excluded_versions": {
+ "type": "array",
+ "description": "List of versions to exclude (supports wildcards like 1.5.*)",
+ "items": {
+ "type": "string"
+ }
+ },
+ "no_prereleases": {
+ "type": "boolean",
+ "description": "If true, exclude pre-release versions (alpha, beta, rc)"
+ }
+ }
+ },
"targets": {
"type": "array",
"description": "List of target paths where the component will be vendored",
diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go
index 0888f4e35c..9fef4cb8ad 100644
--- a/pkg/schema/schema.go
+++ b/pkg/schema/schema.go
@@ -921,14 +921,22 @@ type ConfigSources map[string]map[string]ConfigSourcesItem
// Atmos vendoring (`vendor.yaml` file)
type AtmosVendorSource struct {
- Component string `yaml:"component" json:"component" mapstructure:"component"`
- Source string `yaml:"source" json:"source" mapstructure:"source"`
- Version string `yaml:"version" json:"version" mapstructure:"version"`
- File string `yaml:"file" json:"file" mapstructure:"file"`
- Targets []string `yaml:"targets" json:"targets" mapstructure:"targets"`
- IncludedPaths []string `yaml:"included_paths,omitempty" json:"included_paths,omitempty" mapstructure:"included_paths"`
- ExcludedPaths []string `yaml:"excluded_paths,omitempty" json:"excluded_paths,omitempty" mapstructure:"excluded_paths"`
- Tags []string `yaml:"tags" json:"tags" mapstructure:"tags"`
+ Component string `yaml:"component" json:"component" mapstructure:"component"`
+ Source string `yaml:"source" json:"source" mapstructure:"source"`
+ Version string `yaml:"version" json:"version" mapstructure:"version"`
+ Constraints *VendorConstraints `yaml:"constraints,omitempty" json:"constraints,omitempty" mapstructure:"constraints"`
+ File string `yaml:"file" json:"file" mapstructure:"file"`
+ Targets []string `yaml:"targets" json:"targets" mapstructure:"targets"`
+ IncludedPaths []string `yaml:"included_paths,omitempty" json:"included_paths,omitempty" mapstructure:"included_paths"`
+ ExcludedPaths []string `yaml:"excluded_paths,omitempty" json:"excluded_paths,omitempty" mapstructure:"excluded_paths"`
+ Tags []string `yaml:"tags" json:"tags" mapstructure:"tags"`
+}
+
+// VendorConstraints defines version constraints for vendor updates.
+type VendorConstraints struct {
+ Version string `yaml:"version,omitempty" json:"version,omitempty" mapstructure:"version"`
+ ExcludedVersions []string `yaml:"excluded_versions,omitempty" json:"excluded_versions,omitempty" mapstructure:"excluded_versions"`
+ NoPrereleases bool `yaml:"no_prereleases,omitempty" json:"no_prereleases,omitempty" mapstructure:"no_prereleases"`
}
type AtmosVendorSpec struct {
diff --git a/pkg/vender/component_vendor_test.go b/pkg/vender/component_vendor_test.go
index 406ba408a3..d7b71bebe8 100644
--- a/pkg/vender/component_vendor_test.go
+++ b/pkg/vender/component_vendor_test.go
@@ -7,9 +7,9 @@ import (
"github.com/stretchr/testify/assert"
- e "github.com/cloudposse/atmos/internal/exec"
cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/schema"
+ e "github.com/cloudposse/atmos/pkg/vendoring"
"github.com/cloudposse/atmos/tests"
)
diff --git a/pkg/vender/vendor_config_test.go b/pkg/vender/vendor_config_test.go
index 24a6b77052..eac966933c 100644
--- a/pkg/vender/vendor_config_test.go
+++ b/pkg/vender/vendor_config_test.go
@@ -9,8 +9,8 @@ import (
"github.com/stretchr/testify/assert"
- e "github.com/cloudposse/atmos/internal/exec"
"github.com/cloudposse/atmos/pkg/schema"
+ e "github.com/cloudposse/atmos/pkg/vendoring"
)
func TestVendorConfigScenarios(t *testing.T) {
diff --git a/internal/exec/vendor_component_utils.go b/pkg/vendoring/component_utils.go
similarity index 88%
rename from internal/exec/vendor_component_utils.go
rename to pkg/vendoring/component_utils.go
index 824bc83104..1255b9d40d 100644
--- a/internal/exec/vendor_component_utils.go
+++ b/pkg/vendoring/component_utils.go
@@ -1,9 +1,8 @@
-package exec
+package vendoring
import (
"bytes"
"context"
- "errors"
"fmt"
"net/url"
"os"
@@ -12,34 +11,22 @@ import (
"text/template"
"time"
- "github.com/cloudposse/atmos/pkg/perf"
-
tea "github.com/charmbracelet/bubbletea"
"github.com/hairyhenderson/gomplate/v3"
"github.com/jfrog/jfrog-client-go/utils/log"
cp "github.com/otiai10/copy"
errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/internal/exec"
cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/downloader"
+ "github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/schema"
u "github.com/cloudposse/atmos/pkg/utils"
)
const ociScheme = "oci://"
-var (
- ErrMissingMixinURI = errors.New("'uri' must be specified for each 'mixin' in the 'component.yaml' file")
- ErrMissingMixinFilename = errors.New("'filename' must be specified for each 'mixin' in the 'component.yaml' file")
- ErrMixinEmpty = errors.New("mixin URI cannot be empty")
- ErrMixinNotImplemented = errors.New("local mixin installation not implemented")
- ErrStackPullNotSupported = errors.New("command 'atmos vendor pull --stack ' is not supported yet")
- ErrComponentConfigFileNotFound = errors.New("component vendoring config file does not exist in the folder")
- ErrFolderNotFound = errors.New("folder does not exist")
- ErrInvalidComponentKind = errors.New("invalid 'kind' in the component vendoring config file. Supported kinds: 'ComponentVendorConfig'")
- ErrUriMustSpecified = errors.New("'uri' must be specified in 'source.uri' in the component vendoring config file")
-)
-
type ComponentSkipFunc func(os.FileInfo, string, string) (bool, error)
// findComponentConfigFile identifies the component vendoring config file (`component.yaml` or `component.yml`).
@@ -52,7 +39,7 @@ func findComponentConfigFile(basePath, fileName string) (string, error) {
return configFilePath, nil
}
}
- return "", fmt.Errorf("%w:%s", ErrComponentConfigFileNotFound, basePath)
+ return "", fmt.Errorf("%w:%s", errUtils.ErrComponentConfigFileNotFound, basePath)
}
// ReadAndProcessComponentVendorConfigFile reads and processes the component vendoring config file `component.yaml`.
@@ -85,7 +72,7 @@ func ReadAndProcessComponentVendorConfigFile(
}
if !dirExists {
- return componentConfig, "", fmt.Errorf("%w:%s", ErrFolderNotFound, componentPath)
+ return componentConfig, "", fmt.Errorf("%w:%s", errUtils.ErrFolderNotFound, componentPath)
}
componentConfigFile, err := findComponentConfigFile(componentPath, strings.TrimSuffix(cfg.ComponentVendorConfigFileName, ".yaml"))
@@ -104,7 +91,7 @@ func ReadAndProcessComponentVendorConfigFile(
}
if componentConfig.Kind != "ComponentVendorConfig" {
- return componentConfig, "", fmt.Errorf("%w: '%s' in file '%s'", ErrInvalidComponentKind, componentConfig.Kind, cfg.ComponentVendorConfigFileName)
+ return componentConfig, "", fmt.Errorf("%w: '%s' in file '%s'", errUtils.ErrInvalidComponentKind, componentConfig.Kind, cfg.ComponentVendorConfigFileName)
}
return componentConfig, componentPath, nil
@@ -127,7 +114,7 @@ func ExecuteStackVendorInternal(
) error {
defer perf.Track(nil, "exec.ExecuteStackVendorInternal")()
- return ErrStackPullNotSupported
+ return errUtils.ErrStackPullNotSupported
}
func copyComponentToDestination(tempDir, componentPath string, vendorComponentSpec *schema.VendorComponentSpec, sourceIsLocalFile bool, uri string) error {
@@ -153,7 +140,7 @@ func copyComponentToDestination(tempDir, componentPath string, vendorComponentSp
componentPath2 := componentPath
if sourceIsLocalFile {
if filepath.Ext(componentPath) == "" {
- componentPath2 = filepath.Join(componentPath, SanitizeFileName(uri))
+ componentPath2 = filepath.Join(componentPath, exec.SanitizeFileName(uri))
}
}
@@ -237,12 +224,12 @@ func ExecuteComponentVendorInternal(
defer perf.Track(atmosConfig, "exec.ExecuteComponentVendorInternal")()
if vendorComponentSpec.Source.Uri == "" {
- return fmt.Errorf("%w:'%s'", ErrUriMustSpecified, cfg.ComponentVendorConfigFileName)
+ return fmt.Errorf("%w:'%s'", errUtils.ErrURIMustBeSpecified, cfg.ComponentVendorConfigFileName)
}
uri := vendorComponentSpec.Source.Uri
// Parse 'uri' template
if vendorComponentSpec.Source.Version != "" {
- t, err := template.New(fmt.Sprintf("source-uri-%s", vendorComponentSpec.Source.Version)).Funcs(getSprigFuncMap()).Funcs(gomplate.CreateFuncs(context.Background(), nil)).Parse(vendorComponentSpec.Source.Uri)
+ t, err := template.New(fmt.Sprintf("source-uri-%s", vendorComponentSpec.Source.Version)).Funcs(exec.GetSprigFuncMap()).Funcs(gomplate.CreateFuncs(context.Background(), nil)).Parse(vendorComponentSpec.Source.Uri)
if err != nil {
return err
}
@@ -322,11 +309,11 @@ func processComponentMixins(vendorComponentSpec *schema.VendorComponentSpec, com
var packages []pkgComponentVendor
for _, mixin := range vendorComponentSpec.Mixins {
if mixin.Uri == "" {
- return nil, ErrMissingMixinURI
+ return nil, errUtils.ErrMissingMixinURI
}
if mixin.Filename == "" {
- return nil, ErrMissingMixinFilename
+ return nil, errUtils.ErrMissingMixinFilename
}
// Parse 'uri' template
@@ -380,7 +367,7 @@ func parseMixinURI(mixin *schema.VendorComponentMixins) (string, error) {
return mixin.Uri, nil
}
- tmpl, err := template.New("mixin-uri").Funcs(getSprigFuncMap()).Funcs(gomplate.CreateFuncs(context.Background(), nil)).Parse(mixin.Uri)
+ tmpl, err := template.New("mixin-uri").Funcs(exec.GetSprigFuncMap()).Funcs(gomplate.CreateFuncs(context.Background(), nil)).Parse(mixin.Uri)
if err != nil {
return "", err
}
@@ -459,11 +446,11 @@ func installComponent(p *pkgComponentVendor, atmosConfig *schema.AtmosConfigurat
return err
}
- defer removeTempDir(tempDir)
+ defer exec.RemoveTempDir(tempDir)
switch p.pkgType {
case pkgTypeRemote:
- tempDir = filepath.Join(tempDir, SanitizeFileName(p.uri))
+ tempDir = filepath.Join(tempDir, exec.SanitizeFileName(p.uri))
if err := downloader.NewGoGetterDownloader(atmosConfig).Fetch(p.uri, tempDir, downloader.ClientModeAny, 10*time.Minute); err != nil {
return fmt.Errorf("failed to download package %s error %w", p.name, err)
@@ -471,7 +458,7 @@ func installComponent(p *pkgComponentVendor, atmosConfig *schema.AtmosConfigurat
case pkgTypeOci:
// Download the Image from the OCI-compatible registry, extract the layers from the tarball, and write to the destination directory
- if err := processOciImage(atmosConfig, p.uri, tempDir); err != nil {
+ if err := exec.ProcessOciImage(atmosConfig, p.uri, tempDir); err != nil {
return fmt.Errorf("Failed to process OCI image %s error %w", p.name, err)
}
@@ -502,7 +489,7 @@ func handlePkgTypeLocalComponent(tempDir string, p *pkgComponentVendor) error {
tempDir2 := tempDir
if p.sourceIsLocalFile {
- tempDir2 = filepath.Join(tempDir, SanitizeFileName(p.uri))
+ tempDir2 = filepath.Join(tempDir, exec.SanitizeFileName(p.uri))
}
if err := cp.Copy(p.uri, tempDir2, copyOptions); err != nil {
@@ -517,7 +504,7 @@ func installMixin(p *pkgComponentVendor, atmosConfig *schema.AtmosConfiguration)
return fmt.Errorf("Failed to create temp directory %w", err)
}
- defer removeTempDir(tempDir)
+ defer exec.RemoveTempDir(tempDir)
switch p.pkgType {
case pkgTypeRemote:
@@ -527,17 +514,17 @@ func installMixin(p *pkgComponentVendor, atmosConfig *schema.AtmosConfiguration)
case pkgTypeOci:
// Download the Image from the OCI-compatible registry, extract the layers from the tarball, and write to the destination directory
- err = processOciImage(atmosConfig, p.uri, tempDir)
+ err = exec.ProcessOciImage(atmosConfig, p.uri, tempDir)
if err != nil {
return fmt.Errorf("failed to process OCI image %s error %w", p.name, err)
}
case pkgTypeLocal:
if p.uri == "" {
- return ErrMixinEmpty
+ return errUtils.ErrMixinEmpty
}
// Implement local mixin installation logic
- return ErrMixinNotImplemented
+ return errUtils.ErrMixinNotImplemented
default:
return fmt.Errorf("%w %s for package %s", errUtils.ErrUnknownPackageType, p.pkgType.String(), p.name)
diff --git a/pkg/vendoring/diff.go b/pkg/vendoring/diff.go
new file mode 100644
index 0000000000..6cc6b9ce4b
--- /dev/null
+++ b/pkg/vendoring/diff.go
@@ -0,0 +1,164 @@
+package vendoring
+
+import (
+ "fmt"
+ "os"
+ "strings"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ cfg "github.com/cloudposse/atmos/pkg/config"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+ "github.com/cloudposse/atmos/pkg/vendoring/version"
+)
+
+// diffFlags holds flags specific to vendor diff command.
+type diffFlags struct {
+ Component string
+ From string
+ To string
+ File string
+ Context int
+ Unified bool
+ NoColor bool
+}
+
+// Diff executes the vendor diff operation with typed params.
+func Diff(atmosConfig *schema.AtmosConfiguration, params *DiffParams) error {
+ defer perf.Track(atmosConfig, "vendor.Diff")()
+
+ // Convert params to internal diffFlags format.
+ flags := &diffFlags{
+ Component: params.Component,
+ From: params.From,
+ To: params.To,
+ File: params.File,
+ Context: params.Context,
+ Unified: params.Unified,
+ NoColor: params.NoColor,
+ }
+
+ // Validate component flag is provided.
+ if flags.Component == "" {
+ return errUtils.ErrComponentFlagRequired
+ }
+
+ // Execute vendor diff with the new Git operations interface.
+ return executeVendorDiff(atmosConfig, flags)
+}
+
+// executeVendorDiff performs the vendor diff logic.
+func executeVendorDiff(atmosConfig *schema.AtmosConfiguration, flags *diffFlags) error {
+ return executeVendorDiffWithGitOps(atmosConfig, flags, NewGitOperations())
+}
+
+// executeVendorDiffWithGitOps performs the vendor diff logic with injectable Git operations.
+// This function allows for testing with mocked Git operations.
+//
+//nolint:revive,nestif,cyclop,funlen // Complex vendor diff logic with conditional ref resolution.
+func executeVendorDiffWithGitOps(atmosConfig *schema.AtmosConfiguration, flags *diffFlags, gitOps GitOperations) error {
+ defer perf.Track(atmosConfig, "vendor.executeVendorDiffWithGitOps")()
+
+ // Determine the vendor config file path.
+ vendorConfigFileName := cfg.AtmosVendorConfigFileName
+ if atmosConfig.Vendor.BasePath != "" {
+ vendorConfigFileName = atmosConfig.Vendor.BasePath
+ }
+
+ // Read the main vendor config.
+ vendorConfig, vendorConfigExists, foundVendorConfigFile, err := ReadAndProcessVendorConfigFile(
+ atmosConfig,
+ vendorConfigFileName,
+ true,
+ )
+ if err != nil {
+ return err
+ }
+
+ if !vendorConfigExists {
+ // Try component vendor config if no main vendor config.
+ return executeComponentVendorDiff(atmosConfig, flags)
+ }
+
+ // Find the component in vendor sources.
+ var componentSource *schema.AtmosVendorSource
+ for i := range vendorConfig.Spec.Sources {
+ if vendorConfig.Spec.Sources[i].Component == flags.Component {
+ componentSource = &vendorConfig.Spec.Sources[i]
+ break
+ }
+ }
+
+ if componentSource == nil {
+ return fmt.Errorf("%w: %s in %s", errUtils.ErrComponentNotFound, flags.Component, foundVendorConfigFile)
+ }
+
+ // Verify it's a Git source.
+ if !strings.HasPrefix(componentSource.Source, "git::") &&
+ !strings.HasPrefix(componentSource.Source, "github.com/") &&
+ !strings.HasPrefix(componentSource.Source, "https://") &&
+ !strings.HasPrefix(componentSource.Source, "git@") {
+ return fmt.Errorf("%w: only Git sources are supported for diff", errUtils.ErrUnsupportedVendorSource)
+ }
+
+ // Extract Git URI from source.
+ gitURI := version.ExtractGitURI(componentSource.Source)
+
+ // Determine from/to refs.
+ fromRef := flags.From
+ if fromRef == "" {
+ // Default to current version.
+ fromRef = componentSource.Version
+ }
+
+ toRef := flags.To
+ if toRef == "" {
+ // Default to latest version using injected Git operations.
+ tags, err := gitOps.GetRemoteTags(gitURI)
+ if err != nil {
+ return fmt.Errorf("failed to get remote tags: %w", err)
+ }
+
+ if len(tags) == 0 {
+ return errUtils.ErrNoTagsFound
+ }
+
+ // Find latest semantic version.
+ _, latestTag := version.FindLatestSemVerTag(tags)
+ if latestTag == "" {
+ // No semantic versions found, use first tag.
+ toRef = tags[0]
+ } else {
+ toRef = latestTag
+ }
+ }
+
+ // Generate the diff using injected Git operations.
+ diff, err := gitOps.GetDiffBetweenRefs(atmosConfig, gitURI, fromRef, toRef, flags.Context, flags.NoColor)
+ if err != nil {
+ return err
+ }
+
+ // Output the diff.
+ if len(diff) == 0 {
+ fmt.Fprintf(os.Stderr, "No differences between %s and %s\n", fromRef, toRef)
+ return nil
+ }
+
+ _, err = os.Stdout.Write(diff)
+ return err
+}
+
+// executeComponentVendorDiff handles vendor diff for component.yaml files.
+func executeComponentVendorDiff(atmosConfig *schema.AtmosConfiguration, flags *diffFlags) error {
+ defer perf.Track(atmosConfig, "vendor.executeComponentVendorDiff")()
+
+ // TODO: Implement component vendor diff.
+ // When implemented, this should:
+ // 1. Read component.yaml from components/{type}/{component}/component.yaml.
+ // 2. Extract version and source information.
+ // 3. Call git diff operations similar to vendor.yaml handling.
+ fmt.Fprintf(os.Stderr, "Component vendor diff for component.yaml is not yet implemented for component %s\n", flags.Component)
+
+ return errUtils.ErrNotImplemented
+}
diff --git a/pkg/vendoring/diff_integration_test.go b/pkg/vendoring/diff_integration_test.go
new file mode 100644
index 0000000000..e424b512bf
--- /dev/null
+++ b/pkg/vendoring/diff_integration_test.go
@@ -0,0 +1,238 @@
+package vendoring
+
+//go:generate mockgen -source=git_interface.go -destination=mock_git_interface.go -package=vendoring
+
+import (
+ "errors"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+ "go.uber.org/mock/gomock"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/pkg/schema"
+)
+
+func TestExecuteVendorDiffWithGitOps(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ vendorYAML string
+ flags *diffFlags
+ mockSetup func(*MockGitOperations)
+ expectError bool
+ expectedErr error
+ }{
+ {
+ name: "successful diff with explicit from/to",
+ vendorYAML: `apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.0.0
+ targets:
+ - components/terraform/vpc
+`,
+ flags: &diffFlags{
+ Component: "vpc",
+ From: "v1.0.0",
+ To: "v1.2.0",
+ Context: 3,
+ NoColor: true,
+ },
+ mockSetup: func(m *MockGitOperations) {
+ m.EXPECT().
+ GetDiffBetweenRefs(gomock.Any(), "https://github.com/cloudposse/terraform-aws-components", "v1.0.0", "v1.2.0", 3, true).
+ Return([]byte("diff output"), nil)
+ },
+ expectError: false,
+ },
+ {
+ name: "diff with automatic latest version detection",
+ vendorYAML: `apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.0.0
+ targets:
+ - components/terraform/vpc
+`,
+ flags: &diffFlags{
+ Component: "vpc",
+ From: "v1.0.0",
+ To: "", // Should auto-detect latest
+ Context: 3,
+ NoColor: true,
+ },
+ mockSetup: func(m *MockGitOperations) {
+ m.EXPECT().
+ GetRemoteTags("https://github.com/cloudposse/terraform-aws-components").
+ Return([]string{"v1.0.0", "v1.1.0", "v1.2.0", "v2.0.0"}, nil)
+ m.EXPECT().
+ GetDiffBetweenRefs(gomock.Any(), "https://github.com/cloudposse/terraform-aws-components", "v1.0.0", "v2.0.0", 3, true).
+ Return([]byte("diff output"), nil)
+ },
+ expectError: false,
+ },
+ {
+ name: "component not found error",
+ vendorYAML: `apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.0.0
+ targets:
+ - components/terraform/vpc
+`,
+ flags: &diffFlags{
+ Component: "nonexistent",
+ From: "v1.0.0",
+ To: "v1.2.0",
+ },
+ mockSetup: func(m *MockGitOperations) {},
+ expectError: true,
+ expectedErr: errUtils.ErrComponentNotFound,
+ },
+ {
+ name: "unsupported source type",
+ vendorYAML: `apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ - component: vpc
+ source: oci://registry/image
+ version: 1.0.0
+ targets:
+ - components/terraform/vpc
+`,
+ flags: &diffFlags{
+ Component: "vpc",
+ From: "v1.0.0",
+ To: "v1.2.0",
+ },
+ mockSetup: func(m *MockGitOperations) {},
+ expectError: true,
+ expectedErr: errUtils.ErrUnsupportedVendorSource,
+ },
+ {
+ name: "no tags found error",
+ vendorYAML: `apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.0.0
+ targets:
+ - components/terraform/vpc
+`,
+ flags: &diffFlags{
+ Component: "vpc",
+ From: "v1.0.0",
+ To: "", // Should try to auto-detect but fail.
+ Context: 3,
+ NoColor: true,
+ },
+ mockSetup: func(m *MockGitOperations) {
+ m.EXPECT().
+ GetRemoteTags("https://github.com/cloudposse/terraform-aws-components").
+ Return([]string{}, nil) // Empty tags.
+ },
+ expectError: true,
+ expectedErr: errUtils.ErrNoTagsFound,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ t.Parallel()
+
+ // Create temp directory and vendor.yaml.
+ tempDir := t.TempDir()
+ vendorFile := filepath.Join(tempDir, "vendor.yaml")
+ err := os.WriteFile(vendorFile, []byte(tt.vendorYAML), 0o644)
+ require.NoError(t, err)
+
+ // Setup Atmos configuration.
+ atmosConfig := &schema.AtmosConfiguration{
+ Vendor: schema.Vendor{
+ BasePath: vendorFile,
+ },
+ }
+
+ // Setup mock.
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGit := NewMockGitOperations(ctrl)
+ tt.mockSetup(mockGit)
+
+ // Execute.
+ err = executeVendorDiffWithGitOps(atmosConfig, tt.flags, mockGit)
+
+ // Assert.
+ if tt.expectError {
+ assert.Error(t, err)
+ if tt.expectedErr != nil {
+ assert.True(t, errors.Is(err, tt.expectedErr))
+ }
+ } else {
+ assert.NoError(t, err)
+ }
+ })
+ }
+}
+
+func TestExecuteVendorDiffWithGitOps_DefaultFromVersion(t *testing.T) {
+ // This test verifies that when --from is not specified, it defaults to current version.
+ vendorYAML := `apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.5.0
+ targets:
+ - components/terraform/vpc
+`
+
+ tempDir := t.TempDir()
+ vendorFile := filepath.Join(tempDir, "vendor.yaml")
+ err := os.WriteFile(vendorFile, []byte(vendorYAML), 0o644)
+ require.NoError(t, err)
+
+ atmosConfig := &schema.AtmosConfiguration{
+ Vendor: schema.Vendor{
+ BasePath: vendorFile,
+ },
+ }
+
+ flags := &diffFlags{
+ Component: "vpc",
+ From: "", // Should default to 1.5.0
+ To: "v2.0.0",
+ Context: 3,
+ NoColor: true,
+ }
+
+ ctrl := gomock.NewController(t)
+ defer ctrl.Finish()
+ mockGit := NewMockGitOperations(ctrl)
+
+ // Expect the call with fromRef="1.5.0" (current version from vendor.yaml).
+ mockGit.EXPECT().
+ GetDiffBetweenRefs(gomock.Any(), "https://github.com/cloudposse/terraform-aws-components", "1.5.0", "v2.0.0", 3, true).
+ Return([]byte("diff output"), nil)
+
+ err = executeVendorDiffWithGitOps(atmosConfig, flags, mockGit)
+ assert.NoError(t, err)
+}
diff --git a/pkg/vendoring/git_diff.go b/pkg/vendoring/git_diff.go
new file mode 100644
index 0000000000..8402a78021
--- /dev/null
+++ b/pkg/vendoring/git_diff.go
@@ -0,0 +1,176 @@
+package vendoring
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "os"
+ "os/exec"
+ "regexp"
+
+ "github.com/mattn/go-isatty"
+ "github.com/spf13/viper"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+)
+
+// GitDiffOptions holds options for generating a Git diff.
+type GitDiffOptions struct {
+ GitURI string
+ FromRef string
+ ToRef string
+ FilePath string // Optional: filter to specific file
+ Context int // Number of context lines
+ Unified bool // Use unified diff format
+ NoColor bool // Disable colorization
+ OutputFile string // Optional: write to file instead of stdout
+}
+
+// buildGitDiffArgs builds the arguments for the git diff command.
+func buildGitDiffArgs(opts *GitDiffOptions, colorize bool) []string {
+ args := []string{"diff"}
+
+ // Add color option
+ if colorize {
+ args = append(args, "--color=always")
+ } else {
+ args = append(args, "--color=never")
+ }
+
+ // Add context lines
+ if opts.Context >= 0 {
+ args = append(args, fmt.Sprintf("-U%d", opts.Context))
+ }
+
+ // Add unified format if requested (this is usually the default)
+ if opts.Unified {
+ args = append(args, "--unified")
+ }
+
+ // Add the refs to compare
+ refRange := fmt.Sprintf("%s..%s", opts.FromRef, opts.ToRef)
+ args = append(args, refRange)
+
+ // Add file path filter if specified
+ if opts.FilePath != "" {
+ args = append(args, "--", opts.FilePath)
+ }
+
+ return args
+}
+
+// shouldColorizeOutput determines if output should be colorized based on:
+// - no-color flag.
+// - TERM environment variable.
+// - TTY detection.
+// - output redirection.
+func shouldColorizeOutput(noColor bool, outputFile string) bool {
+ // Explicit no-color flag
+ if noColor {
+ return false
+ }
+
+ // Writing to file
+ if outputFile != "" {
+ return false
+ }
+
+ // Check TERM environment variable
+ _ = viper.BindEnv("ATMOS_TERM", "ATMOS_TERM", "TERM")
+ term := viper.GetString("ATMOS_TERM")
+ if term == "dumb" || term == "" {
+ return false
+ }
+
+ // Check if stdout is a TTY
+ if !isatty.IsTerminal(os.Stdout.Fd()) {
+ return false
+ }
+
+ return true
+}
+
+// writeOutput writes the diff output to stdout or a file.
+func writeOutput(data []byte, outputFile string) error {
+ if outputFile != "" {
+ // Write to file
+ return os.WriteFile(outputFile, data, 0o644) //nolint:gosec,revive // Standard file permissions for generated output
+ }
+
+ // Write to stdout
+ _, err := os.Stdout.Write(data)
+ return err
+}
+
+// getGitDiffBetweenRefs is a convenience function that generates a diff for a remote Git repository.
+// It uses git's ability to diff remote refs without cloning.
+//
+//nolint:revive // Six parameters needed for Git diff configuration.
+func getGitDiffBetweenRefs(atmosConfig *schema.AtmosConfiguration, gitURI string, fromRef string, toRef string, contextLines int, noColor bool) ([]byte, error) {
+ defer perf.Track(atmosConfig, "exec.getGitDiffBetweenRefs")()
+
+ // For remote diffs, we need to use a temporary shallow clone approach
+ // or use git archive + diff, since git diff doesn't work with remote refs directly
+
+ // We'll use the approach of fetching both refs and then diffing
+ tempDir, err := os.MkdirTemp("", "atmos-vendor-diff-*")
+ if err != nil {
+ return nil, fmt.Errorf("%w: %w", errUtils.ErrCreateTempDir, err)
+ }
+ defer os.RemoveAll(tempDir)
+
+ // Initialize a bare repository
+ ctx := context.Background()
+ cmd := exec.CommandContext(ctx, "git", "init", "--bare", tempDir)
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("%w: %w", errUtils.ErrGitCommandFailed, err)
+ }
+
+ // Fetch the specific refs
+ cmd = exec.CommandContext(ctx, "git", "-C", tempDir, "fetch", "--depth=1", gitURI, fromRef+":"+fromRef, toRef+":"+toRef)
+ if err := cmd.Run(); err != nil {
+ return nil, fmt.Errorf("%w: failed to fetch refs: %w", errUtils.ErrGitCommandFailed, err)
+ }
+
+ // Now we can diff
+ args := []string{"-C", tempDir, "diff"}
+
+ // Add color if appropriate
+ if !noColor && isatty.IsTerminal(os.Stdout.Fd()) {
+ args = append(args, "--color=always")
+ } else {
+ args = append(args, "--color=never")
+ }
+
+ // Add context
+ args = append(args, fmt.Sprintf("-U%d", contextLines))
+
+ // Add refs
+ args = append(args, fromRef, toRef)
+
+ cmd = exec.CommandContext(ctx, "git", args...)
+ output, err := cmd.Output()
+ if err != nil {
+ var exitErr *exec.ExitError
+ if errors.As(err, &exitErr) {
+ // Exit code 1 means differences found (expected)
+ if exitErr.ExitCode() == 1 && len(output) > 0 {
+ return output, nil
+ }
+ return nil, fmt.Errorf("%w: %s", errUtils.ErrGitDiffFailed, string(exitErr.Stderr))
+ }
+ return nil, fmt.Errorf("%w: %w", errUtils.ErrGitDiffFailed, err)
+ }
+
+ return output, nil
+}
+
+// ansiRegex matches ANSI escape codes (SGR sequences).
+var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*m`)
+
+// stripANSICodes removes ANSI escape codes from byte data.
+func stripANSICodes(data []byte) []byte {
+ return ansiRegex.ReplaceAll(data, nil)
+}
diff --git a/pkg/vendoring/git_diff_test.go b/pkg/vendoring/git_diff_test.go
new file mode 100644
index 0000000000..d10e795e05
--- /dev/null
+++ b/pkg/vendoring/git_diff_test.go
@@ -0,0 +1,244 @@
+package vendoring
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestBuildGitDiffArgs(t *testing.T) {
+ tests := []struct {
+ name string
+ opts *GitDiffOptions
+ colorize bool
+ expectedArgs []string
+ }{
+ {
+ name: "basic diff with color",
+ opts: &GitDiffOptions{
+ FromRef: "v1.0.0",
+ ToRef: "v1.2.0",
+ Context: 3,
+ Unified: true,
+ },
+ colorize: true,
+ expectedArgs: []string{
+ "diff",
+ "--color=always",
+ "-U3",
+ "--unified",
+ "v1.0.0..v1.2.0",
+ },
+ },
+ {
+ name: "diff without color",
+ opts: &GitDiffOptions{
+ FromRef: "v1.0.0",
+ ToRef: "v1.2.0",
+ Context: 3,
+ Unified: true,
+ },
+ colorize: false,
+ expectedArgs: []string{
+ "diff",
+ "--color=never",
+ "-U3",
+ "--unified",
+ "v1.0.0..v1.2.0",
+ },
+ },
+ {
+ name: "diff with file filter",
+ opts: &GitDiffOptions{
+ FromRef: "v1.0.0",
+ ToRef: "v1.2.0",
+ Context: 5,
+ Unified: true,
+ FilePath: "variables.tf",
+ },
+ colorize: false,
+ expectedArgs: []string{
+ "diff",
+ "--color=never",
+ "-U5",
+ "--unified",
+ "v1.0.0..v1.2.0",
+ "--",
+ "variables.tf",
+ },
+ },
+ {
+ name: "diff with zero context",
+ opts: &GitDiffOptions{
+ FromRef: "abc123",
+ ToRef: "def456",
+ Context: 0,
+ Unified: false,
+ },
+ colorize: true,
+ expectedArgs: []string{
+ "diff",
+ "--color=always",
+ "-U0",
+ "abc123..def456",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ args := buildGitDiffArgs(tt.opts, tt.colorize)
+ assert.Equal(t, tt.expectedArgs, args)
+ })
+ }
+}
+
+func TestShouldColorizeOutput(t *testing.T) {
+ tests := []struct {
+ name string
+ noColor bool
+ outputFile string
+ termEnv string
+ expected bool
+ skipOnCI bool
+ }{
+ {
+ name: "no-color flag set",
+ noColor: true,
+ outputFile: "",
+ termEnv: "xterm-256color",
+ expected: false,
+ },
+ {
+ name: "output to file",
+ noColor: false,
+ outputFile: "/tmp/output.txt",
+ termEnv: "xterm-256color",
+ expected: false,
+ },
+ {
+ name: "TERM is dumb",
+ noColor: false,
+ outputFile: "",
+ termEnv: "dumb",
+ expected: false,
+ },
+ {
+ name: "TERM is empty",
+ noColor: false,
+ outputFile: "",
+ termEnv: "",
+ expected: false,
+ },
+ {
+ name: "all conditions met for colorization",
+ noColor: false,
+ outputFile: "",
+ termEnv: "xterm-256color",
+ expected: false, // Will be false in test environment (no TTY)
+ skipOnCI: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.skipOnCI && os.Getenv("CI") != "" {
+ t.Skip("Skipping TTY-dependent test in CI environment")
+ }
+
+ // Set TERM environment variable
+ if tt.termEnv != "" {
+ t.Setenv("TERM", tt.termEnv)
+ }
+
+ result := shouldColorizeOutput(tt.noColor, tt.outputFile)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestWriteOutput(t *testing.T) {
+ tests := []struct {
+ name string
+ data []byte
+ outputFile string
+ expectErr bool
+ }{
+ {
+ name: "write to file",
+ data: []byte("test output"),
+ outputFile: "output.txt",
+ expectErr: false,
+ },
+ {
+ name: "write to stdout",
+ data: []byte("test output"),
+ outputFile: "",
+ expectErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.outputFile != "" {
+ // Create temp directory for file output.
+ tempDir := t.TempDir()
+ tt.outputFile = filepath.Join(tempDir, tt.outputFile)
+ }
+
+ err := writeOutput(tt.data, tt.outputFile)
+
+ if tt.expectErr {
+ assert.Error(t, err)
+ } else {
+ require.NoError(t, err)
+
+ if tt.outputFile != "" {
+ // Verify file was written
+ content, err := os.ReadFile(tt.outputFile)
+ require.NoError(t, err)
+ assert.Equal(t, tt.data, content)
+ }
+ }
+ })
+ }
+}
+
+func TestStripANSICodes(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ {
+ name: "text with ANSI codes",
+ input: "\x1b[31mred text\x1b[0m",
+ expected: "red text",
+ },
+ {
+ name: "text without ANSI codes",
+ input: "plain text",
+ expected: "plain text",
+ },
+ {
+ name: "empty string",
+ input: "",
+ expected: "",
+ },
+ {
+ name: "multiple ANSI codes",
+ input: "\x1b[1m\x1b[31mbold red\x1b[0m\x1b[0m",
+ expected: "bold red",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := stripANSICodes([]byte(tt.input))
+ assert.Equal(t, tt.expected, string(result))
+ })
+ }
+}
diff --git a/pkg/vendoring/git_interface.go b/pkg/vendoring/git_interface.go
new file mode 100644
index 0000000000..cbf84f5788
--- /dev/null
+++ b/pkg/vendoring/git_interface.go
@@ -0,0 +1,55 @@
+package vendoring
+
+//go:generate mockgen -source=$GOFILE -destination=mock_$GOFILE -package=$GOPACKAGE
+
+import (
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+ "github.com/cloudposse/atmos/pkg/vendoring/version"
+)
+
+// GitOperations defines the interface for Git operations used by vendor commands.
+// This interface allows for mocking Git operations in tests.
+type GitOperations interface {
+ // GetRemoteTags fetches all tags from a remote Git repository.
+ GetRemoteTags(gitURI string) ([]string, error)
+
+ // CheckRef verifies that a Git reference exists in a remote repository.
+ CheckRef(gitURI string, ref string) (bool, error)
+
+ // GetDiffBetweenRefs generates a diff between two Git refs.
+ GetDiffBetweenRefs(atmosConfig *schema.AtmosConfiguration, gitURI string, fromRef string, toRef string, contextLines int, noColor bool) ([]byte, error)
+}
+
+// realGitOperations implements GitOperations using actual git commands.
+type realGitOperations struct{}
+
+// NewGitOperations creates a new GitOperations implementation.
+func NewGitOperations() GitOperations {
+ defer perf.Track(nil, "exec.NewGitOperations")()
+
+ return &realGitOperations{}
+}
+
+// GetRemoteTags implements GitOperations.GetRemoteTags.
+func (g *realGitOperations) GetRemoteTags(gitURI string) ([]string, error) {
+ defer perf.Track(nil, "exec.GetRemoteTags")()
+
+ return version.GetGitRemoteTags(gitURI)
+}
+
+// CheckRef implements GitOperations.CheckRef.
+func (g *realGitOperations) CheckRef(gitURI string, ref string) (bool, error) {
+ defer perf.Track(nil, "exec.CheckRef")()
+
+ return version.CheckGitRef(gitURI, ref)
+}
+
+// GetDiffBetweenRefs implements GitOperations.GetDiffBetweenRefs.
+//
+//nolint:revive // Six parameters needed for Git diff configuration.
+func (g *realGitOperations) GetDiffBetweenRefs(atmosConfig *schema.AtmosConfiguration, gitURI string, fromRef string, toRef string, contextLines int, noColor bool) ([]byte, error) {
+ defer perf.Track(atmosConfig, "exec.GetDiffBetweenRefs")()
+
+ return getGitDiffBetweenRefs(atmosConfig, gitURI, fromRef, toRef, contextLines, noColor)
+}
diff --git a/pkg/vendoring/mock_git_interface.go b/pkg/vendoring/mock_git_interface.go
new file mode 100644
index 0000000000..4e5b5d0b4a
--- /dev/null
+++ b/pkg/vendoring/mock_git_interface.go
@@ -0,0 +1,86 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: pkg/vendoring/git_interface.go
+//
+// Generated by this command:
+//
+// mockgen -source=pkg/vendoring/git_interface.go -destination=pkg/vendoring/mock_git_interface.go -package=vendoring
+//
+
+// Package vendoring is a generated GoMock package.
+package vendoring
+
+import (
+ reflect "reflect"
+
+ schema "github.com/cloudposse/atmos/pkg/schema"
+ gomock "go.uber.org/mock/gomock"
+)
+
+// MockGitOperations is a mock of GitOperations interface.
+type MockGitOperations struct {
+ ctrl *gomock.Controller
+ recorder *MockGitOperationsMockRecorder
+ isgomock struct{}
+}
+
+// MockGitOperationsMockRecorder is the mock recorder for MockGitOperations.
+type MockGitOperationsMockRecorder struct {
+ mock *MockGitOperations
+}
+
+// NewMockGitOperations creates a new mock instance.
+func NewMockGitOperations(ctrl *gomock.Controller) *MockGitOperations {
+ mock := &MockGitOperations{ctrl: ctrl}
+ mock.recorder = &MockGitOperationsMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockGitOperations) EXPECT() *MockGitOperationsMockRecorder {
+ return m.recorder
+}
+
+// CheckRef mocks base method.
+func (m *MockGitOperations) CheckRef(gitURI, ref string) (bool, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "CheckRef", gitURI, ref)
+ ret0, _ := ret[0].(bool)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// CheckRef indicates an expected call of CheckRef.
+func (mr *MockGitOperationsMockRecorder) CheckRef(gitURI, ref any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CheckRef", reflect.TypeOf((*MockGitOperations)(nil).CheckRef), gitURI, ref)
+}
+
+// GetDiffBetweenRefs mocks base method.
+func (m *MockGitOperations) GetDiffBetweenRefs(atmosConfig *schema.AtmosConfiguration, gitURI, fromRef, toRef string, contextLines int, noColor bool) ([]byte, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetDiffBetweenRefs", atmosConfig, gitURI, fromRef, toRef, contextLines, noColor)
+ ret0, _ := ret[0].([]byte)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetDiffBetweenRefs indicates an expected call of GetDiffBetweenRefs.
+func (mr *MockGitOperationsMockRecorder) GetDiffBetweenRefs(atmosConfig, gitURI, fromRef, toRef, contextLines, noColor any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDiffBetweenRefs", reflect.TypeOf((*MockGitOperations)(nil).GetDiffBetweenRefs), atmosConfig, gitURI, fromRef, toRef, contextLines, noColor)
+}
+
+// GetRemoteTags mocks base method.
+func (m *MockGitOperations) GetRemoteTags(gitURI string) ([]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetRemoteTags", gitURI)
+ ret0, _ := ret[0].([]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetRemoteTags indicates an expected call of GetRemoteTags.
+func (mr *MockGitOperationsMockRecorder) GetRemoteTags(gitURI any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetRemoteTags", reflect.TypeOf((*MockGitOperations)(nil).GetRemoteTags), gitURI)
+}
diff --git a/internal/exec/vendor_model.go b/pkg/vendoring/model.go
similarity index 96%
rename from internal/exec/vendor_model.go
rename to pkg/vendoring/model.go
index 43185979b4..6c97cf3063 100644
--- a/internal/exec/vendor_model.go
+++ b/pkg/vendoring/model.go
@@ -1,4 +1,4 @@
-package exec
+package vendoring
import (
"fmt"
@@ -8,19 +8,19 @@ import (
"strings"
"time"
- "github.com/cloudposse/atmos/pkg/perf"
-
"github.com/charmbracelet/bubbles/progress"
"github.com/charmbracelet/bubbles/spinner"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
- log "github.com/cloudposse/atmos/pkg/logger"
"github.com/hashicorp/go-getter"
cp "github.com/otiai10/copy"
errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/internal/exec"
"github.com/cloudposse/atmos/internal/tui/templates/term"
"github.com/cloudposse/atmos/pkg/downloader"
+ log "github.com/cloudposse/atmos/pkg/logger"
+ "github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/pkg/ui/theme"
u "github.com/cloudposse/atmos/pkg/utils"
@@ -128,7 +128,7 @@ func executeVendorModel[T pkgComponentVendor | pkgAtmosVendor](
}
if model.failedPkg > 0 {
- return fmt.Errorf("%w: %d", ErrVendorComponents, model.failedPkg)
+ return fmt.Errorf("%w: %d", errUtils.ErrVendorComponents, model.failedPkg)
}
return nil
}
@@ -356,12 +356,12 @@ func downloadAndInstall(p *pkgAtmosVendor, dryRun bool, atmosConfig *schema.Atmo
return newInstallError(err, p.name)
}
- defer removeTempDir(tempDir)
+ defer exec.RemoveTempDir(tempDir)
if err := p.installer(&tempDir, atmosConfig); err != nil {
return newInstallError(err, p.name)
}
- if err := copyToTargetWithPatterns(tempDir, p.targetPath, &p.atmosVendorSource, p.sourceIsLocalFile); err != nil {
+ if err := exec.CopyToTargetWithPatterns(tempDir, p.targetPath, &p.atmosVendorSource, p.sourceIsLocalFile); err != nil {
return newInstallError(fmt.Errorf("failed to copy package: %w", err), p.name)
}
return installedPkgMsg{
@@ -381,7 +381,7 @@ func (p *pkgAtmosVendor) installer(tempDir *string, atmosConfig *schema.AtmosCon
case pkgTypeOci:
// Process OCI images
- if err := processOciImage(atmosConfig, p.uri, *tempDir); err != nil {
+ if err := exec.ProcessOciImage(atmosConfig, p.uri, *tempDir); err != nil {
return fmt.Errorf("failed to process OCI image: %w", err)
}
@@ -393,7 +393,7 @@ func (p *pkgAtmosVendor) installer(tempDir *string, atmosConfig *schema.AtmosCon
OnSymlink: func(src string) cp.SymlinkAction { return cp.Deep },
}
if p.sourceIsLocalFile {
- *tempDir = filepath.Join(*tempDir, SanitizeFileName(p.uri))
+ *tempDir = filepath.Join(*tempDir, exec.SanitizeFileName(p.uri))
}
if err := cp.Copy(p.uri, *tempDir, copyOptions); err != nil {
return fmt.Errorf("failed to copy package: %w", err)
diff --git a/pkg/vendoring/params.go b/pkg/vendoring/params.go
new file mode 100644
index 0000000000..658bf19f12
--- /dev/null
+++ b/pkg/vendoring/params.go
@@ -0,0 +1,23 @@
+package vendoring
+
+// DiffParams contains parameters for the Diff operation.
+type DiffParams struct {
+ Component string
+ ComponentType string
+ From string
+ To string
+ File string
+ Context int
+ Unified bool
+ NoColor bool
+}
+
+// UpdateParams contains parameters for the Update operation.
+type UpdateParams struct {
+ Component string
+ ComponentType string
+ Check bool
+ Pull bool
+ Tags string
+ Outdated bool
+}
diff --git a/pkg/vendoring/pull.go b/pkg/vendoring/pull.go
new file mode 100644
index 0000000000..1728427232
--- /dev/null
+++ b/pkg/vendoring/pull.go
@@ -0,0 +1,143 @@
+package vendoring
+
+import (
+ "fmt"
+ "strings"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ cfg "github.com/cloudposse/atmos/pkg/config"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+)
+
+// PullParams contains parameters for the Pull operation.
+type PullParams struct {
+ Component string
+ Stack string
+ ComponentType string
+ DryRun bool
+ Tags string
+ Everything bool
+}
+
+// vendorFlags is the internal representation of vendor flags.
+type vendorFlags struct {
+ DryRun bool
+ Component string
+ Stack string
+ Tags []string
+ Everything bool
+ ComponentType string
+}
+
+// Pull executes the vendor pull operation with typed params.
+func Pull(atmosConfig *schema.AtmosConfiguration, params *PullParams) error {
+ defer perf.Track(atmosConfig, "vendor.Pull")()
+
+ // Convert params to internal vendorFlags format.
+ var tags []string
+ if params.Tags != "" {
+ tags = strings.Split(params.Tags, ",")
+ }
+
+ flg := &vendorFlags{
+ DryRun: params.DryRun,
+ Component: params.Component,
+ Stack: params.Stack,
+ Tags: tags,
+ Everything: params.Everything,
+ ComponentType: params.ComponentType,
+ }
+
+ // Set default for 'everything' if no specific flags are provided.
+ if !flg.Everything && flg.Component == "" && flg.Stack == "" && len(flg.Tags) == 0 {
+ flg.Everything = true
+ }
+
+ // Validate flags.
+ if err := validateVendorFlags(flg); err != nil {
+ return err
+ }
+
+ // Handle stack vendor.
+ if flg.Stack != "" {
+ return ExecuteStackVendorInternal(flg.Stack, flg.DryRun)
+ }
+
+ // Handle config-based vendor.
+ return handleVendorConfig(atmosConfig, flg, nil)
+}
+
+func validateVendorFlags(flg *vendorFlags) error {
+ if flg.Component != "" && flg.Stack != "" {
+ return errUtils.ErrValidateComponentStackFlag
+ }
+
+ if flg.Component != "" && len(flg.Tags) > 0 {
+ return errUtils.ErrValidateComponentFlag
+ }
+
+ if flg.Everything && (flg.Component != "" || flg.Stack != "" || len(flg.Tags) > 0) {
+ return errUtils.ErrValidateEverythingFlag
+ }
+
+ return nil
+}
+
+func handleVendorConfig(atmosConfig *schema.AtmosConfiguration, flg *vendorFlags, args []string) error {
+ vendorConfig, vendorConfigExists, foundVendorConfigFile, err := ReadAndProcessVendorConfigFile(
+ atmosConfig,
+ cfg.AtmosVendorConfigFileName,
+ true,
+ )
+ if err != nil {
+ return err
+ }
+ if !vendorConfigExists && flg.Everything {
+ return fmt.Errorf("%w: %s", errUtils.ErrVendorConfigNotExist, cfg.AtmosVendorConfigFileName)
+ }
+ if vendorConfigExists {
+ return ExecuteAtmosVendorInternal(&executeVendorOptions{
+ vendorConfigFileName: foundVendorConfigFile,
+ dryRun: flg.DryRun,
+ atmosConfig: atmosConfig,
+ atmosVendorSpec: vendorConfig.Spec,
+ component: flg.Component,
+ tags: flg.Tags,
+ })
+ }
+
+ if flg.Component != "" {
+ return handleComponentVendor(atmosConfig, flg)
+ }
+
+ if len(args) > 0 {
+ q := fmt.Sprintf("Did you mean 'atmos vendor pull -c %s'?", args[0])
+ return fmt.Errorf("%w\n%s", errUtils.ErrVendorMissingComponent, q)
+ }
+ return errUtils.ErrVendorMissingComponent
+}
+
+func handleComponentVendor(atmosConfig *schema.AtmosConfiguration, flg *vendorFlags) error {
+ componentType := flg.ComponentType
+ if componentType == "" {
+ componentType = "terraform"
+ }
+
+ config, path, err := ReadAndProcessComponentVendorConfigFile(
+ atmosConfig,
+ flg.Component,
+ componentType,
+ )
+ if err != nil {
+ return err
+ }
+
+ return ExecuteComponentVendorInternal(
+ atmosConfig,
+ &config.Spec,
+ flg.Component,
+ path,
+ flg.DryRun,
+ )
+}
diff --git a/internal/exec/vendor_pull_integration_test.go b/pkg/vendoring/pull_integration_test.go
similarity index 70%
rename from internal/exec/vendor_pull_integration_test.go
rename to pkg/vendoring/pull_integration_test.go
index e656bb6cd5..98ac726a6b 100644
--- a/internal/exec/vendor_pull_integration_test.go
+++ b/pkg/vendoring/pull_integration_test.go
@@ -1,14 +1,14 @@
-package exec
+package vendoring
import (
"os"
"path/filepath"
"testing"
- "github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/tests"
)
@@ -16,7 +16,7 @@ import (
// TestVendorPullBasicExecution tests basic vendor pull command execution.
// It verifies the command runs without errors using the vendor2 fixture.
func TestVendorPullBasicExecution(t *testing.T) {
- // Skip long tests in short mode (this test takes ~4 seconds due to network I/O and Git operations)
+ // Skip long tests in short mode (this test takes ~4 seconds due to network I/O and Git operations).
tests.SkipIfShort(t)
// Check for GitHub access with rate limit check.
@@ -31,25 +31,14 @@ func TestVendorPullBasicExecution(t *testing.T) {
t.Setenv("ATMOS_CLI_CONFIG_PATH", stacksPath)
t.Setenv("ATMOS_BASE_PATH", stacksPath)
- // Create command with global flags (including profile flag).
- cmd := newTestCommandWithGlobalFlags("pull")
- cmd.Short = "Pull the latest vendor configurations or dependencies"
- cmd.Long = "Pull and update vendor-specific configurations or dependencies to ensure the project has the latest required resources."
- cmd.FParseErrWhitelist = struct{ UnknownFlags bool }{UnknownFlags: false}
- cmd.Args = cobra.NoArgs
- cmd.RunE = ExecuteVendorPullCmd
-
- // Add vendor-specific flags.
- cmd.DisableFlagParsing = false
- cmd.PersistentFlags().StringP("component", "c", "", "Only vendor the specified component")
- cmd.PersistentFlags().StringP("stack", "s", "", "Only vendor the specified stack")
- cmd.PersistentFlags().StringP("type", "t", "terraform", "The type of the vendor (terraform or helmfile).")
- cmd.PersistentFlags().Bool("dry-run", false, "Simulate pulling the latest version of the specified component from the remote repository without making any changes.")
- cmd.PersistentFlags().String("tags", "", "Only vendor the components that have the specified tags")
- cmd.PersistentFlags().Bool("everything", false, "Vendor all components")
-
- // Execute the command.
- err := cmd.RunE(cmd, []string{})
+ // Initialize atmos config.
+ atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
+ require.NoError(t, err, "Failed to initialize atmos config")
+
+ // Execute Pull with default params (should vendor everything).
+ err = Pull(&atmosConfig, &PullParams{
+ Everything: true,
+ })
assert.NoError(t, err, "'atmos vendor pull' command should execute without error")
}
@@ -69,7 +58,7 @@ func TestVendorPullConfigFileProcessing(t *testing.T) {
// TestVendorPullFullWorkflow tests the complete vendor pull workflow including file verification.
// It verifies that vendor components are correctly pulled from various sources (git, file, OCI).
func TestVendorPullFullWorkflow(t *testing.T) {
- // Skip long tests in short mode (this test requires network I/O and OCI pulls)
+ // Skip long tests in short mode (this test requires network I/O and OCI pulls).
tests.SkipIfShort(t)
// Check for GitHub access with rate limit check.
@@ -85,18 +74,14 @@ func TestVendorPullFullWorkflow(t *testing.T) {
workDir := "../../tests/fixtures/scenarios/vendor"
t.Chdir(workDir)
- // Set up vendor pull command with global flags.
- cmd := newTestCommandWithGlobalFlags("pull")
-
- flags := cmd.Flags()
- flags.String("component", "", "")
- flags.String("stack", "", "")
- flags.String("tags", "", "")
- flags.Bool("dry-run", false, "")
- flags.Bool("everything", false, "")
+ // Initialize atmos config.
+ atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
+ require.NoError(t, err, "Failed to initialize atmos config")
// Test 1: Execute vendor pull and verify files are created.
- err := ExecuteVendorPullCommand(cmd, []string{})
+ err = Pull(&atmosConfig, &PullParams{
+ Everything: true,
+ })
require.NoError(t, err, "Failed to execute vendor pull command")
expectedFiles := []string{
@@ -135,20 +120,16 @@ func TestVendorPullFullWorkflow(t *testing.T) {
})
// Test 2: Dry-run flag should not fail.
- err = flags.Set("dry-run", "true")
- require.NoError(t, err, "Failed to set dry-run flag")
-
- err = ExecuteVendorPullCommand(cmd, []string{})
+ err = Pull(&atmosConfig, &PullParams{
+ Everything: true,
+ DryRun: true,
+ })
require.NoError(t, err, "Dry run should execute without error")
// Test 3: Tag filtering should work.
- err = flags.Set("dry-run", "false")
- require.NoError(t, err, "Failed to reset dry-run flag")
-
- err = flags.Set("tags", "demo")
- require.NoError(t, err, "Failed to set tags flag")
-
- err = ExecuteVendorPullCommand(cmd, []string{})
+ err = Pull(&atmosConfig, &PullParams{
+ Tags: "demo",
+ })
require.NoError(t, err, "Tag filtering should execute without error")
}
@@ -168,18 +149,14 @@ func TestVendorPullTripleSlashNormalization(t *testing.T) {
testDir := "../../tests/fixtures/scenarios/vendor-triple-slash"
t.Chdir(testDir)
- // Set up command with global flags.
- cmd := newTestCommandWithGlobalFlags("pull")
+ // Initialize atmos config.
+ atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
+ require.NoError(t, err, "Failed to initialize atmos config")
- flags := cmd.Flags()
- flags.String("component", "s3-bucket", "")
- flags.String("stack", "", "")
- flags.String("tags", "", "")
- flags.Bool("dry-run", false, "")
- flags.Bool("everything", false, "")
-
- // Execute vendor pull command.
- err := ExecuteVendorPullCommand(cmd, []string{})
+ // Execute vendor pull command with component filter.
+ err = Pull(&atmosConfig, &PullParams{
+ Component: "s3-bucket",
+ })
require.NoError(t, err, "Vendor pull command with triple-slash URI should execute without error")
// Verify target directory was created.
diff --git a/pkg/vendoring/source/git.go b/pkg/vendoring/source/git.go
new file mode 100644
index 0000000000..df39d065bf
--- /dev/null
+++ b/pkg/vendoring/source/git.go
@@ -0,0 +1,81 @@
+package source
+
+import (
+ "fmt"
+ "strings"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+ "github.com/cloudposse/atmos/pkg/vendoring/version"
+)
+
+// GenericGitProvider implements Provider for generic Git repositories.
+// This provider has limited functionality compared to GitHub provider.
+type GenericGitProvider struct{}
+
+// NewGenericGitProvider creates a new generic Git source provider.
+func NewGenericGitProvider() Provider {
+ defer perf.Track(nil, "source.NewGenericGitProvider")()
+
+ return &GenericGitProvider{}
+}
+
+// GetAvailableVersions implements Provider.GetAvailableVersions.
+func (g *GenericGitProvider) GetAvailableVersions(source string) ([]string, error) {
+ defer perf.Track(nil, "source.GenericGitProvider.GetAvailableVersions")()
+
+ gitURI := version.ExtractGitURI(source)
+ return version.GetGitRemoteTags(gitURI)
+}
+
+// VerifyVersion implements Provider.VerifyVersion.
+func (g *GenericGitProvider) VerifyVersion(source string, ver string) (bool, error) {
+ defer perf.Track(nil, "source.GenericGitProvider.VerifyVersion")()
+
+ gitURI := version.ExtractGitURI(source)
+ return version.CheckGitRef(gitURI, ver)
+}
+
+// GetDiff implements Provider.GetDiff.
+// For generic Git providers, diff functionality is not implemented.
+//
+//nolint:revive // Seven parameters needed for interface compatibility.
+func (g *GenericGitProvider) GetDiff(
+ atmosConfig *schema.AtmosConfiguration,
+ source string,
+ fromVersion string,
+ toVersion string,
+ filePath string,
+ contextLines int,
+ noColor bool,
+) ([]byte, error) {
+ defer perf.Track(atmosConfig, "source.GenericGitProvider.GetDiff")()
+
+ return nil, fmt.Errorf("%w: diff functionality is only supported for GitHub sources", errUtils.ErrNotImplemented)
+}
+
+// SupportsOperation implements Provider.SupportsOperation.
+func (g *GenericGitProvider) SupportsOperation(operation Operation) bool {
+ defer perf.Track(nil, "source.GenericGitProvider.SupportsOperation")()
+
+ switch operation {
+ case OperationListVersions, OperationVerifyVersion, OperationFetchSource:
+ return true
+ case OperationGetDiff:
+ return false // Not implemented for generic Git.
+ default:
+ return false
+ }
+}
+
+// IsGitSource checks if a source URL is a Git repository.
+func IsGitSource(source string) bool {
+ defer perf.Track(nil, "source.IsGitSource")()
+
+ return strings.HasPrefix(source, "git::") ||
+ strings.HasPrefix(source, "https://") ||
+ strings.HasPrefix(source, "http://") ||
+ strings.HasPrefix(source, "git@") ||
+ strings.HasSuffix(source, ".git")
+}
diff --git a/pkg/vendoring/source/github.go b/pkg/vendoring/source/github.go
new file mode 100644
index 0000000000..c257df94a3
--- /dev/null
+++ b/pkg/vendoring/source/github.go
@@ -0,0 +1,223 @@
+package source
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/spf13/viper"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+ "github.com/cloudposse/atmos/pkg/vendoring/version"
+)
+
+const (
+ // DefaultHTTPTimeout is the default timeout for HTTP requests to GitHub API.
+ defaultHTTPTimeout = 30 * time.Second
+)
+
+// GitHubProvider implements Provider for GitHub repositories.
+type GitHubProvider struct {
+ httpClient *http.Client
+}
+
+// NewGitHubProvider creates a new GitHub source provider.
+func NewGitHubProvider() Provider {
+ defer perf.Track(nil, "source.NewGitHubProvider")()
+
+ return &GitHubProvider{
+ httpClient: &http.Client{
+ Timeout: defaultHTTPTimeout,
+ },
+ }
+}
+
+// GetAvailableVersions implements Provider.GetAvailableVersions.
+func (g *GitHubProvider) GetAvailableVersions(source string) ([]string, error) {
+ defer perf.Track(nil, "source.GitHubProvider.GetAvailableVersions")()
+
+ // Use existing Git operations to get tags.
+ gitURI := version.ExtractGitURI(source)
+ return version.GetGitRemoteTags(gitURI)
+}
+
+// VerifyVersion implements Provider.VerifyVersion.
+func (g *GitHubProvider) VerifyVersion(source string, ver string) (bool, error) {
+ defer perf.Track(nil, "source.GitHubProvider.VerifyVersion")()
+
+ gitURI := version.ExtractGitURI(source)
+ return version.CheckGitRef(gitURI, ver)
+}
+
+// GetDiff implements Provider.GetDiff using GitHub's Compare API.
+//
+//nolint:revive // Seven parameters needed for comprehensive diff configuration.
+func (g *GitHubProvider) GetDiff(
+ atmosConfig *schema.AtmosConfiguration,
+ source string,
+ fromVersion string,
+ toVersion string,
+ filePath string,
+ contextLines int,
+ noColor bool,
+) ([]byte, error) {
+ defer perf.Track(atmosConfig, "source.GitHubProvider.GetDiff")()
+
+ // Parse GitHub owner/repo from source.
+ owner, repo, err := ParseGitHubRepo(source)
+ if err != nil {
+ return nil, err
+ }
+
+ // Use GitHub Compare API.
+ // https://docs.github.com/en/rest/commits/commits#compare-two-commits
+ // Path-escape ref names since they may contain slashes.
+ compareURL := fmt.Sprintf("https://api.github.com/repos/%s/%s/compare/%s...%s",
+ owner, repo, url.PathEscape(fromVersion), url.PathEscape(toVersion))
+
+ req, err := http.NewRequest("GET", compareURL, nil)
+ if err != nil {
+ return nil, fmt.Errorf("%w: failed to create request: %w", errUtils.ErrGitDiffFailed, err)
+ }
+
+ // Add GitHub API headers.
+ req.Header.Set("Accept", "application/vnd.github.v3.diff")
+
+ // Add authentication if available (from environment).
+ if token := GetGitHubToken(); token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+
+ resp, err := g.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("%w: failed to fetch diff from GitHub: %w", errUtils.ErrGitDiffFailed, err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ body, _ := io.ReadAll(resp.Body)
+ return nil, fmt.Errorf("%w: GitHub API returned status %d: %s",
+ errUtils.ErrGitDiffFailed, resp.StatusCode, string(body))
+ }
+
+ diff, err := io.ReadAll(resp.Body)
+ if err != nil {
+ return nil, fmt.Errorf("%w: failed to read diff: %w", errUtils.ErrGitDiffFailed, err)
+ }
+
+ // TODO: Apply file filtering if filePath is specified.
+ // TODO: Apply context line configuration if contextLines is specified.
+ // TODO: Strip ANSI codes if noColor is true.
+
+ return diff, nil
+}
+
+// SupportsOperation implements Provider.SupportsOperation.
+func (g *GitHubProvider) SupportsOperation(operation Operation) bool {
+ defer perf.Track(nil, "source.GitHubProvider.SupportsOperation")()
+
+ switch operation {
+ case OperationListVersions, OperationVerifyVersion, OperationGetDiff, OperationFetchSource:
+ return true
+ default:
+ return false
+ }
+}
+
+// ParseGitHubRepo extracts owner and repository name from a GitHub source URL.
+func ParseGitHubRepo(source string) (owner, repo string, err error) {
+ defer perf.Track(nil, "source.ParseGitHubRepo")()
+
+ // Remove common prefixes.
+ source = strings.TrimPrefix(source, "git::")
+ source = strings.TrimPrefix(source, "https://")
+ source = strings.TrimPrefix(source, "http://")
+ source = strings.TrimPrefix(source, "github.com/")
+
+ // Handle SSH format.
+ source = strings.TrimPrefix(source, "git@github.com:")
+
+ // Remove .git suffix.
+ source = strings.TrimSuffix(source, ".git")
+
+ // Remove query parameters.
+ if idx := strings.Index(source, "?"); idx != -1 {
+ source = source[:idx]
+ }
+
+ // Remove path after repo (e.g., //modules/vpc).
+ if idx := strings.Index(source, "//"); idx != -1 {
+ source = source[:idx]
+ }
+
+ // Split into owner/repo.
+ parts := strings.SplitN(source, "/", 2)
+ if len(parts) != 2 {
+ return "", "", fmt.Errorf("%w: invalid GitHub repository format: %s", errUtils.ErrParseURL, source)
+ }
+
+ return parts[0], parts[1], nil
+}
+
+// IsGitHubSource checks if a source URL is a GitHub repository.
+func IsGitHubSource(source string) bool {
+ defer perf.Track(nil, "source.IsGitHubSource")()
+
+ return strings.Contains(source, "github.com")
+}
+
+// GetGitHubToken retrieves the GitHub token from environment.
+// Checks ATMOS_GITHUB_TOKEN first (per ATMOS_ prefix convention), then GITHUB_TOKEN.
+func GetGitHubToken() string {
+ defer perf.Track(nil, "source.GetGitHubToken")()
+
+ // Use viper to check environment variables per project conventions.
+ // BindEnv maps the key to environment variables.
+ v := viper.New()
+ _ = v.BindEnv("github_token", "ATMOS_GITHUB_TOKEN", "GITHUB_TOKEN")
+ return v.GetString("github_token")
+}
+
+// GitHubRateLimitResponse represents the GitHub API rate limit response.
+type GitHubRateLimitResponse struct {
+ Resources struct {
+ Core struct {
+ Limit int `json:"limit"`
+ Remaining int `json:"remaining"`
+ Reset int64 `json:"reset"`
+ } `json:"core"`
+ } `json:"resources"`
+}
+
+// CheckGitHubRateLimit checks the current GitHub API rate limit.
+func (g *GitHubProvider) CheckGitHubRateLimit() (*GitHubRateLimitResponse, error) {
+ defer perf.Track(nil, "source.CheckGitHubRateLimit")()
+
+ req, err := http.NewRequest("GET", "https://api.github.com/rate_limit", nil)
+ if err != nil {
+ return nil, fmt.Errorf("%w: failed to create rate limit request: %w", errUtils.ErrFailedToCreateRequest, err)
+ }
+
+ if token := GetGitHubToken(); token != "" {
+ req.Header.Set("Authorization", "Bearer "+token)
+ }
+
+ resp, err := g.httpClient.Do(req)
+ if err != nil {
+ return nil, fmt.Errorf("%w: failed to execute rate limit request: %w", errUtils.ErrHTTPRequestFailed, err)
+ }
+ defer resp.Body.Close()
+
+ var rateLimit GitHubRateLimitResponse
+ if err := json.NewDecoder(resp.Body).Decode(&rateLimit); err != nil {
+ return nil, fmt.Errorf("%w: failed to decode rate limit response: %w", errUtils.ErrFailedToUnmarshalAPIResponse, err)
+ }
+
+ return &rateLimit, nil
+}
diff --git a/pkg/vendoring/source/mock_provider.go b/pkg/vendoring/source/mock_provider.go
new file mode 100644
index 0000000000..2ff5a4ba3c
--- /dev/null
+++ b/pkg/vendoring/source/mock_provider.go
@@ -0,0 +1,100 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: provider.go
+//
+// Generated by this command:
+//
+// mockgen -source=provider.go -destination=mock_provider.go -package=source
+//
+
+// Package source is a generated GoMock package.
+package source
+
+import (
+ reflect "reflect"
+
+ schema "github.com/cloudposse/atmos/pkg/schema"
+ gomock "go.uber.org/mock/gomock"
+)
+
+// MockProvider is a mock of Provider interface.
+type MockProvider struct {
+ ctrl *gomock.Controller
+ recorder *MockProviderMockRecorder
+ isgomock struct{}
+}
+
+// MockProviderMockRecorder is the mock recorder for MockProvider.
+type MockProviderMockRecorder struct {
+ mock *MockProvider
+}
+
+// NewMockProvider creates a new mock instance.
+func NewMockProvider(ctrl *gomock.Controller) *MockProvider {
+ mock := &MockProvider{ctrl: ctrl}
+ mock.recorder = &MockProviderMockRecorder{mock}
+ return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockProvider) EXPECT() *MockProviderMockRecorder {
+ return m.recorder
+}
+
+// GetAvailableVersions mocks base method.
+func (m *MockProvider) GetAvailableVersions(source string) ([]string, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetAvailableVersions", source)
+ ret0, _ := ret[0].([]string)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetAvailableVersions indicates an expected call of GetAvailableVersions.
+func (mr *MockProviderMockRecorder) GetAvailableVersions(source any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetAvailableVersions", reflect.TypeOf((*MockProvider)(nil).GetAvailableVersions), source)
+}
+
+// GetDiff mocks base method.
+func (m *MockProvider) GetDiff(atmosConfig *schema.AtmosConfiguration, source, fromVersion, toVersion, filePath string, contextLines int, noColor bool) ([]byte, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "GetDiff", atmosConfig, source, fromVersion, toVersion, filePath, contextLines, noColor)
+ ret0, _ := ret[0].([]byte)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// GetDiff indicates an expected call of GetDiff.
+func (mr *MockProviderMockRecorder) GetDiff(atmosConfig, source, fromVersion, toVersion, filePath, contextLines, noColor any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetDiff", reflect.TypeOf((*MockProvider)(nil).GetDiff), atmosConfig, source, fromVersion, toVersion, filePath, contextLines, noColor)
+}
+
+// SupportsOperation mocks base method.
+func (m *MockProvider) SupportsOperation(operation Operation) bool {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "SupportsOperation", operation)
+ ret0, _ := ret[0].(bool)
+ return ret0
+}
+
+// SupportsOperation indicates an expected call of SupportsOperation.
+func (mr *MockProviderMockRecorder) SupportsOperation(operation any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SupportsOperation", reflect.TypeOf((*MockProvider)(nil).SupportsOperation), operation)
+}
+
+// VerifyVersion mocks base method.
+func (m *MockProvider) VerifyVersion(source, version string) (bool, error) {
+ m.ctrl.T.Helper()
+ ret := m.ctrl.Call(m, "VerifyVersion", source, version)
+ ret0, _ := ret[0].(bool)
+ ret1, _ := ret[1].(error)
+ return ret0, ret1
+}
+
+// VerifyVersion indicates an expected call of VerifyVersion.
+func (mr *MockProviderMockRecorder) VerifyVersion(source, version any) *gomock.Call {
+ mr.mock.ctrl.T.Helper()
+ return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "VerifyVersion", reflect.TypeOf((*MockProvider)(nil).VerifyVersion), source, version)
+}
diff --git a/pkg/vendoring/source/provider.go b/pkg/vendoring/source/provider.go
new file mode 100644
index 0000000000..6cdb14a577
--- /dev/null
+++ b/pkg/vendoring/source/provider.go
@@ -0,0 +1,61 @@
+package source
+
+//go:generate mockgen -source=$GOFILE -destination=mock_provider.go -package=$GOPACKAGE
+
+import (
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+)
+
+// Provider defines the interface for vendor source operations.
+// This interface allows for different implementations based on the source type (GitHub, GitLab, etc.).
+type Provider interface {
+ // GetAvailableVersions fetches all available versions/tags from the source.
+ GetAvailableVersions(source string) ([]string, error)
+
+ // VerifyVersion checks if a specific version exists in the source.
+ VerifyVersion(source string, version string) (bool, error)
+
+ // GetDiff generates a diff between two versions of a component.
+ // Returns the diff output and an error if the operation is not supported or fails.
+ GetDiff(atmosConfig *schema.AtmosConfiguration, source string, fromVersion string, toVersion string, filePath string, contextLines int, noColor bool) ([]byte, error)
+
+ // SupportsOperation checks if the provider supports a specific operation.
+ SupportsOperation(operation Operation) bool
+}
+
+// Operation represents different operations a vendor source provider can support.
+type Operation string
+
+const (
+ // OperationListVersions indicates the provider can list available versions.
+ OperationListVersions Operation = "list_versions"
+
+ // OperationVerifyVersion indicates the provider can verify version existence.
+ OperationVerifyVersion Operation = "verify_version"
+
+ // OperationGetDiff indicates the provider can generate diffs between versions.
+ OperationGetDiff Operation = "get_diff"
+
+ // OperationFetchSource indicates the provider can fetch/download source code.
+ OperationFetchSource Operation = "fetch_source"
+)
+
+// GetProviderForSource returns the appropriate Provider for a given source URL.
+func GetProviderForSource(source string) Provider {
+ defer perf.Track(nil, "source.GetProviderForSource")()
+
+ // Determine provider type from source URL.
+ if IsGitHubSource(source) {
+ return NewGitHubProvider()
+ }
+
+ // For all other Git sources, return a generic Git provider
+ // (which has limited functionality compared to GitHub).
+ if IsGitSource(source) {
+ return NewGenericGitProvider()
+ }
+
+ // For non-Git sources (OCI, HTTP, local), return unsupported provider.
+ return NewUnsupportedProvider()
+}
diff --git a/pkg/vendoring/source/provider_test.go b/pkg/vendoring/source/provider_test.go
new file mode 100644
index 0000000000..31caf212d6
--- /dev/null
+++ b/pkg/vendoring/source/provider_test.go
@@ -0,0 +1,277 @@
+package source
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestGetProviderForSource(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ expectedType interface{}
+ expectedSupported Operation
+ }{
+ {
+ name: "GitHub HTTPS URL",
+ source: "https://github.com/cloudposse/terraform-aws-vpc.git",
+ expectedType: &GitHubProvider{},
+ expectedSupported: OperationGetDiff,
+ },
+ {
+ name: "GitHub shorthand",
+ source: "github.com/cloudposse/terraform-aws-vpc",
+ expectedType: &GitHubProvider{},
+ expectedSupported: OperationGetDiff,
+ },
+ {
+ name: "GitHub SSH URL",
+ source: "git@github.com:cloudposse/terraform-aws-vpc.git",
+ expectedType: &GitHubProvider{},
+ expectedSupported: OperationGetDiff,
+ },
+ {
+ name: "GitHub git:: prefix",
+ source: "git::https://github.com/cloudposse/terraform-aws-vpc.git",
+ expectedType: &GitHubProvider{},
+ expectedSupported: OperationGetDiff,
+ },
+ {
+ name: "GitLab HTTPS URL",
+ source: "https://gitlab.com/example/repo.git",
+ expectedType: &GenericGitProvider{},
+ expectedSupported: OperationListVersions,
+ },
+ {
+ name: "Generic Git HTTPS",
+ source: "https://git.example.com/repo.git",
+ expectedType: &GenericGitProvider{},
+ expectedSupported: OperationListVersions,
+ },
+ {
+ name: "Generic Git SSH",
+ source: "git@git.example.com:repo.git",
+ expectedType: &GenericGitProvider{},
+ expectedSupported: OperationListVersions,
+ },
+ {
+ name: "OCI registry",
+ source: "oci://registry.example.com/component",
+ expectedType: &UnsupportedProvider{},
+ expectedSupported: "",
+ },
+ {
+ name: "Local path",
+ source: "/path/to/local/component",
+ expectedType: &UnsupportedProvider{},
+ expectedSupported: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ provider := GetProviderForSource(tt.source)
+ assert.IsType(t, tt.expectedType, provider)
+ if tt.expectedSupported != "" {
+ assert.True(t, provider.SupportsOperation(tt.expectedSupported))
+ }
+ })
+ }
+}
+
+func TestGitHubProvider_SupportsOperation(t *testing.T) {
+ provider := NewGitHubProvider()
+
+ assert.True(t, provider.SupportsOperation(OperationListVersions))
+ assert.True(t, provider.SupportsOperation(OperationVerifyVersion))
+ assert.True(t, provider.SupportsOperation(OperationGetDiff))
+ assert.True(t, provider.SupportsOperation(OperationFetchSource))
+ assert.False(t, provider.SupportsOperation(Operation("unknown")))
+}
+
+func TestGenericGitProvider_SupportsOperation(t *testing.T) {
+ provider := NewGenericGitProvider()
+
+ assert.True(t, provider.SupportsOperation(OperationListVersions))
+ assert.True(t, provider.SupportsOperation(OperationVerifyVersion))
+ assert.False(t, provider.SupportsOperation(OperationGetDiff))
+ assert.True(t, provider.SupportsOperation(OperationFetchSource))
+ assert.False(t, provider.SupportsOperation(Operation("unknown")))
+}
+
+func TestUnsupportedProvider_SupportsOperation(t *testing.T) {
+ provider := NewUnsupportedProvider()
+
+ assert.False(t, provider.SupportsOperation(OperationListVersions))
+ assert.False(t, provider.SupportsOperation(OperationVerifyVersion))
+ assert.False(t, provider.SupportsOperation(OperationGetDiff))
+ assert.False(t, provider.SupportsOperation(OperationFetchSource))
+}
+
+func TestParseGitHubRepo(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ wantOwner string
+ wantRepo string
+ wantErr bool
+ }{
+ {
+ name: "HTTPS URL",
+ source: "https://github.com/cloudposse/terraform-aws-vpc.git",
+ wantOwner: "cloudposse",
+ wantRepo: "terraform-aws-vpc",
+ wantErr: false,
+ },
+ {
+ name: "Shorthand",
+ source: "github.com/cloudposse/terraform-aws-vpc",
+ wantOwner: "cloudposse",
+ wantRepo: "terraform-aws-vpc",
+ wantErr: false,
+ },
+ {
+ name: "SSH URL",
+ source: "git@github.com:cloudposse/terraform-aws-vpc.git",
+ wantOwner: "cloudposse",
+ wantRepo: "terraform-aws-vpc",
+ wantErr: false,
+ },
+ {
+ name: "With git:: prefix",
+ source: "git::https://github.com/cloudposse/terraform-aws-vpc.git",
+ wantOwner: "cloudposse",
+ wantRepo: "terraform-aws-vpc",
+ wantErr: false,
+ },
+ {
+ name: "With module path",
+ source: "github.com/cloudposse/terraform-aws-vpc//modules/subnets",
+ wantOwner: "cloudposse",
+ wantRepo: "terraform-aws-vpc",
+ wantErr: false,
+ },
+ {
+ name: "With query params",
+ source: "github.com/cloudposse/terraform-aws-vpc?ref=tags/1.0.0",
+ wantOwner: "cloudposse",
+ wantRepo: "terraform-aws-vpc",
+ wantErr: false,
+ },
+ {
+ name: "Invalid format",
+ source: "invalid",
+ wantOwner: "",
+ wantRepo: "",
+ wantErr: true,
+ },
+ {
+ name: "Missing repo name",
+ source: "github.com/cloudposse",
+ wantOwner: "",
+ wantRepo: "",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ owner, repo, err := ParseGitHubRepo(tt.source)
+ if tt.wantErr {
+ assert.Error(t, err)
+ } else {
+ assert.NoError(t, err)
+ assert.Equal(t, tt.wantOwner, owner)
+ assert.Equal(t, tt.wantRepo, repo)
+ }
+ })
+ }
+}
+
+func TestIsGitHubSource(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ want bool
+ }{
+ {
+ name: "GitHub HTTPS",
+ source: "https://github.com/cloudposse/terraform-aws-vpc.git",
+ want: true,
+ },
+ {
+ name: "GitHub shorthand",
+ source: "github.com/cloudposse/terraform-aws-vpc",
+ want: true,
+ },
+ {
+ name: "GitHub SSH",
+ source: "git@github.com:cloudposse/terraform-aws-vpc.git",
+ want: true,
+ },
+ {
+ name: "GitLab",
+ source: "https://gitlab.com/example/repo.git",
+ want: false,
+ },
+ {
+ name: "Generic Git",
+ source: "https://git.example.com/repo.git",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := IsGitHubSource(tt.source)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestIsGitSource(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ want bool
+ }{
+ {
+ name: "HTTPS Git URL",
+ source: "https://gitlab.com/example/repo.git",
+ want: true,
+ },
+ {
+ name: "SSH Git URL",
+ source: "git@gitlab.com:example/repo.git",
+ want: true,
+ },
+ {
+ name: "git:: prefix",
+ source: "git::https://example.com/repo.git",
+ want: true,
+ },
+ {
+ name: ".git suffix",
+ source: "https://example.com/repo.git",
+ want: true,
+ },
+ {
+ name: "OCI registry",
+ source: "oci://registry.example.com/component",
+ want: false,
+ },
+ {
+ name: "Local path",
+ source: "/path/to/local/component",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := IsGitSource(tt.source)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
diff --git a/pkg/vendoring/source/unsupported.go b/pkg/vendoring/source/unsupported.go
new file mode 100644
index 0000000000..b47ae3f0ac
--- /dev/null
+++ b/pkg/vendoring/source/unsupported.go
@@ -0,0 +1,58 @@
+package source
+
+import (
+ "fmt"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+)
+
+// UnsupportedProvider implements Provider for unsupported source types.
+// This includes OCI registries, local files, HTTP sources, etc.
+type UnsupportedProvider struct{}
+
+// NewUnsupportedProvider creates a new unsupported source provider.
+func NewUnsupportedProvider() Provider {
+ defer perf.Track(nil, "source.NewUnsupportedProvider")()
+
+ return &UnsupportedProvider{}
+}
+
+// GetAvailableVersions implements Provider.GetAvailableVersions.
+func (u *UnsupportedProvider) GetAvailableVersions(source string) ([]string, error) {
+ defer perf.Track(nil, "source.UnsupportedProvider.GetAvailableVersions")()
+
+ return nil, fmt.Errorf("%w: version listing not supported for this source type", errUtils.ErrUnsupportedVendorSource)
+}
+
+// VerifyVersion implements Provider.VerifyVersion.
+func (u *UnsupportedProvider) VerifyVersion(source string, version string) (bool, error) {
+ defer perf.Track(nil, "source.UnsupportedProvider.VerifyVersion")()
+
+ return false, fmt.Errorf("%w: version verification not supported for this source type", errUtils.ErrUnsupportedVendorSource)
+}
+
+// GetDiff implements Provider.GetDiff.
+//
+//nolint:revive // Seven parameters needed for interface compatibility.
+func (u *UnsupportedProvider) GetDiff(
+ atmosConfig *schema.AtmosConfiguration,
+ source string,
+ fromVersion string,
+ toVersion string,
+ filePath string,
+ contextLines int,
+ noColor bool,
+) ([]byte, error) {
+ defer perf.Track(atmosConfig, "source.UnsupportedProvider.GetDiff")()
+
+ return nil, fmt.Errorf("%w: diff functionality not supported for this source type", errUtils.ErrUnsupportedVendorSource)
+}
+
+// SupportsOperation implements Provider.SupportsOperation.
+func (u *UnsupportedProvider) SupportsOperation(operation Operation) bool {
+ defer perf.Track(nil, "source.UnsupportedProvider.SupportsOperation")()
+
+ return false
+}
diff --git a/internal/exec/vendor_template_tokens_test.go b/pkg/vendoring/template_tokens_test.go
similarity index 95%
rename from internal/exec/vendor_template_tokens_test.go
rename to pkg/vendoring/template_tokens_test.go
index 7e0c371311..9a6770a2db 100644
--- a/internal/exec/vendor_template_tokens_test.go
+++ b/pkg/vendoring/template_tokens_test.go
@@ -1,4 +1,4 @@
-package exec
+package vendoring
import (
"os"
@@ -9,6 +9,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/cloudposse/atmos/internal/exec"
"github.com/cloudposse/atmos/pkg/schema"
)
@@ -117,7 +118,7 @@ func TestVendorPull_TemplateTokenInjection(t *testing.T) {
Version string
}{sourceConfig.Component, sourceConfig.Version}
- processedURL, err := ProcessTmpl(&atmosConfig, "test-source", sourceConfig.Source, tmplData, false)
+ processedURL, err := exec.ProcessTmpl(&atmosConfig, "test-source", sourceConfig.Source, tmplData, false)
require.NoError(t, err, "Template processing should succeed for %s", tt.componentName)
t.Logf("Testing: %s - %s", tt.componentName, tt.description)
@@ -241,7 +242,7 @@ base_path: "./"
Version string
}{source.Component, source.Version}
- processedURL, err := ProcessTmpl(&atmosConfig, "test-source", source.Source, tmplData, false)
+ processedURL, err := exec.ProcessTmpl(&atmosConfig, "test-source", source.Source, tmplData, false)
if tt.expectError {
require.Error(t, err, "Should get error for: %s", tt.name)
@@ -339,10 +340,10 @@ settings:
Version string
}{nativeSource.Component, nativeSource.Version}
- templateProcessedURL, err := ProcessTmpl(&atmosConfig, "template-source", templateSource.Source, templateTmplData, false)
+ templateProcessedURL, err := exec.ProcessTmpl(&atmosConfig, "template-source", templateSource.Source, templateTmplData, false)
require.NoError(t, err, "Template processing should succeed for template-creds")
- nativeProcessedURL, err := ProcessTmpl(&atmosConfig, "native-source", nativeSource.Source, nativeTmplData, false)
+ nativeProcessedURL, err := exec.ProcessTmpl(&atmosConfig, "native-source", nativeSource.Source, nativeTmplData, false)
require.NoError(t, err, "Template processing should succeed for native-creds")
// Template source should have credentials from template processing.
diff --git a/internal/exec/vendor_triple_slash_test.go b/pkg/vendoring/triple_slash_test.go
similarity index 87%
rename from internal/exec/vendor_triple_slash_test.go
rename to pkg/vendoring/triple_slash_test.go
index c2b9f2c034..a4533c89e7 100644
--- a/internal/exec/vendor_triple_slash_test.go
+++ b/pkg/vendoring/triple_slash_test.go
@@ -1,4 +1,4 @@
-package exec
+package vendoring
import (
"os"
@@ -8,6 +8,8 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ cfg "github.com/cloudposse/atmos/pkg/config"
+ "github.com/cloudposse/atmos/pkg/schema"
"github.com/cloudposse/atmos/tests"
)
@@ -32,18 +34,14 @@ func TestVendorPullWithTripleSlashPattern(t *testing.T) {
// Change to the test directory.
t.Chdir(testDir)
- // Set up the command with global flags.
- cmd := newTestCommandWithGlobalFlags("pull")
-
- flags := cmd.Flags()
- flags.String("component", "s3-bucket", "")
- flags.String("stack", "", "")
- flags.String("tags", "", "")
- flags.Bool("dry-run", false, "")
- flags.Bool("everything", false, "")
+ // Initialize atmos config.
+ atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
+ require.NoError(t, err, "Failed to initialize atmos config")
// Execute vendor pull command.
- err := ExecuteVendorPullCommand(cmd, []string{})
+ err = Pull(&atmosConfig, &PullParams{
+ Component: "s3-bucket",
+ })
require.NoError(t, err, "Vendor pull command should execute without error")
// Check that the target directory was created.
@@ -115,18 +113,14 @@ func TestVendorPullWithMultipleVendorFiles(t *testing.T) {
assert.FileExists(t, file, "Vendor file should exist: %s", file)
}
- // Set up the command with global flags.
- cmd := newTestCommandWithGlobalFlags("pull")
-
- flags := cmd.Flags()
- flags.String("component", "", "")
- flags.String("stack", "", "")
- flags.String("tags", "aws", "")
- flags.Bool("dry-run", false, "")
- flags.Bool("everything", false, "")
+ // Initialize atmos config.
+ atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
+ require.NoError(t, err, "Failed to initialize atmos config")
// Execute vendor pull command with tags filter.
- err := ExecuteVendorPullCommand(cmd, []string{})
+ err = Pull(&atmosConfig, &PullParams{
+ Tags: "aws",
+ })
require.NoError(t, err, "Vendor pull command should execute without error even with multiple vendor files")
// Check that the s3-bucket component was pulled (it has the 'aws' tag).
diff --git a/pkg/vendoring/update.go b/pkg/vendoring/update.go
new file mode 100644
index 0000000000..ae9299068e
--- /dev/null
+++ b/pkg/vendoring/update.go
@@ -0,0 +1,115 @@
+package vendoring
+
+import (
+ "fmt"
+ "strings"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ cfg "github.com/cloudposse/atmos/pkg/config"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+)
+
+// updateFlags holds flags specific to vendor update command.
+type updateFlags struct {
+ Check bool
+ Pull bool
+ Component string
+ Tags []string
+ ComponentType string
+ Outdated bool
+}
+
+// Update executes the vendor update operation with typed params.
+func Update(atmosConfig *schema.AtmosConfiguration, params *UpdateParams) error {
+ defer perf.Track(atmosConfig, "vendor.Update")()
+
+ // Convert params to internal updateFlags format.
+ var tags []string
+ if params.Tags != "" {
+ tags = splitAndTrim(params.Tags, ",")
+ }
+
+ flags := &updateFlags{
+ Check: params.Check,
+ Pull: params.Pull,
+ Component: params.Component,
+ Tags: tags,
+ ComponentType: params.ComponentType,
+ Outdated: params.Outdated,
+ }
+
+ // Execute vendor update.
+ return executeVendorUpdate(atmosConfig, flags)
+}
+
+// executeVendorUpdate performs the vendor update logic.
+func executeVendorUpdate(atmosConfig *schema.AtmosConfiguration, flags *updateFlags) error {
+ defer perf.Track(atmosConfig, "vendor.executeVendorUpdate")()
+
+ // Determine the vendor config file path.
+ vendorConfigFileName := cfg.AtmosVendorConfigFileName
+ if atmosConfig.Vendor.BasePath != "" {
+ vendorConfigFileName = atmosConfig.Vendor.BasePath
+ }
+
+ // Read the main vendor config.
+ vendorConfig, vendorConfigExists, foundVendorConfigFile, err := ReadAndProcessVendorConfigFile(
+ atmosConfig,
+ vendorConfigFileName,
+ true,
+ )
+ if err != nil {
+ return err
+ }
+
+ if !vendorConfigExists {
+ // Try component vendor config if no main vendor config.
+ if flags.Component != "" {
+ return executeComponentVendorUpdate(atmosConfig, flags)
+ }
+ return fmt.Errorf("%w: %s", errUtils.ErrVendorConfigNotFound, vendorConfigFileName)
+ }
+
+ // TODO: Implement actual update logic.
+ // 1. Process imports and get sources.
+ // 2. Filter sources by component/tags.
+ // 3. Check for updates using Git.
+ // 4. Display results (TUI).
+ // 5. Update YAML files if not --check.
+ // 6. Execute vendor pull if --pull.
+
+ // Use vendorConfig and foundVendorConfigFile to avoid "declared and not used" error.
+ _ = vendorConfig
+ _ = foundVendorConfigFile
+
+ return errUtils.ErrNotImplemented
+}
+
+// executeComponentVendorUpdate handles vendor update for component.yaml files.
+func executeComponentVendorUpdate(atmosConfig *schema.AtmosConfiguration, flags *updateFlags) error {
+ defer perf.Track(atmosConfig, "vendor.executeComponentVendorUpdate")()
+
+ // TODO: Implement component vendor update.
+ // Use flags.Component and flags.ComponentType (default: "terraform") to avoid unused variable warning.
+ _ = flags
+
+ return errUtils.ErrNotImplemented
+}
+
+// splitAndTrim splits a string by delimiter and trims whitespace from each element.
+func splitAndTrim(s, delimiter string) []string {
+ if s == "" {
+ return nil
+ }
+
+ parts := []string{}
+ for _, part := range strings.Split(s, delimiter) {
+ trimmed := strings.TrimSpace(part)
+ if trimmed != "" {
+ parts = append(parts, trimmed)
+ }
+ }
+
+ return parts
+}
diff --git a/pkg/vendoring/update_test.go b/pkg/vendoring/update_test.go
new file mode 100644
index 0000000000..cd5fed2b0d
--- /dev/null
+++ b/pkg/vendoring/update_test.go
@@ -0,0 +1,56 @@
+package vendoring
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestSplitAndTrim(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ sep string
+ expected []string
+ }{
+ {
+ name: "comma separated with spaces",
+ input: "tag1, tag2, tag3",
+ sep: ",",
+ expected: []string{"tag1", "tag2", "tag3"},
+ },
+ {
+ name: "comma separated without spaces",
+ input: "tag1,tag2,tag3",
+ sep: ",",
+ expected: []string{"tag1", "tag2", "tag3"},
+ },
+ {
+ name: "single item",
+ input: "tag1",
+ sep: ",",
+ expected: []string{"tag1"},
+ },
+ {
+ name: "empty string",
+ input: "",
+ sep: ",",
+ expected: nil, // Empty input returns nil, not []string{""}.
+ },
+ {
+ name: "with extra spaces",
+ input: " tag1 , tag2 , tag3 ",
+ sep: ",",
+ expected: []string{"tag1", "tag2", "tag3"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := splitAndTrim(tt.input, tt.sep)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+// splitString and trimString tests removed - now using standard library strings.Split and strings.TrimSpace.
diff --git a/internal/exec/vendor_uri_helpers.go b/pkg/vendoring/uri/helpers.go
similarity index 60%
rename from internal/exec/vendor_uri_helpers.go
rename to pkg/vendoring/uri/helpers.go
index ee973c026f..d842e13a35 100644
--- a/internal/exec/vendor_uri_helpers.go
+++ b/pkg/vendoring/uri/helpers.go
@@ -1,4 +1,4 @@
-package exec
+package uri
import (
"net/url"
@@ -6,49 +6,63 @@ import (
"strings"
"github.com/hashicorp/go-getter"
+
+ "github.com/cloudposse/atmos/pkg/perf"
)
// scpURLPattern matches SCP-style Git URLs (e.g., git@github.com:owner/repo.git).
// This pattern is also used by CustomGitDetector.rewriteSCPURL in pkg/downloader/.
var scpURLPattern = regexp.MustCompile(`^(([\w.-]+)@)?([\w.-]+\.[\w.-]+):([\w./-]+)(\.git)?(.*)$`)
-// isFileURI checks if the URI is a file:// scheme.
-func isFileURI(uri string) bool {
+// IsFileURI checks if the URI is a file:// scheme.
+func IsFileURI(uri string) bool {
+ defer perf.Track(nil, "uri.IsFileURI")()
+
return strings.HasPrefix(uri, "file://")
}
-// isOCIURI checks if the URI is an OCI registry URI.
-func isOCIURI(uri string) bool {
+// IsOCIURI checks if the URI is an OCI registry URI.
+func IsOCIURI(uri string) bool {
+ defer perf.Track(nil, "uri.IsOCIURI")()
+
return strings.HasPrefix(uri, "oci://")
}
// IsS3URI checks if the URI is an S3 URI.
// Go-getter supports both explicit s3:: prefix and auto-detected .amazonaws.com URLs.
-func isS3URI(uri string) bool {
+func IsS3URI(uri string) bool {
+ defer perf.Track(nil, "uri.IsS3URI")()
+
return strings.HasPrefix(uri, "s3::") || strings.Contains(uri, ".amazonaws.com/")
}
-// hasLocalPathPrefix checks if the URI starts with local path prefixes.
+// HasLocalPathPrefix checks if the URI starts with local path prefixes.
// Examples:
// - true: "/absolute/path", "./relative/path", "../parent/path"
// - false: "github.com/repo", "https://example.com", "components/terraform"
-func hasLocalPathPrefix(uri string) bool {
+func HasLocalPathPrefix(uri string) bool {
+ defer perf.Track(nil, "uri.HasLocalPathPrefix")()
+
return strings.HasPrefix(uri, "/") || strings.HasPrefix(uri, "./") || strings.HasPrefix(uri, "../")
}
-// hasSchemeSeparator checks if the URI contains a scheme separator.
+// HasSchemeSeparator checks if the URI contains a scheme separator.
// Examples:
// - true: "https://github.com", "git::https://...", "s3::https://..."
// - false: "github.com/repo", "./local/path", "components/terraform"
-func hasSchemeSeparator(uri string) bool {
+func HasSchemeSeparator(uri string) bool {
+ defer perf.Track(nil, "uri.HasSchemeSeparator")()
+
return strings.Contains(uri, "://") || strings.Contains(uri, "::")
}
-// hasSubdirectoryDelimiter checks if the URI contains the go-getter subdirectory delimiter.
+// HasSubdirectoryDelimiter checks if the URI contains the go-getter subdirectory delimiter.
// Examples:
// - true: "github.com/repo//path", "git.company.com/repo//modules"
// - false: "github.com/repo", "https://github.com/repo", "./local/path"
-func hasSubdirectoryDelimiter(uri string) bool {
+func HasSubdirectoryDelimiter(uri string) bool {
+ defer perf.Track(nil, "uri.HasSubdirectoryDelimiter")()
+
idx := strings.Index(uri, "//")
if idx == -1 {
return false
@@ -62,66 +76,72 @@ func hasSubdirectoryDelimiter(uri string) bool {
return true
}
-// isLocalPath checks if the URI is a local file system path.
+// IsLocalPath checks if the URI is a local file system path.
// Examples:
// - Local: "/absolute/path", "./relative/path", "../parent/path", "components/terraform"
// - Remote: "github.com/owner/repo", "https://example.com", "git.company.com/repo"
-func isLocalPath(uri string) bool {
- // Local paths start with /, ./, ../, or are relative paths without scheme
- // Examples: "/abs/path", "./rel/path", "../parent", "components/terraform"
- if hasLocalPathPrefix(uri) {
+func IsLocalPath(uri string) bool {
+ defer perf.Track(nil, "uri.IsLocalPath")()
+
+ // Local paths start with /, ./, ../, or are relative paths without scheme.
+ // Examples: "/abs/path", "./rel/path", "../parent", "components/terraform".
+ if HasLocalPathPrefix(uri) {
return true
}
- // If it contains a scheme separator, it's not a local path
- // Examples: "https://github.com", "git::https://...", "s3::..."
- // This check must come BEFORE the '//' check to avoid false positives from "://"
- if hasSchemeSeparator(uri) {
+ // If it contains a scheme separator, it's not a local path.
+ // Examples: "https://github.com", "git::https://...", "s3::...".
+ // This check must come BEFORE the '//' check to avoid false positives from "://".
+ if HasSchemeSeparator(uri) {
return false
}
- // If it contains the go-getter subdirectory delimiter, it's not a local path
- // Examples: "github.com/repo//path", "git.company.com/repo//modules"
- if hasSubdirectoryDelimiter(uri) {
+ // If it contains the go-getter subdirectory delimiter, it's not a local path.
+ // Examples: "github.com/repo//path", "git.company.com/repo//modules".
+ if HasSubdirectoryDelimiter(uri) {
return false
}
- // If it looks like a Git repository, it's not a local path
- // Examples: "github.com/owner/repo", "gitlab.com/project", "repo.git", "org/_git/repo" (Azure DevOps)
- if isGitURI(uri) {
+ // If it looks like a Git repository, it's not a local path.
+ // Examples: "github.com/owner/repo", "gitlab.com/project", "repo.git", "org/_git/repo" (Azure DevOps).
+ if IsGitURI(uri) {
return false
}
- // If it has a domain-like structure (hostname.domain/path), it's not a local path
- // Examples: "git.company.com/repo", "gitea.io/owner/repo"
- if isDomainLikeURI(uri) {
+ // If it has a domain-like structure (hostname.domain/path), it's not a local path.
+ // Examples: "git.company.com/repo", "gitea.io/owner/repo".
+ if IsDomainLikeURI(uri) {
return false
}
- // Otherwise, it's likely a relative local path
- // Examples: "components/terraform", "mixins/context.tf"
+ // Otherwise, it's likely a relative local path.
+ // Examples: "components/terraform", "mixins/context.tf".
return true
}
-// isDomainLikeURI checks if the URI has a domain-like structure (hostname.domain/path).
-func isDomainLikeURI(uri string) bool {
+// IsDomainLikeURI checks if the URI has a domain-like structure (hostname.domain/path).
+func IsDomainLikeURI(uri string) bool {
+ defer perf.Track(nil, "uri.IsDomainLikeURI")()
+
dotPos := strings.Index(uri, ".")
if dotPos <= 0 || dotPos >= len(uri)-1 {
return false
}
- // Check if there's a slash after the dot (indicating a domain with path)
+ // Check if there's a slash after the dot (indicating a domain with path).
afterDot := uri[dotPos+1:]
slashPos := strings.Index(afterDot, "/")
return slashPos > 0
}
-// isNonGitHTTPURI checks if the URI is an HTTP/HTTPS URL that doesn't appear to be a Git repository.
-func isNonGitHTTPURI(uri string) bool {
+// IsNonGitHTTPURI checks if the URI is an HTTP/HTTPS URL that doesn't appear to be a Git repository.
+func IsNonGitHTTPURI(uri string) bool {
+ defer perf.Track(nil, "uri.IsNonGitHTTPURI")()
+
if !strings.HasPrefix(uri, "http://") && !strings.HasPrefix(uri, "https://") {
return false
}
- // Check for common archive extensions that indicate it's not a Git repo
+ // Check for common archive extensions that indicate it's not a Git repo.
lowerURI := strings.ToLower(uri)
archiveExtensions := []string{".tar.gz", ".tgz", ".tar.bz2", ".zip", ".tar", ".gz", ".bz2"}
for _, ext := range archiveExtensions {
@@ -132,7 +152,7 @@ func isNonGitHTTPURI(uri string) bool {
return false
}
-// isGitURI checks if the URI appears to be a Git repository URL.
+// IsGitURI checks if the URI appears to be a Git repository URL.
// We cannot use go-getter's Detect() because it only detects specific platforms (GitHub/GitLab/BitBucket)
// and treats everything else as file://. We need broader detection for self-hosted Git, Azure DevOps, etc.
//
@@ -143,23 +163,25 @@ func isNonGitHTTPURI(uri string) bool {
// 3. Known Git hosting platforms (github.com, gitlab.com, bitbucket.org) in host.
// 4. .git extension in path (not in host).
// 5. Azure DevOps _git/ pattern in path.
-func isGitURI(uri string) bool {
- // Check for explicit git:: forced getter prefix
+func IsGitURI(uri string) bool {
+ defer perf.Track(nil, "uri.IsGitURI")()
+
+ // Check for explicit git:: forced getter prefix.
if strings.HasPrefix(uri, "git::") {
return true
}
- // Remove go-getter's subdirectory delimiter for parsing
+ // Remove go-getter's subdirectory delimiter for parsing.
srcURI, _ := getter.SourceDirSubdir(uri)
- // Check for SCP-style URLs (git@github.com:owner/repo.git)
- // Use same pattern as CustomGitDetector.rewriteSCPURL
+ // Check for SCP-style URLs (git@github.com:owner/repo.git).
+ // Use same pattern as CustomGitDetector.rewriteSCPURL.
if scpURLPattern.MatchString(srcURI) {
return true
}
- // Use standard library url.Parse for proper URL parsing
- // Add https:// scheme if missing to help url.Parse identify the host
+ // Use standard library url.Parse for proper URL parsing.
+ // Add https:// scheme if missing to help url.Parse identify the host.
parseURI := srcURI
if !strings.Contains(parseURI, "://") {
parseURI = "https://" + parseURI
@@ -167,14 +189,14 @@ func isGitURI(uri string) bool {
parsedURL, err := url.Parse(parseURI)
if err != nil {
- // If URL parsing fails, it's likely not a valid Git URL
+ // If URL parsing fails, it's likely not a valid Git URL.
return false
}
host := strings.ToLower(parsedURL.Host)
path := parsedURL.Path
- // Check for known Git hosting platforms
+ // Check for known Git hosting platforms.
knownHosts := []string{"github.com", "gitlab.com", "bitbucket.org"}
for _, knownHost := range knownHosts {
if host == knownHost || strings.HasSuffix(host, "."+knownHost) {
@@ -182,12 +204,12 @@ func isGitURI(uri string) bool {
}
}
- // Check for .git extension in path (not in host)
+ // Check for .git extension in path (not in host).
if strings.Contains(path, ".git") {
return true
}
- // Check for Azure DevOps _git/ pattern
+ // Check for Azure DevOps _git/ pattern.
if strings.Contains(path, "/_git/") {
return true
}
@@ -195,52 +217,60 @@ func isGitURI(uri string) bool {
return false
}
-// hasSubdirectory checks if the URI already has a subdirectory delimiter.
+// HasSubdirectory checks if the URI already has a subdirectory delimiter.
// Uses go-getter's SourceDirSubdir to properly parse the URL.
-func hasSubdirectory(uri string) bool {
- // Use go-getter's built-in parser to extract subdirectory
+func HasSubdirectory(uri string) bool {
+ defer perf.Track(nil, "uri.HasSubdirectory")()
+
+ // Use go-getter's built-in parser to extract subdirectory.
_, subdir := getter.SourceDirSubdir(uri)
return subdir != ""
}
-// containsTripleSlash checks if the URI contains the triple-slash pattern.
+// ContainsTripleSlash checks if the URI contains the triple-slash pattern.
// This is a legacy pattern that needs normalization.
-func containsTripleSlash(uri string) bool {
- // Check for literal triple-slash pattern in the URI
- // This is the most reliable way to detect the pattern regardless of platform
+func ContainsTripleSlash(uri string) bool {
+ defer perf.Track(nil, "uri.ContainsTripleSlash")()
+
+ // Check for literal triple-slash pattern in the URI.
+ // This is the most reliable way to detect the pattern regardless of platform.
return strings.Contains(uri, "///")
}
-// parseSubdirFromTripleSlash extracts source and subdirectory from a triple-slash URI.
+// ParseSubdirFromTripleSlash extracts source and subdirectory from a triple-slash URI.
// Uses go-getter's SourceDirSubdir for proper parsing.
// Examples:
// - Input: "github.com/owner/repo.git///?ref=v1.0" → source="github.com/owner/repo.git?ref=v1.0", subdir=""
// - Input: "github.com/owner/repo.git///path?ref=v1.0" → source="github.com/owner/repo.git?ref=v1.0", subdir="path"
-func parseSubdirFromTripleSlash(uri string) (source string, subdir string) {
+func ParseSubdirFromTripleSlash(uri string) (source string, subdir string) {
+ defer perf.Track(nil, "uri.ParseSubdirFromTripleSlash")()
+
source, subdir = getter.SourceDirSubdir(uri)
- // If subdirectory starts with "/", it means triple-slash was used
- // Remove the leading "/" to get the actual subdirectory path
- // Examples: "/" → "", "/path" → "path"
+ // If subdirectory starts with "/", it means triple-slash was used.
+ // Remove the leading "/" to get the actual subdirectory path.
+ // Examples: "/" → "", "/path" → "path".
subdir = strings.TrimPrefix(subdir, "/")
return source, subdir
}
-// needsDoubleSlashDot determines if a URI needs double-slash-dot appended.
-func needsDoubleSlashDot(uri string) bool {
- // Only Git URIs need double-slash-dot (e.g., github.com/owner/repo.git needs github.com/owner/repo.git//.)
- if !isGitURI(uri) {
+// NeedsDoubleSlashDot determines if a URI needs double-slash-dot appended.
+func NeedsDoubleSlashDot(uri string) bool {
+ defer perf.Track(nil, "uri.NeedsDoubleSlashDot")()
+
+ // Only Git URIs need double-slash-dot (e.g., github.com/owner/repo.git needs github.com/owner/repo.git//.).
+ if !IsGitURI(uri) {
return false // Not a Git URI, doesn't need //.
}
// Already has subdirectory specified, no need to add //.
- if hasSubdirectory(uri) {
+ if HasSubdirectory(uri) {
return false
}
// These special URI types shouldn't be modified even if they look like Git.
- if isFileURI(uri) || isOCIURI(uri) || isS3URI(uri) || isLocalPath(uri) || isNonGitHTTPURI(uri) {
+ if IsFileURI(uri) || IsOCIURI(uri) || IsS3URI(uri) || IsLocalPath(uri) || IsNonGitHTTPURI(uri) {
return false
}
@@ -248,10 +278,12 @@ func needsDoubleSlashDot(uri string) bool {
return true
}
-// appendDoubleSlashDot adds double-slash-dot to a URI, handling query parameters correctly.
+// AppendDoubleSlashDot adds double-slash-dot to a URI, handling query parameters correctly.
// Removes any trailing "//" from the base URI before appending "//." to avoid creating "////".
-func appendDoubleSlashDot(uri string) string {
- // Find the position of query parameters if they exist
+func AppendDoubleSlashDot(uri string) string {
+ defer perf.Track(nil, "uri.AppendDoubleSlashDot")()
+
+ // Find the position of query parameters if they exist.
queryPos := strings.Index(uri, "?")
var base, queryPart string
@@ -263,9 +295,9 @@ func appendDoubleSlashDot(uri string) string {
queryPart = ""
}
- // Remove trailing "//" if present to avoid creating "////"
+ // Remove trailing "//" if present to avoid creating "////".
base = strings.TrimSuffix(base, "//")
- // Append //. and query parameters
+ // Append //. and query parameters.
return base + "//." + queryPart
}
diff --git a/internal/exec/vendor_uri_helpers_test.go b/pkg/vendoring/uri/helpers_test.go
similarity index 90%
rename from internal/exec/vendor_uri_helpers_test.go
rename to pkg/vendoring/uri/helpers_test.go
index fa7809cd29..a1c6eff2c3 100644
--- a/internal/exec/vendor_uri_helpers_test.go
+++ b/pkg/vendoring/uri/helpers_test.go
@@ -1,4 +1,4 @@
-package exec
+package uri
import (
"strings"
@@ -13,7 +13,7 @@ func TestHasLocalPathPrefix(t *testing.T) {
uri string
expected bool
}{
- // Absolute paths
+ // Absolute paths.
{
name: "absolute unix path",
uri: "/absolute/path/to/components",
@@ -22,9 +22,9 @@ func TestHasLocalPathPrefix(t *testing.T) {
{
name: "absolute windows path",
uri: "C:\\Users\\components",
- expected: false, // Not a Unix absolute path
+ expected: false, // Not a Unix absolute path.
},
- // Relative paths
+ // Relative paths.
{
name: "current directory prefix",
uri: "./relative/path",
@@ -35,7 +35,7 @@ func TestHasLocalPathPrefix(t *testing.T) {
uri: "../parent/path",
expected: true,
},
- // Non-local paths
+ // Non-local paths.
{
name: "github URL",
uri: "github.com/owner/repo.git",
@@ -51,16 +51,16 @@ func TestHasLocalPathPrefix(t *testing.T) {
uri: "components/terraform/vpc",
expected: false,
},
- // Edge cases
+ // Edge cases.
{
name: "single dot",
uri: ".",
- expected: false, // Only matches "./" not just "."
+ expected: false, // Only matches "./" not just ".".
},
{
name: "double dot",
uri: "..",
- expected: false, // Only matches "../" not just ".."
+ expected: false, // Only matches "../" not just "..".
},
{
name: "path starting with dot but not ./",
@@ -76,7 +76,7 @@ func TestHasLocalPathPrefix(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := hasLocalPathPrefix(tt.uri)
+ result := HasLocalPathPrefix(tt.uri)
assert.Equal(t, tt.expected, result)
})
}
@@ -88,7 +88,7 @@ func TestHasSchemeSeparator(t *testing.T) {
uri string
expected bool
}{
- // Scheme separators present
+ // Scheme separators present.
{
name: "https scheme",
uri: "https://github.com/owner/repo.git",
@@ -124,13 +124,13 @@ func TestHasSchemeSeparator(t *testing.T) {
uri: "oci://ghcr.io/owner/image:tag",
expected: true,
},
- // go-getter subdirectory delimiter (not a scheme)
+ // go-getter subdirectory delimiter (not a scheme).
{
name: "subdirectory delimiter only",
uri: "github.com/owner/repo.git//modules/vpc",
expected: false, // Has // but not :// or ::, so no scheme separator.
},
- // No scheme separators
+ // No scheme separators.
{
name: "implicit https",
uri: "github.com/owner/repo.git",
@@ -151,11 +151,11 @@ func TestHasSchemeSeparator(t *testing.T) {
uri: "components/terraform/vpc",
expected: false,
},
- // Edge cases
+ // Edge cases.
{
name: "colon but not scheme separator",
uri: "host:port/path",
- expected: false, // Port notation, not a scheme
+ expected: false, // Port notation, not a scheme.
},
{
name: "empty string",
@@ -166,7 +166,7 @@ func TestHasSchemeSeparator(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := hasSchemeSeparator(tt.uri)
+ result := HasSchemeSeparator(tt.uri)
assert.Equal(t, tt.expected, result)
})
}
@@ -179,7 +179,7 @@ func TestHasSubdirectoryDelimiter(t *testing.T) {
uri string
expected bool
}{
- // With subdirectory delimiter
+ // With subdirectory delimiter.
{
name: "github with subdirectory",
uri: "github.com/owner/repo.git//modules/vpc",
@@ -205,7 +205,7 @@ func TestHasSubdirectoryDelimiter(t *testing.T) {
uri: "git::https://github.com/owner/repo.git//examples",
expected: true,
},
- // Without subdirectory delimiter
+ // Without subdirectory delimiter.
{
name: "https without subdirectory",
uri: "https://github.com/owner/repo.git",
@@ -226,16 +226,16 @@ func TestHasSubdirectoryDelimiter(t *testing.T) {
uri: "file:///absolute/path",
expected: false,
},
- // Edge cases
+ // Edge cases.
{
name: "path with double slash but not delimiter",
uri: "http://example.com/path",
- expected: false, // The // is part of the scheme, not a delimiter
+ expected: false, // The // is part of the scheme, not a delimiter.
},
{
name: "oci scheme",
uri: "oci://ghcr.io/owner/image:tag",
- expected: false, // OCI uses :// not //
+ expected: false, // OCI uses :// not //.
},
{
name: "empty string",
@@ -246,7 +246,7 @@ func TestHasSubdirectoryDelimiter(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := hasSubdirectoryDelimiter(tt.uri)
+ result := HasSubdirectoryDelimiter(tt.uri)
assert.Equal(t, tt.expected, result)
})
}
@@ -258,7 +258,7 @@ func TestIsGitURI(t *testing.T) {
uri string
expected bool
}{
- // Git URIs - known Git hosting platforms
+ // Git URIs - known Git hosting platforms.
{
name: "github.com URL",
uri: "github.com/cloudposse/atmos.git",
@@ -284,7 +284,7 @@ func TestIsGitURI(t *testing.T) {
uri: "git::https://github.com/cloudposse/atmos.git",
expected: true,
},
- // Git URIs - .git extension
+ // Git URIs - .git extension.
{
name: "URL with .git extension",
uri: "example.com/path/repo.git",
@@ -305,7 +305,7 @@ func TestIsGitURI(t *testing.T) {
uri: "git.company.com/repo.git",
expected: true,
},
- // Git URIs - Azure DevOps
+ // Git URIs - Azure DevOps.
{
name: "Azure DevOps URL",
uri: "dev.azure.com/org/project/_git/repo",
@@ -316,7 +316,7 @@ func TestIsGitURI(t *testing.T) {
uri: "dev.azure.com/org/project/_git/repo//modules",
expected: true,
},
- // Not Git URIs - false positives to avoid
+ // Not Git URIs - false positives to avoid.
{
name: "www.gitman.com should not match",
uri: "www.gitman.com/page",
@@ -325,14 +325,14 @@ func TestIsGitURI(t *testing.T) {
{
name: ".git in middle of word",
uri: "example.com/digit.github.io/page",
- expected: true, // Contains .git so it matches
+ expected: true, // Contains .git so it matches.
},
{
name: "github in path, not domain",
uri: "evil.com/github.com/fake",
expected: false,
},
- // Not Git URIs - local paths
+ // Not Git URIs - local paths.
{
name: "simple relative path",
uri: "components/terraform/vpc",
@@ -343,7 +343,7 @@ func TestIsGitURI(t *testing.T) {
uri: "/absolute/path/to/components",
expected: false,
},
- // Not Git URIs - other schemes
+ // Not Git URIs - other schemes.
{
name: "http archive URL",
uri: "https://example.com/archive.tar.gz",
@@ -359,77 +359,77 @@ func TestIsGitURI(t *testing.T) {
uri: "s3::https://s3.amazonaws.com/bucket/key",
expected: false,
},
- // Security / Malicious Edge Cases
+ // Security / Malicious Edge Cases.
{
name: "path traversal in URL",
uri: "github.com/owner/repo/../../../etc/passwd",
- expected: true, // Still a Git URL, path traversal handled by downloader
+ expected: true, // Still a Git URL, path traversal handled by downloader.
},
{
name: "null bytes in URL (Go's url.Parse handles this)",
uri: "github.com/owner/repo\x00/malicious",
- expected: false, // URL parsing fails, returns false
+ expected: false, // URL parsing fails, returns false.
},
{
name: "unicode homograph attack - gιthub.com (Greek iota)",
uri: "gιthub.com/owner/repo",
- expected: false, // Not actual github.com
+ expected: false, // Not actual github.com.
},
{
name: "double scheme exploitation attempt",
uri: "git::git::https://github.com/owner/repo",
- expected: true, // Has git:: prefix
+ expected: true, // Has git:: prefix.
},
{
name: "file:// with git patterns to trick detection",
uri: "file:///tmp/fake.git",
- expected: true, // Has .git in path, but file:// scheme prevents actual Git clone
+ expected: true, // Has .git in path, but file:// scheme prevents actual Git clone.
},
{
name: "javascript: pseudo-protocol",
uri: "javascript:alert('XSS').git",
- expected: false, // Not a Git URL
+ expected: false, // Not a Git URL.
},
{
name: "data: URL with .git",
uri: "data:text/html,.git",
- expected: false, // Not a Git URL
+ expected: false, // Not a Git URL.
},
{
name: "extremely long URL to test DoS",
uri: "github.com/" + strings.Repeat("a", 10000) + "/repo.git",
- expected: true, // Still valid Git URL, length handled elsewhere
+ expected: true, // Still valid Git URL, length handled elsewhere.
},
{
name: "URL with credentials in path segment",
uri: "evil.com/https://user:pass@github.com/fake",
- expected: false, // Not actual GitHub in host
+ expected: false, // Not actual GitHub in host.
},
{
name: "Mixed case attack - GiThUb.CoM",
uri: "GiThUb.CoM/owner/repo",
- expected: true, // Case-insensitive matching
+ expected: true, // Case-insensitive matching.
},
{
name: "Subdomain confusion - evil-github.com",
uri: "evil-github.com/owner/repo.git",
- expected: true, // Has .git extension, would attempt Git clone
+ expected: true, // Has .git extension, would attempt Git clone.
},
{
name: "Port number in URL (url.Parse handles correctly)",
uri: "github.com:22/owner/repo.git",
- expected: true, // url.Parse treats :22 as port, still github.com host with .git
+ expected: true, // url.Parse treats :22 as port, still github.com host with .git.
},
{
name: "SCP-style Git URL (not standard HTTP URL)",
uri: "git@github.com:owner/repo.git",
- expected: true, // SCP-style detected via regex pattern before url.Parse
+ expected: true, // SCP-style detected via regex pattern before url.Parse.
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := isGitURI(tt.uri)
+ result := IsGitURI(tt.uri)
assert.Equal(t, tt.expected, result)
})
}
@@ -441,7 +441,7 @@ func TestIsDomainLikeURI(t *testing.T) {
uri string
expected bool
}{
- // Domain-like structures (hostname.domain/path)
+ // Domain-like structures (hostname.domain/path).
{
name: "self-hosted git server",
uri: "git.company.com/team/repo",
@@ -467,7 +467,7 @@ func TestIsDomainLikeURI(t *testing.T) {
uri: "code.example.org/path/to/repo",
expected: true,
},
- // Not domain-like
+ // Not domain-like.
{
name: "no dot in URI",
uri: "localhost/path",
@@ -481,7 +481,7 @@ func TestIsDomainLikeURI(t *testing.T) {
{
name: "dot at end with no slash",
uri: "example.com",
- expected: false, // No slash after domain
+ expected: false, // No slash after domain.
},
{
name: "relative path with ../",
@@ -512,7 +512,7 @@ func TestIsDomainLikeURI(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := isDomainLikeURI(tt.uri)
+ result := IsDomainLikeURI(tt.uri)
assert.Equal(t, tt.expected, result)
})
}
@@ -524,7 +524,7 @@ func TestIsLocalPath(t *testing.T) {
uri string
expected bool
}{
- // Local paths - with prefix
+ // Local paths - with prefix.
{
name: "absolute unix path",
uri: "/absolute/path/to/components",
@@ -540,7 +540,7 @@ func TestIsLocalPath(t *testing.T) {
uri: "../parent/path",
expected: true,
},
- // Local paths - without prefix (relative)
+ // Local paths - without prefix (relative).
{
name: "relative path without prefix",
uri: "components/terraform/vpc",
@@ -556,7 +556,7 @@ func TestIsLocalPath(t *testing.T) {
uri: "components",
expected: true,
},
- // Remote paths - scheme separators
+ // Remote paths - scheme separators.
{
name: "https scheme",
uri: "https://github.com/owner/repo.git",
@@ -582,7 +582,7 @@ func TestIsLocalPath(t *testing.T) {
uri: "oci://ghcr.io/owner/image:tag",
expected: false,
},
- // Remote paths - go-getter subdirectory delimiter
+ // Remote paths - go-getter subdirectory delimiter.
{
name: "github with subdirectory delimiter",
uri: "github.com/owner/repo.git//modules/vpc",
@@ -593,7 +593,7 @@ func TestIsLocalPath(t *testing.T) {
uri: "git.company.com/repo.git//path",
expected: false,
},
- // Remote paths - Git URIs
+ // Remote paths - Git URIs.
{
name: "github URL",
uri: "github.com/cloudposse/atmos.git",
@@ -619,7 +619,7 @@ func TestIsLocalPath(t *testing.T) {
uri: "dev.azure.com/org/project/_git/repo",
expected: false,
},
- // Domain-like URIs
+ // Domain-like URIs.
{
name: "self-hosted git server",
uri: "git.company.com/team/repo",
@@ -639,7 +639,7 @@ func TestIsLocalPath(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := isLocalPath(tt.uri)
+ result := IsLocalPath(tt.uri)
assert.Equal(t, tt.expected, result)
})
}
@@ -652,7 +652,7 @@ func TestContainsTripleSlash(t *testing.T) {
uri string
expected bool
}{
- // Contains triple-slash
+ // Contains triple-slash.
{
name: "triple-slash at end",
uri: "github.com/owner/repo.git///?ref=v1.0",
@@ -678,7 +678,7 @@ func TestContainsTripleSlash(t *testing.T) {
uri: "git::https://github.com/owner/repo.git///examples",
expected: true,
},
- // Does not contain triple-slash
+ // Does not contain triple-slash.
{
name: "double-slash only",
uri: "github.com/owner/repo.git//modules",
@@ -718,7 +718,7 @@ func TestContainsTripleSlash(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := containsTripleSlash(tt.uri)
+ result := ContainsTripleSlash(tt.uri)
assert.Equal(t, tt.expected, result)
})
}
@@ -731,7 +731,7 @@ func TestParseSubdirFromTripleSlash(t *testing.T) {
expectedSource string
expectedSubdir string
}{
- // Triple-slash with subdirectory
+ // Triple-slash with subdirectory.
{
name: "triple-slash with modules path",
uri: "github.com/owner/repo.git///modules?ref=v1.0",
@@ -756,7 +756,7 @@ func TestParseSubdirFromTripleSlash(t *testing.T) {
expectedSource: "https://dev.azure.com/org/proj/_git/repo?ref=main",
expectedSubdir: "modules",
},
- // Triple-slash at root (no path after ///)
+ // Triple-slash at root (no path after ///).
{
name: "triple-slash at root with query",
uri: "github.com/owner/repo.git///?ref=v1.0",
@@ -769,7 +769,7 @@ func TestParseSubdirFromTripleSlash(t *testing.T) {
expectedSource: "github.com/owner/repo.git",
expectedSubdir: "",
},
- // Double-slash patterns (should not have leading / in subdir)
+ // Double-slash patterns (should not have leading / in subdir).
{
name: "double-slash-dot",
uri: "github.com/owner/repo.git//.?ref=v1.0",
@@ -782,7 +782,7 @@ func TestParseSubdirFromTripleSlash(t *testing.T) {
expectedSource: "github.com/owner/repo.git?ref=v1.0",
expectedSubdir: "modules",
},
- // Edge cases
+ // Edge cases.
{
name: "no delimiter",
uri: "github.com/owner/repo.git?ref=v1.0",
@@ -799,7 +799,7 @@ func TestParseSubdirFromTripleSlash(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- source, subdir := parseSubdirFromTripleSlash(tt.uri)
+ source, subdir := ParseSubdirFromTripleSlash(tt.uri)
assert.Equal(t, tt.expectedSource, source)
assert.Equal(t, tt.expectedSubdir, subdir)
})
@@ -890,7 +890,7 @@ func TestNeedsDoubleSlashDot(t *testing.T) {
uri: "https://example.com/archive.tar.gz",
expected: false,
},
- // Special case: URIs that pass isGitURI() but are special types (lines 243-245).
+ // Special case: URIs that pass IsGitURI() but are special types (lines 243-245).
{
name: "file:// with .git pattern",
uri: "file:///tmp/repo.git",
@@ -911,7 +911,7 @@ func TestNeedsDoubleSlashDot(t *testing.T) {
uri: "https://gitlab.com/group/project/-/archive/main/project.tar.gz",
expected: false, // Contains gitlab.com but is an archive URL.
},
- // Edge cases
+ // Edge cases.
{
name: "empty string",
uri: "",
@@ -926,7 +926,7 @@ func TestNeedsDoubleSlashDot(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := needsDoubleSlashDot(tt.uri)
+ result := NeedsDoubleSlashDot(tt.uri)
assert.Equal(t, tt.expected, result)
})
}
@@ -938,7 +938,7 @@ func TestAppendDoubleSlashDot(t *testing.T) {
uri string
expected string
}{
- // Without query parameters
+ // Without query parameters.
{
name: "simple github URL",
uri: "github.com/owner/repo.git",
@@ -959,7 +959,7 @@ func TestAppendDoubleSlashDot(t *testing.T) {
uri: "git.company.com/team/repo.git",
expected: "git.company.com/team/repo.git//.",
},
- // With query parameters - should preserve query string
+ // With query parameters - should preserve query string.
{
name: "with ref query param",
uri: "github.com/owner/repo.git?ref=v1.0",
@@ -980,7 +980,7 @@ func TestAppendDoubleSlashDot(t *testing.T) {
uri: "git::https://github.com/owner/repo.git?ref=main",
expected: "git::https://github.com/owner/repo.git//.?ref=main",
},
- // Edge cases
+ // Edge cases.
{
name: "empty string",
uri: "",
@@ -1005,7 +1005,7 @@ func TestAppendDoubleSlashDot(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- result := appendDoubleSlashDot(tt.uri)
+ result := AppendDoubleSlashDot(tt.uri)
assert.Equal(t, tt.expected, result)
})
}
diff --git a/internal/exec/vendor_utils.go b/pkg/vendoring/utils.go
similarity index 75%
rename from internal/exec/vendor_utils.go
rename to pkg/vendoring/utils.go
index 1d31e6a872..da2e974002 100644
--- a/internal/exec/vendor_utils.go
+++ b/pkg/vendoring/utils.go
@@ -1,4 +1,4 @@
-package exec
+package vendoring
import (
"fmt"
@@ -9,41 +9,25 @@ import (
"strings"
"github.com/bmatcuk/doublestar/v4"
- cp "github.com/otiai10/copy"
- "github.com/pkg/errors"
"github.com/samber/lo"
"go.yaml.in/yaml/v3"
+ errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/internal/exec"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/schema"
u "github.com/cloudposse/atmos/pkg/utils"
+ "github.com/cloudposse/atmos/pkg/vendoring/uri"
)
-// Dedicated logger for stderr to keep stdout clean of detailed messaging, e.g. for files vendoring.
+// StderrLogger is a dedicated logger for stderr to keep stdout clean of detailed messaging, e.g. for files vendoring.
var StderrLogger = func() *log.AtmosLogger {
l := log.New()
l.SetOutput(os.Stderr)
return l
}()
-var (
- ErrVendorComponents = errors.New("failed to vendor components")
- ErrSourceMissing = errors.New("'source' must be specified in 'sources' in the vendor config file")
- ErrTargetsMissing = errors.New("'targets' must be specified for the source in the vendor config file")
- ErrVendorConfigSelfImport = errors.New("vendor config file imports itself in 'spec.imports'")
- ErrMissingVendorConfigDefinition = errors.New("either 'spec.sources' or 'spec.imports' (or both) must be defined in the vendor config file")
- ErrVendoringNotConfigured = errors.New("Vendoring is not configured. To set up vendoring, please see https://atmos.tools/core-concepts/vendor/")
- ErrPermissionDenied = errors.New("Permission denied when accessing")
- ErrEmptySources = errors.New("'spec.sources' is empty in the vendor config file and the imports")
- ErrNoComponentsWithTags = errors.New("there are no components in the vendor config file")
- ErrNoYAMLConfigFiles = errors.New("no YAML configuration files found in directory")
- ErrDuplicateComponents = errors.New("duplicate component names")
- ErrDuplicateImport = errors.New("Duplicate import")
- ErrDuplicateComponentsFound = errors.New("duplicate component")
- ErrComponentNotDefined = errors.New("the flag '--component' is passed, but the component is not defined in any of the 'sources' in the vendor config file and the imports")
-)
-
type processTargetsParams struct {
AtmosConfig *schema.AtmosConfiguration
IndexSource int
@@ -63,6 +47,13 @@ type executeVendorOptions struct {
dryRun bool
}
+// sourceTypeResult contains the result of determining a source URI's type.
+type sourceTypeResult struct {
+ UseOciScheme bool
+ UseLocalFileSystem bool
+ SourceIsLocalFile bool
+}
+
type vendorSourceParams struct {
atmosConfig *schema.AtmosConfiguration
sources []schema.AtmosVendorSource
@@ -131,10 +122,10 @@ func getConfigFiles(path string) ([]string, error) {
fileInfo, err := os.Stat(path)
if err != nil {
if os.IsNotExist(err) {
- return nil, ErrVendoringNotConfigured
+ return nil, errUtils.ErrVendoringNotConfigured
}
if os.IsPermission(err) {
- return nil, fmt.Errorf("%w '%s'. Please check the file permissions", ErrPermissionDenied, path)
+ return nil, fmt.Errorf("%w '%s'. Please check the file permissions", errUtils.ErrPermissionDenied, path)
}
return nil, fmt.Errorf("An error occurred while accessing the vendoring configuration: %w", err)
}
@@ -147,7 +138,7 @@ func getConfigFiles(path string) ([]string, error) {
}
if len(matches) == 0 {
- return nil, fmt.Errorf("%w '%s'", ErrNoYAMLConfigFiles, path)
+ return nil, fmt.Errorf("%w '%s'", errUtils.ErrNoYAMLConfigFiles, path)
}
for i, match := range matches {
matches[i] = filepath.Join(path, match)
@@ -179,7 +170,7 @@ func mergeVendorConfigFiles(configFiles []string) (schema.AtmosVendorConfig, err
source := currentConfig.Spec.Sources[i]
if source.Component != "" {
if sourceMap[source.Component] {
- return vendorConfig, fmt.Errorf("%w '%s' found in config file '%s'", ErrDuplicateComponentsFound, source.Component, configFile)
+ return vendorConfig, fmt.Errorf("%w '%s' found in config file '%s'", errUtils.ErrDuplicateComponentsFound, source.Component, configFile)
}
sourceMap[source.Component] = true
}
@@ -206,7 +197,7 @@ func ExecuteAtmosVendorInternal(params *executeVendorOptions) error {
logInitialMessage(params.vendorConfigFileName, params.tags)
if len(params.atmosVendorSpec.Sources) == 0 && len(params.atmosVendorSpec.Imports) == 0 {
- return fmt.Errorf("%w '%s'", ErrMissingVendorConfigDefinition, params.vendorConfigFileName)
+ return fmt.Errorf("%w '%s'", errUtils.ErrMissingVendorConfigDefinition, params.vendorConfigFileName)
}
// Process imports and return all sources from all the imports and from `vendor.yaml`.
sources, _, err := processVendorImports(
@@ -221,7 +212,7 @@ func ExecuteAtmosVendorInternal(params *executeVendorOptions) error {
}
if len(sources) == 0 {
- return fmt.Errorf("%w %s", ErrEmptySources, params.vendorConfigFileName)
+ return fmt.Errorf("%w %s", errUtils.ErrEmptySources, params.vendorConfigFileName)
}
if err := validateTagsAndComponents(sources, params.vendorConfigFileName, params.component, params.tags); err != nil {
@@ -259,7 +250,7 @@ func validateTagsAndComponents(
if len(lo.Intersect(tags, componentTags)) == 0 {
return fmt.Errorf("%w '%s' tagged with the tags %v",
- ErrNoComponentsWithTags, vendorConfigFileName, tags)
+ errUtils.ErrNoComponentsWithTags, vendorConfigFileName, tags)
}
}
@@ -269,12 +260,12 @@ func validateTagsAndComponents(
if duplicates := lo.FindDuplicates(components); len(duplicates) > 0 {
return fmt.Errorf("%w %v in the vendor config file '%s' and the imports",
- ErrDuplicateComponents, duplicates, vendorConfigFileName)
+ errUtils.ErrDuplicateComponents, duplicates, vendorConfigFileName)
}
if component != "" && !u.SliceContainsString(components, component) {
return fmt.Errorf("%w component '%s', file '%s'",
- ErrComponentNotDefined, component, vendorConfigFileName)
+ errUtils.ErrVendorComponentNotDefinedInConfig, component, vendorConfigFileName)
}
return nil
@@ -297,7 +288,7 @@ func processAtmosVendorSource(params *vendorSourceParams) ([]pkgAtmosVendor, err
}{params.sources[indexSource].Component, params.sources[indexSource].Version}
// Parse 'source' template
- uri, err := ProcessTmpl(params.atmosConfig, fmt.Sprintf("source-%d", indexSource), params.sources[indexSource].Source, tmplData, false)
+ uri, err := exec.ProcessTmpl(params.atmosConfig, fmt.Sprintf("source-%d", indexSource), params.sources[indexSource].Source, tmplData, false)
if err != nil {
return nil, err
}
@@ -307,11 +298,11 @@ func processAtmosVendorSource(params *vendorSourceParams) ([]pkgAtmosVendor, err
// security fixes.
uri = normalizeVendorURI(uri)
- useOciScheme, useLocalFileSystem, sourceIsLocalFile, err := determineSourceType(&uri, params.vendorConfigFilePath)
+ sourceType, err := determineSourceType(&uri, params.vendorConfigFilePath)
if err != nil {
return nil, err
}
- if !useLocalFileSystem {
+ if !sourceType.UseLocalFileSystem {
err = u.ValidateURI(uri)
if err != nil {
if strings.Contains(uri, "..") {
@@ -321,10 +312,10 @@ func processAtmosVendorSource(params *vendorSourceParams) ([]pkgAtmosVendor, err
}
}
- // Determine package type
- pType := determinePackageType(useOciScheme, useLocalFileSystem)
+ // Determine package type.
+ pType := determinePackageType(sourceType.UseOciScheme, sourceType.UseLocalFileSystem)
- // Process each target within the source
+ // Process each target within the source.
pkgs, err := processTargets(&processTargetsParams{
AtmosConfig: params.atmosConfig,
IndexSource: indexSource,
@@ -333,7 +324,7 @@ func processAtmosVendorSource(params *vendorSourceParams) ([]pkgAtmosVendor, err
VendorConfigFilePath: params.vendorConfigFilePath,
URI: uri,
PkgType: pType,
- SourceIsLocalFile: sourceIsLocalFile,
+ SourceIsLocalFile: sourceType.SourceIsLocalFile,
})
if err != nil {
return nil, err
@@ -356,7 +347,7 @@ func determinePackageType(useOciScheme, useLocalFileSystem bool) pkgType {
func processTargets(params *processTargetsParams) ([]pkgAtmosVendor, error) {
var packages []pkgAtmosVendor
for indexTarget, tgt := range params.Source.Targets {
- target, err := ProcessTmpl(params.AtmosConfig, fmt.Sprintf("target-%d-%d", params.IndexSource, indexTarget), tgt, params.TemplateData, false)
+ target, err := exec.ProcessTmpl(params.AtmosConfig, fmt.Sprintf("target-%d-%d", params.IndexSource, indexTarget), tgt, params.TemplateData, false)
if err != nil {
return nil, err
}
@@ -392,7 +383,7 @@ func processVendorImports(
for _, imp := range imports {
if u.SliceContainsString(allImports, imp) {
return nil, nil, fmt.Errorf("%w '%s' in the vendor config file '%s'. It was already imported in the import chain",
- ErrDuplicateImport,
+ errUtils.ErrDuplicateImport,
imp,
vendorConfigFile,
)
@@ -406,11 +397,11 @@ func processVendorImports(
}
if u.SliceContainsString(vendorConfig.Spec.Imports, imp) {
- return nil, nil, fmt.Errorf("%w file '%s'", ErrVendorConfigSelfImport, imp)
+ return nil, nil, fmt.Errorf("%w file '%s'", errUtils.ErrVendorConfigSelfImport, imp)
}
if len(vendorConfig.Spec.Sources) == 0 && len(vendorConfig.Spec.Imports) == 0 {
- return nil, nil, fmt.Errorf("%w '%s'", ErrMissingVendorConfigDefinition, imp)
+ return nil, nil, fmt.Errorf("%w '%s'", errUtils.ErrMissingVendorConfigDefinition, imp)
}
mergedSources, allImports, err = processVendorImports(atmosConfig, imp, vendorConfig.Spec.Imports, mergedSources, allImports)
@@ -442,10 +433,10 @@ func validateSourceFields(s *schema.AtmosVendorSource, vendorConfigFileName stri
s.File = vendorConfigFileName
}
if s.Source == "" {
- return fmt.Errorf("%w `%s`", ErrSourceMissing, s.File)
+ return fmt.Errorf("%w `%s`", errUtils.ErrSourceMissing, s.File)
}
if len(s.Targets) == 0 {
- return fmt.Errorf("%w for source '%s' in file '%s'", ErrTargetsMissing, s.Source, s.File)
+ return fmt.Errorf("%w for source '%s' in file '%s'", errUtils.ErrTargetsMissing, s.Source, s.File)
}
return nil
}
@@ -474,109 +465,91 @@ func shouldSkipSource(s *schema.AtmosVendorSource, component string, tags []stri
// - "github.com/repo.git//some/path?ref=v1.0.0" -> unchanged
//
//nolint:godot // Private function, follows standard Go documentation style.
-func normalizeVendorURI(uri string) string {
- // Skip normalization for special URI types
- if isFileURI(uri) || isOCIURI(uri) || isS3URI(uri) || isLocalPath(uri) || isNonGitHTTPURI(uri) {
- return uri
+func normalizeVendorURI(vendorURI string) string {
+ // Skip normalization for special URI types.
+ if uri.IsFileURI(vendorURI) || uri.IsOCIURI(vendorURI) || uri.IsS3URI(vendorURI) || uri.IsLocalPath(vendorURI) || uri.IsNonGitHTTPURI(vendorURI) {
+ return vendorURI
}
- // Handle triple-slash pattern first
- if containsTripleSlash(uri) {
- uri = normalizeTripleSlash(uri)
+ // Handle triple-slash pattern first.
+ if uri.ContainsTripleSlash(vendorURI) {
+ vendorURI = normalizeTripleSlash(vendorURI)
}
- // Add //. to Git URLs without subdirectory
- if needsDoubleSlashDot(uri) {
- uri = appendDoubleSlashDot(uri)
- log.Debug("Added //. to Git URL without subdirectory", "normalized", uri)
+ // Add //. to Git URLs without subdirectory.
+ if uri.NeedsDoubleSlashDot(vendorURI) {
+ vendorURI = uri.AppendDoubleSlashDot(vendorURI)
+ log.Debug("Added //. to Git URL without subdirectory", "normalized", vendorURI)
}
- return uri
+ return vendorURI
}
// normalizeTripleSlash converts triple-slash patterns to appropriate double-slash patterns.
// Uses go-getter's SourceDirSubdir for robust parsing across all Git platforms.
-func normalizeTripleSlash(uri string) string {
- // Use go-getter to parse the URI and extract subdirectory
- // Note: source will include query parameters from the original URI
- source, subdir := parseSubdirFromTripleSlash(uri)
+func normalizeTripleSlash(vendorURI string) string {
+ // Use go-getter to parse the URI and extract subdirectory.
+ // Note: source will include query parameters from the original URI.
+ source, subdir := uri.ParseSubdirFromTripleSlash(vendorURI)
- // Separate query parameters from source if present
+ // Separate query parameters from source if present.
var queryParams string
if queryPos := strings.Index(source, "?"); queryPos != -1 {
queryParams = source[queryPos:]
source = source[:queryPos]
}
- // Determine the normalized form based on subdirectory
+ // Determine the normalized form based on subdirectory.
var normalized string
if subdir == "" {
// Root of repository case: convert /// to //.
normalized = source + "//." + queryParams
log.Debug("Normalized triple-slash to double-slash-dot for repository root",
- "original", uri, "normalized", normalized)
+ "original", vendorURI, "normalized", normalized)
} else {
- // Path specified after triple slash: convert /// to //
+ // Path specified after triple slash: convert /// to //.
normalized = source + "//" + subdir + queryParams
log.Debug("Normalized triple-slash to double-slash with path",
- "original", uri, "normalized", normalized)
+ "original", vendorURI, "normalized", normalized)
}
return normalized
}
-func determineSourceType(uri *string, vendorConfigFilePath string) (bool, bool, bool, error) {
- // Determine if the URI is an OCI scheme, a local file, or remote
- useLocalFileSystem := false
- sourceIsLocalFile := false
- useOciScheme := strings.HasPrefix(*uri, "oci://")
- if useOciScheme {
- *uri = strings.TrimPrefix(*uri, "oci://")
- return useOciScheme, useLocalFileSystem, sourceIsLocalFile, nil
+func determineSourceType(sourceURI *string, vendorConfigFilePath string) (sourceTypeResult, error) {
+ // Determine if the URI is an OCI scheme, a local file, or remote.
+ result := sourceTypeResult{}
+
+ result.UseOciScheme = strings.HasPrefix(*sourceURI, "oci://")
+ if result.UseOciScheme {
+ *sourceURI = strings.TrimPrefix(*sourceURI, "oci://")
+ return result, nil
}
- absPath, err := u.JoinPathAndValidate(filepath.ToSlash(vendorConfigFilePath), *uri)
- // if URI contain path traversal is path should be resolved
- if err != nil && strings.Contains(*uri, "..") && !strings.HasPrefix(*uri, "file://") {
- return useOciScheme, useLocalFileSystem, sourceIsLocalFile, fmt.Errorf("invalid source path '%s': %w", *uri, err)
+ absPath, err := u.JoinPathAndValidate(filepath.ToSlash(vendorConfigFilePath), *sourceURI)
+ // if URI contain path traversal is path should be resolved.
+ if err != nil && strings.Contains(*sourceURI, "..") && !strings.HasPrefix(*sourceURI, "file://") {
+ return result, fmt.Errorf("invalid source path '%s': %w", *sourceURI, err)
}
if err == nil {
- uri = &absPath
- useLocalFileSystem = true
- sourceIsLocalFile = u.FileExists(*uri)
+ *sourceURI = absPath
+ result.UseLocalFileSystem = true
+ result.SourceIsLocalFile = u.FileExists(*sourceURI)
}
- parsedURL, err := url.Parse(*uri)
+ parsedURL, err := url.Parse(*sourceURI)
if err != nil {
- return useOciScheme, useLocalFileSystem, sourceIsLocalFile, err
+ return result, err
}
if parsedURL.Scheme != "" {
if parsedURL.Scheme == "file" {
trimmedPath := strings.TrimPrefix(filepath.ToSlash(parsedURL.Path), "/")
- *uri = filepath.Clean(trimmedPath)
- useLocalFileSystem = true
+ *sourceURI = filepath.Clean(trimmedPath)
+ result.UseLocalFileSystem = true
}
}
- return useOciScheme, useLocalFileSystem, sourceIsLocalFile, nil
-}
-
-func copyToTarget(tempDir, targetPath string, s *schema.AtmosVendorSource, sourceIsLocalFile bool, uri string) error {
- copyOptions := cp.Options{
- Skip: generateSkipFunction(tempDir, s),
- PreserveTimes: false,
- PreserveOwner: false,
- OnSymlink: func(src string) cp.SymlinkAction { return cp.Deep },
- }
-
- // Adjust the target path if it's a local file with no extension
- if sourceIsLocalFile && filepath.Ext(targetPath) == "" {
- // Sanitize the URI for safe filenames, especially on Windows
- sanitizedBase := SanitizeFileName(uri)
- targetPath = filepath.Join(targetPath, sanitizedBase)
- }
-
- return cp.Copy(tempDir, targetPath, copyOptions)
+ return result, nil
}
// GenerateSkipFunction creates a function that determines whether to skip files during copying.
diff --git a/internal/exec/vendor_utils_test.go b/pkg/vendoring/utils_test.go
similarity index 98%
rename from internal/exec/vendor_utils_test.go
rename to pkg/vendoring/utils_test.go
index ea05fc5aba..c6d4a15d54 100644
--- a/internal/exec/vendor_utils_test.go
+++ b/pkg/vendoring/utils_test.go
@@ -1,4 +1,4 @@
-package exec
+package vendoring
import (
"os"
@@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
+ "github.com/cloudposse/atmos/internal/exec"
cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/schema"
)
@@ -460,7 +461,7 @@ spec:
Version string
}{source.Component, source.Version}
- processedURI, err := ProcessTmpl(&atmosConfig, "test-source", source.Source, tmplData, false)
+ processedURI, err := exec.ProcessTmpl(&atmosConfig, "test-source", source.Source, tmplData, false)
require.NoError(t, err, "Template processing should succeed")
// Verify the template was processed correctly
@@ -522,7 +523,7 @@ spec:
Version string
}{source.Component, source.Version}
- processedURI, err := ProcessTmpl(&atmosConfig, "test-source", source.Source, tmplData, false)
+ processedURI, err := exec.ProcessTmpl(&atmosConfig, "test-source", source.Source, tmplData, false)
require.NoError(t, err, "Template processing should succeed")
// Verify version was substituted but no token in URL yet (automatic injection happens in go-getter)
@@ -626,7 +627,7 @@ spec:
Version string
}{source.Component, source.Version}
- processedURI, err := ProcessTmpl(&atmosConfig, "test-source", source.Source, tmplData, false)
+ processedURI, err := exec.ProcessTmpl(&atmosConfig, "test-source", source.Source, tmplData, false)
require.NoError(t, err, "Template processing should succeed")
// Verify the expected URI format
diff --git a/pkg/vendoring/version/check.go b/pkg/vendoring/version/check.go
new file mode 100644
index 0000000000..d76090e10d
--- /dev/null
+++ b/pkg/vendoring/version/check.go
@@ -0,0 +1,163 @@
+package version
+
+import (
+ "context"
+ "fmt"
+ "os/exec"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/Masterminds/semver/v3"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/pkg/perf"
+)
+
+// CheckResult represents the result of checking for version updates.
+type CheckResult struct {
+ Component string
+ CurrentVersion string
+ LatestVersion string
+ UpdateType string // "major", "minor", "patch", "none"
+ IsOutdated bool
+ GitURI string
+}
+
+// GetGitRemoteTags fetches all tags from a remote Git repository using git ls-remote.
+func GetGitRemoteTags(gitURI string) ([]string, error) {
+ defer perf.Track(nil, "version.GetGitRemoteTags")()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ cmd := exec.CommandContext(ctx, "git", "ls-remote", "--tags", "--refs", gitURI)
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("%w: GetGitRemoteTags %s: %w", errUtils.ErrGitLsRemoteFailed, gitURI, err)
+ }
+
+ // Parse output: each line is "commit_hash\trefs/tags/tag_name".
+ lines := strings.Split(strings.TrimSpace(string(output)), "\n")
+ tags := make([]string, 0, len(lines))
+
+ for _, line := range lines {
+ parts := strings.Split(line, "\t")
+ if len(parts) != 2 {
+ continue
+ }
+
+ // Extract tag name from "refs/tags/tag_name".
+ tagRef := parts[1]
+ if strings.HasPrefix(tagRef, "refs/tags/") {
+ tagName := strings.TrimPrefix(tagRef, "refs/tags/")
+ tags = append(tags, tagName)
+ }
+ }
+
+ return tags, nil
+}
+
+// ParseSemVer attempts to parse a version string as a semantic version.
+func ParseSemVer(version string) (*semver.Version, error) {
+ defer perf.Track(nil, "version.ParseSemVer")()
+
+ // Remove common prefixes.
+ version = strings.TrimPrefix(version, "v")
+ version = strings.TrimPrefix(version, "V")
+
+ return semver.NewVersion(version)
+}
+
+// FindLatestSemVerTag finds the latest semantic version from a list of tags.
+func FindLatestSemVerTag(tags []string) (*semver.Version, string) {
+ defer perf.Track(nil, "version.FindLatestSemVerTag")()
+
+ var latestVer *semver.Version
+ var latestTag string
+
+ for _, tag := range tags {
+ ver, err := ParseSemVer(tag)
+ if err != nil {
+ // Skip non-semantic version tags.
+ continue
+ }
+
+ if latestVer == nil || ver.GreaterThan(latestVer) {
+ latestVer = ver
+ latestTag = tag
+ }
+ }
+
+ return latestVer, latestTag
+}
+
+// CheckGitRef verifies that a Git reference (tag, branch, or commit) exists in a remote repository.
+func CheckGitRef(gitURI string, ref string) (bool, error) {
+ defer perf.Track(nil, "version.CheckGitRef")()
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ // Try as tag first.
+ cmd := exec.CommandContext(ctx, "git", "ls-remote", "--tags", gitURI, ref)
+ output, err := cmd.Output()
+ if err != nil {
+ return false, fmt.Errorf("%w: CheckGitRef %s %s (tag): %w", errUtils.ErrGitLsRemoteFailed, gitURI, ref, err)
+ }
+ if len(strings.TrimSpace(string(output))) > 0 {
+ return true, nil
+ }
+
+ // Try as branch.
+ cmd = exec.CommandContext(ctx, "git", "ls-remote", "--heads", gitURI, ref)
+ output, err = cmd.Output()
+ if err != nil {
+ return false, fmt.Errorf("%w: CheckGitRef %s %s (branch): %w", errUtils.ErrGitLsRemoteFailed, gitURI, ref, err)
+ }
+ if len(strings.TrimSpace(string(output))) > 0 {
+ return true, nil
+ }
+
+ // Try as commit SHA (this requires fetching, so we'll just validate format).
+ if IsValidCommitSHA(ref) {
+ // We assume it exists if it's a valid SHA format.
+ // Full validation would require cloning/fetching.
+ return true, nil
+ }
+
+ return false, nil
+}
+
+// IsValidCommitSHA checks if a string looks like a valid Git commit SHA.
+func IsValidCommitSHA(ref string) bool {
+ defer perf.Track(nil, "version.IsValidCommitSHA")()
+
+ // Full SHA: 40 hex chars, short SHA: 7-40 hex chars.
+ matched, _ := regexp.MatchString(`^[0-9a-f]{7,40}$`, ref)
+ return matched
+}
+
+// ExtractGitURI extracts a clean Git URI from a vendor source string.
+// It handles git:: prefixes, github.com/ shorthand, query parameters, and .git suffixes.
+func ExtractGitURI(source string) string {
+ defer perf.Track(nil, "version.ExtractGitURI")()
+
+ // Handle git:: prefix.
+ source = strings.TrimPrefix(source, "git::")
+
+ // Handle github.com/ shorthand.
+ if strings.HasPrefix(source, "github.com/") {
+ source = "https://" + source
+ }
+
+ // Remove query parameters and fragments (like ?ref=xxx).
+ if idx := strings.Index(source, "?"); idx != -1 {
+ source = source[:idx]
+ }
+
+ // Clean up .git suffix if present.
+ source = strings.TrimSuffix(source, ".git")
+
+ return source
+}
diff --git a/pkg/vendoring/version/check_test.go b/pkg/vendoring/version/check_test.go
new file mode 100644
index 0000000000..8746175898
--- /dev/null
+++ b/pkg/vendoring/version/check_test.go
@@ -0,0 +1,229 @@
+package version
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+)
+
+func TestParseSemVer(t *testing.T) {
+ tests := []struct {
+ name string
+ version string
+ expectError bool
+ expected string
+ }{
+ {
+ name: "version with v prefix",
+ version: "v1.2.3",
+ expectError: false,
+ expected: "1.2.3",
+ },
+ {
+ name: "version with V prefix",
+ version: "V2.0.0",
+ expectError: false,
+ expected: "2.0.0",
+ },
+ {
+ name: "version without prefix",
+ version: "1.5.0",
+ expectError: false,
+ expected: "1.5.0",
+ },
+ {
+ name: "invalid version",
+ version: "not-a-version",
+ expectError: true,
+ },
+ {
+ name: "version with prerelease",
+ version: "v1.0.0-alpha.1",
+ expectError: false,
+ expected: "1.0.0-alpha.1",
+ },
+ {
+ name: "version with build metadata",
+ version: "1.0.0+build.123",
+ expectError: false,
+ expected: "1.0.0+build.123",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ver, err := ParseSemVer(tt.version)
+
+ if tt.expectError {
+ assert.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.expected, ver.String())
+ }
+ })
+ }
+}
+
+func TestFindLatestSemVerTag(t *testing.T) {
+ tests := []struct {
+ name string
+ tags []string
+ expectedVersion string
+ expectedTag string
+ }{
+ {
+ name: "mixed semantic versions",
+ tags: []string{"v1.0.0", "v1.2.3", "v1.1.0", "v2.0.0", "v1.5.0"},
+ expectedVersion: "2.0.0",
+ expectedTag: "v2.0.0",
+ },
+ {
+ name: "versions with and without v prefix",
+ tags: []string{"1.0.0", "v2.0.0", "1.5.0"},
+ expectedVersion: "2.0.0",
+ expectedTag: "v2.0.0",
+ },
+ {
+ name: "versions with prerelease",
+ tags: []string{"v1.0.0", "v1.1.0-alpha", "v1.0.5"},
+ expectedVersion: "1.1.0-alpha",
+ expectedTag: "v1.1.0-alpha",
+ },
+ {
+ name: "non-semantic version tags ignored",
+ tags: []string{"latest", "main", "v1.0.0", "dev", "v0.5.0"},
+ expectedVersion: "1.0.0",
+ expectedTag: "v1.0.0",
+ },
+ {
+ name: "no semantic versions",
+ tags: []string{"latest", "main", "dev"},
+ expectedVersion: "",
+ expectedTag: "",
+ },
+ {
+ name: "empty tag list",
+ tags: []string{},
+ expectedVersion: "",
+ expectedTag: "",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ ver, tag := FindLatestSemVerTag(tt.tags)
+
+ if tt.expectedVersion == "" {
+ assert.Nil(t, ver)
+ assert.Empty(t, tag)
+ } else {
+ require.NotNil(t, ver)
+ assert.Equal(t, tt.expectedVersion, ver.String())
+ assert.Equal(t, tt.expectedTag, tag)
+ }
+ })
+ }
+}
+
+func TestIsValidCommitSHA(t *testing.T) {
+ tests := []struct {
+ name string
+ ref string
+ expected bool
+ }{
+ {
+ name: "full SHA",
+ ref: "a1b2c3d4e5f61728192021222324252627282930",
+ expected: true,
+ },
+ {
+ name: "short SHA - 7 chars",
+ ref: "a1b2c3d",
+ expected: true,
+ },
+ {
+ name: "short SHA - 8 chars",
+ ref: "a1b2c3d4",
+ expected: true,
+ },
+ {
+ name: "too short - 6 chars",
+ ref: "a1b2c3",
+ expected: false,
+ },
+ {
+ name: "too long - 41 chars",
+ ref: "a1b2c3d4e5f617281920212223242526272829301",
+ expected: false,
+ },
+ {
+ name: "contains uppercase",
+ ref: "A1B2C3D",
+ expected: false,
+ },
+ {
+ name: "contains invalid characters",
+ ref: "xyz123g",
+ expected: false,
+ },
+ {
+ name: "not a SHA",
+ ref: "v1.0.0",
+ expected: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := IsValidCommitSHA(tt.ref)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
+
+func TestExtractGitURI(t *testing.T) {
+ tests := []struct {
+ name string
+ source string
+ expected string
+ }{
+ {
+ name: "git:: prefix",
+ source: "git::https://github.com/cloudposse/terraform-aws-components",
+ expected: "https://github.com/cloudposse/terraform-aws-components",
+ },
+ {
+ name: "github.com shorthand",
+ source: "github.com/cloudposse/terraform-aws-components",
+ expected: "https://github.com/cloudposse/terraform-aws-components",
+ },
+ {
+ name: "https URL",
+ source: "https://github.com/cloudposse/terraform-aws-components.git",
+ expected: "https://github.com/cloudposse/terraform-aws-components",
+ },
+ {
+ name: "git@ SSH URL",
+ source: "git@github.com:cloudposse/terraform-aws-components.git",
+ expected: "git@github.com:cloudposse/terraform-aws-components",
+ },
+ {
+ name: "URL with query parameters",
+ source: "https://github.com/cloudposse/terraform-aws-components?ref=main",
+ expected: "https://github.com/cloudposse/terraform-aws-components",
+ },
+ {
+ name: "complex git:: URL with ref",
+ source: "git::https://github.com/cloudposse/terraform-aws-components.git?ref=tags/0.1.0",
+ expected: "https://github.com/cloudposse/terraform-aws-components",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ result := ExtractGitURI(tt.source)
+ assert.Equal(t, tt.expected, result)
+ })
+ }
+}
diff --git a/pkg/vendoring/version/constraints.go b/pkg/vendoring/version/constraints.go
new file mode 100644
index 0000000000..5b85c79e78
--- /dev/null
+++ b/pkg/vendoring/version/constraints.go
@@ -0,0 +1,184 @@
+package version
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/Masterminds/semver/v3"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+)
+
+// ResolveVersionConstraints applies version constraints to filter a list of available versions.
+// Returns the latest version that satisfies all constraints, or an error if no version matches.
+func ResolveVersionConstraints(
+ availableVersions []string,
+ constraints *schema.VendorConstraints,
+) (string, error) {
+ defer perf.Track(nil, "version.ResolveVersionConstraints")()
+
+ if constraints == nil {
+ // No constraints - return latest version.
+ if len(availableVersions) == 0 {
+ return "", errUtils.ErrNoVersionsAvailable
+ }
+ return SelectLatestVersion(availableVersions)
+ }
+
+ // Filter through constraint pipeline.
+ filtered := availableVersions
+
+ // Step 1: Filter by semver constraint.
+ if constraints.Version != "" {
+ var err error
+ filtered, err = FilterBySemverConstraint(filtered, constraints.Version)
+ if err != nil {
+ return "", err
+ }
+ }
+
+ // Step 2: Filter excluded versions.
+ if len(constraints.ExcludedVersions) > 0 {
+ filtered = FilterExcludedVersions(filtered, constraints.ExcludedVersions)
+ }
+
+ // Step 3: Filter pre-releases.
+ if constraints.NoPrereleases {
+ filtered = FilterPrereleases(filtered)
+ }
+
+ // Step 4: Select latest from remaining versions.
+ if len(filtered) == 0 {
+ return "", errUtils.ErrNoVersionsMatchConstraints
+ }
+
+ return SelectLatestVersion(filtered)
+}
+
+// FilterBySemverConstraint filters versions by semantic version constraint.
+func FilterBySemverConstraint(versions []string, constraint string) ([]string, error) {
+ defer perf.Track(nil, "version.FilterBySemverConstraint")()
+
+ c, err := semver.NewConstraint(constraint)
+ if err != nil {
+ return nil, errors.Join(
+ errUtils.ErrInvalidSemverConstraint,
+ fmt.Errorf("invalid semver constraint %q: %w", constraint, err),
+ )
+ }
+
+ var filtered []string
+ for _, v := range versions {
+ // Try parsing as semver.
+ sv, err := semver.NewVersion(v)
+ if err != nil {
+ // Not a valid semver - skip it.
+ continue
+ }
+
+ if c.Check(sv) {
+ filtered = append(filtered, v)
+ }
+ }
+
+ return filtered, nil
+}
+
+// FilterExcludedVersions filters out excluded versions (supports wildcards).
+func FilterExcludedVersions(versions []string, excluded []string) []string {
+ defer perf.Track(nil, "version.FilterExcludedVersions")()
+
+ var filtered []string
+
+ for _, v := range versions {
+ exclude := false
+ for _, pattern := range excluded {
+ if MatchesWildcard(v, pattern) {
+ exclude = true
+ break
+ }
+ }
+ if !exclude {
+ filtered = append(filtered, v)
+ }
+ }
+
+ return filtered
+}
+
+// MatchesWildcard checks if a version matches a wildcard pattern.
+// Supports patterns like "1.5.*" or exact matches like "1.2.3".
+func MatchesWildcard(version, pattern string) bool {
+ defer perf.Track(nil, "version.MatchesWildcard")()
+
+ // Exact match.
+ if version == pattern {
+ return true
+ }
+
+ // Wildcard pattern.
+ if strings.Contains(pattern, "*") {
+ prefix := strings.TrimSuffix(pattern, "*")
+ return strings.HasPrefix(version, prefix)
+ }
+
+ return false
+}
+
+// FilterPrereleases filters out pre-release versions.
+func FilterPrereleases(versions []string) []string {
+ defer perf.Track(nil, "version.FilterPrereleases")()
+
+ var filtered []string
+
+ for _, v := range versions {
+ sv, err := semver.NewVersion(v)
+ if err != nil {
+ // Not a valid semver - keep it.
+ filtered = append(filtered, v)
+ continue
+ }
+
+ // Keep if not a pre-release.
+ if sv.Prerelease() == "" {
+ filtered = append(filtered, v)
+ }
+ }
+
+ return filtered
+}
+
+// SelectLatestVersion selects the latest version from a list using semver comparison.
+func SelectLatestVersion(versions []string) (string, error) {
+ defer perf.Track(nil, "version.SelectLatestVersion")()
+
+ if len(versions) == 0 {
+ return "", errUtils.ErrNoVersionsAvailable
+ }
+
+ var latest *semver.Version
+ var latestStr string
+
+ for _, v := range versions {
+ sv, err := semver.NewVersion(v)
+ if err != nil {
+ // Not a valid semver - skip it.
+ continue
+ }
+
+ if latest == nil || sv.GreaterThan(latest) {
+ latest = sv
+ latestStr = v
+ }
+ }
+
+ if latest == nil {
+ // No valid semver found - return first version as fallback.
+ return versions[0], nil
+ }
+
+ return latestStr, nil
+}
diff --git a/pkg/vendoring/version/constraints_test.go b/pkg/vendoring/version/constraints_test.go
new file mode 100644
index 0000000000..98d06c9f93
--- /dev/null
+++ b/pkg/vendoring/version/constraints_test.go
@@ -0,0 +1,409 @@
+package version
+
+import (
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/cloudposse/atmos/pkg/schema"
+)
+
+func TestResolveVersionConstraints(t *testing.T) {
+ tests := []struct {
+ name string
+ versions []string
+ constraints *schema.VendorConstraints
+ want string
+ wantErr bool
+ }{
+ {
+ name: "no constraints returns latest",
+ versions: []string{"1.0.0", "1.1.0", "1.2.0"},
+ constraints: nil,
+ want: "1.2.0",
+ wantErr: false,
+ },
+ {
+ name: "caret constraint allows minor updates",
+ versions: []string{"1.0.0", "1.1.0", "1.2.0", "2.0.0"},
+ constraints: &schema.VendorConstraints{
+ Version: "^1.0.0",
+ },
+ want: "1.2.0",
+ wantErr: false,
+ },
+ {
+ name: "tilde constraint allows patch updates",
+ versions: []string{"1.2.0", "1.2.1", "1.2.2", "1.3.0"},
+ constraints: &schema.VendorConstraints{
+ Version: "~1.2.0",
+ },
+ want: "1.2.2",
+ wantErr: false,
+ },
+ {
+ name: "range constraint",
+ versions: []string{"1.0.0", "1.5.0", "2.0.0", "2.5.0"},
+ constraints: &schema.VendorConstraints{
+ Version: ">=1.0.0 <2.0.0",
+ },
+ want: "1.5.0",
+ wantErr: false,
+ },
+ {
+ name: "excluded versions filter specific versions",
+ versions: []string{"1.0.0", "1.1.0", "1.2.0", "1.3.0"},
+ constraints: &schema.VendorConstraints{
+ ExcludedVersions: []string{"1.2.0"},
+ },
+ want: "1.3.0",
+ wantErr: false,
+ },
+ {
+ name: "excluded versions with wildcard",
+ versions: []string{"1.5.0", "1.5.1", "1.5.2", "1.6.0"},
+ constraints: &schema.VendorConstraints{
+ ExcludedVersions: []string{"1.5.*"},
+ },
+ want: "1.6.0",
+ wantErr: false,
+ },
+ {
+ name: "no prereleases filter",
+ versions: []string{"1.0.0-alpha", "1.0.0-beta", "1.0.0", "1.1.0"},
+ constraints: &schema.VendorConstraints{
+ NoPrereleases: true,
+ },
+ want: "1.1.0",
+ wantErr: false,
+ },
+ {
+ name: "combined constraints - version and excluded",
+ versions: []string{"1.0.0", "1.1.0", "1.2.0", "1.3.0", "2.0.0"},
+ constraints: &schema.VendorConstraints{
+ Version: "^1.0.0",
+ ExcludedVersions: []string{"1.2.0"},
+ },
+ want: "1.3.0",
+ wantErr: false,
+ },
+ {
+ name: "combined constraints - all filters",
+ versions: []string{"1.0.0", "1.1.0-rc1", "1.2.0", "1.3.0", "2.0.0"},
+ constraints: &schema.VendorConstraints{
+ Version: "^1.0.0",
+ ExcludedVersions: []string{"1.2.0"},
+ NoPrereleases: true,
+ },
+ want: "1.3.0",
+ wantErr: false,
+ },
+ {
+ name: "no versions match constraints",
+ versions: []string{"1.0.0", "1.1.0"},
+ constraints: &schema.VendorConstraints{
+ Version: "^2.0.0",
+ },
+ want: "",
+ wantErr: true,
+ },
+ {
+ name: "empty version list",
+ versions: []string{},
+ constraints: nil,
+ want: "",
+ wantErr: true,
+ },
+ {
+ name: "invalid semver constraint",
+ versions: []string{"1.0.0"},
+ constraints: &schema.VendorConstraints{
+ Version: "invalid",
+ },
+ want: "",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := ResolveVersionConstraints(tt.versions, tt.constraints)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
+
+func TestFilterBySemverConstraint(t *testing.T) {
+ tests := []struct {
+ name string
+ versions []string
+ constraint string
+ want []string
+ wantErr bool
+ }{
+ {
+ name: "caret allows compatible versions",
+ versions: []string{"1.0.0", "1.1.0", "1.2.0", "2.0.0"},
+ constraint: "^1.0.0",
+ want: []string{"1.0.0", "1.1.0", "1.2.0"},
+ wantErr: false,
+ },
+ {
+ name: "tilde allows patch versions",
+ versions: []string{"1.2.0", "1.2.1", "1.2.2", "1.3.0"},
+ constraint: "~1.2.0",
+ want: []string{"1.2.0", "1.2.1", "1.2.2"},
+ wantErr: false,
+ },
+ {
+ name: "greater than or equal",
+ versions: []string{"1.0.0", "1.5.0", "2.0.0"},
+ constraint: ">=1.5.0",
+ want: []string{"1.5.0", "2.0.0"},
+ wantErr: false,
+ },
+ {
+ name: "range constraint",
+ versions: []string{"1.0.0", "1.5.0", "2.0.0", "2.5.0"},
+ constraint: ">=1.0.0 <2.0.0",
+ want: []string{"1.0.0", "1.5.0"},
+ wantErr: false,
+ },
+ {
+ name: "invalid constraint",
+ versions: []string{"1.0.0"},
+ constraint: "not-a-constraint",
+ want: nil,
+ wantErr: true,
+ },
+ {
+ name: "filters non-semver versions",
+ versions: []string{"1.0.0", "latest", "1.1.0", "main"},
+ constraint: "^1.0.0",
+ want: []string{"1.0.0", "1.1.0"},
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := FilterBySemverConstraint(tt.versions, tt.constraint)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
+
+func TestFilterExcludedVersions(t *testing.T) {
+ tests := []struct {
+ name string
+ versions []string
+ excluded []string
+ want []string
+ }{
+ {
+ name: "exact match exclusion",
+ versions: []string{"1.0.0", "1.1.0", "1.2.0"},
+ excluded: []string{"1.1.0"},
+ want: []string{"1.0.0", "1.2.0"},
+ },
+ {
+ name: "wildcard exclusion",
+ versions: []string{"1.5.0", "1.5.1", "1.5.2", "1.6.0"},
+ excluded: []string{"1.5.*"},
+ want: []string{"1.6.0"},
+ },
+ {
+ name: "multiple exact exclusions",
+ versions: []string{"1.0.0", "1.1.0", "1.2.0", "1.3.0"},
+ excluded: []string{"1.0.0", "1.2.0"},
+ want: []string{"1.1.0", "1.3.0"},
+ },
+ {
+ name: "mixed exact and wildcard",
+ versions: []string{"1.0.0", "1.5.0", "1.5.1", "1.6.0"},
+ excluded: []string{"1.0.0", "1.5.*"},
+ want: []string{"1.6.0"},
+ },
+ {
+ name: "no exclusions",
+ versions: []string{"1.0.0", "1.1.0"},
+ excluded: []string{},
+ want: []string{"1.0.0", "1.1.0"},
+ },
+ {
+ name: "no matches",
+ versions: []string{"1.0.0", "1.1.0"},
+ excluded: []string{"2.0.0"},
+ want: []string{"1.0.0", "1.1.0"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := FilterExcludedVersions(tt.versions, tt.excluded)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestMatchesWildcard(t *testing.T) {
+ tests := []struct {
+ name string
+ version string
+ pattern string
+ want bool
+ }{
+ {
+ name: "exact match",
+ version: "1.2.3",
+ pattern: "1.2.3",
+ want: true,
+ },
+ {
+ name: "wildcard match patch",
+ version: "1.2.3",
+ pattern: "1.2.*",
+ want: true,
+ },
+ {
+ name: "wildcard match minor",
+ version: "1.2.3",
+ pattern: "1.*",
+ want: true,
+ },
+ {
+ name: "wildcard no match",
+ version: "2.0.0",
+ pattern: "1.*",
+ want: false,
+ },
+ {
+ name: "no wildcard no match",
+ version: "1.2.3",
+ pattern: "1.2.4",
+ want: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := MatchesWildcard(tt.version, tt.pattern)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestFilterPrereleases(t *testing.T) {
+ tests := []struct {
+ name string
+ versions []string
+ want []string
+ }{
+ {
+ name: "filters alpha versions",
+ versions: []string{"1.0.0-alpha", "1.0.0"},
+ want: []string{"1.0.0"},
+ },
+ {
+ name: "filters beta versions",
+ versions: []string{"1.0.0-beta.1", "1.0.0"},
+ want: []string{"1.0.0"},
+ },
+ {
+ name: "filters rc versions",
+ versions: []string{"1.0.0-rc1", "1.0.0"},
+ want: []string{"1.0.0"},
+ },
+ {
+ name: "keeps stable versions",
+ versions: []string{"1.0.0", "1.1.0", "1.2.0"},
+ want: []string{"1.0.0", "1.1.0", "1.2.0"},
+ },
+ {
+ name: "mixed prereleases and stable",
+ versions: []string{"1.0.0-alpha", "1.0.0", "1.1.0-beta", "1.1.0"},
+ want: []string{"1.0.0", "1.1.0"},
+ },
+ {
+ name: "keeps non-semver strings",
+ versions: []string{"latest", "main", "1.0.0-alpha", "1.0.0"},
+ want: []string{"latest", "main", "1.0.0"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := FilterPrereleases(tt.versions)
+ assert.Equal(t, tt.want, got)
+ })
+ }
+}
+
+func TestSelectLatestVersion(t *testing.T) {
+ tests := []struct {
+ name string
+ versions []string
+ want string
+ wantErr bool
+ }{
+ {
+ name: "selects highest semver",
+ versions: []string{"1.0.0", "1.2.0", "1.1.0"},
+ want: "1.2.0",
+ wantErr: false,
+ },
+ {
+ name: "handles major version differences",
+ versions: []string{"1.9.0", "2.0.0", "1.10.0"},
+ want: "2.0.0",
+ wantErr: false,
+ },
+ {
+ name: "handles patch versions",
+ versions: []string{"1.0.0", "1.0.1", "1.0.2"},
+ want: "1.0.2",
+ wantErr: false,
+ },
+ {
+ name: "fallback to first for non-semver",
+ versions: []string{"latest", "main"},
+ want: "latest",
+ wantErr: false,
+ },
+ {
+ name: "empty list",
+ versions: []string{},
+ want: "",
+ wantErr: true,
+ },
+ {
+ name: "single version",
+ versions: []string{"1.0.0"},
+ want: "1.0.0",
+ wantErr: false,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := SelectLatestVersion(tt.versions)
+ if tt.wantErr {
+ require.Error(t, err)
+ } else {
+ require.NoError(t, err)
+ assert.Equal(t, tt.want, got)
+ }
+ })
+ }
+}
diff --git a/pkg/vendoring/yaml_updater.go b/pkg/vendoring/yaml_updater.go
new file mode 100644
index 0000000000..f674dd60da
--- /dev/null
+++ b/pkg/vendoring/yaml_updater.go
@@ -0,0 +1,223 @@
+//nolint:revive // Error wrapping pattern used throughout.
+package vendoring
+
+import (
+ "fmt"
+ "os"
+
+ "gopkg.in/yaml.v3"
+
+ errUtils "github.com/cloudposse/atmos/errors"
+ "github.com/cloudposse/atmos/pkg/perf"
+ "github.com/cloudposse/atmos/pkg/schema"
+)
+
+// updateYAMLVersion updates a version field in a YAML file while preserving structure.
+// This uses yaml.v3's Node API to preserve comments, anchors, and formatting.
+func updateYAMLVersion(atmosConfig *schema.AtmosConfiguration, filePath string, componentName string, newVersion string) error {
+ defer perf.Track(atmosConfig, "exec.updateYAMLVersion")()
+
+ // Read the YAML file
+ data, err := os.ReadFile(filePath)
+ if err != nil {
+ return fmt.Errorf("%w: %w", errUtils.ErrReadFile, err)
+ }
+
+ // Parse into yaml.Node to preserve structure
+ var root yaml.Node
+ if err := yaml.Unmarshal(data, &root); err != nil {
+ return fmt.Errorf("%w: %w", errUtils.ErrParseFile, err)
+ }
+
+ // Find and update the version field for the specified component
+ updated := false
+ if err := updateVersionInNode(&root, componentName, newVersion, &updated); err != nil {
+ return err
+ }
+
+ if !updated {
+ return fmt.Errorf("%w: %s", errUtils.ErrComponentNotFound, componentName)
+ }
+
+ // Marshal back to YAML, preserving structure
+ out, err := yaml.Marshal(&root)
+ if err != nil {
+ return fmt.Errorf("%w: %w", errUtils.ErrYAMLUpdateFailed, err)
+ }
+
+ // Write back to file
+ if err := os.WriteFile(filePath, out, 0o644); err != nil { //nolint:gosec,revive // Standard file permissions for YAML config files
+ return fmt.Errorf("%w: %w", errUtils.ErrYAMLUpdateFailed, err)
+ }
+
+ return nil
+}
+
+// updateVersionInNode recursively searches for a component and updates its version.
+// This preserves the Node structure including comments, anchors, and formatting.
+//
+//nolint:gocognit,nestif,revive,cyclop // YAML tree traversal naturally has high complexity.
+func updateVersionInNode(node *yaml.Node, componentName string, newVersion string, updated *bool) error {
+ if node == nil {
+ return nil
+ }
+
+ switch node.Kind {
+ case yaml.DocumentNode:
+ // Document node wraps the content
+ for _, child := range node.Content {
+ if err := updateVersionInNode(child, componentName, newVersion, updated); err != nil {
+ return err
+ }
+ }
+
+ case yaml.MappingNode:
+ // Mapping node is a key-value map
+ // Content is [key1, value1, key2, value2, ...]
+ for i := 0; i < len(node.Content); i += 2 {
+ keyNode := node.Content[i]
+ valueNode := node.Content[i+1]
+
+ // Check if this is a "sources" section
+ if keyNode.Value == "sources" && valueNode.Kind == yaml.SequenceNode {
+ // Search sources for the component
+ for _, sourceNode := range valueNode.Content {
+ if sourceNode.Kind == yaml.MappingNode {
+ // Look for component and version fields
+ var compNameFound bool
+ var versionNodeIndex int
+
+ for j := 0; j < len(sourceNode.Content); j += 2 {
+ k := sourceNode.Content[j]
+ v := sourceNode.Content[j+1]
+
+ if k.Value == "component" && v.Value == componentName {
+ compNameFound = true
+ }
+ if k.Value == "version" {
+ versionNodeIndex = j + 1
+ }
+ }
+
+ // If we found the component, update its version
+ if compNameFound && versionNodeIndex > 0 {
+ sourceNode.Content[versionNodeIndex].Value = newVersion
+ *updated = true
+ return nil
+ }
+ }
+ }
+ }
+
+ // Recursively search in nested structures
+ if err := updateVersionInNode(valueNode, componentName, newVersion, updated); err != nil {
+ return err
+ }
+ }
+
+ case yaml.SequenceNode:
+ // Sequence node is an array
+ for _, child := range node.Content {
+ if err := updateVersionInNode(child, componentName, newVersion, updated); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
+
+// findComponentVersion finds the current version of a component in a YAML file.
+func findComponentVersion(atmosConfig *schema.AtmosConfiguration, filePath string, componentName string) (string, error) {
+ defer perf.Track(atmosConfig, "exec.findComponentVersion")()
+
+ // Read the YAML file
+ data, err := os.ReadFile(filePath)
+ if err != nil {
+ return "", fmt.Errorf("%w: %w", errUtils.ErrReadFile, err)
+ }
+
+ // Parse into yaml.Node
+ var root yaml.Node
+ if err := yaml.Unmarshal(data, &root); err != nil {
+ return "", fmt.Errorf("%w: %w", errUtils.ErrParseFile, err)
+ }
+
+ // Find the version
+ version := ""
+ if err := findVersionInNode(&root, componentName, &version); err != nil {
+ return "", err
+ }
+
+ if version == "" {
+ return "", fmt.Errorf("%w: %s", errUtils.ErrComponentNotFound, componentName)
+ }
+
+ return version, nil
+}
+
+// findVersionInNode recursively searches for a component and returns its version.
+//
+//nolint:gocognit,nestif,revive,cyclop // YAML tree traversal naturally has high complexity.
+func findVersionInNode(node *yaml.Node, componentName string, version *string) error {
+ if node == nil {
+ return nil
+ }
+
+ switch node.Kind {
+ case yaml.DocumentNode:
+ for _, child := range node.Content {
+ if err := findVersionInNode(child, componentName, version); err != nil {
+ return err
+ }
+ }
+
+ case yaml.MappingNode:
+ // Content is [key1, value1, key2, value2, ...]
+ for i := 0; i < len(node.Content); i += 2 {
+ keyNode := node.Content[i]
+ valueNode := node.Content[i+1]
+
+ // Check if this is a "sources" section
+ if keyNode.Value == "sources" && valueNode.Kind == yaml.SequenceNode {
+ for _, sourceNode := range valueNode.Content {
+ if sourceNode.Kind == yaml.MappingNode {
+ var compNameFound bool
+ var compVersion string
+
+ for j := 0; j < len(sourceNode.Content); j += 2 {
+ k := sourceNode.Content[j]
+ v := sourceNode.Content[j+1]
+
+ if k.Value == "component" && v.Value == componentName {
+ compNameFound = true
+ }
+ if k.Value == "version" {
+ compVersion = v.Value
+ }
+ }
+
+ if compNameFound && compVersion != "" {
+ *version = compVersion
+ return nil
+ }
+ }
+ }
+ }
+
+ // Recursively search
+ if err := findVersionInNode(valueNode, componentName, version); err != nil {
+ return err
+ }
+ }
+
+ case yaml.SequenceNode:
+ for _, child := range node.Content {
+ if err := findVersionInNode(child, componentName, version); err != nil {
+ return err
+ }
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/vendoring/yaml_updater_test.go b/pkg/vendoring/yaml_updater_test.go
new file mode 100644
index 0000000000..67478b4181
--- /dev/null
+++ b/pkg/vendoring/yaml_updater_test.go
@@ -0,0 +1,205 @@
+package vendoring
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
+
+ "github.com/cloudposse/atmos/pkg/schema"
+)
+
+func TestUpdateYAMLVersion(t *testing.T) {
+ tests := []struct {
+ name string
+ inputYAML string
+ componentName string
+ newVersion string
+ expectError bool
+ }{
+ {
+ name: "update version in simple vendor.yaml",
+ inputYAML: `apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.0.0
+ targets:
+ - components/terraform/vpc
+`,
+ componentName: "vpc",
+ newVersion: "1.2.3",
+ expectError: false,
+ },
+ {
+ name: "update version with comments preserved",
+ inputYAML: `# Vendor configuration
+apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ # VPC component
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.0.0 # current stable version
+ targets:
+ - components/terraform/vpc
+`,
+ componentName: "vpc",
+ newVersion: "2.0.0",
+ expectError: false,
+ },
+ {
+ name: "update specific component in multi-component config",
+ inputYAML: `apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.0.0
+ targets:
+ - components/terraform/vpc
+ - component: eks
+ source: github.com/cloudposse/terraform-aws-components
+ version: 2.0.0
+ targets:
+ - components/terraform/eks
+`,
+ componentName: "eks",
+ newVersion: "2.5.0",
+ expectError: false,
+ },
+ {
+ name: "component not found",
+ inputYAML: `apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.0.0
+ targets:
+ - components/terraform/vpc
+`,
+ componentName: "nonexistent",
+ newVersion: "1.0.0",
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create temp file
+ tempDir := t.TempDir()
+ tempFile := filepath.Join(tempDir, "vendor.yaml")
+ err := os.WriteFile(tempFile, []byte(tt.inputYAML), 0o644)
+ require.NoError(t, err)
+
+ // Update version
+ atmosConfig := &schema.AtmosConfiguration{}
+ err = updateYAMLVersion(atmosConfig, tempFile, tt.componentName, tt.newVersion)
+
+ if tt.expectError {
+ assert.Error(t, err)
+ return
+ }
+
+ require.NoError(t, err)
+
+ // Read back and verify version was updated
+ version, err := findComponentVersion(atmosConfig, tempFile, tt.componentName)
+ require.NoError(t, err)
+ assert.Equal(t, tt.newVersion, version)
+ })
+ }
+}
+
+func TestFindComponentVersion(t *testing.T) {
+ tests := []struct {
+ name string
+ inputYAML string
+ componentName string
+ expectedVersion string
+ expectError bool
+ }{
+ {
+ name: "find version in simple config",
+ inputYAML: `apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.2.3
+ targets:
+ - components/terraform/vpc
+`,
+ componentName: "vpc",
+ expectedVersion: "1.2.3",
+ expectError: false,
+ },
+ {
+ name: "find version in multi-component config",
+ inputYAML: `apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.0.0
+ targets:
+ - components/terraform/vpc
+ - component: eks
+ source: github.com/cloudposse/terraform-aws-components
+ version: 2.5.0
+ targets:
+ - components/terraform/eks
+`,
+ componentName: "eks",
+ expectedVersion: "2.5.0",
+ expectError: false,
+ },
+ {
+ name: "component not found",
+ inputYAML: `apiVersion: atmos/v1
+kind: AtmosVendorConfig
+spec:
+ sources:
+ - component: vpc
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.0.0
+ targets:
+ - components/terraform/vpc
+`,
+ componentName: "nonexistent",
+ expectError: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ // Create temp file
+ tempDir := t.TempDir()
+ tempFile := filepath.Join(tempDir, "vendor.yaml")
+ err := os.WriteFile(tempFile, []byte(tt.inputYAML), 0o644)
+ require.NoError(t, err)
+
+ // Find version
+ atmosConfig := &schema.AtmosConfiguration{}
+ version, err := findComponentVersion(atmosConfig, tempFile, tt.componentName)
+
+ if tt.expectError {
+ assert.Error(t, err)
+ return
+ }
+
+ require.NoError(t, err)
+ assert.Equal(t, tt.expectedVersion, version)
+ })
+ }
+}
diff --git a/website/blog/2025-10-21-vendor-update-and-diff.md b/website/blog/2025-10-21-vendor-update-and-diff.md
new file mode 100644
index 0000000000..254bc4c940
--- /dev/null
+++ b/website/blog/2025-10-21-vendor-update-and-diff.md
@@ -0,0 +1,410 @@
+---
+slug: vendor-update-and-diff
+title: "Automated Component Updates with Vendor Update and Diff"
+authors: [atmos]
+tags: [feature, vendoring, automation, components]
+date: 2025-10-21
+---
+
+The fundamental problem with infrastructure dependencies isn't just keeping up—it's **getting left behind**. Today we're excited to announce two new commands that break this cycle: `atmos vendor update` and `atmos vendor diff`.
+
+
+
+## The Real Problem: Compounding Technical Debt
+
+The harder it is to update your infrastructure components, the more likely you'll fall behind. And once you fall behind, each passing day makes updates exponentially harder:
+
+**The Vicious Cycle:**
+1. **Updates are manual and risky** → You delay them
+2. **Delays accumulate** → Your components drift further from upstream
+3. **The gap widens** → Breaking changes pile up across multiple versions
+4. **Fear increases** → Updates become "migration projects" instead of routine maintenance
+5. **You're stuck** → Trapped on old versions with security vulnerabilities and missing features
+
+**The Cost of Falling Behind:**
+
+This isn't just about missing new features—it's about **losing access to the core value of open source**:
+
+- **Security patches**: Vulnerabilities get fixed upstream, but you're still exposed
+- **Bug fixes**: Issues you're working around have already been solved
+- **Community support**: Maintainers support current versions, not year-old releases
+- **Continuous improvement**: Performance optimizations, new capabilities, better patterns
+- **Compatibility**: Modern cloud services evolve; old components break
+
+### The Shared Responsibility Model
+
+AWS promotes the [Shared Responsibility Model](https://aws.amazon.com/compliance/shared-responsibility-model/) for cloud security—AWS secures the infrastructure, you secure what runs on it. The same principle applies to open source infrastructure components:
+
+- **Upstream maintainers**: Provide security patches, bug fixes, and improvements
+- **Your team**: Keep your components up-to-date to receive those benefits
+
+When you vendor components but never update them, you're abandoning your half of the shared responsibility model. You lose access to the continuous stream of patches, fixes, and improvements that make open source valuable.
+
+**The harder it is to update, the more you get left in the dust.**
+
+## The Challenge We're Solving
+
+Before today, updating vendored components meant:
+
+- Manually checking GitHub releases or tags for new versions
+- Editing `vendor.yaml` to update version numbers
+- Running `atmos vendor pull` to download new versions
+- Using external tools to diff the changes
+- Hoping you didn't introduce breaking changes
+- Repeating this process for every component, every time
+
+This manual overhead creates **update friction**—the resistance that keeps teams on old versions even when they know they should update.
+
+## The Solution
+
+We've added two powerful commands that automate this workflow while giving you complete control and visibility.
+
+### atmos vendor update
+
+Automatically check for and update to newer versions of vendored components, with intelligent version constraints to ensure safe updates.
+
+```bash
+# Update a specific component
+atmos vendor update --component vpc
+
+# Update all components
+atmos vendor update --all
+
+# Dry run to see what would be updated
+atmos vendor update --all --dry-run
+```
+
+**Key Features:**
+
+- **Semantic version constraints**: Use caret (`^`), tilde (`~`), ranges, or wildcards
+- **Version exclusion**: Blacklist specific versions or patterns
+- **Pre-release filtering**: Automatically skip alpha/beta/rc versions
+- **Dry run mode**: Preview updates before making changes
+- **Automatic vendor.yaml updates**: Updates version fields in place
+
+### atmos vendor diff
+
+Compare two versions of a component before updating, with GitHub's native diff integration for rich, formatted output.
+
+```bash
+# Diff current version against latest
+atmos vendor diff --component vpc
+
+# Diff between specific versions
+atmos vendor diff --component vpc --from 1.323.0 --to 1.400.0
+
+# Focus on specific files
+atmos vendor diff --component vpc --file main.tf
+
+# Control output format
+atmos vendor diff --component vpc --context 10 --no-color
+```
+
+**Key Features:**
+
+- **GitHub Compare API integration**: Rich, formatted diffs for GitHub sources
+- **File-specific diffs**: Focus on just the files you care about
+- **Customizable context**: Control how many lines of context to show
+- **Color/no-color output**: Terminal-friendly or pipeline-ready
+- **Version comparison**: Compare any two versions, not just current vs. latest
+
+## How It Works
+
+### Version Constraints
+
+Define update rules directly in your `vendor.yaml`:
+
+```yaml
+sources:
+ - component: "vpc"
+ source: "github.com/cloudposse/terraform-aws-components//modules/vpc?ref={{.Version}}"
+ version: "1.323.0" # Current pinned version
+ constraints:
+ version: "^1.0.0" # Stay on v1.x, allow minor/patch updates
+ excluded_versions:
+ - "1.100.0" # Skip version with breaking bug
+ - "1.5.*" # Skip problematic 1.5.x series
+ no_prereleases: true # Only stable releases
+ targets:
+ - "components/terraform/vpc"
+```
+
+**Constraint Syntax:**
+
+- **Caret (`^`)**: Compatible updates (minor and patch)
+ - `^1.0.0` → allows `1.0.0` to `1.999.999`, blocks `2.0.0`
+- **Tilde (`~`)**: Patch-level updates only
+ - `~1.2.0` → allows `1.2.0` to `1.2.999`, blocks `1.3.0`
+- **Ranges**: Explicit boundaries
+ - `>=1.0.0 <2.0.0` → any 1.x version
+- **Wildcards**: Flexible matching
+ - `1.x` or `1.*` → any 1.x version
+
+### Update Process
+
+When you run `atmos vendor update --component vpc`:
+
+1. **Fetch available versions** from the source repository
+2. **Apply constraints** to filter valid versions
+3. **Exclude blacklisted versions** from consideration
+4. **Filter pre-releases** if configured
+5. **Select latest remaining version**
+6. **Update `vendor.yaml`** with new version
+7. **Pull new component** (or use `--dry-run` to preview)
+
+### Diff Workflow
+
+Before updating, review what changed:
+
+```bash
+# Step 1: Check for updates
+atmos vendor update --component vpc --dry-run
+
+# Output:
+# Component "vpc" can be updated:
+# Current version: 1.323.0
+# Latest version: 1.400.0
+
+# Step 2: Review the diff
+atmos vendor diff --component vpc --from 1.323.0 --to 1.400.0
+
+# Output shows GitHub-style diff of all changes
+
+# Step 3: If satisfied, update
+atmos vendor update --component vpc
+```
+
+## Real-World Examples
+
+### Safe Automated Updates
+
+Lock to major versions while allowing safe updates:
+
+```yaml
+sources:
+ - component: "eks"
+ source: "github.com/cloudposse/terraform-aws-eks-cluster?ref={{.Version}}"
+ version: "2.5.0"
+ constraints:
+ version: "^2.0.0" # Stay on v2, allow minor/patch
+ no_prereleases: true # Production stability
+ targets:
+ - "components/terraform/eks"
+```
+
+### Avoiding Known Issues
+
+Skip specific problematic versions:
+
+```yaml
+sources:
+ - component: "rds"
+ source: "github.com/cloudposse/terraform-aws-rds?ref={{.Version}}"
+ version: "1.10.0"
+ constraints:
+ version: "^1.0.0"
+ excluded_versions:
+ - "1.5.0" # Has critical bug
+ - "1.6.*" # Entire series has issues
+ targets:
+ - "components/terraform/rds"
+```
+
+### Controlled Pre-release Testing
+
+Test pre-releases in dev, stable in prod:
+
+```yaml
+# dev/vendor.yaml
+sources:
+ - component: "app"
+ version: "2.0.0-beta.5"
+ constraints:
+ version: "^2.0.0-0" # Allow pre-releases
+ targets:
+ - "components/terraform/app"
+
+# prod/vendor.yaml
+sources:
+ - component: "app"
+ version: "1.8.0"
+ constraints:
+ version: "^1.0.0"
+ no_prereleases: true # Stable only
+ targets:
+ - "components/terraform/app"
+```
+
+### Bulk Updates with Review
+
+Update all components safely:
+
+```bash
+# 1. See what would change
+atmos vendor update --all --dry-run
+
+# 2. Review diffs for critical components
+atmos vendor diff --component vpc
+atmos vendor diff --component eks
+atmos vendor diff --component rds
+
+# 3. Update everything
+atmos vendor update --all
+```
+
+## Multi-Provider Architecture
+
+The diff functionality uses a provider-based architecture:
+
+- **GitHub sources**: Full diff support using GitHub Compare API
+- **Generic Git sources**: Basic operations, diff returns "not implemented"
+- **Other sources** (OCI, local, HTTP): Gracefully handled with clear errors
+
+This design allows us to provide the best experience for each source type while maintaining a consistent interface.
+
+## Integration with CI/CD
+
+Automate dependency updates in your pipelines:
+
+```yaml
+# .github/workflows/update-components.yml
+name: Update Vendored Components
+on:
+ schedule:
+ - cron: '0 0 * * 1' # Weekly on Monday
+ workflow_dispatch:
+
+jobs:
+ update:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+
+ - name: Install Atmos
+ run: |
+ curl -L https://github.com/cloudposse/atmos/releases/download/vX.X.X/atmos -o /usr/local/bin/atmos
+ chmod +x /usr/local/bin/atmos
+
+ - name: Check for updates
+ id: check
+ run: |
+ atmos vendor update --all --dry-run > updates.txt
+ cat updates.txt
+
+ - name: Create PR if updates available
+ if: contains(steps.check.outputs.stdout, 'can be updated')
+ uses: peter-evans/create-pull-request@v5
+ with:
+ commit-message: "chore: Update vendored components"
+ title: "Update Vendored Components"
+ body: |
+ Automated component updates detected.
+
+ ```
+ $(cat updates.txt)
+ ```
+
+ Review the diffs and merge if acceptable.
+ branch: automated-vendor-updates
+```
+
+## Error Handling and Safety
+
+Both commands provide comprehensive error handling:
+
+- **Version not found**: Clear message with available versions
+- **Constraint syntax errors**: Helpful validation messages
+- **Network issues**: Graceful degradation with retry suggestions
+- **No updates available**: Informative message about current state
+- **Unsupported sources**: Clear explanation of capabilities per source type
+
+## Migration Guide
+
+Existing `vendor.yaml` files work without changes. To add constraints:
+
+```yaml
+# Before (still works)
+sources:
+ - component: "vpc"
+ source: "github.com/cloudposse/terraform-aws-components//modules/vpc?ref={{.Version}}"
+ version: "1.323.0"
+ targets:
+ - "components/terraform/vpc"
+
+# After (with automated updates)
+sources:
+ - component: "vpc"
+ source: "github.com/cloudposse/terraform-aws-components//modules/vpc?ref={{.Version}}"
+ version: "1.323.0"
+ constraints: # Add this section
+ version: "^1.0.0"
+ no_prereleases: true
+ targets:
+ - "components/terraform/vpc"
+```
+
+## Why This Matters: Breaking the Update Debt Cycle
+
+These commands fundamentally change the economics of staying current:
+
+**Eliminate Update Friction**: What took hours now takes seconds. No more manual version hunting, no more copy-pasting diffs from GitHub.
+
+**Maintain Shared Responsibility**: Upstream maintainers hold up their end by shipping patches and improvements. These tools make it trivial for you to hold up yours by staying current.
+
+**Prevent Technical Debt Accumulation**: Small, frequent updates are easier than large, infrequent migrations. Automate the small updates to avoid the painful migrations.
+
+**Preserve Open Source Value**: Access the continuous stream of security patches, bug fixes, and improvements that make open source powerful. Don't vendor once and forget—vendor and evolve.
+
+**Reduce Risk Through Visibility**: Diff before updating. See exactly what changed. Make informed decisions instead of blind updates.
+
+**Enable Automation**: Integrate into CI/CD pipelines. Get weekly update PRs automatically. Review and merge instead of hunting and gathering.
+
+The goal isn't just to make updates easier—it's to **make falling behind harder**. By removing update friction, we make staying current the path of least resistance.
+
+## Technical Implementation
+
+For contributors and the curious:
+
+- **Provider interface pattern**: Clean abstraction for different source types
+- **GitHub Compare API**: Native integration for rich diffs
+- **Semver library**: Industry-standard constraint parsing
+- **Error wrapping**: Comprehensive error context for debugging
+- **Performance tracking**: Instrumented for monitoring and optimization
+
+See [docs/prd/vendor-update.md](https://github.com/cloudposse/atmos/blob/main/docs/prd/vendor-update.md) for complete technical specifications.
+
+## Getting Started
+
+Available in Atmos vX.X.X and later:
+
+```bash
+# Install or upgrade Atmos
+brew upgrade atmos # macOS
+# or download from GitHub releases
+
+# Try it out
+atmos vendor update --component vpc --dry-run
+atmos vendor diff --component vpc
+```
+
+## Resources
+
+- [`atmos vendor update` Documentation](/cli/commands/vendor/update)
+- [`atmos vendor diff` Documentation](/cli/commands/vendor/diff)
+- [Vendor Manifest Reference](/core-concepts/vendor/vendor-manifest)
+- [Vendoring Cheatsheet](/cheatsheets/vendoring)
+- [GitHub Repository](https://github.com/cloudposse/atmos)
+
+## What's Next
+
+We're exploring additional enhancements:
+
+- **Change detection**: Notify when new versions are available
+- **Rollback support**: Quickly revert to previous versions
+- **Batch operations**: Update multiple components with single command
+- **Custom update hooks**: Run validation after updates
+- **Provider expansion**: Support for more source types
+
+---
+
+*Have feedback or questions? Join our [Slack community](https://slack.cloudposse.com/) or [open an issue on GitHub](https://github.com/cloudposse/atmos/issues).*
diff --git a/website/docs/cli/commands/vendor/diff.mdx b/website/docs/cli/commands/vendor/diff.mdx
new file mode 100644
index 0000000000..94ff4eb02f
--- /dev/null
+++ b/website/docs/cli/commands/vendor/diff.mdx
@@ -0,0 +1,273 @@
+---
+title: atmos vendor diff
+sidebar_label: diff
+sidebar_class_name: command
+id: diff
+description: Show Git diff between two versions of a vendored component
+---
+
+import Screengrab from '@site/src/components/Screengrab'
+import Terminal from '@site/src/components/Terminal'
+
+:::note Purpose
+Use this command to see what changed between two versions of a vendored component by showing a Git diff directly from the remote repository, without requiring a local clone.
+:::
+
+
+
+## Usage
+
+```shell
+atmos vendor diff --component [options]
+```
+
+## Examples
+
+### Show diff between current version and latest
+
+```shell
+atmos vendor diff --component vpc
+```
+
+This compares the current version in your `vendor.yaml` with the latest version in the repository.
+
+### Show diff between specific versions
+
+```shell
+atmos vendor diff --component vpc --from v1.0.0 --to v1.2.3
+```
+
+### Show diff for a specific file
+
+```shell
+atmos vendor diff --component vpc --file variables.tf
+```
+
+### Show diff with more context lines
+
+```shell
+atmos vendor diff --component vpc --context 10
+```
+
+### Disable color output
+
+```shell
+atmos vendor diff --component vpc --no-color
+```
+
+Useful when piping output to files or other commands.
+
+## Flags
+
+
+ - `--component` / `-c`
+ - **Required**. The component to show diff for
+
+ - `--from`
+ - Starting version, tag, branch, or commit SHA (default: current version in vendor.yaml)
+
+ - `--to`
+ - Ending version, tag, branch, or commit SHA (default: latest version)
+
+ - `--file`
+ - Show diff for a specific file within the component (optional)
+
+ - `--context` / `-C`
+ - Number of context lines to show around changes (default: 3)
+
+ - `--unified`
+ - Use unified diff format (default: true)
+
+
+## How It Works
+
+1. **Finds component**: Locates the component in your `vendor.yaml` configuration
+2. **Extracts Git URI**: Parses the component's source to get the Git repository URL
+3. **Resolves versions**: Determines the `from` and `to` refs:
+ - If not specified, `from` defaults to current version in vendor.yaml
+ - If not specified, `to` defaults to latest semantic version tag
+4. **Fetches refs**: Uses shallow Git fetch to retrieve only the specified refs
+5. **Generates diff**: Runs `git diff` between the two refs
+6. **Applies colorization**: Automatically colorizes output if writing to a terminal
+
+## Colorization Behavior
+
+Output is automatically colorized when all of these conditions are met:
+
+- ✅ Output is to a terminal (TTY)
+- ✅ `--no-color` flag is NOT set
+- ✅ `TERM` environment variable is not `dumb` or empty
+- ✅ Output is not being piped to another command
+
+### Force disable colors
+
+```shell
+# Using flag
+atmos vendor diff --component vpc --no-color
+
+# Using environment variable
+TERM=dumb atmos vendor diff --component vpc
+
+# Piping automatically disables colors
+atmos vendor diff --component vpc | less
+```
+
+## Supported Version Formats
+
+The `--from` and `--to` flags accept various Git reference formats:
+
+- **Semantic versions**: `1.2.3`, `v1.2.3`
+- **Git tags**: Any tag name from the repository
+- **Git branches**: `main`, `develop`, `feature/new-feature`
+- **Commit SHAs**: Full (40 chars) or short (7+ chars) commit hashes
+- **Version ranges**: `~1.2.0`, `^1.0.0`, `>= 1.2.0` (resolves to highest matching tag)
+
+## Examples with Output
+
+### Default diff (current → latest)
+
+```shell
+$ atmos vendor diff --component vpc
+
+diff --git a/main.tf b/main.tf
+index a1b2c3d..e4f5g6h 100644
+--- a/main.tf
++++ b/main.tf
+@@ -10,7 +10,7 @@ resource "aws_vpc" "main" {
+ cidr_block = var.cidr_block
+ enable_dns_hostnames = var.enable_dns_hostnames
+ enable_dns_support = var.enable_dns_support
+- instance_tenancy = "default"
++ instance_tenancy = var.instance_tenancy
+
+ tags = module.this.tags
+ }
+```
+
+### Diff between specific versions
+
+```shell
+$ atmos vendor diff --component vpc --from v1.0.0 --to v1.2.0
+
+Showing changes between v1.0.0 and v1.2.0...
+
+diff --git a/variables.tf b/variables.tf
+index 1234567..89abcdef 100644
+--- a/variables.tf
++++ b/variables.tf
+@@ -15,6 +15,12 @@ variable "enable_dns_support" {
+ default = true
+ }
+
++variable "instance_tenancy" {
++ type = string
++ description = "Tenancy option for instances launched into the VPC"
++ default = "default"
++}
++
+ variable "tags" {
+ type = map(string)
+ description = "Additional tags"
+```
+
+### No differences found
+
+```shell
+$ atmos vendor diff --component vpc --from v1.2.3 --to v1.2.3
+
+No differences between v1.2.3 and v1.2.3
+```
+
+## Supported Sources
+
+Currently, `atmos vendor diff` only works with **Git-based sources**:
+
+- ✅ GitHub repositories (`github.com/org/repo`)
+- ✅ Git URLs (`git::https://...`, `git@...`)
+- ✅ HTTPS Git URLs
+- ❌ OCI images (not supported)
+- ❌ Local paths (not supported)
+- ❌ HTTP archives (not supported)
+
+## Use Cases
+
+### Review changes before updating
+
+```shell
+# See what changed in the latest version
+atmos vendor diff --component vpc
+
+# If changes look good, update
+atmos vendor update --component vpc
+```
+
+### Compare two release tags
+
+```shell
+# Compare what changed between two stable releases
+atmos vendor diff --component eks --from v2.0.0 --to v2.1.0
+```
+
+### Check changes in a specific file
+
+```shell
+# Only show changes to variables
+atmos vendor diff --component vpc --file variables.tf
+```
+
+### Review changes after update
+
+```shell
+# Check what actually changed after updating
+atmos vendor diff --component vpc --from 1.0.0 --to 1.2.3
+```
+
+## Troubleshooting
+
+### Error: Component not found
+
+The component name must match exactly as it appears in your `vendor.yaml`:
+
+```yaml
+spec:
+ sources:
+ - component: vpc # Use this exact name
+ source: github.com/cloudposse/terraform-aws-components
+ version: 1.0.0
+```
+
+### Error: Git reference not found
+
+Verify the version/tag/branch exists in the repository:
+
+```shell
+git ls-remote --tags https://github.com/cloudposse/terraform-aws-components
+```
+
+### Error: Unsupported vendor source
+
+This command only works with Git sources. Check that your component uses a Git-based source:
+
+```yaml
+# ✅ Supported
+source: github.com/cloudposse/terraform-aws-components
+source: git::https://github.com/org/repo
+source: git@github.com:org/repo.git
+
+# ❌ Not supported
+source: oci://registry/image
+source: ./local/path
+source: https://example.com/archive.zip
+```
+
+## Related Commands
+
+- [`atmos vendor update`](/cli/commands/vendor/update) - Update vendored component versions
+- [`atmos vendor pull`](/cli/commands/vendor/pull) - Pull vendored components
+- [`atmos describe component`](/cli/commands/describe/describe-component) - Show component configuration
+
+## See Also
+
+- [Vendor Configuration](/core-concepts/vendor) - Learn about vendor configuration format
+- [Vendoring Cheatsheet](/cheatsheets/vendoring) - Component vendoring guide
+- [Git Diff Documentation](https://git-scm.com/docs/git-diff) - Understanding Git diffs
diff --git a/website/docs/cli/commands/vendor/update.mdx b/website/docs/cli/commands/vendor/update.mdx
new file mode 100644
index 0000000000..371f889b82
--- /dev/null
+++ b/website/docs/cli/commands/vendor/update.mdx
@@ -0,0 +1,180 @@
+---
+title: atmos vendor update
+sidebar_label: update
+sidebar_class_name: command
+id: update
+description: Check for and update vendored component versions in your vendor configuration files
+---
+
+import Screengrab from '@site/src/components/Screengrab'
+import Terminal from '@site/src/components/Terminal'
+
+:::note Purpose
+Use this command to check for newer versions of vendored components and optionally update the version references in your vendor configuration files while preserving YAML structure, comments, and anchors.
+:::
+
+
+
+## Usage
+
+```shell
+atmos vendor update [options]
+```
+
+## Examples
+
+### Check for updates without making changes
+
+```shell
+atmos vendor update --check
+```
+
+This will scan your `vendor.yaml` file(s) and report any components with newer versions available, without modifying any files.
+
+### Update all components and pull them
+
+```shell
+atmos vendor update --pull
+```
+
+This will update version references in your vendor configuration files and immediately pull the new versions.
+
+### Check updates for a specific component
+
+```shell
+atmos vendor update --component vpc --check
+```
+
+### Update a specific component by tag
+
+```shell
+atmos vendor update --component vpc --tags networking
+```
+
+### Show only outdated components
+
+```shell
+atmos vendor update --outdated
+```
+
+This will only display components that have newer versions available, hiding components that are already up-to-date.
+
+## Flags
+
+
+ - `--check`
+ - Check for updates without modifying vendor configuration files (dry-run mode)
+
+ - `--pull`
+ - After updating version references, automatically execute `atmos vendor pull` to download the new versions
+
+ - `--component` / `-c`
+ - Check updates for a specific component by name
+
+ - `--tags`
+ - Filter components by tags (comma-separated list)
+
+ - `--type`
+ - Component type to check: `terraform`, `helmfile`, or `packer` (default: `terraform`)
+
+ - `--outdated`
+ - Only show components with available updates
+
+
+## How It Works
+
+1. **Scans vendor configuration**: Reads `vendor.yaml` or component-specific `component.yaml` files
+2. **Checks Git repositories**: Uses `git ls-remote` to fetch available tags without cloning
+3. **Compares versions**: Uses semantic versioning to determine if newer versions exist
+4. **Displays results**: Shows current version, latest version, and update type (major/minor/patch)
+5. **Updates YAML**: If not in `--check` mode, updates version fields while preserving:
+ - Comments
+ - YAML anchors and aliases
+ - Merge keys
+ - Original formatting
+6. **Optional pull**: If `--pull` is specified, automatically downloads the updated components
+
+## Version Detection
+
+The command supports various version formats:
+
+- **Semantic versions**: `1.2.3`, `v1.2.3`
+- **Git tags**: Any tag in the repository
+- **Git branches**: `main`, `develop`, etc.
+- **Git commits**: Full or short SHA hashes.
+
+Version comparison uses semantic versioning rules to determine:
+- **Major updates**: Breaking changes (e.g., `1.x.x` → `2.0.0`)
+- **Minor updates**: New features (e.g., `1.0.x` → `1.1.0`)
+- **Patch updates**: Bug fixes (e.g., `1.0.0` → `1.0.1`)
+
+## Supported Sources
+
+Currently, `atmos vendor update` only works with **Git-based sources**:
+
+- ✅ GitHub repositories (`github.com/org/repo`)
+- ✅ Git URLs (`git::https://...`, `git@...`)
+- ✅ HTTPS Git URLs
+- ❌ OCI images (not supported)
+- ❌ Local paths (not supported)
+- ❌ HTTP archives (not supported)
+
+## Configuration
+
+The command respects your Atmos configuration and looks for vendor files in these locations:
+
+1. Main vendor configuration: `vendor.yaml` (or path specified in `atmos.yaml`)
+2. Component vendor configs: `components/{type}/{component}/component.yaml`
+
+## Examples with Output
+
+### Checking for updates
+
+```shell
+$ atmos vendor update --check
+
+Checking for updates...
+
+Component: vpc
+ Current: 1.0.0
+ Latest: 1.2.3
+ Update: minor (1.0.0 → 1.2.3)
+
+Component: eks
+ Current: 2.5.0
+ Latest: 3.0.0
+ Update: major (2.5.0 → 3.0.0)
+
+Component: rds
+ Current: 1.5.2
+ Latest: 1.5.2
+ Status: up-to-date
+```
+
+### Updating and pulling
+
+```shell
+$ atmos vendor update --pull
+
+Updating vendor.yaml...
+✓ Updated vpc: 1.0.0 → 1.2.3
+✓ Updated eks: 2.5.0 → 3.0.0
+
+Pulling updated components...
+✓ Pulled vpc@1.2.3
+✓ Pulled eks@3.0.0
+
+Successfully updated 2 components
+```
+
+## Related Commands
+
+- [`atmos vendor pull`](/cli/commands/vendor/pull) - Pull vendored components
+- [`atmos vendor diff`](/cli/commands/vendor/diff) - Show diff between component versions
+- [`atmos describe component`](/cli/commands/describe/component) - Show component configuration
+
+## See Also
+
+- [Vendor Configuration](/core-concepts/vendor) - Learn about vendor configuration format
+- [Component Vendoring](/core-concepts/vendor) - Component vendoring concepts
+- [Semantic Versioning](https://semver.org/) - Version numbering specification