diff --git a/cmd/git/executor.go b/cmd/git/executor.go index 8a81f5625f..a44b0212dc 100644 --- a/cmd/git/executor.go +++ b/cmd/git/executor.go @@ -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" @@ -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) { @@ -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, @@ -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) }) @@ -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 { @@ -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, @@ -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) { @@ -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, @@ -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 } @@ -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 } @@ -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 } @@ -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, diff --git a/internal/exec/describe_affected_components.go b/internal/exec/describe_affected_components.go index f9c616443f..7311760df4 100644 --- a/internal/exec/describe_affected_components.go +++ b/internal/exec/describe_affected_components.go @@ -590,6 +590,7 @@ func addKubernetesSectionAffected( {sectionNamePaths, affectedReasonStackPaths}, {sectionNameManifests, affectedReasonStackManifests}, {sectionNameRender, affectedReasonStackRender}, + {cfg.ValidateSectionName, fmt.Sprintf("stack.%s", cfg.ValidateSectionName)}, }...) sections = appendSectionChecks(sections, resolveComponentSectionChecks(atmosConfig)...) diff --git a/internal/exec/stack_processor_cache.go b/internal/exec/stack_processor_cache.go index bdbabf31d0..434a6e0f6e 100644 --- a/internal/exec/stack_processor_cache.go +++ b/internal/exec/stack_processor_cache.go @@ -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 diff --git a/internal/exec/stack_processor_merge.go b/internal/exec/stack_processor_merge.go index ea7a106354..c700da51e4 100644 --- a/internal/exec/stack_processor_merge.go +++ b/internal/exec/stack_processor_merge.go @@ -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( @@ -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 } diff --git a/internal/exec/stack_processor_merge_test.go b/internal/exec/stack_processor_merge_test.go index b1557fd8f9..b6e228545d 100644 --- a/internal/exec/stack_processor_merge_test.go +++ b/internal/exec/stack_processor_merge_test.go @@ -735,6 +735,48 @@ func TestMergeComponentConfigurations_Kubernetes(t *testing.T) { assert.Equal(t, "global-wd", provision["workdir"]) assert.Equal(t, "5m", provision["timeout"]) }) + + t.Run("validate-component-instance-false-overrides-base-true", func(t *testing.T) { + opts := ComponentProcessorOptions{ + ComponentType: cfg.KubernetesComponentType, + Component: "api", + AtmosConfig: atmosCfg, + } + res := minimalComponentResult() + res.BaseComponentValidate = true + res.ComponentValidate = false + comp, err := mergeComponentConfigurations(atmosCfg, &opts, res) + require.NoError(t, err) + assert.Equal(t, false, comp[cfg.ValidateSectionName], + "an explicit component-instance validate:false must override a base-component validate:true") + }) + + t.Run("validate-base-true-flows-through-when-component-unset", func(t *testing.T) { + opts := ComponentProcessorOptions{ + ComponentType: cfg.KubernetesComponentType, + Component: "api", + AtmosConfig: atmosCfg, + } + res := minimalComponentResult() + res.BaseComponentValidate = true + comp, err := mergeComponentConfigurations(atmosCfg, &opts, res) + require.NoError(t, err) + assert.Equal(t, true, comp[cfg.ValidateSectionName], + "base-component validate:true must flow through when the component instance sets nothing") + }) + + t.Run("validate-unset-everywhere-is-absent-from-comp", func(t *testing.T) { + opts := ComponentProcessorOptions{ + ComponentType: cfg.KubernetesComponentType, + Component: "api", + AtmosConfig: atmosCfg, + } + res := minimalComponentResult() + comp, err := mergeComponentConfigurations(atmosCfg, &opts, res) + require.NoError(t, err) + _, ok := comp[cfg.ValidateSectionName] + assert.False(t, ok, "validate must be absent (not defaulted to any value) when unset at every layer") + }) } // TestMergeComponentConfigurations_Retry covers the per-component retry merge added by diff --git a/internal/exec/stack_processor_process_stacks.go b/internal/exec/stack_processor_process_stacks.go index 3d5093bcea..54b812958c 100644 --- a/internal/exec/stack_processor_process_stacks.go +++ b/internal/exec/stack_processor_process_stacks.go @@ -166,6 +166,7 @@ func ProcessStackConfig( var kubernetesPaths any var kubernetesManifests any kubernetesRender := map[string]any{} + var kubernetesValidate any helmVars := map[string]any{} helmSettings := map[string]any{} @@ -812,6 +813,10 @@ func ProcessStackConfig( } } + if i, ok := globalKubernetesSection[cfg.ValidateSectionName]; ok { + kubernetesValidate = i + } + // Helm section. if i, ok := globalHelmSection[cfg.CommandSectionName]; ok { helmCommand, ok = i.(string) @@ -1146,6 +1151,7 @@ func ProcessStackConfig( GlobalKubernetesPaths: kubernetesPaths, GlobalKubernetesManifests: kubernetesManifests, GlobalKubernetesRender: kubernetesRender, + GlobalKubernetesValidate: kubernetesValidate, AtmosConfig: atmosConfig, }, nil } diff --git a/internal/exec/stack_processor_process_stacks_helpers.go b/internal/exec/stack_processor_process_stacks_helpers.go index ac46e29841..50bc0cd3e4 100644 --- a/internal/exec/stack_processor_process_stacks_helpers.go +++ b/internal/exec/stack_processor_process_stacks_helpers.go @@ -53,6 +53,7 @@ type ComponentProcessorOptions struct { GlobalKubernetesPaths any GlobalKubernetesManifests any GlobalKubernetesRender map[string]any + GlobalKubernetesValidate any // Atmos configuration. AtmosConfig *schema.AtmosConfiguration @@ -70,6 +71,7 @@ type ComponentProcessorResult struct { ComponentProvider string ComponentPaths any ComponentManifests any + ComponentValidate any // ComponentPlugins holds the Helm CLI plugins list (helm/helmfile components). ComponentPlugins any ComponentRender map[string]any @@ -94,6 +96,7 @@ type ComponentProcessorResult struct { BaseComponentProvider string BaseComponentPaths any BaseComponentManifests any + BaseComponentValidate any // BaseComponentPlugins holds the inherited Helm CLI plugins list from base components. BaseComponentPlugins any BaseComponentRender map[string]any diff --git a/internal/exec/stack_processor_process_stacks_helpers_extraction.go b/internal/exec/stack_processor_process_stacks_helpers_extraction.go index af301269fe..bb2f930d9f 100644 --- a/internal/exec/stack_processor_process_stacks_helpers_extraction.go +++ b/internal/exec/stack_processor_process_stacks_helpers_extraction.go @@ -290,6 +290,10 @@ func extractComponentSections(opts *ComponentProcessorOptions, result *Component result.ComponentManifests = i } + if i, ok := opts.ComponentMap[cfg.ValidateSectionName]; ok { + result.ComponentValidate = i + } + if i, ok := opts.ComponentMap[cfg.RenderSectionName]; ok { componentRender, ok := i.(map[string]any) if !ok { diff --git a/internal/exec/stack_processor_process_stacks_helpers_inheritance.go b/internal/exec/stack_processor_process_stacks_helpers_inheritance.go index 25c6cac3cf..8e7e997e5c 100644 --- a/internal/exec/stack_processor_process_stacks_helpers_inheritance.go +++ b/internal/exec/stack_processor_process_stacks_helpers_inheritance.go @@ -235,6 +235,7 @@ func applyBaseComponentConfig(opts *ComponentProcessorOptions, result *Component result.BaseComponentProvider = baseComponentConfig.BaseComponentProvider result.BaseComponentPaths = baseComponentConfig.BaseComponentPaths result.BaseComponentManifests = baseComponentConfig.BaseComponentManifests + result.BaseComponentValidate = baseComponentConfig.BaseComponentValidate result.BaseComponentRender = baseComponentConfig.BaseComponentRender result.BaseComponentHelm = baseComponentConfig.BaseComponentHelm // BaseComponentRetry flows from the inheritance chain through to merge — see diff --git a/internal/exec/stack_processor_utils.go b/internal/exec/stack_processor_utils.go index b856ad5704..7230dcf138 100644 --- a/internal/exec/stack_processor_utils.go +++ b/internal/exec/stack_processor_utils.go @@ -2319,6 +2319,7 @@ func processBaseComponentConfigInternal( var baseComponentProvider string var baseComponentPaths any var baseComponentManifests any + var baseComponentValidate any var baseComponentPlugins any var baseComponentRender map[string]any var baseComponentHelm map[string]any @@ -2547,6 +2548,10 @@ func processBaseComponentConfigInternal( baseComponentManifests = baseComponentManifestsSection } + if baseComponentValidateSection, baseComponentValidateSectionExist := baseComponentMap[cfg.ValidateSectionName]; baseComponentValidateSectionExist { + baseComponentValidate = baseComponentValidateSection + } + if baseComponentPluginsSection, baseComponentPluginsSectionExist := baseComponentMap[cfg.PluginsSectionName]; baseComponentPluginsSectionExist { baseComponentPlugins = baseComponentPluginsSection } @@ -2784,6 +2789,13 @@ func processBaseComponentConfigInternal( } baseComponentConfig.BaseComponentManifests = mergedAny + // Base component `validate` + mergedAny, err = mergeComponentAnySection(levelMergeConfig, cfg.ValidateSectionName, baseComponentConfig.BaseComponentValidate, baseComponentValidate) + if err != nil { + return err + } + baseComponentConfig.BaseComponentValidate = mergedAny + // Base component `plugins` (Helm CLI plugins list). mergedAny, err = mergeComponentAnySection(levelMergeConfig, cfg.PluginsSectionName, baseComponentConfig.BaseComponentPlugins, baseComponentPlugins) if err != nil { diff --git a/pkg/component/kubernetes/executor.go b/pkg/component/kubernetes/executor.go index 576723675d..01d100436b 100644 --- a/pkg/component/kubernetes/executor.go +++ b/pkg/component/kubernetes/executor.go @@ -248,14 +248,29 @@ func executeKubernetesOperation(ctx *component.ExecutionContext, atmosConfig *sc case OperationApply: // Auto-gate apply/deploy: fail fast on structurally invalid manifests // before contacting the cluster or delivering to a provision target. - if err := validateObjectsStructural(objects); err != nil { - return nil, err + // Component-level `validate: false` opts out explicitly. + if resolveComponentValidateEnabled(info.ComponentSection) { + if err := validateObjectsStructural(objects); err != nil { + return nil, err + } } return deliverApply(atmosConfig, info, ctx.Flags, objects) case OperationDelete: return runDelete(objects) case OperationValidate: - return runValidate(objects, resolveValidateOptions(ctx.Flags)) + options := resolveValidateOptions(ctx.Flags) + if !resolveComponentValidateEnabled(info.ComponentSection) { + // `validate: false` opts out of Atmos's own offline structural opinion + // only. An explicit --server request still validates against the live + // cluster's own API, which is authoritative regardless of this flag. + if !options.Server { + ui.Warningf("structural validation skipped: 'validate: false' is set for this component") + return objectsToResults("skipped", objects), nil + } + ui.Warningf("offline structural validation skipped: 'validate: false' is set for this component") + return runServerValidate(objects) + } + return runValidate(objects, options) default: return nil, fmt.Errorf("%w: %q", errUtils.ErrKubernetesUnsupportedOperation, operation) } diff --git a/pkg/component/kubernetes/executor_test.go b/pkg/component/kubernetes/executor_test.go index aa892ca021..14e8676aac 100644 --- a/pkg/component/kubernetes/executor_test.go +++ b/pkg/component/kubernetes/executor_test.go @@ -634,6 +634,79 @@ func TestRunOperationApplyGateRejectsInvalidManifest(t *testing.T) { assert.Equal(t, 1, result.ObjectsTotal) } +func TestRunOperationApplyGateSkippedWhenValidateDisabled(t *testing.T) { + original := newKubernetesSDKClient + t.Cleanup(func() { newKubernetesSDKClient = original }) + + // A structurally invalid manifest (DNS-1123-invalid name — Atmos's own + // offline opinion, not a mechanical requirement of the K8s client itself, so + // it's deliverable once the auto-gate is out of the way) that would normally + // trip the auto-gate. With `validate: false` set on the component, the gate + // must be skipped entirely and delivery must proceed to the fake cluster client. + object := kubernetesObject("v1", "ConfigMap", "Bad_Name", "") + newKubernetesSDKClient = func() (*sdkClient, error) { + client, fakeClient := newFakeSDKClientWithFake(object.DeepCopy()) + prependApplyDryRunReactor(fakeClient, object.DeepCopy()) + return client, nil + } + + result, err := runOperation( + &component.ExecutionContext{}, + &schema.AtmosConfiguration{}, + &schema.ConfigAndStacksInfo{ComponentSection: map[string]any{"validate": false}}, + OperationApply, + []*unstructured.Unstructured{object}, + ) + require.NoError(t, err) + assert.Equal(t, 1, result.ObjectsTotal) +} + +func TestRunOperationValidateSkippedWhenValidateDisabled(t *testing.T) { + original := newKubernetesSDKClient + t.Cleanup(func() { newKubernetesSDKClient = original }) + newKubernetesSDKClient = func() (*sdkClient, error) { + t.Fatal("validate: false must short-circuit before any structural or cluster check") + return nil, nil + } + + objects := []*unstructured.Unstructured{kubernetesObject("v1", "ConfigMap", "", "")} + result, err := runOperation( + &component.ExecutionContext{}, + &schema.AtmosConfiguration{}, + &schema.ConfigAndStacksInfo{ComponentSection: map[string]any{"validate": false}}, + OperationValidate, + objects, + ) + require.NoError(t, err) + assert.Equal(t, map[string]int{"skipped": 1}, result.ActionCounts) +} + +func TestRunOperationValidateServerRunsDespiteValidateDisabled(t *testing.T) { + original := newKubernetesSDKClient + t.Cleanup(func() { newKubernetesSDKClient = original }) + + // A DNS-1123-invalid name is exactly what `validate: false` opts out of + // (Atmos's own offline opinion) — but --server must still validate against + // the live cluster regardless of the component-level flag. + object := kubernetesObject("v1", "ConfigMap", "Bad_Name", "") + newKubernetesSDKClient = func() (*sdkClient, error) { + client, fakeClient := newFakeSDKClientWithFake(object.DeepCopy()) + prependApplyDryRunReactor(fakeClient, object.DeepCopy()) + return client, nil + } + + result, err := runOperation( + &component.ExecutionContext{Flags: map[string]any{"server": true}}, + &schema.AtmosConfiguration{}, + &schema.ConfigAndStacksInfo{ComponentSection: map[string]any{"validate": false}}, + OperationValidate, + []*unstructured.Unstructured{object}, + ) + require.NoError(t, err) + // "valid" (not "skipped") proves the server dry-run actually ran. + assert.Equal(t, map[string]int{"valid": 1}, result.ActionCounts) +} + func TestRunOperationValidateDispatches(t *testing.T) { original := newKubernetesSDKClient t.Cleanup(func() { newKubernetesSDKClient = original }) diff --git a/pkg/component/kubernetes/provision.go b/pkg/component/kubernetes/provision.go index e7631d5651..fe5eb8de59 100644 --- a/pkg/component/kubernetes/provision.go +++ b/pkg/component/kubernetes/provision.go @@ -10,6 +10,7 @@ import ( "github.com/cloudposse/atmos/pkg/perf" "github.com/cloudposse/atmos/pkg/provisioner/target" "github.com/cloudposse/atmos/pkg/schema" + "github.com/cloudposse/atmos/pkg/ui" // Blank import registers the "git" provision target kind so it is available // for delivery whenever Kubernetes components are executed. @@ -62,6 +63,7 @@ func deliverApply( }); err != nil { return nil, err } + ui.Successf("delivered %d Kubernetes object(s) to %q", len(objects), selected.Name) return objectsToResults("delivered", objects), nil } diff --git a/pkg/component/kubernetes/provision_test.go b/pkg/component/kubernetes/provision_test.go index a363695335..0e68a5cca8 100644 --- a/pkg/component/kubernetes/provision_test.go +++ b/pkg/component/kubernetes/provision_test.go @@ -1,6 +1,7 @@ package kubernetes import ( + "bytes" "context" "testing" @@ -11,8 +12,10 @@ import ( errUtils "github.com/cloudposse/atmos/errors" authtypes "github.com/cloudposse/atmos/pkg/auth/types" + iolib "github.com/cloudposse/atmos/pkg/io" "github.com/cloudposse/atmos/pkg/provisioner/target" "github.com/cloudposse/atmos/pkg/schema" + "github.com/cloudposse/atmos/pkg/ui" ) func TestAuthManagerForReturnsNilWhenNoManager(t *testing.T) { @@ -99,6 +102,47 @@ func TestDeliverApplyRoutesToExternalTarget(t *testing.T) { assert.Contains(t, combined, "kind: Service") } +// TestDeliverApplyPrintsSuccessConfirmation guards against a successful +// git/external-target delivery silently producing no output: before this +// fix, a user running `apply --target ` against a healthy +// repository saw nothing at all on success, unlike cluster apply and +// `validate`. A successful delivery must print a human-facing confirmation. +func TestDeliverApplyPrintsSuccessConfirmation(t *testing.T) { + const kind = "test-capture-kind-success-message" + target.Register(kind, &captureProvisioner{}) + + ioCtx, err := iolib.NewContext() + require.NoError(t, err) + ui.InitFormatter(ioCtx) + t.Cleanup(ui.Reset) + var uiOutput bytes.Buffer + restoreUI := iolib.PushUIWriter(&uiOutput) + t.Cleanup(restoreUI) + + info := &schema.ConfigAndStacksInfo{ + ComponentFromArg: "argocd", + Stack: "dev", + ComponentSection: map[string]any{ + "provision": map[string]any{ + "targets": map[string]any{ + "deployment-repo": map[string]any{ + "kind": kind, + "path": "clusters/dev/argocd", + }, + }, + }, + }, + } + flags := map[string]any{"target": "deployment-repo"} + objects := []*unstructured.Unstructured{newObject("Namespace", "atmos-demo")} + + _, err = deliverApply(&schema.AtmosConfiguration{}, info, flags, objects) + require.NoError(t, err) + + assert.Contains(t, uiOutput.String(), "deployment-repo", + "a successful external-target delivery must print a human-facing confirmation naming the target") +} + func TestDeliverApplyUnknownTargetErrors(t *testing.T) { info := &schema.ConfigAndStacksInfo{ ComponentSection: map[string]any{ diff --git a/pkg/component/kubernetes/render.go b/pkg/component/kubernetes/render.go index 614e8c1c33..ea7a65aea0 100644 --- a/pkg/component/kubernetes/render.go +++ b/pkg/component/kubernetes/render.go @@ -1,7 +1,6 @@ package kubernetes import ( - "bytes" "fmt" "os" "path/filepath" @@ -13,6 +12,7 @@ import ( errUtils "github.com/cloudposse/atmos/errors" "github.com/cloudposse/atmos/pkg/data" + "github.com/cloudposse/atmos/pkg/provisioner/target" "github.com/cloudposse/atmos/pkg/schema" "github.com/cloudposse/atmos/pkg/ui" u "github.com/cloudposse/atmos/pkg/utils" @@ -157,21 +157,15 @@ func writeSplitManifestFiles(outputDir string, objects []*unstructured.Unstructu } func multiDocumentYAML(objects []*unstructured.Unstructured) ([]byte, error) { - var buffer bytes.Buffer - for i, obj := range objects { - if i > 0 { - buffer.WriteString("---\n") - } + docs := make([][]byte, 0, len(objects)) + for _, obj := range objects { manifest, err := objectYAML(obj) if err != nil { return nil, err } - buffer.Write(manifest) - if !bytes.HasSuffix(manifest, []byte("\n")) { - buffer.WriteByte('\n') - } + docs = append(docs, manifest) } - return buffer.Bytes(), nil + return target.MergeYAMLDocuments(docs), nil } func objectYAML(obj *unstructured.Unstructured) ([]byte, error) { diff --git a/pkg/component/kubernetes/validate.go b/pkg/component/kubernetes/validate.go index 8ed939aad6..592cda5bbb 100644 --- a/pkg/component/kubernetes/validate.go +++ b/pkg/component/kubernetes/validate.go @@ -9,8 +9,10 @@ import ( "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" apivalidation "k8s.io/apimachinery/pkg/util/validation" + kustomizetypes "sigs.k8s.io/kustomize/api/types" errUtils "github.com/cloudposse/atmos/errors" + cfg "github.com/cloudposse/atmos/pkg/config" "github.com/cloudposse/atmos/pkg/perf" "github.com/cloudposse/atmos/pkg/ui" ) @@ -66,13 +68,18 @@ func validateObjectsStructural(objects []*unstructured.Unstructured) error { // structuralErrorsForObject returns the offline validation errors for a single // object: a present, DNS-1123-conformant metadata.name and a resolvable GVK. // (apiVersion/kind presence is already guaranteed upstream by decodeObjects.) +// Kustomize's own config objects (see isKustomizeConfigObject) are exempt from +// the metadata.name presence requirement only — a name, if given, is still +// validated, and the GVK check remains unconditional. func structuralErrorsForObject(index int, obj *unstructured.Unstructured) []error { var errs []error ref := objectRef(index, obj) name := obj.GetName() if name == "" { - errs = append(errs, fmt.Errorf("%s: %w", ref, errUtils.ErrKubernetesMissingMetadataName)) + if !isKustomizeConfigObject(obj) { + errs = append(errs, fmt.Errorf("%s: %w", ref, errUtils.ErrKubernetesMissingMetadataName)) + } } else if msgs := apivalidation.IsDNS1123Subdomain(name); len(msgs) > 0 { errs = append(errs, fmt.Errorf("%s: %w: %s", ref, errUtils.ErrKubernetesManifestInvalidName, strings.Join(msgs, "; "))) } @@ -84,6 +91,38 @@ func structuralErrorsForObject(index int, obj *unstructured.Unstructured) []erro return errs } +// isKustomizeConfigObject reports whether obj is one of Kustomize's own reserved +// config-object kinds (Kustomization or Component). These are matched against +// Kustomize's own exported kind/version constants (sigs.k8s.io/kustomize/api/types, +// already vendored by this repo's native kustomize provider) rather than a guessed +// string, mirroring exactly what Kustomize's own EnforceFields validation checks. +// Such objects are never submitted to the Kubernetes API — they are local build +// input consumed by the kustomize tool itself — and Kustomize does not require +// (or, historically, even permit) a metadata.name on them. +func isKustomizeConfigObject(obj *unstructured.Unstructured) bool { + apiVersion, kind := obj.GetAPIVersion(), obj.GetKind() + switch { + case apiVersion == kustomizetypes.KustomizationVersion && kind == kustomizetypes.KustomizationKind: + return true + case apiVersion == kustomizetypes.ComponentVersion && kind == kustomizetypes.ComponentKind: + return true + default: + return false + } +} + +// resolveComponentValidateEnabled reports whether structural validation is +// enabled for this component. Component-level `validate: false` opts out of all +// automatic (apply/deploy auto-gate) and explicit (`atmos kubernetes validate`) +// structural checks; it does not affect --server, which validates against the +// live cluster's own API rather than Atmos's offline opinion. +func resolveComponentValidateEnabled(componentSection map[string]any) bool { + if v, ok := componentSection[cfg.ValidateSectionName].(bool); ok { + return v + } + return true +} + // objectRef builds a human-readable identifier for an object in validation // messages, falling back to a positional reference when the name is missing. func objectRef(index int, obj *unstructured.Unstructured) string { diff --git a/pkg/component/kubernetes/validate_test.go b/pkg/component/kubernetes/validate_test.go index 48c8425c5d..88a05696b2 100644 --- a/pkg/component/kubernetes/validate_test.go +++ b/pkg/component/kubernetes/validate_test.go @@ -45,6 +45,73 @@ func TestValidateObjectsStructuralReportsAllFailures(t *testing.T) { assert.ErrorContains(t, err, "missing group/version/kind") } +func TestValidateObjectsStructuralKustomizeConfigObjectsExemptFromName(t *testing.T) { + objects := []*unstructured.Unstructured{ + kubernetesObject("kustomize.config.k8s.io/v1beta1", "Kustomization", "", ""), + kubernetesObject("kustomize.config.k8s.io/v1alpha1", "Component", "", ""), + } + + require.NoError(t, validateObjectsStructural(objects), "Kustomize's own config objects have no metadata.name in the real Kustomize schema") +} + +func TestValidateObjectsStructuralKustomizeConfigObjectInvalidNameStillFails(t *testing.T) { + // The exemption is presence-only: a name that IS given is still validated. + objects := []*unstructured.Unstructured{ + kubernetesObject("kustomize.config.k8s.io/v1alpha1", "Component", "Bad_Name", ""), + } + + err := validateObjectsStructural(objects) + require.Error(t, err) + assert.ErrorContains(t, err, "not a valid DNS-1123 subdomain") +} + +func TestValidateObjectsStructuralNonKustomizeObjectStillRequiresName(t *testing.T) { + // Guards against over-broad matching: a normal Kubernetes API object with no + // name must still fail, regardless of delivery target. + objects := []*unstructured.Unstructured{ + kubernetesObject("apps/v1", "Deployment", "", ""), + } + + err := validateObjectsStructural(objects) + require.Error(t, err) + assert.ErrorContains(t, err, "is missing metadata.name") +} + +func TestIsKustomizeConfigObject(t *testing.T) { + tests := []struct { + name string + obj *unstructured.Unstructured + want bool + }{ + {"Kustomization at its canonical version", kubernetesObject("kustomize.config.k8s.io/v1beta1", "Kustomization", "", ""), true}, + {"Component at its canonical version", kubernetesObject("kustomize.config.k8s.io/v1alpha1", "Component", "", ""), true}, + {"plain Deployment", kubernetesObject("apps/v1", "Deployment", "app", ""), false}, + { + "Kustomize API group but an unrecognized kind does not match", + kubernetesObject("kustomize.config.k8s.io/v1alpha1", "SomeFutureKind", "", ""), + false, + }, + { + "Kustomization kind at the wrong (non-canonical) version does not match", + kubernetesObject("kustomize.config.k8s.io/v2", "Kustomization", "", ""), + false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, isKustomizeConfigObject(tt.obj)) + }) + } +} + +func TestResolveComponentValidateEnabled(t *testing.T) { + assert.True(t, resolveComponentValidateEnabled(nil), "unset defaults to enabled") + assert.True(t, resolveComponentValidateEnabled(map[string]any{}), "unset defaults to enabled") + assert.True(t, resolveComponentValidateEnabled(map[string]any{"validate": true})) + assert.False(t, resolveComponentValidateEnabled(map[string]any{"validate": false})) + assert.True(t, resolveComponentValidateEnabled(map[string]any{"validate": "false"}), "non-bool values are ignored, defaulting to enabled") +} + func TestRunValidate(t *testing.T) { original := newKubernetesSDKClient t.Cleanup(func() { newKubernetesSDKClient = original }) diff --git a/pkg/config/const.go b/pkg/config/const.go index 06f4e89d80..1601fa5fa5 100644 --- a/pkg/config/const.go +++ b/pkg/config/const.go @@ -126,6 +126,7 @@ const ( PathsSectionName = "paths" ManifestsSectionName = "manifests" RenderSectionName = "render" + ValidateSectionName = "validate" ValuesSectionName = "values" ValuesFilesSectionName = "values_files" PluginsSectionName = "plugins" diff --git a/pkg/datafetcher/schema/atmos/manifest/1.0.json b/pkg/datafetcher/schema/atmos/manifest/1.0.json index 81f40cb10b..5011884087 100644 --- a/pkg/datafetcher/schema/atmos/manifest/1.0.json +++ b/pkg/datafetcher/schema/atmos/manifest/1.0.json @@ -1112,6 +1112,10 @@ }, "dependencies": { "$ref": "#/definitions/dependencies" + }, + "validate": { + "type": "boolean", + "description": "Enables structural validation of this component's manifests (default true). Set to false to opt out of both the implicit apply/deploy auto-gate and the explicit 'atmos kubernetes validate' command for this component; does not affect '--server' dry-run validation against a live cluster." } }, "required": [] diff --git a/pkg/datafetcher/schema/stacks/stack-config/1.0.json b/pkg/datafetcher/schema/stacks/stack-config/1.0.json index 83f34afa98..383de407ee 100644 --- a/pkg/datafetcher/schema/stacks/stack-config/1.0.json +++ b/pkg/datafetcher/schema/stacks/stack-config/1.0.json @@ -799,6 +799,10 @@ }, "dependencies": { "$ref": "#/definitions/dependencies" + }, + "validate": { + "type": "boolean", + "description": "Enables structural validation of this component's manifests (default true). Set to false to opt out of both the implicit apply/deploy auto-gate and the explicit 'atmos kubernetes validate' command for this component; does not affect '--server' dry-run validation against a live cluster." } }, "required": [] @@ -1200,6 +1204,10 @@ "type": "string", "description": "For kind 'git': destination path inside the deployment repository (supports Go templates)" }, + "split": { + "type": "boolean", + "description": "For kind 'git': true fans out one file per rendered object under 'path' (a directory); false writes 'path' as a single multi-document YAML file. When unset, inferred from whether the last segment of 'path' looks like a manifest filename (matches /\\.(ya?ml|json)$/i): a match defaults to single-file mode, otherwise the directory default is preserved." + }, "auth": { "type": "object", "properties": { diff --git a/pkg/datafetcher/schema_condition_validation_test.go b/pkg/datafetcher/schema_condition_validation_test.go index de9059ce5e..5ff148fc1d 100644 --- a/pkg/datafetcher/schema_condition_validation_test.go +++ b/pkg/datafetcher/schema_condition_validation_test.go @@ -185,6 +185,52 @@ func TestManifestSchema_TerraformComponentMocks(t *testing.T) { } } +// TestManifestSchema_KubernetesComponentValidateField guards against the +// validate property drifting out of sync between the schema copies again: it +// was added to stack-config/1.0.json but omitted from atmos/manifest/1.0.json, +// the schema that atmos describe stacks and atmos validate stacks actually +// enforce by default, causing an additionalProperties rejection. The fixture +// copy under tests/fixtures/schemas predates the Kubernetes component feature +// entirely and is intentionally excluded here. +func TestManifestSchema_KubernetesComponentValidateField(t *testing.T) { + schemas := map[string][]byte{ + "embedded": loadEmbeddedSchemaBytes(t), + "website": loadWebsiteSchemaBytes(t), + "stack-config": loadStackConfigSchemaBytes(t), + } + + for schemaName, schemaData := range schemas { + t.Run(schemaName+"/accepts validate false", func(t *testing.T) { + assertSchemaValid(t, schemaData, kubernetesComponentManifestWithValidate(false)) + }) + + t.Run(schemaName+"/accepts validate true", func(t *testing.T) { + assertSchemaValid(t, schemaData, kubernetesComponentManifestWithValidate(true)) + }) + } +} + +func kubernetesComponentManifestWithValidate(validate bool) map[string]any { + return map[string]any{ + "components": map[string]any{ + "kubernetes": map[string]any{ + "legacy-manifests": map[string]any{ + "metadata": map[string]any{ + "type": "real", + }, + "validate": validate, + "manifests": []any{ + map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + }, + }, + }, + }, + }, + } +} + func workflowManifestWithWhen(condition any) map[string]any { return workflowManifestWithStep(map[string]any{ "command": "echo ok", diff --git a/pkg/datafetcher/schema_section_coverage_test.go b/pkg/datafetcher/schema_section_coverage_test.go index 9085f0139a..78eae9b340 100644 --- a/pkg/datafetcher/schema_section_coverage_test.go +++ b/pkg/datafetcher/schema_section_coverage_test.go @@ -87,6 +87,7 @@ var nonManifestSections = map[string]struct{}{ "paths": {}, // Kubernetes component sub-field (manifest paths). "manifests": {}, // Kubernetes component sub-field (inline manifests). "render": {}, // Kubernetes component sub-field (render output config). + "validate": {}, // Kubernetes component sub-field (structural validation opt-out). "chart": {}, // Native Helm component sub-field (chart reference). "values": {}, // Native Helm component sub-field (inline chart values). "values_files": {}, // Native Helm component sub-field (chart values file paths). diff --git a/pkg/git/errors.go b/pkg/git/errors.go new file mode 100644 index 0000000000..e76896cb0e --- /dev/null +++ b/pkg/git/errors.go @@ -0,0 +1,68 @@ +package git + +import ( + "bytes" + "fmt" + "io" + "strings" + + errUtils "github.com/cloudposse/atmos/errors" + iolib "github.com/cloudposse/atmos/pkg/io" +) + +// StderrSwapper is implemented by providers that support swapping their +// stderr writer for the duration of a single operation, letting callers +// capture subprocess stderr without holding a lock on Provider construction. +type StderrSwapper interface { + SwapStderr(io.Writer) func() +} + +// CaptureStderr runs operation with the provider's stderr swapped to a +// masked capture buffer (when the provider supports StderrSwapper), and +// returns the captured, trimmed text alongside operation's error. The buffer +// is routed through iolib.MaskWriter so captured text is safe to embed in an +// error message: never read RunResult.StderrTail directly for this purpose, +// since that field is documented as bypassing masking. +// +// Providers that do not implement StderrSwapper (e.g. test doubles) run +// operation unmodified and return an empty capture. +func CaptureStderr(provider Provider, operation func() error) (string, error) { + swapper, ok := 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 +} + +// WrapOperationError builds an actionable error from a failed Git operation: +// an action description, the workdir it operated on, the (masked) captured +// stderr text, the underlying error, and an optional hint. Returns nil when +// err is nil. +func WrapOperationError(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() +} diff --git a/pkg/git/errors_test.go b/pkg/git/errors_test.go new file mode 100644 index 0000000000..c5ebd69055 --- /dev/null +++ b/pkg/git/errors_test.go @@ -0,0 +1,111 @@ +package git + +import ( + "context" + "errors" + "io" + "testing" + + cockroacherrors "github.com/cockroachdb/errors" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// stubSwappableProvider is a minimal Provider that also implements +// StderrSwapper, so tests can assert CaptureStderr routes writes through the +// swapped writer. +type stubSwappableProvider struct { + writeOnSwap string +} + +func (s *stubSwappableProvider) SwapStderr(w io.Writer) func() { + if s.writeOnSwap != "" { + _, _ = w.Write([]byte(s.writeOnSwap)) + } + return func() {} +} + +func (s *stubSwappableProvider) Init(context.Context, *InitOptions) error { return nil } +func (s *stubSwappableProvider) Clone(context.Context, *CloneOptions) error { return nil } +func (s *stubSwappableProvider) Pull(context.Context, *PullOptions) error { return nil } +func (s *stubSwappableProvider) Push(context.Context, *PushOptions) error { return nil } +func (s *stubSwappableProvider) Status(context.Context, *StatusOptions) (*StatusResult, error) { + return &StatusResult{}, nil +} + +func (s *stubSwappableProvider) Diff(context.Context, *DiffOptions) (*DiffResult, error) { + return &DiffResult{}, nil +} + +func (s *stubSwappableProvider) Commit(context.Context, *CommitOptions) (*CommitResult, error) { + return &CommitResult{}, nil +} + +// stubNonSwappableProvider implements Provider but not StderrSwapper, mirroring +// test doubles elsewhere in the codebase that don't need stderr capture. +type stubNonSwappableProvider struct{ stubSwappableProvider } + +func TestCaptureStderr_SwappableProvider(t *testing.T) { + provider := &stubSwappableProvider{writeOnSwap: "fatal: something went wrong\n"} + + stderr, err := CaptureStderr(provider, func() error { + return errors.New("boom") + }) + + require.Error(t, err) + assert.Equal(t, "fatal: something went wrong", stderr, "captured stderr must be trimmed") +} + +func TestCaptureStderr_NonSwappableProviderRunsUnmodified(t *testing.T) { + var nonSwappable Provider = &struct{ stubNonSwappableProvider }{} + + ran := false + stderr, err := CaptureStderr(nonSwappable, func() error { + ran = true + return nil + }) + + require.NoError(t, err) + assert.Empty(t, stderr) + assert.True(t, ran, "operation must still run when the provider doesn't support stderr capture") +} + +func TestWrapOperationError_NilErrorReturnsNil(t *testing.T) { + assert.NoError(t, WrapOperationError("do something", "/workdir", "", nil, "a hint")) +} + +func TestWrapOperationError_EmbedsWorkdirStderrAndHint(t *testing.T) { + base := errors.New("git command exited with non-zero status") + + err := WrapOperationError("clone Git repository", "/tmp/workdir", "fatal: remote branch not found", base, "try again") + require.Error(t, err) + assert.ErrorIs(t, err, base, "the underlying sentinel/error must remain in the chain for errors.Is") + + details := cockroacherrors.GetAllDetails(err) + joined := "" + for _, d := range details { + joined += d + } + assert.Contains(t, joined, "/tmp/workdir") + assert.Contains(t, joined, "fatal: remote branch not found") + assert.Contains(t, joined, base.Error()) + + hints := cockroacherrors.GetAllHints(err) + joinedHints := "" + for _, h := range hints { + joinedHints += h + } + assert.Contains(t, joinedHints, "try again") +} + +func TestWrapOperationError_NoWorkdirOmitsQuotedEmptyPath(t *testing.T) { + base := errors.New("failed") + err := WrapOperationError("do something", "", "", base, "") + require.Error(t, err) + details := cockroacherrors.GetAllDetails(err) + joined := "" + for _, d := range details { + joined += d + } + assert.NotContains(t, joined, `in ""`) +} diff --git a/pkg/provisioner/target/git/git.go b/pkg/provisioner/target/git/git.go index b6a0b92a9b..b0a00be6c5 100644 --- a/pkg/provisioner/target/git/git.go +++ b/pkg/provisioner/target/git/git.go @@ -11,6 +11,7 @@ import ( "fmt" "os" "path/filepath" + "regexp" "sort" "strings" @@ -44,6 +45,25 @@ type config struct { CommitMessage string Signing string PullRequest bool + // Split selects file-vs-directory semantics for Path: true fans out one file + // per manifest under Path (a directory); false writes Path as a single + // multi-document YAML file. nil defers to resolveSplit's extension inference. + Split *bool +} + +// manifestPathRE matches a manifest-looking filename in the last path segment, +// used by resolveSplit to infer single-file mode when Split is left unset. +var manifestPathRE = regexp.MustCompile(`(?i)\.(ya?ml|json)$`) + +// resolveSplit implements the Split tri-state: an explicit target-config value +// wins; otherwise the last path segment is matched against manifestPathRE — a +// match defaults to single-file mode, no match preserves the unconditional +// directory-fan-out default every existing configuration already relies on. +func resolveSplit(split *bool, path string) bool { + if split != nil { + return *split + } + return !manifestPathRE.MatchString(filepath.Base(path)) } // repoSession bundles the resolved repository and its execution context for a @@ -96,7 +116,7 @@ func (g *gitProvisioner) Deliver(ctx context.Context, in *target.DeliverInput) e return err } - if err := writeArtifact(resolved.Workdir, cfg.Path, &in.Artifact); err != nil { + if err := writeArtifact(resolved.Workdir, cfg.Path, &in.Artifact, resolveSplit(cfg.Split, cfg.Path)); err != nil { return err } @@ -214,44 +234,72 @@ func walkManagedDir(root string) (map[string][]byte, error) { // reconcile clones the repository if absent, otherwise fetches and fast-forwards. func reconcile(ctx context.Context, s *repoSession) error { - return s.provider.Clone(ctx, &atmosgit.CloneOptions{ - RepoContext: s.rc, - URI: s.resolved.URI, - Depth: s.resolved.Clone.Depth, - Filter: s.resolved.Clone.Filter, - SingleBranch: s.resolved.Clone.SingleBranch, - Submodules: s.resolved.Clone.Submodules, + stderr, err := atmosgit.CaptureStderr(s.provider, func() error { + return s.provider.Clone(ctx, &atmosgit.CloneOptions{ + RepoContext: s.rc, + URI: s.resolved.URI, + Depth: s.resolved.Clone.Depth, + Filter: s.resolved.Clone.Filter, + SingleBranch: s.resolved.Clone.SingleBranch, + Submodules: s.resolved.Clone.Submodules, + }) }) + return atmosgit.WrapOperationError( + "clone/reconcile Git repository", + s.rc.Workdir, + stderr, + err, + "Confirm the configured branch exists and has commits, and that the resolved identity has read access.", + ) } // commitAndPush stages the managed path, commits any changes, and pushes when a // commit was created. func commitAndPush(ctx context.Context, s *repoSession, cfg *config, artifact *target.ProvisionArtifact) error { - result, err := s.provider.Commit(ctx, &atmosgit.CommitOptions{ - RepoContext: s.rc, - Message: cfg.CommitMessage, - Paths: []string{cfg.Path}, - Signing: signingMode(cfg, s.resolved), - Author: s.resolved.Author, - Trailers: trailers(artifact), + var result atmosgit.CommitResult + stderr, err := atmosgit.CaptureStderr(s.provider, func() error { + res, commitErr := s.provider.Commit(ctx, &atmosgit.CommitOptions{ + RepoContext: s.rc, + Message: cfg.CommitMessage, + Paths: []string{cfg.Path}, + Signing: signingMode(cfg, s.resolved), + Author: s.resolved.Author, + Trailers: trailers(artifact), + }) + if res != nil { + result = *res + } + return commitErr }) if err != nil { - return err + return atmosgit.WrapOperationError("commit Git changes", s.rc.Workdir, stderr, err, "") } if !result.Committed { // Nothing changed in the managed path; a no-op is a clean success. return nil } - return s.provider.Push(ctx, &atmosgit.PushOptions{ - RepoContext: s.rc, - Retries: s.resolved.PushRetries, + stderr, err = atmosgit.CaptureStderr(s.provider, func() error { + return s.provider.Push(ctx, &atmosgit.PushOptions{ + RepoContext: s.rc, + Retries: s.resolved.PushRetries, + }) }) + return atmosgit.WrapOperationError( + "push Git repository", + s.rc.Workdir, + stderr, + err, + "Run 'atmos git status' and 'atmos git pull' on the configured repository before retrying.", + ) } -// writeArtifact replaces the managed subtree under / with the -// artifact files, so removals propagate deterministically. -func writeArtifact(workdir, path string, artifact *target.ProvisionArtifact) error { +// writeArtifact replaces the managed path / with the artifact +// files, so removals propagate deterministically. When split is true, path is +// a directory root fanned out into one file per artifact entry (unchanged, +// historical behavior). When split is false, path is the exact output file: all +// artifact entries are merged into a single multi-document YAML file. +func writeArtifact(workdir, path string, artifact *target.ProvisionArtifact, split bool) error { // Guard against deleting the worktree root: ValidateRepoRelativePath resolves // root-equivalent paths ("", ".", "./", "a/..") to the worktree root, and a // subsequent os.RemoveAll there would destroy the entire repository (including @@ -268,6 +316,10 @@ func writeArtifact(workdir, path string, artifact *target.ProvisionArtifact) err return fmt.Errorf("%w: clearing managed path %q: %w", errUtils.ErrGitArtifactWrite, path, err) } + if !split { + return writeSingleArtifactFile(absPath, path, artifact) + } + for _, rel := range sortedFileKeys(artifact.Files) { repoRel := filepath.Join(path, rel) abs, err := atmosgit.ValidateRepoRelativePath(workdir, repoRel) @@ -284,12 +336,35 @@ func writeArtifact(workdir, path string, artifact *target.ProvisionArtifact) err return nil } +// writeSingleArtifactFile merges every artifact file (in deterministic order) +// into one multi-document YAML stream and writes it to absPath (repo-relative +// path, for error messages) as a single file. +func writeSingleArtifactFile(absPath, path string, artifact *target.ProvisionArtifact) error { + keys := sortedFileKeys(artifact.Files) + docs := make([][]byte, 0, len(keys)) + for _, rel := range keys { + docs = append(docs, artifact.Files[rel]) + } + merged := target.MergeYAMLDocuments(docs) + + if err := os.MkdirAll(filepath.Dir(absPath), dirPerm); err != nil { + return fmt.Errorf("%w: creating directory for %q: %w", errUtils.ErrGitArtifactWrite, path, err) + } + if err := os.WriteFile(absPath, merged, filePerm); err != nil { + return fmt.Errorf("%w: writing %q: %w", errUtils.ErrGitArtifactWrite, path, err) + } + return nil +} + // parseConfig extracts the git target settings from the merged target block. func parseConfig(block map[string]any) config { cfg := config{ Repository: stringField(block, "repository"), Path: stringField(block, "path"), } + if split, ok := block["split"].(bool); ok { + cfg.Split = &split + } if auth, ok := block["auth"].(map[string]any); ok { cfg.Identity = stringField(auth, "identity") } diff --git a/pkg/provisioner/target/git/git_test.go b/pkg/provisioner/target/git/git_test.go index 1f3fcf381d..0caf2491a0 100644 --- a/pkg/provisioner/target/git/git_test.go +++ b/pkg/provisioner/target/git/git_test.go @@ -8,8 +8,10 @@ import ( "os/user" "path/filepath" "runtime" + "strings" "testing" + cockroacherrors "github.com/cockroachdb/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -33,6 +35,7 @@ func TestParseConfig(t *testing.T) { block := map[string]any{ "repository": "deployments", "path": "clusters/dev/argocd", + "split": false, "auth": map[string]any{"identity": "platform-admin"}, "commit": map[string]any{ "message": "Render argocd", @@ -47,6 +50,8 @@ func TestParseConfig(t *testing.T) { assert.Equal(t, "Render argocd", cfg.CommitMessage) assert.Equal(t, "always", cfg.Signing) assert.True(t, cfg.PullRequest) + require.NotNil(t, cfg.Split) + assert.False(t, *cfg.Split) } func TestParseConfigEmpty(t *testing.T) { @@ -54,6 +59,30 @@ func TestParseConfigEmpty(t *testing.T) { assert.Empty(t, cfg.Repository) assert.Empty(t, cfg.Identity) assert.False(t, cfg.PullRequest) + assert.Nil(t, cfg.Split, "split is unset until the target block explicitly configures it") +} + +func TestResolveSplit(t *testing.T) { + trueVal, falseVal := true, false + + tests := []struct { + name string + split *bool + path string + want bool + }{ + {"explicit true overrides manifest-looking path", &trueVal, filepath.Join("kustomize", "overlays", "prod", "kustomization.yaml"), true}, + {"explicit false overrides directory-looking path", &falseVal, filepath.Join("clusters", "dev"), false}, + {"unset with .yaml extension infers single-file mode", nil, filepath.Join("kustomize", "overlays", "prod", "kustomization.yaml"), false}, + {"unset with .yml extension infers single-file mode", nil, filepath.Join("overlays", "prod", "patch.yml"), false}, + {"unset with .json extension infers single-file mode", nil, filepath.Join("overlays", "prod", "patch.JSON"), false}, + {"unset with no extension preserves directory default", nil, filepath.Join("clusters", "dev", "argocd"), true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, resolveSplit(tt.split, tt.path)) + }) + } } func TestDeliverPullRequestNotSupported(t *testing.T) { @@ -88,7 +117,7 @@ func TestWriteArtifactReplacesManagedSubtree(t *testing.T) { "deployment.yaml": []byte("kind: Deployment\n"), "old/stale.yaml": []byte("stale\n"), }} - require.NoError(t, writeArtifact(workdir, path, &first)) + require.NoError(t, writeArtifact(workdir, path, &first, true)) assert.FileExists(t, filepath.Join(workdir, "clusters", "dev", "argocd", "namespace.yaml")) assert.FileExists(t, filepath.Join(workdir, "clusters", "dev", "argocd", "old", "stale.yaml")) @@ -96,7 +125,7 @@ func TestWriteArtifactReplacesManagedSubtree(t *testing.T) { second := target.ProvisionArtifact{Files: map[string][]byte{ "namespace.yaml": []byte("kind: Namespace\n"), }} - require.NoError(t, writeArtifact(workdir, path, &second)) + require.NoError(t, writeArtifact(workdir, path, &second, true)) assert.FileExists(t, filepath.Join(workdir, "clusters", "dev", "argocd", "namespace.yaml")) assert.NoFileExists(t, filepath.Join(workdir, "clusters", "dev", "argocd", "old", "stale.yaml")) assert.NoFileExists(t, filepath.Join(workdir, "clusters", "dev", "argocd", "deployment.yaml")) @@ -110,7 +139,7 @@ func TestReadManagedTreeRoundTrip(t *testing.T) { "namespace.yaml": []byte("kind: Namespace\n"), "app/deployment.yaml": []byte("kind: Deployment\n"), }} - require.NoError(t, writeArtifact(workdir, path, &written)) + require.NoError(t, writeArtifact(workdir, path, &written, true)) got, err := readManagedTree(workdir, path) require.NoError(t, err) @@ -140,7 +169,7 @@ func TestReadManagedTreeSingleFile(t *testing.T) { func TestWriteArtifactRejectsPathEscape(t *testing.T) { workdir := t.TempDir() - err := writeArtifact(workdir, "../escape", &target.ProvisionArtifact{Files: map[string][]byte{"x.yaml": []byte("x")}}) + err := writeArtifact(workdir, "../escape", &target.ProvisionArtifact{Files: map[string][]byte{"x.yaml": []byte("x")}}, true) require.Error(t, err) assert.ErrorIs(t, err, errUtils.ErrGitPathEscapesWorktree) } @@ -153,7 +182,7 @@ func TestWriteArtifactRejectsRootPath(t *testing.T) { sentinel := filepath.Join(workdir, ".git") require.NoError(t, os.WriteFile(sentinel, []byte("gitdir"), 0o600)) - err := writeArtifact(workdir, path, &target.ProvisionArtifact{Files: map[string][]byte{"x.yaml": []byte("x")}}) + err := writeArtifact(workdir, path, &target.ProvisionArtifact{Files: map[string][]byte{"x.yaml": []byte("x")}}, true) require.ErrorIs(t, err, errUtils.ErrGitTargetPathInvalid) assert.FileExists(t, sentinel) } @@ -211,11 +240,64 @@ func TestWriteArtifactRejectsFilePathEscape(t *testing.T) { // the per-file ValidateRepoRelativePath must reject it. err := writeArtifact(workdir, "clusters/dev", &target.ProvisionArtifact{Files: map[string][]byte{ "../../../escape.yaml": []byte("x"), - }}) + }}, true) require.Error(t, err) assert.ErrorIs(t, err, errUtils.ErrGitPathEscapesWorktree) } +func TestWriteArtifactSingleFileMode(t *testing.T) { + workdir := t.TempDir() + path := filepath.Join("kustomize", "overlays", "prod", "kustomization.yaml") + + artifact := &target.ProvisionArtifact{Files: map[string][]byte{ + "001_kustomize.config.k8s.io_v1alpha1_Component_cert-manager.yaml": []byte("apiVersion: kustomize.config.k8s.io/v1alpha1\nkind: Component\n"), + }} + require.NoError(t, writeArtifact(workdir, path, artifact, false)) + + abs := filepath.Join(workdir, "kustomize", "overlays", "prod", "kustomization.yaml") + info, err := os.Stat(abs) + require.NoError(t, err) + assert.False(t, info.IsDir(), "path must be written as a file, not a directory") + + got, err := os.ReadFile(abs) + require.NoError(t, err) + assert.Equal(t, "apiVersion: kustomize.config.k8s.io/v1alpha1\nkind: Component\n", string(got)) +} + +func TestWriteArtifactSingleFileModeMergesMultipleDocuments(t *testing.T) { + workdir := t.TempDir() + path := filepath.Join("clusters", "dev", "manifest.yaml") + + artifact := &target.ProvisionArtifact{Files: map[string][]byte{ + "a.yaml": []byte("kind: Namespace\n"), + "b.yaml": []byte("kind: Deployment\n"), + }} + require.NoError(t, writeArtifact(workdir, path, artifact, false)) + + got, err := os.ReadFile(filepath.Join(workdir, "clusters", "dev", "manifest.yaml")) + require.NoError(t, err) + assert.Equal(t, "kind: Namespace\n---\nkind: Deployment\n", string(got)) +} + +func TestWriteArtifactSingleFileModeReplacesExistingDirectory(t *testing.T) { + workdir := t.TempDir() + path := filepath.Join("kustomize", "overlays", "prod", "kustomization.yaml") + + // Simulate the reported bug's on-disk state: a stale directory left over from a + // prior split=true delivery to the same path. + stale := filepath.Join(workdir, "kustomize", "overlays", "prod", "kustomization.yaml") + require.NoError(t, os.MkdirAll(filepath.Join(stale, "001_stale.yaml"), 0o755)) + + artifact := &target.ProvisionArtifact{Files: map[string][]byte{ + "001_kustomize.config.k8s.io_v1alpha1_Component.yaml": []byte("kind: Component\n"), + }} + require.NoError(t, writeArtifact(workdir, path, artifact, false)) + + info, err := os.Stat(stale) + require.NoError(t, err) + assert.False(t, info.IsDir(), "the stale directory must be replaced by a single file") +} + func TestWriteArtifactWriteFailure(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("file-mode permissions behave differently on Windows") @@ -235,7 +317,31 @@ func TestWriteArtifactWriteFailure(t *testing.T) { err := writeArtifact(workdir, filepath.Join("clusters", "dev"), &target.ProvisionArtifact{Files: map[string][]byte{ "namespace.yaml": []byte("kind: Namespace\n"), - }}) + }}, true) + require.Error(t, err) + assert.ErrorIs(t, err, errUtils.ErrGitArtifactWrite) +} + +func TestWriteArtifactSingleFileModeWriteFailure(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("file-mode permissions behave differently on Windows") + } + if currentUser, userErr := user.Current(); userErr == nil && (currentUser.Uid == "0" || currentUser.Username == "root") { + t.Skip("running as root ignores filesystem permissions") + } + + workdir := t.TempDir() + // Make a read-only managed-path parent so writeSingleArtifactFile's + // MkdirAll/WriteFile under it fail. + managed := filepath.Join(workdir, "clusters") + require.NoError(t, os.Mkdir(managed, 0o555)) + t.Cleanup(func() { + _ = os.Chmod(managed, 0o755) + }) + + err := writeArtifact(workdir, filepath.Join("clusters", "dev", "manifest.yaml"), &target.ProvisionArtifact{Files: map[string][]byte{ + "namespace.yaml": []byte("kind: Namespace\n"), + }}, false) require.Error(t, err) assert.ErrorIs(t, err, errUtils.ErrGitArtifactWrite) } @@ -374,6 +480,61 @@ func TestDeliverIntegrationCommitError(t *testing.T) { assert.ErrorIs(t, err, errUtils.ErrGitDirtyUnmanagedFiles) } +// TestDeliverIntegrationCloneErrorSurfacesStderrAndHint reproduces the +// first-time-GitOps-bootstrap failure mode: a deployment repository that +// exists but has no commits on the configured branch yet (a brand-new, +// empty repo). Before this fix, Deliver returned only "git clone (exit 128)" +// with no indication of the real cause. It must now surface git's own stderr +// output (naming the missing remote branch) and an actionable hint. +func TestDeliverIntegrationCloneErrorSurfacesStderrAndHint(t *testing.T) { + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git binary not available") + } + isolatedGitEnv(t) + + root := t.TempDir() + bare := filepath.Join(root, "empty.git") + gitCmd(t, "", "init", "--bare", bare) + gitCmd(t, bare, "symbolic-ref", "HEAD", "refs/heads/main") + + atmosConfig := &schema.AtmosConfiguration{ + Git: schema.GitConfig{ + Repositories: map[string]schema.GitRepository{ + "deployments": { + URI: bare, + Branch: "main", + Workdir: filepath.Join(root, "workdir"), + }, + }, + }, + } + in := &target.DeliverInput{ + AtmosConfig: atmosConfig, + TargetName: "deployment-repo", + TargetConfig: map[string]any{ + "repository": "deployments", + "path": "clusters/dev/argocd", + "commit": map[string]any{"message": "Render argocd", "signing": "never"}, + }, + Artifact: target.ProvisionArtifact{ + Kind: target.ArtifactKindKubernetesManifests, + Format: target.FormatYAML, + Files: map[string][]byte{"namespace.yaml": []byte("apiVersion: v1\nkind: Namespace\n")}, + }, + } + + g := &gitProvisioner{} + err := g.Deliver(context.Background(), in) + require.Error(t, err) + + details := strings.Join(cockroacherrors.GetAllDetails(err), "\n") + assert.Contains(t, details, "Remote branch", "the real git stderr must be surfaced, not just an exit code") + + hints := strings.Join(cockroacherrors.GetAllHints(err), "\n") + assert.Contains(t, hints, "Confirm the configured branch exists and has commits", + "an actionable hint must be attached") +} + func TestDeliverIntegrationPublishesToRepo(t *testing.T) { if _, err := exec.LookPath("git"); err != nil { t.Skip("git binary not available") diff --git a/pkg/provisioner/target/manifest.go b/pkg/provisioner/target/manifest.go new file mode 100644 index 0000000000..59452ddcbb --- /dev/null +++ b/pkg/provisioner/target/manifest.go @@ -0,0 +1,27 @@ +package target + +import ( + "bytes" + + "github.com/cloudposse/atmos/pkg/perf" +) + +// MergeYAMLDocuments concatenates already-rendered YAML documents into a single +// "---\n"-separated multi-document stream, matching Kubernetes' native +// multi-document YAML convention. Used wherever a target must deliver several +// rendered manifests as one file rather than one file per manifest. +func MergeYAMLDocuments(docs [][]byte) []byte { + defer perf.Track(nil, "target.MergeYAMLDocuments")() + + var buffer bytes.Buffer + for i, doc := range docs { + if i > 0 { + buffer.WriteString("---\n") + } + buffer.Write(doc) + if !bytes.HasSuffix(doc, []byte("\n")) { + buffer.WriteByte('\n') + } + } + return buffer.Bytes() +} diff --git a/pkg/provisioner/target/manifest_test.go b/pkg/provisioner/target/manifest_test.go new file mode 100644 index 0000000000..64d206c70c --- /dev/null +++ b/pkg/provisioner/target/manifest_test.go @@ -0,0 +1,33 @@ +package target + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestMergeYAMLDocuments(t *testing.T) { + tests := []struct { + name string + docs [][]byte + want string + }{ + {"empty", nil, ""}, + {"single document", [][]byte{[]byte("kind: Namespace\n")}, "kind: Namespace\n"}, + { + "multiple documents joined with separator", + [][]byte{[]byte("kind: Namespace\n"), []byte("kind: Deployment\n")}, + "kind: Namespace\n---\nkind: Deployment\n", + }, + { + "trailing newline is normalized when missing", + [][]byte{[]byte("kind: Namespace"), []byte("kind: Deployment")}, + "kind: Namespace\n---\nkind: Deployment\n", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, string(MergeYAMLDocuments(tt.docs))) + }) + } +} diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index 2076fc85ca..c6785361e0 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -1881,6 +1881,7 @@ type BaseComponentConfig struct { BaseComponentProvider string BaseComponentPaths any BaseComponentManifests any + BaseComponentValidate any BaseComponentPlugins any BaseComponentRender AtmosSectionMapType BaseComponentHelm AtmosSectionMapType diff --git a/website/blog/2026-08-05-kustomize-gitops-delivery.mdx b/website/blog/2026-08-05-kustomize-gitops-delivery.mdx new file mode 100644 index 0000000000..5cd0f9c43e --- /dev/null +++ b/website/blog/2026-08-05-kustomize-gitops-delivery.mdx @@ -0,0 +1,89 @@ +--- +slug: kustomize-gitops-delivery +title: "Injecting Terraform Values into Kustomize Without Hand-Editing Overlays" +authors: [osterman] +tags: [bugfix, enhancement] +--- + +Kustomize expects the files it consumes to have exact, reserved names. A remote base or +component can only be included if the location it points to contains a file with one of a +handful of recognized names (`kustomization.yaml` is the common one) — that's not +configurable on Kustomize's side. So when a value only Terraform knows — a security group +ID, a Route53 zone ID, an ARN — needs to land inside a Kustomize-managed GitOps repo, teams +are usually stuck hand-editing the overlay after every apply, or routing the value through a +separate tool just to produce one correctly-named file. + + + +## The Problem + +Delivering rendered Kubernetes manifests to a Git deployment repository (the source Argo CD +or Flux reconciles) always wrote them as a directory — one generated file per manifest, no +way to land a single file under an exact, caller-chosen name. That's a fine default for a +directory of standalone resources, but it can't produce `kustomization.yaml`, so it couldn't +support this pattern at all. Separately, Kustomize's own `Kustomization` and `Component` +config objects don't have a `metadata.name` in the real Kustomize schema — they're local +input to the `kustomize` build tool, not Kubernetes API resources — but Atmos's manifest +validator required one anyway, rejecting perfectly valid Kustomize files. + +## The Fix + +A git provision target's `path` can now be an exact single-file destination, not just a +directory. Set `split: false` to merge every rendered manifest into one file written at that +path; leave it unset and Atmos infers the right mode from whether the path looks like a +manifest filename (`.yaml`, `.yml`, or `.json`). Every existing configuration keeps its +current directory behavior unchanged. + +Atmos also now recognizes Kustomize's own `Kustomization` and `Component` kinds and no +longer requires a `metadata.name` on them — matching Kustomize's own validation, not an +opinion Atmos invented. For anything else, a new `validate: false` component setting opts +out of Atmos's structural checks entirely. + +## How to Use It + +```yaml +components: + kubernetes: + cert-manager-patch: + provision: + targets: + deployment-repo: + kind: git + repository: deployments + path: "kustomize/overlays/{{ .vars.environment }}/kustomization.yaml" + commit: + message: "Render manifests for {{ .vars.environment }}" + manifests: + - apiVersion: kustomize.config.k8s.io/v1alpha1 + kind: Component + patches: + - target: + kind: ClusterIssuer + name: letsencrypt-dns + patch: | + - op: add + path: /spec/acme/solvers + value: + - dns01: + route53: + region: "{{ .vars.aws_region }}" + hostedZoneID: "{{ atmos.Resolve \"!terraform.state route53 public_zone_id\" }}" +``` + +```shell +atmos kubernetes deploy cert-manager-patch -s plat-ue2-dev --target=deployment-repo +``` + +No `split` is set here — the path ends in `kustomization.yaml`, so Atmos writes it as a +single file automatically. No `metadata.name` is needed on the `Component` object either. +The real Kustomize overlay then includes the generated file as a remote component, so the +Terraform-derived value flows through on every deploy without anyone touching the overlay by +hand. See [Generating a Kustomize component for GitOps](/stacks/components/kubernetes#generating-a-kustomize-component-for-gitops) +for the full walkthrough. + +## Get Involved + +Try delivering a Kustomize component or patch through a git provision target in your own +GitOps repo. Tell us what's missing — pull-request publishing for the git target, support +for other Kustomize-only object kinds, or something else — by opening an issue at +[github.com/cloudposse/atmos](https://github.com/cloudposse/atmos). diff --git a/website/docs/cli/commands/kubernetes/kubernetes-deploy.mdx b/website/docs/cli/commands/kubernetes/kubernetes-deploy.mdx index d60b1d51bf..ec02e753e6 100644 --- a/website/docs/cli/commands/kubernetes/kubernetes-deploy.mdx +++ b/website/docs/cli/commands/kubernetes/kubernetes-deploy.mdx @@ -95,6 +95,47 @@ When `--target` is omitted, `provision.default` is used, otherwise the cluster. Credentials for cloning and pushing come from Atmos Auth (GitHub STS), so no tokens are stored in the manifests. +### File vs. directory delivery (`split`) + +A git target's `path` can be either a directory (one file per rendered object) or +the exact name of a single output file. Set `split` on the target to control +which: + +
+
`split: true`
+
`path` is a directory; every rendered object is written to its own generated filename inside it.
+ +
`split: false`
+
`path` is the exact output file; every rendered object is merged into one multi-document YAML file written to that path.
+ +
unset (default)
+
+ Inferred from `path`: if the last path segment looks like a manifest + filename (matches `/\.(ya?ml|json)$/i`, e.g. `kustomization.yaml`), `split` + defaults to `false`; otherwise it defaults to `true`, preserving the + directory behavior every existing configuration already relies on. +
+
+ +```yaml +components: + kubernetes: + argocd: + provision: + targets: + deployment-repo: + kind: git + repository: deployments + path: "kustomize/overlays/{{ .vars.environment }}/kustomization.yaml" + # split is omitted here: the path ends in .yaml, so it is inferred + # as split: false and written as a single file, not a directory. + commit: + message: "Render manifests for {{ .vars.environment }}" +``` + +See [Generating a Kustomize component for GitOps](/stacks/components/kubernetes#generating-a-kustomize-component-for-gitops) +for a complete walkthrough of this pattern. + ## Flags
diff --git a/website/docs/cli/commands/kubernetes/kubernetes-validate.mdx b/website/docs/cli/commands/kubernetes/kubernetes-validate.mdx index 5267da637b..eafaf177cd 100644 --- a/website/docs/cli/commands/kubernetes/kubernetes-validate.mdx +++ b/website/docs/cli/commands/kubernetes/kubernetes-validate.mdx @@ -90,7 +90,8 @@ atmos kubernetes validate --affected --labels cost-center=platform Offline (default): - `apiVersion` and `kind` are present (also enforced during rendering). -- `metadata.name` is present and is a valid DNS-1123 subdomain. +- `metadata.name` is present and is a valid DNS-1123 subdomain (with a + [Kustomize-specific exemption](#kustomize-config-objects) for `Kustomization`/`Component` objects). - The object resolves to a non-empty group/version/kind. With `--server`: @@ -100,6 +101,45 @@ With `--server`: Custom Resource Definitions authoritatively. Requires a reachable cluster and a configured kubeconfig. +### Kustomize config objects + +Kustomize's own `Kustomization` and `Component` objects are not Kubernetes API +resources — they are local input consumed by the `kustomize` build tool +itself, and Kustomize does not require (or, historically, even permit) a +`metadata.name` on them. `validate` recognizes these two exact, versioned +pairs and does not require `metadata.name` for them: + +- `apiVersion: kustomize.config.k8s.io/v1beta1`, `kind: Kustomization` +- `apiVersion: kustomize.config.k8s.io/v1alpha1`, `kind: Component` + +A `Kustomization`/`Component` object at a different `apiVersion` is not +recognized and still requires `metadata.name`. Every other offline check (a +resolvable group/version/kind, and DNS-1123 validity for a name that *is* +given) still applies regardless. See +[Generating a Kustomize component for GitOps](/stacks/components/kubernetes#generating-a-kustomize-component-for-gitops) +for the pattern this supports. + +### Disabling validation + +Set `validate: false` on a component to opt out of both the offline checks +above and the automatic pre-apply/deploy gate for that component entirely: + +```yaml +components: + kubernetes: + legacy-manifests: + validate: false + manifests: + - apiVersion: v1 + kind: ConfigMap + # ... +``` + +This is a manual override for manifests Atmos has no reserved-kind knowledge +of — for example objects owned by another tool's format. It does not affect +`--server`, which validates against the live cluster's own API rather than +Atmos's offline opinion. + ## Flags
diff --git a/website/docs/stacks/components/kubernetes.mdx b/website/docs/stacks/components/kubernetes.mdx index 068d4f2e32..8e4e20222a 100644 --- a/website/docs/stacks/components/kubernetes.mdx +++ b/website/docs/stacks/components/kubernetes.mdx @@ -236,6 +236,84 @@ The git target clones (or fast-forwards) the repository, replaces the managed is a clean no-op. Credentials come from Atmos Auth (GitHub STS); no tokens are written into the manifests. Pull-request publishing is not yet supported. +When `split` is unset, Atmos infers delivery mode from `path`. A manifest +filename writes one exact file, while other paths use directory delivery. Set +`split: true` or `split: false` to override the inference. Single-file delivery +is required whenever the consumer expects a specific filename, such as +Kustomize's `kustomization.yaml`. See +[File vs. directory delivery](/cli/commands/kubernetes/deploy#file-vs-directory-delivery-split) +and the walkthrough below. + +## Generating a Kustomize component for GitOps + +A common pattern: inject a Terraform-derived value (a security group ID, a +Route53 zone ID, an ARN) into an existing Kustomize/Argo CD deployment without +hand-editing the Kustomize overlay. Atmos renders a Kustomize `Component` +manifest — a JSON6902 patch, using `atmos.Component`/`!terraform.state` to pull +the value — and commits it to the deployment repository as an exact +`kustomization.yaml`, which the real overlay then includes as a remote +component. + +```yaml +components: + kubernetes: + cert-manager-patch: + provision: + targets: + deployment-repo: + kind: git + repository: deployments + path: "kustomize/overlays/{{ .vars.environment }}/kustomization.yaml" + commit: + message: "Render manifests for {{ .vars.environment }}" + manifests: + - apiVersion: kustomize.config.k8s.io/v1alpha1 + kind: Component + patches: + - target: + kind: ClusterIssuer + name: letsencrypt-dns + patch: | + - op: add + path: /spec/acme/solvers + value: + - dns01: + cnameStrategy: Follow + route53: + region: "{{ .vars.aws_region }}" + hostedZoneID: "{{ atmos.Resolve \"!terraform.state route53 public_zone_id\" }}" +``` + +```shell +atmos kubernetes deploy cert-manager-patch -s plat-ue2-dev --target=deployment-repo +``` + +Two things make this work that are easy to miss: + +- **No `split` is set.** The `path` ends in `kustomization.yaml`, so Atmos + infers `split: false` and writes it as a single file rather than a directory + by that name — see [File vs. directory delivery](/cli/commands/kubernetes/deploy#file-vs-directory-delivery-split). +- **No `metadata.name` is needed on the `Component` object.** Kustomize's own + `Kustomization`/`Component` objects are not Kubernetes API resources — they + are local input to the `kustomize` build tool — and Kustomize does not + require a name on them. Atmos recognizes these two kinds specifically and + does not require `metadata.name` for them; see + [`atmos kubernetes validate`](/cli/commands/kubernetes/validate#kustomize-config-objects) + for details, including the `validate: false` escape hatch for other cases. + +The real Kustomize overlay then includes the generated file as a remote +component: + +```yaml +# kustomize/overlays/prod/kustomization.yaml (hand-maintained, in the same repo) +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - ../../base +components: + - github.com/acme/deployments//kustomize/overlays/prod?ref=main +``` + ## Auth Kubernetes operations use the kubeconfig and client configuration visible to diff --git a/website/src/data/roadmap.js b/website/src/data/roadmap.js index 7912fca777..a99842ecf2 100644 --- a/website/src/data/roadmap.js +++ b/website/src/data/roadmap.js @@ -369,7 +369,7 @@ export const roadmapConfig = { tagline: 'Truly extensible architecture', description: 'Modern infrastructure spans Terraform, Kubernetes, serverless, and custom tooling. A single orchestration layer should manage all of it consistently, not just a subset.', - progress: 95, + progress: 93, status: 'in-progress', milestones: [ { label: 'Custom commands support interactive step types', status: 'shipped', quarter: 'q4-2025', docs: '/cli/configuration/commands/steps#extended-step-types', changelog: 'custom-commands-step-types', pr: 1899, description: 'Custom commands now support all 25+ step types from workflows—input, choose, confirm, filter, markdown, toast, and more. Build interactive CLI wizards directly in atmos.yaml.', category: 'featured', priority: 'high', benefits: 'Build interactive deployment wizards and guided experiences in custom commands. Same step types as workflows, discoverable via atmos help.' }, @@ -393,6 +393,7 @@ export const roadmapConfig = { { label: 'Custom component types with registry', status: 'shipped', quarter: 'q4-2025', pr: 1904, docs: '/cli/configuration/commands', changelog: 'custom-component-types', description: 'Register custom component types that integrate seamlessly with Atmos commands and workflows.', benefits: 'Your custom commands get the same tab completion, list commands, and workflow integration as built-ins.' }, { label: 'Native Ansible component support', status: 'shipped', quarter: 'q1-2026', pr: 2042, docs: '/cli/commands/ansible/usage', changelog: 'ansible-component-support', description: 'Ansible as a first-class component type with full stack processor support, variable inheritance, and playbook execution.', benefits: 'Unified orchestration of infrastructure provisioning (Terraform) and configuration management (Ansible) from the same stack manifests.' }, { label: 'Native Kubernetes component support with GitOps delivery', status: 'shipped', quarter: 'q2-2026', docs: '/stacks/components/kubernetes', changelog: 'native-kubernetes-components', description: 'Kubernetes as a first-class component type: render/diff/apply/delete inline manifests, files, directories, and Kustomize overlays through the Kubernetes Go SDK (no kubectl/kustomize binary). provision.targets adds delivery destinations so apply/deploy can publish rendered manifests to a Git deployment repository for Argo CD/Flux instead of applying to a cluster.', codeExample: 'atmos kubernetes apply argocd -s plat-ue2-dev --target=deployment-repo', benefits: 'Orchestrate Kubernetes with the same stacks, inheritance, auth, and affected detection as Terraform. Publish to GitOps repos with one flag — no glue scripts for rendering, committing, or credentials.' }, + { label: 'Single-file GitOps delivery and Kustomize object support', status: 'shipped', quarter: 'q3-2026', pr: 2874, docs: '/stacks/components/kubernetes#generating-a-kustomize-component-for-gitops', changelog: 'kustomize-gitops-delivery', description: 'A git provision target\'s path can now be an exact single-file destination (split: false), not just a directory of generated files — inferred automatically when the path looks like a manifest filename such as kustomization.yaml. Atmos also recognizes Kustomize\'s own Kustomization/Component object kinds and no longer requires metadata.name on them, matching Kustomize\'s own validation rules; a new validate: false component setting opts out of structural checks for other cases.', codeExample: 'atmos kubernetes deploy cert-manager-patch -s plat-ue2-dev --target=deployment-repo', benefits: 'Inject Terraform-derived values into a Kustomize-managed GitOps repo as a proper Kustomize component or patch, without hand-editing overlays after every apply.' }, { label: 'Native Helm component support with GitOps delivery', status: 'shipped', quarter: 'q2-2026', docs: '/cli/commands/helm/usage', changelog: 'native-helm-components', description: 'Helm as a first-class component type: template/diff/apply/delete local, remote-repository, and OCI charts through the Helm Go SDK (no helm/helmfile binary). The component values: map is the chart values (merged via Atmos inheritance); secret values flow in via !secret. provision.targets lets apply/deploy publish rendered manifests to a Git deployment repository for Argo CD/Flux. Also adds atmos helmfile template to render and deliver existing Helmfile components (issue #2069).', codeExample: 'atmos helm deploy monitoring -s plat-ue2-dev --target=deployment-repo', benefits: 'Skip Helmfile for new projects and orchestrate Helm with the same stacks, inheritance, auth, secrets, and affected detection as Terraform. Publish to GitOps repos with one flag.' }, { label: 'Semantic type completion', status: 'shipped', quarter: 'q4-2025', pr: 1904, docs: '/cli/commands/completion', changelog: 'custom-component-types', description: 'Context-aware shell completion that understands your component types and suggests valid options.', benefits: 'Tab completion suggests values that make sense for each argument, not just filenames.' }, { label: 'Auth support for custom commands', status: 'shipped', quarter: 'q4-2025', docs: '/cli/configuration/auth/identities', changelog: 'authentication-for-workflows-and-custom-commands', version: 'v1.197.0', description: 'Custom commands can leverage Atmos authentication for cloud provider access.', benefits: 'Custom scripts use the same identity management as Terraform. One auth system for everything.' },