Skip to content
Open
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
1 change: 1 addition & 0 deletions cmd/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ root; use the global --config flag to target a specific file.`,
}

func init() {
configGetCmd.Flags().StringP("format", "f", "raw", "Output format: raw or json")
configCmd.AddCommand(configGetCmd)
configCmd.AddCommand(configSetCmd)
configCmd.AddCommand(configDeleteCmd)
Expand Down
34 changes: 34 additions & 0 deletions cmd/config/get_output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package config

import (
"os"
"path/filepath"
"testing"

"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestConfigGetJSON(t *testing.T) {
file := filepath.Join(t.TempDir(), "atmos.yaml")
require.NoError(t, os.WriteFile(file, []byte("base_path: \"false\"\nlogs:\n level: Info\nstacks:\n included_paths: [one, two]\n"), 0o600))
stdout := initConfigTestWriter(t)
viper.Reset()
originalArgs := os.Args
t.Cleanup(func() {
os.Args = originalArgs
viper.Reset()
require.NoError(t, configGetCmd.Flags().Set("format", "raw"))
configGetCmd.Flags().Lookup("format").Changed = false
})
os.Args = []string{"atmos", "--config", file, "config", "get", "base_path"}
require.NoError(t, configGetCmd.Flags().Set("format", "json"))
for _, tc := range []struct{ path, expected string }{
{"base_path", `"false"`}, {"stacks.included_paths", `["one","two"]`},
} {
stdout.Reset()
require.NoError(t, configGetCmd.RunE(configGetCmd, []string{tc.path}))
assert.JSONEq(t, tc.expected, stdout.String())
}
}
6 changes: 5 additions & 1 deletion cmd/config/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ to inspect one file's own declared value instead.`,
return err
}

value, err := atmosyaml.Get([]byte(effectiveYAML), args[0])
format, err := cmd.Flags().GetString("format")
if err != nil {
return err
}
value, err := atmosyaml.GetFormatted([]byte(effectiveYAML), args[0], format)
if err != nil {
return err
}
Expand Down
3 changes: 2 additions & 1 deletion cmd/stack/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ var stackConfigGetCmd = &cobra.Command{
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
defer perf.Track(atmosConfigPtr, "stack.config.getRunE")()
return runStackGet(args)
return runStackGetCommand(cmd, args)
},
}

Expand Down Expand Up @@ -101,6 +101,7 @@ func stackPathPatternArg(args []string) string {
}

func init() {
stackConfigGetCmd.Flags().StringP("format", "f", "raw", "Output format: raw or json")
for _, c := range []*cobra.Command{stackConfigGetCmd, stackConfigSetCmd, stackConfigDeleteCmd, stackConfigFormatCmd} {
registerStackEditFlags(c)
}
Expand Down
38 changes: 38 additions & 0 deletions cmd/stack/get_output.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package stack

import (
"github.com/spf13/cobra"

"github.com/cloudposse/atmos/pkg/data"
"github.com/cloudposse/atmos/pkg/ui"
atmosyaml "github.com/cloudposse/atmos/pkg/yaml"
)

func runStackGetCommand(cmd *cobra.Command, args []string) error {
format, err := cmd.Flags().GetString("format")
if err != nil {
return err
}
if format == "raw" {
return runStackGet(args)
}
return runStackGetFormat(args, format)
}

func runStackGetFormat(args []string, format string) error {
tgt, err := resolveEditTarget(args[0], false)
if err != nil {
return err
}
if tgt.provFile != "" {
ui.Infof("%s resolves from %s:%d", args[0], tgt.provFile, tgt.provLine)
}
if format == "raw" {
return data.Writeln(tgt.value)
}
value, err := atmosyaml.GetFormatted(tgt.valueContent, tgt.valuePath, format)
if err != nil {
return err
}
return data.Writeln(value)
}
37 changes: 37 additions & 0 deletions cmd/stack/get_output_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package stack

import (
"os"
"path/filepath"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestStackGetJSON(t *testing.T) {
resetEditFlags(t)
chdirToValidAtmosProject(t)
stdout := initStackConfigTestWriter(t)
flagStack, flagComponent = "nonprod", "mycomponent"
flagFile = filepath.Join(t.TempDir(), "stack.yaml")
require.NoError(t, os.WriteFile(flagFile, []byte(`components:
terraform:
mycomponent:
vars:
text: "false"
boolean: false
items: [one, 2]
`), 0o600))
for _, tc := range []struct{ path, expected string }{
{"vars.text", `"false"`}, {"vars.boolean", `false`}, {"vars.items", `["one",2]`},
} {
stdout.Reset()
require.NoError(t, runStackGetFormat([]string{tc.path}, "json"))
assert.JSONEq(t, tc.expected, stdout.String())
}
stdout.Reset()
flagFile = ""
require.NoError(t, runStackGetFormat([]string{"vars.foo"}, "json"))
assert.JSONEq(t, `"foo nonprod override"`, stdout.String())
}
24 changes: 12 additions & 12 deletions cmd/stack/operations.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package stack

import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
Expand All @@ -11,7 +12,6 @@ import (
errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/internal/exec"
cfg "github.com/cloudposse/atmos/pkg/config"
"github.com/cloudposse/atmos/pkg/data"
"github.com/cloudposse/atmos/pkg/merge"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/schema"
Expand All @@ -34,6 +34,8 @@ var (
// editTarget holds the resolved file and in-file path for an edit, plus the
// effective merged value and where it currently resolves from.
type editTarget struct {
valueContent []byte // original YAML for typed output, before scalar unwrapping.
valuePath string // path within valueContent.
file string // manifest file to edit
inFilePath string // raw dot-path used as the provenance lookup key (components.<type>.<name>.<rel>)
yqPath string // escaped dot-path used to address the YAML node safely
Expand All @@ -54,7 +56,7 @@ var stackGetCmd = &cobra.Command{
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
defer perf.Track(atmosConfigPtr, "stack.getRunE")()
return runStackGet(args)
return runStackGetCommand(cmd, args)
},
}

Expand Down Expand Up @@ -102,6 +104,7 @@ values. Use --file to format one manifest explicitly.`,

func init() {
registerStackEditFlags(stackGetCmd)
stackGetCmd.Flags().StringP("format", "f", "raw", "Output format: raw or json")
registerStackEditFlags(stackSetCmd)
registerStackEditFlags(stackDeleteCmd)
registerStackEditFlags(stackFormatCmd)
Expand All @@ -120,14 +123,7 @@ func registerStackEditFlags(c *cobra.Command) {
}

func runStackGet(args []string) error {
tgt, err := resolveEditTarget(args[0], false)
if err != nil {
return err
}
if tgt.provFile != "" {
ui.Infof("%s resolves from %s:%d", args[0], tgt.provFile, tgt.provLine)
}
return data.Writeln(tgt.value)
return runStackGetFormat(args, "raw")
}

func runStackSet(args []string) error {
Expand Down Expand Up @@ -415,6 +411,7 @@ func resolveEditTarget(dotPath string, requireEditable bool) (*editTarget, error
// draw on an *inherited* value, since GetFileType only ever sees the
// literal target file's own bytes.
if sectionYAML, convErr := u.ConvertToYAML(result.ComponentSection); convErr == nil {
tgt.valueContent, tgt.valuePath = []byte(sectionYAML), dotPath
if v, getErr := atmosyaml.Get([]byte(sectionYAML), dotPath); getErr == nil {
tgt.value = v
}
Expand All @@ -435,8 +432,11 @@ func resolveEditTarget(dotPath string, requireEditable bool) (*editTarget, error
// For read-only get, reflect the value actually stored in the explicit
// file rather than the merged value.
if !requireEditable {
if v, getErr := atmosyaml.GetFile(flagFile, tgt.yqPath); getErr == nil {
tgt.value = v
if content, readErr := os.ReadFile(flagFile); readErr == nil {
if v, getErr := atmosyaml.Get(content, tgt.yqPath); getErr == nil {
tgt.value = v
tgt.valueContent, tgt.valuePath = content, tgt.yqPath
}
}
}
return tgt, nil
Expand Down
1 change: 1 addition & 0 deletions cmd/standalone_script.go
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ func runStandaloneScript(cmd *cobra.Command, file *script.File) error {
name, sourcePath = "<stdin>", ""
}
result, err := engine.Execute(cmd.Context(), script.Spec{
Steps: runnerstep.NewAutomationLibrary(vars, nil),
InstallTools: runnerstep.ScriptToolInstaller(&atmosConfig),
ParseCommand: standaloneCommandParser(file, streams.Data()),
// SourcePath anchors imports and tracebacks for files; stdin has no source path.
Expand Down
6 changes: 6 additions & 0 deletions errors/automation.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package errors

import "errors"

// ErrAutomation identifies invalid automation requests independently of the calling language.
var ErrAutomation = errors.New("automation request failed")
1 change: 1 addition & 0 deletions internal/exec/custom_command_control_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func newCustomCommandControlExecutor(control *CustomCommandControlContext) *work
vars = control.Executor.Variables()
}
return &workflow.ControlCommandExecutor{
ScriptSteps: stepPkg.NewAutomationLibrary(vars, workflowDefinition),
InstallTools: stepPkg.ScriptToolInstaller(&control.AtmosConfig),
// Custom commands have no dry-run mode; children always execute.
DryRun: false,
Expand Down
1 change: 1 addition & 0 deletions internal/exec/workflow_control_adapter.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ type workflowControlContext struct {
func executeWorkflowControlStep(ctx context.Context, control *workflowControlContext, parent *schema.WorkflowStep) error {
vars := workflowControlVariables(control)
childExecutor := &workflow.ControlCommandExecutor{
ScriptSteps: stepPkg.NewAutomationLibrary(vars, control.workflowDefinition),
InstallTools: stepPkg.ScriptToolInstaller(&control.atmosConfig),
DryRun: control.dryRun,
ScriptComponent: stepPkg.ScriptComponentRef(vars),
Expand Down
78 changes: 78 additions & 0 deletions pkg/automation/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# Automation Go API

This package defines automation contracts and execution policies shared by Go
callers and embedded language adapters. It has no dependency on Starlark. `pkg/runner/step.NewAutomationLibrary`
implements the API using the existing step registry, schema, and executor.

```text
Go caller ───────────────────────────┐
v
Starlark steps.* -> automation.StepLibrary -> step registry -> existing handlers
```

A future interpreter supplies argument/result conversion and calls this same
interface. It does not implement prompts, HTTP, containers, or step validation
again. Interpreter registration and the other shared host services remain in
`pkg/script`; this package establishes the step-facing SDK contract, not a
separate implementation of those services.

## Go usage

```go
library := step.NewAutomationLibrary(nil, nil)
request := &automation.StepCall{
Type: "join",
Configuration: map[string]any{
"options": []string{"api", "worker"},
"separator": ",",
},
}
if err := library.Validate(request); err != nil {
return err
}
result, err := library.Run(ctx, request)
// result.Value is "api,worker" when err is nil.
```

Import `github.com/cloudposse/atmos/pkg/automation` for the contract and
`github.com/cloudposse/atmos/pkg/runner/step` for its implementation. A CLI host
passes its existing `Variables` and optional `WorkflowDefinition` to the
constructor to supply Atmos configuration, component resolution, and template
context. The CLI also links the workflow package's control/test runners.

## Ownership and execution

- `Names` discovers canonical names and aliases from the registry.
- `Validate` checks configuration without invoking a handler. `Run` validates
again before execution; callers are not required to call `Validate` first.
- `Configuration` contains native Go values using the documented YAML field
names. The shared schema decodes polymorphic fields such as `with` and `output`.
Configuration decoding does not mutate the request.
- `StepResult` carries the handler's value, selections, metadata, named outputs,
skipped flag, and error text. Treat returned results as read-only.
- An instance owns its mutable state. Call `Fork` before using it in a concurrent
branch; fork the parent before starting its children, and wait for them before
mutating the parent again. AAL does this automatically for every task attempt.
- Calls preserve context cancellation and error chains. Handler-level terminal
and workflow-context requirements still apply.
- Explicit writers route command output through the caller. Captures retain raw
values while displayed output uses Atmos masking. Interactive forms and casts
retain the existing handlers' terminal facilities.
- `Parallel` rejects operations that require exclusive terminal/process access.
Nested scripts inherit that restriction. Nested automation calls have a depth
limit so starting another embedded interpreter cannot bypass recursion limits.
- Workflow scheduling, identity preparation, background jobs, and freshness
policies remain responsibilities of the enclosing YAML runner. Unsupported
direct-call policies fail explicitly.

The Go API is exercised independently in `pkg/runner/step/automation_library_test.go`.
The Starlark adapter is tested against a generated mock of `StepLibrary`, with
additional handler integration tests in `pkg/runner/step/script_library_test.go`.

## Execution policy

The `ExecutionPolicy` type applies one timeout across an operation and its retry
waits. The operation receives a context; the caller supplies the predicate that
decides which errors may be retried. It reuses the shared retry implementation.
Script process calls use this policy for `exec.run` and `component.exec`.
The step registry adapter still owns handler-specific execution behavior.
47 changes: 47 additions & 0 deletions pkg/automation/execution.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package automation

import (
"context"
"fmt"
"time"

errUtils "github.com/cloudposse/atmos/errors"
"github.com/cloudposse/atmos/pkg/perf"
"github.com/cloudposse/atmos/pkg/retry"
"github.com/cloudposse/atmos/pkg/schema"
)

// ExecutionPolicy bounds an operation and its retry waits with one deadline.
// A nil Retry runs once. Clock is optional and controls retry backoff only.
type ExecutionPolicy struct {
Timeout time.Duration
Retry *schema.RetryConfig
Clock retry.Clock
}

// Execute runs a context-aware operation using the shared timeout and retry
// policy. The caller decides which failures are safe to retry.
func (p ExecutionPolicy) Execute(ctx context.Context, operation func(context.Context) error, shouldRetry func(error) bool) error {
defer perf.Track(nil, "automation.ExecutionPolicy.Execute")()

if p.Timeout < 0 {
return fmt.Errorf("%w: timeout must not be negative", errUtils.ErrAutomation)
}
if err := retry.Validate(p.Retry); err != nil {
return fmt.Errorf("%w: invalid retry policy: %w", errUtils.ErrAutomation, err)
}
if p.Timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, p.Timeout)
defer cancel()
}
if err := ctx.Err(); err != nil {
return err
}
if p.Retry == nil {
return operation(ctx)
}
return retry.New(*p.Retry, retry.WithClock(p.Clock)).ExecuteWithPredicate(ctx, func() error {
return operation(ctx)
}, shouldRetry)
}
Loading
Loading