Skip to content

Commit f9f53da

Browse files
ostermanclaudeaknysh
authored
feat(hooks): run custom step types as lifecycle hooks (kind: step) (#2658)
* feat(hooks): run custom step types as lifecycle hooks (kind: step) Add a `kind: step` hook that delegates to the workflow/custom-command step registry, making every registered step type (container, http, toast, log, ...) available on terraform lifecycle events. Name a step `type:` and pass its parameters under `with:`; `on_failure` and `retry` are envelope-level policy applied around the step (no import cycle — pkg/hooks imports pkg/runner/step). Also plumb the operation outcome to hooks so they can report what happened: - user hooks now fire on the failure path (not just success) - a `when: success|failure|always` selector (default success) preserves back-compat while letting hooks opt into failure firing - `{{ .status }}`/`{{ .exit_code }}`/`{{ .error }}` template context and ATMOS_HOOK_* env vars expose the outcome alongside component/stack Includes a structured hook-envelope JSON schema (kind enum incl. step, when, type, with), docs, PRD, changelog, and roadmap updates. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): address review feedback (preflight, schema include, perf, roadmap) - preflight/verifyAllBinaries skip hooks that won't run for the current outcome status, so a success-only hook with a missing binary cannot block failure-path (when: failure) hooks; extracted verifyHookBinary helper - hooks JSON schema per-hook value accepts `!include` strings again (oneOf string|object) across all three schema copies - add perf.Track to exported BuildAtmosEnv - roadmap: add pr: 2658 to the kind:step milestone and bump extensibility progress 93 -> 94 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(hooks): avoid CodeQL allocation-size-overflow in withOutcomeTemplateData Size the augmented template-data map from a single len(section) instead of len(section)+3; CodeQL's go/allocation-size-overflow rule flags len(x)+N. The map grows as needed for the three outcome keys. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add conditional when support for steps and hooks * Address conditional when review feedback * Address conditional when follow-up feedback * Document dotted Terraform hook events * Add say hook apply outcome example --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Andriy Knysh <aknysh@users.noreply.github.com>
1 parent ac78e61 commit f9f53da

55 files changed

Lines changed: 4370 additions & 222 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

cmd/cmd_utils.go

Lines changed: 85 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
"github.com/cloudposse/atmos/pkg/reexec"
3636
stepPkg "github.com/cloudposse/atmos/pkg/runner/step"
3737
"github.com/cloudposse/atmos/pkg/schema"
38+
"github.com/cloudposse/atmos/pkg/telemetry"
3839
"github.com/cloudposse/atmos/pkg/ui"
3940
u "github.com/cloudposse/atmos/pkg/utils"
4041
"github.com/cloudposse/atmos/pkg/version"
@@ -52,6 +53,11 @@ var missingConfigFoundMarkdown string
5253
// Define a constant for the dot string that appears multiple times.
5354
const currentDirPath = "."
5455

56+
const (
57+
customCommandKeyCommand = "command"
58+
customCommandKeyIdentity = "identity"
59+
)
60+
5561
// FlagStack is the name of the stack flag used across commands.
5662
const FlagStack = "stack"
5763

@@ -301,7 +307,7 @@ func preCustomCommand(
301307
// no "steps" means a sub command should be specified
302308
if len(commandConfig.Steps) == 0 {
303309
if err := cmd.Help(); err != nil {
304-
log.Trace("Failed to display command help", "error", err, "command", cmd.Name())
310+
log.Trace("Failed to display command help", "error", err, customCommandKeyCommand, cmd.Name())
305311
}
306312
errUtils.Exit(0)
307313
}
@@ -365,7 +371,7 @@ func createCustomCommand(
365371
customCommand.PersistentFlags().Bool("", false, doubleDashHint)
366372

367373
// Add --identity flag to all custom commands to allow runtime override.
368-
customCommand.PersistentFlags().String("identity", "", "Identity to use for authentication (overrides identity in command config)")
374+
customCommand.PersistentFlags().String(customCommandKeyIdentity, "", "Identity to use for authentication (overrides identity in command config)")
369375
AddIdentityCompletion(customCommand)
370376

371377
if err := validateCustomCommandFlags(commandConfig, parentCommand); err != nil {
@@ -404,7 +410,7 @@ func validateFlag(cmdName string, flag *schema.CommandFlag, seen map[string]bool
404410
return errUtils.Build(errUtils.ErrDuplicateFlagRegistration).
405411
WithExplanation(fmt.Sprintf("Custom command '%s' defines duplicate flag '--%s'", cmdName, flag.Name)).
406412
WithHint("Remove or rename the duplicate flag in your atmos.yaml").
407-
WithContext("command", cmdName).
413+
WithContext(customCommandKeyCommand, cmdName).
408414
WithContext("flag", flag.Name).
409415
Err()
410416
}
@@ -441,7 +447,7 @@ func validateFlagTypeConflict(cmdName string, flag *schema.CommandFlag, existing
441447
cmdName, flag.Name, customFlagType, existingFlagType)).
442448
WithHint("Check the 'commands' section in atmos.yaml").
443449
WithHint("Either use the existing flag type, or rename your flag to avoid conflicts").
444-
WithContext("command", cmdName).
450+
WithContext(customCommandKeyCommand, cmdName).
445451
WithContext("flag", flag.Name).
446452
WithContext("declared_type", customFlagType).
447453
WithContext("existing_type", existingFlagType).
@@ -461,7 +467,7 @@ func validateFlagShorthand(cmdName string, flag *schema.CommandFlag, seen map[st
461467
return errUtils.Build(errUtils.ErrDuplicateFlagRegistration).
462468
WithExplanation(fmt.Sprintf("Custom command '%s' defines invalid shorthand '-%s' for flag '--%s'", cmdName, flag.Shorthand, flag.Name)).
463469
WithHint("Use exactly one character for shorthand flags").
464-
WithContext("command", cmdName).
470+
WithContext(customCommandKeyCommand, cmdName).
465471
WithContext("flag", flag.Name).
466472
WithContext("shorthand", flag.Shorthand).
467473
Err()
@@ -471,7 +477,7 @@ func validateFlagShorthand(cmdName string, flag *schema.CommandFlag, seen map[st
471477
return errUtils.Build(errUtils.ErrDuplicateFlagRegistration).
472478
WithExplanation(fmt.Sprintf("Custom command '%s' defines duplicate flag shorthand '-%s'", cmdName, flag.Shorthand)).
473479
WithHint("Remove or change the duplicate shorthand in your atmos.yaml").
474-
WithContext("command", cmdName).
480+
WithContext(customCommandKeyCommand, cmdName).
475481
WithContext("shorthand", flag.Shorthand).
476482
Err()
477483
}
@@ -490,7 +496,7 @@ func validateFlagShorthand(cmdName string, flag *schema.CommandFlag, seen map[st
490496
cmdName, flag.Shorthand, existingByShorthand.Name)).
491497
WithHint("Check the 'commands' section in atmos.yaml").
492498
WithHint("Change the shorthand to avoid conflicts").
493-
WithContext("command", cmdName).
499+
WithContext(customCommandKeyCommand, cmdName).
494500
WithContext("shorthand", flag.Shorthand).
495501
WithContext("existing_flag", existingByShorthand.Name).
496502
WithContext("config_path", fmt.Sprintf("commands.%s.flags", cmdName)).
@@ -610,6 +616,23 @@ func executeCustomCommand(
610616
finalArgs = args
611617
}
612618

619+
conditionContext := customCommandConditionContext()
620+
hasRunnableStep := false
621+
for i := range commandConfig.Steps {
622+
step := &commandConfig.Steps[i]
623+
if err := schema.ValidateStepCondition(step.When); err != nil {
624+
errUtils.CheckErrorPrintAndExit(err, "", "")
625+
}
626+
if step.When.Evaluate(conditionContext) {
627+
hasRunnableStep = true
628+
break
629+
}
630+
}
631+
if !hasRunnableStep {
632+
log.Debug("Skipping custom command, no steps matched `when` conditions", customCommandKeyCommand, commandConfig.Name)
633+
return
634+
}
635+
613636
// Resolve and install command dependencies.
614637
// First, load tools from .tool-versions (project-wide defaults).
615638
// Then merge with command-specific dependencies (command deps override .tool-versions).
@@ -652,7 +675,7 @@ func executeCustomCommand(
652675
}
653676

654677
if len(deps) > 0 {
655-
log.Debug("Installing command dependencies", "command", commandConfig.Name, "tools", deps)
678+
log.Debug("Installing command dependencies", customCommandKeyCommand, commandConfig.Name, "tools", deps)
656679
installer := dependencies.NewInstaller(&atmosConfig)
657680
if err := installer.EnsureTools(deps); err != nil {
658681
err = errUtils.Build(errUtils.ErrToolInstall).
@@ -674,60 +697,25 @@ func executeCustomCommand(
674697
}
675698
}
676699

677-
// Create auth manager if identity is specified for this custom command.
700+
// Create auth manager if identity is specified for this custom command and
701+
// at least one step will run.
678702
// Check for --identity flag first (it overrides the config).
679-
var authManager auth.AuthManager
680-
var authStackInfo *schema.ConfigAndStacksInfo
681-
identityFlag, _ := cmd.Flags().GetString("identity")
703+
identityFlag, _ := cmd.Flags().GetString(customCommandKeyIdentity)
682704
commandIdentity := strings.TrimSpace(identityFlag)
683705
if commandIdentity == "" {
684706
// Fall back to identity from command config
685707
commandIdentity = strings.TrimSpace(commandConfig.Identity)
686708
}
687709

688-
if commandIdentity != "" {
689-
// Create a ConfigAndStacksInfo for the auth manager to populate with AuthContext.
690-
// This enables YAML template functions to access authenticated credentials.
691-
authStackInfo = &schema.ConfigAndStacksInfo{
692-
AuthContext: &schema.AuthContext{},
693-
}
694-
695-
credStore := credentials.NewCredentialStoreWithConfig(&atmosConfig.Auth)
696-
validator := validation.NewValidator()
697-
authManager, err = auth.NewAuthManager(&atmosConfig.Auth, credStore, validator, authStackInfo, atmosConfig.CliConfigPath)
698-
if err != nil {
699-
errUtils.CheckErrorPrintAndExit(fmt.Errorf("%w: %w", errUtils.ErrFailedToInitializeAuthManager, err), "", "")
700-
}
701-
702-
ctx := context.Background()
703-
704-
// Try to use cached credentials first (passive check, no prompts).
705-
// Only authenticate if cached credentials are not available or expired.
706-
_, err = authManager.GetCachedCredentials(ctx, commandIdentity)
707-
if err != nil {
708-
log.Debug("No valid cached credentials found, authenticating", "identity", commandIdentity, "error", err)
709-
// No valid cached credentials - perform full authentication.
710-
_, err = authManager.Authenticate(ctx, commandIdentity)
711-
if err != nil {
712-
// Check for user cancellation - return clean error without wrapping.
713-
if errors.Is(err, errUtils.ErrUserAborted) {
714-
errUtils.CheckErrorPrintAndExit(errUtils.ErrUserAborted, "", "")
715-
}
716-
errUtils.CheckErrorPrintAndExit(fmt.Errorf("%w for identity %q in custom command %q: %w",
717-
errUtils.ErrAuthenticationFailed, commandIdentity, commandConfig.Name, err), "", "")
718-
}
719-
}
720-
721-
log.Debug("Authenticated with identity for custom command", "identity", commandIdentity, "command", commandConfig.Name)
722-
}
710+
authManager := prepareCustomCommandAuth(&atmosConfig, commandIdentity, commandConfig.Name, hasRunnableStep)
723711

724712
// Determine working directory for command execution.
725713
workDir, err := resolveWorkingDirectory(commandConfig.WorkingDirectory, atmosConfig.BasePath, currentDirPath)
726714
if err != nil {
727715
errUtils.CheckErrorPrintAndExit(err, "Invalid working_directory", "https://atmos.tools/cli/configuration/commands/working-directory")
728716
}
729717
if commandConfig.WorkingDirectory != "" {
730-
log.Debug("Using working directory for custom command", "command", commandConfig.Name, "working_directory", workDir)
718+
log.Debug("Using working directory for custom command", customCommandKeyCommand, commandConfig.Name, "working_directory", workDir)
731719
}
732720

733721
// Validate exec steps before executing anything: an exec step replaces
@@ -742,6 +730,11 @@ func executeCustomCommand(
742730

743731
// Execute custom command's steps
744732
for i, step := range commandConfig.Steps {
733+
if !step.When.Evaluate(conditionContext) {
734+
log.Debug("Skipping custom command step, `when` condition did not match", customCommandKeyCommand, commandConfig.Name, "step", i)
735+
continue
736+
}
737+
745738
// Prepare template data for arguments
746739
argumentsData := map[string]string{}
747740
for ix, arg := range commandConfig.Arguments {
@@ -882,7 +875,7 @@ func executeCustomCommand(
882875
errUtils.CheckErrorPrintAndExit(fmt.Errorf("failed to prepare shell environment for identity %q in custom command %q step %d: %w",
883876
commandIdentity, commandConfig.Name, i, err), "", "")
884877
}
885-
log.Debug("Prepared environment with identity for custom command step", "identity", commandIdentity, "command", commandConfig.Name, "step", i)
878+
log.Debug("Prepared environment with identity for custom command step", customCommandKeyIdentity, commandIdentity, customCommandKeyCommand, commandConfig.Name, "step", i)
886879
}
887880

888881
// Process Go templates in the command's steps.
@@ -982,6 +975,47 @@ func executeCustomCommand(
982975
}
983976
}
984977

978+
func customCommandConditionContext() schema.ConditionContext {
979+
return schema.ConditionContext{
980+
CI: telemetry.IsCI(),
981+
Status: schema.ConditionPredicateSuccess,
982+
}
983+
}
984+
985+
func prepareCustomCommandAuth(atmosConfig *schema.AtmosConfiguration, commandIdentity, commandName string, hasRunnableStep bool) auth.AuthManager {
986+
if commandIdentity == "" || !hasRunnableStep {
987+
return nil
988+
}
989+
990+
authStackInfo := &schema.ConfigAndStacksInfo{
991+
AuthContext: &schema.AuthContext{},
992+
}
993+
credStore := credentials.NewCredentialStoreWithConfig(&atmosConfig.Auth)
994+
validator := validation.NewValidator()
995+
authManager, err := auth.NewAuthManager(&atmosConfig.Auth, credStore, validator, authStackInfo, atmosConfig.CliConfigPath)
996+
if err != nil {
997+
errUtils.CheckErrorPrintAndExit(fmt.Errorf("%w: %w", errUtils.ErrFailedToInitializeAuthManager, err), "", "")
998+
}
999+
1000+
ctx := context.Background()
1001+
if _, err = authManager.GetCachedCredentials(ctx, commandIdentity); err == nil {
1002+
log.Debug("Authenticated with cached identity for custom command", customCommandKeyIdentity, commandIdentity, customCommandKeyCommand, commandName)
1003+
return authManager
1004+
}
1005+
1006+
log.Debug("No valid cached credentials found, authenticating", customCommandKeyIdentity, commandIdentity, "error", err)
1007+
if _, err = authManager.Authenticate(ctx, commandIdentity); err == nil {
1008+
log.Debug("Authenticated with identity for custom command", customCommandKeyIdentity, commandIdentity, customCommandKeyCommand, commandName)
1009+
return authManager
1010+
}
1011+
if errors.Is(err, errUtils.ErrUserAborted) {
1012+
errUtils.CheckErrorPrintAndExit(errUtils.ErrUserAborted, "", "")
1013+
}
1014+
errUtils.CheckErrorPrintAndExit(fmt.Errorf("%w for identity %q in custom command %q: %w",
1015+
errUtils.ErrAuthenticationFailed, commandIdentity, commandName, err), "", "")
1016+
return authManager
1017+
}
1018+
9851019
// cloneCommand clones a custom command config into a new struct.
9861020
func cloneCommand(orig *schema.Command) (*schema.Command, error) {
9871021
origJSON, err := json.Marshal(orig)
@@ -1661,8 +1695,8 @@ func identityFlagCompletion(cmd *cobra.Command, args []string, toComplete string
16611695

16621696
// AddIdentityCompletion registers shell completion for the identity flag if present on the command.
16631697
func AddIdentityCompletion(cmd *cobra.Command) {
1664-
if cmd.Flag("identity") != nil {
1665-
if err := cmd.RegisterFlagCompletionFunc("identity", identityFlagCompletion); err != nil {
1698+
if cmd.Flag(customCommandKeyIdentity) != nil {
1699+
if err := cmd.RegisterFlagCompletionFunc(customCommandKeyIdentity, identityFlagCompletion); err != nil {
16661700
log.Trace("Failed to register identity flag completion", "error", err)
16671701
}
16681702
}

cmd/custom_command_integration_test.go

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cmd
22

33
import (
4+
"encoding/base64"
45
"fmt"
56
"os"
67
"path/filepath"
@@ -250,6 +251,91 @@ func TestCustomCommandIntegration_MultipleSteps(t *testing.T) {
250251
assert.Equal(t, step1Identity, step2Identity, "Both steps should use the same identity")
251252
}
252253

254+
func TestCustomCommandIntegration_SkipsStepWhenConditionIsFalse(t *testing.T) {
255+
if testing.Short() {
256+
t.Skipf("Skipping integration test in short mode")
257+
}
258+
259+
testDir := "../tests/fixtures/scenarios/atmos-auth-mock"
260+
t.Setenv("ATMOS_CLI_CONFIG_PATH", testDir)
261+
t.Setenv("ATMOS_BASE_PATH", testDir)
262+
263+
_ = NewTestKit(t)
264+
265+
atmosConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false)
266+
require.NoError(t, err)
267+
268+
tmpDir := t.TempDir()
269+
skippedFile := filepath.Join(tmpDir, "skipped.txt")
270+
ranFile := filepath.Join(tmpDir, "ran.txt")
271+
272+
testCommand := schema.Command{
273+
Name: "test-when-skip",
274+
Description: "Test when skip",
275+
Steps: schema.Tasks{
276+
{
277+
Command: customCommandWriteHelperCommand(t, skippedFile, "skipped"),
278+
Type: "shell",
279+
When: schema.MustCondition("never"),
280+
},
281+
{
282+
Command: customCommandWriteHelperCommand(t, ranFile, "ran"),
283+
Type: "shell",
284+
},
285+
},
286+
}
287+
atmosConfig.Commands = []schema.Command{testCommand}
288+
289+
err = processCustomCommands(atmosConfig, atmosConfig.Commands, RootCmd)
290+
require.NoError(t, err)
291+
292+
var customCmd *cobra.Command
293+
for _, cmd := range RootCmd.Commands() {
294+
if cmd.Name() == "test-when-skip" {
295+
customCmd = cmd
296+
break
297+
}
298+
}
299+
require.NotNil(t, customCmd)
300+
301+
customCmd.Run(customCmd, []string{})
302+
303+
assert.NoFileExists(t, skippedFile)
304+
assert.FileExists(t, ranFile)
305+
}
306+
307+
func customCommandWriteHelperCommand(t *testing.T, path, value string) string {
308+
t.Helper()
309+
310+
exe, err := os.Executable()
311+
require.NoError(t, err)
312+
encodedPath := base64.RawURLEncoding.EncodeToString([]byte(path))
313+
encodedValue := base64.RawURLEncoding.EncodeToString([]byte(value))
314+
return fmt.Sprintf("%q -test.run=TestCustomCommandIntegrationWriteHelper -- %s %s", exe, encodedPath, encodedValue)
315+
}
316+
317+
func TestCustomCommandIntegrationWriteHelper(t *testing.T) {
318+
separator := -1
319+
for i, arg := range os.Args {
320+
if arg == "--" {
321+
separator = i
322+
break
323+
}
324+
}
325+
if separator == -1 {
326+
return
327+
}
328+
329+
args := os.Args[separator+1:]
330+
require.Len(t, args, 2)
331+
pathBytes, err := base64.RawURLEncoding.DecodeString(args[0])
332+
require.NoError(t, err)
333+
valueBytes, err := base64.RawURLEncoding.DecodeString(args[1])
334+
require.NoError(t, err)
335+
require.NoError(t, os.WriteFile(string(pathBytes), valueBytes, 0o600))
336+
os.Exit(0)
337+
}
338+
253339
// TestCustomCommandIntegration_ComponentEnvExported verifies that a custom component's `env`
254340
// section is exported as real environment variables to the command's step subprocess (mirroring
255341
// the built-in terraform/helmfile/packer/ansible providers). This is the behavior that lets a

0 commit comments

Comments
 (0)