Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 15 additions & 60 deletions cmd/git/executor.go
Original file line number Diff line number Diff line change
@@ -1,18 +1,14 @@
package git

import (
"bytes"
"context"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"

errUtils "github.com/cloudposse/atmos/errors"
atmosgit "github.com/cloudposse/atmos/pkg/git"
iolib "github.com/cloudposse/atmos/pkg/io"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/ui"
"github.com/cloudposse/atmos/pkg/ui/spinner"
Expand All @@ -38,10 +34,6 @@ type Executor struct {
provider atmosgit.Provider
}

type stderrSwapper interface {
SwapStderr(io.Writer) func()
}

// newExecutor builds an Executor using the named provider from the registry.
// Pass an empty string to use the default "cli" provider.
func newExecutor(providerName string) (*Executor, error) {
Expand All @@ -65,12 +57,12 @@ func (e *Executor) Init(ctx context.Context, opts *atmosgit.InitOptions, label s
reconcile := initWillReconcile(opts)
progressMsg := initProgressMessage(label, opts, reconcile)
completedMsg := initCompletedMessage(label, opts, reconcile)
stderr, err := e.captureStderr(func() error {
stderr, err := atmosgit.CaptureStderr(e.provider, func() error {
return spinner.ExecWithSpinner(progressMsg, completedMsg, func() error {
return e.provider.Init(ctx, opts)
})
})
return wrapGitOperationError(
return atmosgit.WrapOperationError(
fmt.Sprintf("initialize Git repository %q", label),
opts.Workdir,
stderr,
Expand Down Expand Up @@ -133,7 +125,7 @@ func (e *Executor) Clone(ctx context.Context, opts *atmosgit.CloneOptions, label

progressMsg := fmt.Sprintf("Cloning %s", label)
completedMsg := fmt.Sprintf("Cloned %s into %s.", label, opts.Workdir)
stderr, err := e.captureStderr(func() error {
stderr, err := atmosgit.CaptureStderr(e.provider, func() error {
return spinner.ExecWithSpinner(progressMsg, completedMsg, func() error {
return e.provider.Clone(ctx, opts)
})
Expand All @@ -144,7 +136,7 @@ func (e *Executor) Clone(ctx context.Context, opts *atmosgit.CloneOptions, label
func (e *Executor) CloneWithoutSpinner(ctx context.Context, opts *atmosgit.CloneOptions, label string) error {
defer perf.Track(nil, "git.Executor.CloneWithoutSpinner")()

stderr, err := e.captureStderr(func() error {
stderr, err := atmosgit.CaptureStderr(e.provider, func() error {
return e.provider.Clone(ctx, opts)
})
if err != nil {
Expand All @@ -155,22 +147,8 @@ func (e *Executor) CloneWithoutSpinner(ctx context.Context, opts *atmosgit.Clone
return nil
}

func (e *Executor) captureStderr(operation func() error) (string, error) {
swapper, ok := e.provider.(stderrSwapper)
if !ok {
return "", operation()
}

var stderr bytes.Buffer
restore := swapper.SwapStderr(iolib.MaskWriter(&stderr))
defer restore()

err := operation()
return strings.TrimSpace(stderr.String()), err
}

func wrapCloneError(label, workdir, stderr string, err error) error {
return wrapGitOperationError(
return atmosgit.WrapOperationError(
fmt.Sprintf("clone Git repository %q", label),
workdir,
stderr,
Expand All @@ -179,34 +157,11 @@ func wrapCloneError(label, workdir, stderr string, err error) error {
)
}

func wrapGitOperationError(action, workdir, stderr string, err error, hint string) error {
if err == nil {
return nil
}

explanation := fmt.Sprintf("Failed to %s.", action)
if workdir != "" {
explanation = fmt.Sprintf("Failed to %s in %q.", action, workdir)
}
explanation += "\n\nUnderlying error:\n\n```text\n" + err.Error() + "\n```"
if stderr != "" {
explanation += "\n\nGit output:\n\n```text\n" + stderr + "\n```"
}

builder := errUtils.Build(err).
WithExplanation(explanation).
WithExitCode(2)
if hint != "" {
builder = builder.WithHint(hint)
}
return builder.Err()
}

// Pull delegates to the provider.
func (e *Executor) Pull(ctx context.Context, opts *atmosgit.PullOptions) error {
defer perf.Track(nil, "git.Executor.Pull")()

stderr, err := e.captureStderr(func() error {
stderr, err := atmosgit.CaptureStderr(e.provider, func() error {
return e.provider.Pull(ctx, opts)
})
if errors.Is(err, errUtils.ErrGitNoTrackingBranch) {
Expand All @@ -218,7 +173,7 @@ func (e *Executor) Pull(ctx context.Context, opts *atmosgit.PullOptions) error {
Err()
}
if err != nil {
return wrapGitOperationError(
return atmosgit.WrapOperationError(
"pull Git repository",
opts.Workdir,
stderr,
Expand All @@ -236,13 +191,13 @@ func (e *Executor) Status(ctx context.Context, opts *atmosgit.StatusOptions) (*a
defer perf.Track(nil, "git.Executor.Status")()

var result *atmosgit.StatusResult
stderr, err := e.captureStderr(func() error {
stderr, err := atmosgit.CaptureStderr(e.provider, func() error {
var opErr error
result, opErr = e.provider.Status(ctx, opts)
return opErr
})
if err != nil {
return nil, wrapGitOperationError("read Git status", opts.Workdir, stderr, err, "")
return nil, atmosgit.WrapOperationError("read Git status", opts.Workdir, stderr, err, "")
}
return result, nil
}
Expand All @@ -252,13 +207,13 @@ func (e *Executor) Diff(ctx context.Context, opts *atmosgit.DiffOptions) (*atmos
defer perf.Track(nil, "git.Executor.Diff")()

var result *atmosgit.DiffResult
stderr, err := e.captureStderr(func() error {
stderr, err := atmosgit.CaptureStderr(e.provider, func() error {
var opErr error
result, opErr = e.provider.Diff(ctx, opts)
return opErr
})
if err != nil {
return nil, wrapGitOperationError("show Git diff", opts.Workdir, stderr, err, "")
return nil, atmosgit.WrapOperationError("show Git diff", opts.Workdir, stderr, err, "")
}
return result, nil
}
Expand All @@ -268,13 +223,13 @@ func (e *Executor) Commit(ctx context.Context, opts *atmosgit.CommitOptions) (*a
defer perf.Track(nil, "git.Executor.Commit")()

var result *atmosgit.CommitResult
stderr, err := e.captureStderr(func() error {
stderr, err := atmosgit.CaptureStderr(e.provider, func() error {
var opErr error
result, opErr = e.provider.Commit(ctx, opts)
return opErr
})
if err != nil {
return nil, wrapGitOperationError("commit Git changes", opts.Workdir, stderr, err, "")
return nil, atmosgit.WrapOperationError("commit Git changes", opts.Workdir, stderr, err, "")
}
return result, nil
}
Expand All @@ -283,11 +238,11 @@ func (e *Executor) Commit(ctx context.Context, opts *atmosgit.CommitOptions) (*a
func (e *Executor) Push(ctx context.Context, opts *atmosgit.PushOptions) error {
defer perf.Track(nil, "git.Executor.Push")()

stderr, err := e.captureStderr(func() error {
stderr, err := atmosgit.CaptureStderr(e.provider, func() error {
return e.provider.Push(ctx, opts)
})
if err != nil {
return wrapGitOperationError(
return atmosgit.WrapOperationError(
"push Git repository",
opts.Workdir,
stderr,
Expand Down
11 changes: 8 additions & 3 deletions internal/exec/describe_affected_components.go
Original file line number Diff line number Diff line change
Expand Up @@ -590,6 +590,7 @@ func addKubernetesSectionAffected(
{sectionNamePaths, affectedReasonStackPaths},
{sectionNameManifests, affectedReasonStackManifests},
{sectionNameRender, affectedReasonStackRender},
{cfg.ValidateSectionName, fmt.Sprintf("stack.%s", cfg.ValidateSectionName)},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}...)
sections = appendSectionChecks(sections, resolveComponentSectionChecks(atmosConfig)...)

Expand All @@ -603,9 +604,13 @@ func addKubernetesSectionAffected(
for _, section := range sections {
value, ok := (*componentSection)[section.section]
if !ok {
continue
}
if isSectionValueEqual(locator, value, section.section) {
// validate is presence-sensitive: removing it locally (reverting to the
// enabled default) while the remote stack still has it explicitly set is
// itself a behavior change and must be detected, not silently skipped.
if section.section != cfg.ValidateSectionName || !locator.sectionPresent(section.section) {
continue
}
} else if isSectionValueEqual(locator, value, section.section) {
continue
}
err := addAffectedComponent(affected, atmosConfig, componentName, stackName, cfg.KubernetesComponentType,
Expand Down
44 changes: 44 additions & 0 deletions internal/exec/describe_affected_components_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package exec

import (
"fmt"
"path/filepath"
"testing"

Expand Down Expand Up @@ -252,6 +253,49 @@ func TestAddKubernetesSectionAffected_NoFalsePositives(t *testing.T) {
require.NoError(t, err)
assert.Empty(t, affected)
})

t.Run("validate absent locally and absent remotely is not affected", func(t *testing.T) {
componentSection := map[string]any{}
remoteStacks := k8sRemoteStacksWith(map[string]any{})

var affected []schema.Affected
err := addKubernetesSectionAffected(
&affected, k8sAtmosConfig(), componentName, stackName,
&componentSection, &remoteStacks, &remoteStacks,
false, false,
)
require.NoError(t, err)
assert.Empty(t, affected)
})
}

// TestAddKubernetesSectionAffected_ValidateRemoval proves that removing a component-level
// validate: false locally, while the remote stack still has it explicitly set, is detected
// as affected. This differs from the other Kubernetes-specific sections
// (manifests/paths/provider/render), where local absence is silently skipped (see
// NoFalsePositives above), because removing validate reverts the component from
// "validation disabled" to the enabled default -- a real behavior change.
func TestAddKubernetesSectionAffected_ValidateRemoval(t *testing.T) {
const (
stackName = k8sTestStack
componentName = k8sTestComponent
)

componentSection := map[string]any{}
remoteStacks := k8sRemoteStacksWith(map[string]any{cfg.ValidateSectionName: false})

var affected []schema.Affected
err := addKubernetesSectionAffected(
&affected, k8sAtmosConfig(), componentName, stackName,
&componentSection, &remoteStacks, &remoteStacks,
false, false,
)
require.NoError(t, err)

require.Len(t, affected, 1)
assert.Equal(t, componentName, affected[0].Component)
assert.Equal(t, cfg.KubernetesComponentType, affected[0].ComponentType)
assert.Equal(t, fmt.Sprintf("stack.%s", cfg.ValidateSectionName), affected[0].Affected)
}

// TestProcessKubernetesComponentsIndexed exercises the full kubernetes affected-detection
Expand Down
30 changes: 26 additions & 4 deletions internal/exec/describe_affected_utils_2.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,9 +146,10 @@ type remoteComponentLocator struct {
componentName string
}

// section returns the raw value of the named section for the located remote component,
// and whether it was found.
func (l remoteComponentLocator) section(sectionName string) (any, bool) {
// remoteComponentMap resolves the raw section map for the located remote component, and
// whether the remote component path itself was found (not whether any specific section
// key exists within it).
func (l remoteComponentLocator) remoteComponentMap() (map[string]any, bool) {
remoteStackSection, ok := (*l.remoteStacks)[l.stackName].(map[string]any)
if !ok {
return nil, false
Expand All @@ -162,10 +163,31 @@ func (l remoteComponentLocator) section(sectionName string) (any, bool) {
return nil, false
}
remoteComponentSection, ok := remoteComponentTypeSection[l.componentName].(map[string]any)
return remoteComponentSection, ok
}

// section returns the raw value of the named section for the located remote component,
// and whether it was found.
func (l remoteComponentLocator) section(sectionName string) (any, bool) {
m, ok := l.remoteComponentMap()
if !ok {
return nil, false
}
return remoteComponentSection[sectionName], true
return m[sectionName], true
}

// sectionPresent reports whether sectionName exists as an explicit key on the located
// remote component. Unlike section, which defaults an absent key to nil for value
// comparison, this distinguishes "explicitly set" from "never set" — needed to detect
// when the LOCAL side removes a section the remote still has (section's ok only reflects
// whether the remote component path was found, not per-key presence).
func (l remoteComponentLocator) sectionPresent(sectionName string) bool {
m, ok := l.remoteComponentMap()
if !ok {
return false
}
_, present := m[sectionName]
return present
}

// isSectionValueEqual compares a local component section value with the corresponding value
Expand Down
5 changes: 5 additions & 0 deletions internal/exec/stack_processor_cache.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,11 @@ func deepCopyBaseComponentConfigMaps(dst, src *schema.BaseComponentConfig) error
return err
}
}
if src.BaseComponentValidate != nil {
if dst.BaseComponentValidate, err = deepCopyComponentAnySection(src.BaseComponentValidate); err != nil {
return err
}
}
if src.BaseComponentPlugins != nil {
if dst.BaseComponentPlugins, err = deepCopyComponentAnySection(src.BaseComponentPlugins); err != nil {
return err
Expand Down
14 changes: 14 additions & 0 deletions internal/exec/stack_processor_merge.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,17 @@ func mergeComponentConfigurations(atmosConfig *schema.AtmosConfiguration, opts *
return nil, err
}

finalComponentValidate, err := mergeComponentAnySection(
mergeConfig,
cfg.ValidateSectionName,
opts.GlobalKubernetesValidate,
result.BaseComponentValidate,
result.ComponentValidate,
)
if err != nil {
return nil, err
}

var finalComponentRender map[string]any
if opts.ComponentType == cfg.KubernetesComponentType {
finalComponentRender, err = m.Merge(
Expand Down Expand Up @@ -608,6 +619,9 @@ func mergeComponentConfigurations(atmosConfig *schema.AtmosConfiguration, opts *
if len(finalComponentRender) > 0 {
comp[cfg.RenderSectionName] = finalComponentRender
}
if finalComponentValidate != nil {
comp[cfg.ValidateSectionName] = finalComponentValidate
}
comp[cfg.GenerateSectionName] = finalComponentGenerate
}

Expand Down
Loading
Loading