Skip to content
Closed
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
77 changes: 76 additions & 1 deletion cmd/cmd_utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/spf13/cobra"
"github.com/spf13/pflag"

"github.com/cloudposse/atmos/cmd/internal"
errUtils "github.com/cloudposse/atmos/errors"
e "github.com/cloudposse/atmos/internal/exec"
tuiUtils "github.com/cloudposse/atmos/internal/tui/utils"
Expand All @@ -28,6 +29,7 @@ import (
cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/dependencies"
envpkg "github.com/cloudposse/atmos/pkg/env"
hookspkg "github.com/cloudposse/atmos/pkg/hooks"
l "github.com/cloudposse/atmos/pkg/list"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/perf"
Expand Down Expand Up @@ -732,6 +734,55 @@ func executeCustomCommand(
// Initialize step executor once before loop - reused across steps to preserve outputs.
executor := stepPkg.NewStepExecutor()

// For commands bound to a custom component type, set up an outputs file
// so steps can publish key/value pairs via $ATMOS_OUTPUTS. After all steps
// succeed, atmos fires the after-<type>-<subcommand> hook event, which reads
// the file when resolving `.foo`-style output references in `store` hooks.
var outputsFilePath string
if commandConfig.Component != nil && commandConfig.Component.Type != "" {
f, err := os.CreateTemp("", "atmos-outputs-*")
if err != nil {
errUtils.CheckErrorPrintAndExit(
errUtils.Build(errUtils.ErrCreateOutputsFile).
WithCause(err).
WithExplanation("Failed to create ATMOS_OUTPUTS temp file for custom component").
Err(),
"", "",
)
}
outputsFilePath = f.Name()
_ = f.Close()
defer func() { _ = os.Remove(outputsFilePath) }()
log.Debug("Created outputs file for custom component", "path", outputsFilePath, "type", commandConfig.Component.Type)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// Captured from inside the step loop so the hook-firing closure can reuse
// the resolved component without re-describing it.
var resolvedComponent map[string]any
componentTyped := commandConfig.Component != nil && commandConfig.Component.Type != ""

// fireComponentHook emits a `<phase>.<type>.<subcommand>` lifecycle event for
// a custom-component-typed command — e.g. `before.agent.apply` /
// `after.agent.apply` for `atmos agent apply ...`. This mirrors the built-in
// terraform events (`after.terraform.apply`, …) rather than anchoring custom
// types to terraform's `apply` verb. The event name encodes the subcommand,
// so `atmos agent describe` fires `after.agent.describe` — a distinct event
// that apply-oriented store hooks simply don't subscribe to.
//
// Hooks are loaded from the already-resolved component (passed through to
// RunHooks) so we never re-describe a custom type the built-in describe path
// can't resolve.
fireComponentHook := func(phase string) {
event := hookspkg.ComponentEvent(phase, commandConfig.Component.Type, commandConfig.Name)
populate := func(info *schema.ConfigAndStacksInfo) {
info.ComponentType = commandConfig.Component.Type
info.OutputsFilePath = outputsFilePath
}
if err := internal.RunHooks(event, cmd, args, "", populate, resolvedComponent); err != nil {
log.Warn("Hook event returned an error", "event", event, "error", err)
}
}

// Execute custom command's steps
for i, step := range commandConfig.Steps {
// Prepare template data for arguments
Expand Down Expand Up @@ -768,8 +819,16 @@ func executeCustomCommand(

// If the custom command defines 'component' section with a custom component type,
// register the type, resolve component config, and expose it in {{ .Component.* }} Go template variables.
if commandConfig.Component != nil && commandConfig.Component.Type != "" {
if componentTyped {
processCustomComponentType(&atmosConfig, commandConfig, argumentsData, flagsData, data, authManager)
if rc, ok := data["Component"].(map[string]any); ok {
resolvedComponent = rc
}
// Fire the before-<type>-<subcommand> event once, after the component
// is resolved (so hooks are available) but before any step runs.
if i == 0 {
fireComponentHook(hookspkg.PhaseBefore)
}
} else if commandConfig.ComponentConfig.Component != "" && commandConfig.ComponentConfig.Stack != "" {
// Legacy: If the custom command defines 'component_config' section with 'component' and 'stack' attributes,
// process the component stack config and expose it in {{ .ComponentConfig.xxx.yyy.zzz }} Go template variables.
Expand Down Expand Up @@ -806,6 +865,12 @@ func executeCustomCommand(
// ENV var values support Go templates and have access to {{ .ComponentConfig.xxx.yyy.zzz }} Go template variables
// Start with current environment + global env from atmos.yaml to inherit PATH and other variables.
env := envpkg.MergeGlobalEnv(os.Environ(), atmosConfig.Env)

// For custom component types, expose ATMOS_OUTPUTS so steps can publish
// key/value pairs that the after-<type>-<subcommand> hook reads.
if outputsFilePath != "" {
env = append(env, fmt.Sprintf("ATMOS_OUTPUTS=%s", outputsFilePath))
}
for _, v := range commandConfig.Env {
key := strings.TrimSpace(v.Key)
value := v.Value
Expand Down Expand Up @@ -902,6 +967,14 @@ func executeCustomCommand(
}
errUtils.CheckErrorPrintAndExit(err, "", "")
}

// All steps completed successfully. For custom-component-typed commands, fire
// the after-<type>-<subcommand> event (e.g. after.agent.apply) with the
// OutputsFilePath and ComponentType populated so the `store` hook can resolve
// `.foo` references against the outputs file the steps wrote.
if componentTyped {
fireComponentHook(hookspkg.PhaseAfter)
}
}

// cloneCommand clones a custom command config into a new struct.
Expand Down Expand Up @@ -1621,6 +1694,8 @@ func processCustomComponentType(
data map[string]any,
authManager auth.AuthManager,
) {
defer perf.Track(nil, "cmd.processCustomComponentType")()

// Find component name from argument/flag with type: component.
componentName := findTypedValue(commandConfig, argumentsData, flagsData, "component")
if componentName == "" {
Expand Down
81 changes: 81 additions & 0 deletions cmd/internal/runhooks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package internal

import (
"errors"

"github.com/spf13/cobra"

errUtils "github.com/cloudposse/atmos/errors"
e "github.com/cloudposse/atmos/internal/exec"
cfg "github.com/cloudposse/atmos/pkg/config"
h "github.com/cloudposse/atmos/pkg/hooks"
log "github.com/cloudposse/atmos/pkg/logger"
"github.com/cloudposse/atmos/pkg/schema"
)

// RunHooks fires lifecycle hooks for the active component/stack at the given
// event. This is the shared entrypoint used by both the built-in terraform
// command path and the custom-command path (PR #1904 component types).
//
// `commandName` is the atmos command bucket the args originated from
// (e.g. "terraform"). Pass empty string for custom commands.
//
// `populate` (optional) lets the caller mutate the resolved ConfigAndStacksInfo
// before hook resolution. Custom commands use this to inject ComponentType
// and OutputsFilePath, which the `store` hook uses to look up `.foo`-style
// output references in the ATMOS_OUTPUTS file the steps wrote to.
//
// `preResolvedComponent` (optional) skips the implicit ExecuteDescribeComponent
// call inside GetHooks. The custom-command path already resolved the component
// to populate `{{ .Component.* }}` templates, and the built-in describe path
// does not understand custom component types — re-describing would fail with
// "component not found". When non-nil, hooks are built directly from this map.
func RunHooks(
event h.HookEvent,
cmd *cobra.Command,
args []string,
commandName string,
populate func(info *schema.ConfigAndStacksInfo),
preResolvedComponent map[string]any,
) error {
finalArgs := append([]string{cmd.Name()}, args...)

processCmd := commandName
if processCmd == "" {
processCmd = cmd.Name()
}

info, err := e.ProcessCommandLineArgs(processCmd, cmd, finalArgs, nil)
if err != nil {
return err
}

atmosConfig, err := cfg.InitCliConfig(info, true)
if err != nil {
return errors.Join(errUtils.ErrInitializeCLIConfig, err)
}

if populate != nil {
populate(&info)
}

var hooks *h.Hooks
if preResolvedComponent != nil {
hooks, err = h.HooksFromComponent(&atmosConfig, &info, preResolvedComponent)
} else {
hooks, err = h.GetHooks(&atmosConfig, &info)
}
if err != nil {
return errors.Join(errUtils.ErrGetHooks, err)
}

if hooks == nil || !hooks.HasHooks() {
return nil
}

log.Info("Running hooks", "event", event)
if err := hooks.RunAll(event, &atmosConfig, &info, cmd, args); err != nil {
errUtils.CheckErrorPrintAndExit(err, "", "")
}
return nil
}
Loading
Loading