Skip to content

Commit 7fafce6

Browse files
Merge pull request #1 from AlexanderGrooff/convert-testsh-to-go
Go testcases
2 parents d2cbc20 + c58a86b commit 7fafce6

28 files changed

Lines changed: 1831 additions & 1934 deletions

.cursor/rules/tests.mdc

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,26 @@
11
---
2-
description:
3-
globs: test.sh,test_local.sh,test_remote.sh,tests/**
2+
description: Whenever tests are changed, or when there is significant changes in behaviour of existing code, or new code being added
43
alwaysApply: false
54
---
6-
Tests are done in a large bash script [test.sh](mdc:test.sh) that runs many kinds of playbooks. All modules should be tested in their own playbook to thoroughly check all kinds of behaviour described in the module.
5+
Tests are done in a comprehensive Go test suite [playbook_test.go](mdc:tests/playbook_test.go) that runs many kinds of playbooks with both local and temporal executors. All modules should be tested in their own playbook to thoroughly check all kinds of behaviour described in the module.
6+
All possible cases of a module should be tested, including:
7+
- All fields
8+
- All possible values for each field
9+
- All possible combinations of fields
10+
- All possible values for each field
11+
- Revert functionality
12+
- Parallel execution
13+
- Variable usage detection
14+
- Templating
15+
- Inventory file usage
16+
- Host file usage
17+
- Delegate to functionality
718

819
When writing testcases, think of the following:
9-
- Does it work on both local and remote hosts?
20+
- Does it work on both local and temporal executors?
1021
- Does reverting work?
1122
- Are all fields registered correctly?
12-
- *EVERY* field should be able to be templated! Strings like "{{ somevariable }}" should always work, and should correctly determine the order of execution.
23+
- *EVERY* field should be able to be templated! Strings like "{{ somevariable }}" should always work, and should correctly determine the order of execution.
24+
- Test assertions should check for actual file creation/modification rather than relying on log output for resilience.
25+
- Each test should use the `runPlaybookTest` helper function which automatically tests both executor types.
26+
- Use helper functions like `assertFileExists`, `assertFileContains`, `assertFileDoesNotExist` for consistent test validation.

README.md

Lines changed: 110 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -1,101 +1,138 @@
11
# Spage
22

33
This projects aims to function 'as' Ansible, but hugely more performant. By taking an Ansible playbook + inventory as input, it will generate a Go program that can be compiled for a specific host.
4-
The end result is a generated .go file that can be compiled and shipped to the target host.
4+
The end result is a generated `.go` file that can be compiled and shipped to the target host.
55

6-
To create such a program, this project ships the `spage` binary, with which you can target Ansible playbooks + inventories. Output looks like this:
6+
To create such a program, this project ships the `spage` binary, with which you can target Ansible playbooks + inventories.
7+
8+
Key benefits:
9+
- **(Almost full) Ansible compatibility** - Any playbook that works with Ansible works with Spage
10+
- **Significantly faster execution** - Compiles to native Go code instead of interpreting Python
11+
- **No Python dependency** - Single binary that can run anywhere
12+
- **Extended features** - Built-in parallel execution, automatic rollback on failure, variable usage detection, and support for external executors (like `temporal`)
13+
- **Same syntax, but extra keywords** - Uses identical YAML playbook format and module parameters, with extra options for parallel execution with `before`/`after`
14+
15+
Spage works by:
16+
1. Taking your existing Ansible playbooks and inventory files
17+
2. Generating Go code that implements the same logic
18+
3. Compiling this into a single binary for your target environment
19+
4. Executing tasks with native Go modules where possible, falling back to Python for full compatibility
20+
21+
## Usage
22+
23+
You can use Spage in two ways:
24+
1. Generate the Go code that you can then compile and run (using the `spage generate` command)
25+
2. Run directly across an inventory (using the `spage run` command)
726

827
```bash
9-
$ spage generate -p playbook.yaml
10-
Processing node pkg.TaskNode "ensure were in arch iso" "shell": &{Execute:lsblk -f | grep "/run/archiso/bootmnt" && exit 0 || exit 1 Revert: ModuleInput:<nil>}
11-
Processing node pkg.TaskNode "create ssh dir" "shell": &{Execute:mkdir -p .ssh Revert: ModuleInput:<nil>}
12-
Processing node pkg.TaskNode "copy ssh key" "shell": &{Execute:curl -sSL https://github.com/AlexanderGrooff.keys > .ssh/authorized_keys Revert: ModuleInput:<nil>}
13-
Compiling graph to code:
14-
- Step 0:
15-
- ensure were in arch iso
16-
- create ssh dir
17-
- Step 1:
18-
- copy ssh key
19-
Required inputs:
20-
Processing node pkg.TaskNode "ensure were in arch iso" "shell": &{Execute:lsblk -f | grep "/run/archiso/bootmnt" && exit 0 || exit 1 Revert: ModuleInput:<nil>}
21-
Processing node pkg.TaskNode "create ssh dir" "shell": &{Execute:mkdir -p .ssh Revert: ModuleInput:<nil>}
22-
Processing node pkg.TaskNode "copy ssh key" "shell": &{Execute:curl -sSL https://github.com/AlexanderGrooff.keys > .ssh/authorized_keys Revert: ModuleInput:<nil>}
23-
Compiling graph to code:
24-
- Step 0:
25-
- ensure were in arch iso
26-
- create ssh dir
27-
- Step 1:
28-
- copy ssh key
29-
Required inputs:
30-
```
28+
# Generate the Go code that you can then compile and run
29+
spage generate -p playbook.yaml -o generated_tasks.go
30+
go build -o spage_playbook generated_tasks.go
31+
./spage_playbook -inventory inventory.yaml
3132

32-
This will generate a `generated/tasks.go` file, which can be compiled for a specific host. That file looks like this:
33-
34-
```go
35-
package generated
36-
37-
import (
38-
"github.com/AlexanderGrooff/spage/pkg"
39-
"github.com/AlexanderGrooff/spage/pkg/modules"
40-
)
41-
42-
var GeneratedGraph = pkg.Graph{
43-
RequiredInputs: []string{
44-
},
45-
Tasks: [][]pkg.GraphNode{
46-
[]pkg.GraphNode{
47-
pkg.Task{Name: "ensure were in arch iso", Module: "shell", Register: "", Params: modules.ShellInput{Execute: "lsblk -f | grep \"/run/archiso/bootmnt\" && exit 0 || exit 1", Revert: ""}, RunAs: "", When: ""},
48-
pkg.Task{Name: "create ssh dir", Module: "shell", Register: "", Params: modules.ShellInput{Execute: "mkdir -p .ssh", Revert: ""}, RunAs: "", When: ""},
49-
},
50-
[]pkg.GraphNode{
51-
pkg.Task{Name: "copy ssh key", Module: "shell", Register: "", Params: modules.ShellInput{Execute: "curl -sSL https://github.com/AlexanderGrooff.keys > .ssh/authorized_keys", Revert: ""}, RunAs: "", When: ""},
52-
},
53-
},
54-
}
33+
# Or run directly across an inventory
34+
spage run -i inventory.yaml -p playbook.yaml
5535
```
5636

57-
## Project structure
37+
## FAQ
5838

59-
`spage` makes use of modules to execute tasks, just like Ansible. Modules are located in the `pkg/modules` directory. This includes modules such as `shell`, `template`, `systemd`, etc.
39+
### Q: What is Spage?
6040

61-
## Ansible vs Spage
41+
**A: Spage is a high-performance drop-in replacement for Ansible** that compiles your
42+
playbooks into Go programs. You can then utilize the Golang toolchain to compile and run the playbook, either locally or on the target host.
6243

63-
By default, Spage will generate a program that is functionally identical to Ansible. However, Spage also allows for more complex behavior, such as conditional tasks, multiple hosts, and more.
64-
Spage acts as a drop-in replacement for Ansible, so any playbook that can be run with Ansible can also be run with Spage. There are extra features that Spage offers:
44+
### Q: Why is it called Spage?
6545

66-
- Revert functionality: Spage will automatically revert any changes made by a task if the task fails. You can specify a revert task for each task in the playbook.
67-
- Parallel execution: Spage will automatically parallelize tasks across all hosts, and you can control the flow with `before`/`after`.
68-
- No Python dependency: Spage is a single binary that can be run on any system.
46+
**A: It's a reference to [Factorio: Space Age](https://www.factorio.com/space-age/buy).** I build Spage when Space Age was
47+
not yet released, and I wanted something to do. So while waiting for the release, I
48+
found a very funny Reddit comment calling it Spage, and thus Spage was born.
6949

70-
## Usage
50+
### Q: I have module `x.y.z` from an Ansible Galaxy collection. Is this supported in Spage?
7151

72-
```bash
73-
go generate
74-
# OR
75-
go run . generate -p playbook.yaml
76-
# OR
77-
go run generate_tasks.go -file playbook.yaml
78-
79-
# Run across an inventory
80-
go run generated/tasks.go -i inventory.yaml
81-
# Or compile for a specific host and run
82-
go run generated/tasks.go -i inventory.yaml
52+
**A: Yes, absolutely!** Spage supports **any** Ansible module, including those from Ansible Galaxy collections, through its Python fallback mechanism. See the [Python Fallback Mechanism](#python-fallback-mechanism) section for more details.
53+
54+
## Python Fallback Mechanism
55+
56+
Spage includes a sophisticated Python fallback mechanism that allows it to execute any Ansible module, even those not natively implemented in Go. This ensures 100% compatibility with the Ansible ecosystem while maintaining performance benefits.
57+
58+
### When Python Fallback is Used
59+
60+
The Python fallback automatically activates when:
61+
- A module name is not found in Spage's native Go modules
62+
- You explicitly use the `ansible_python` module type
63+
- Community collections or custom modules are referenced (e.g., `community.general.setup`, `custom.namespace.module`)
64+
65+
### How It Works
66+
67+
1. **Module Detection**: Spage first attempts to find a native Go implementation of the requested module
68+
2. **Fallback Activation**: If no native module exists, the Python fallback mechanism engages
69+
3. **Collection Management**: Required Ansible collections are automatically installed on target hosts if missing
70+
4. **Bundle Transfer**: Essential Ansible core files (~1.4MB) are transferred to the target host
71+
5. **Python Execution**: The module is executed using the same Python infrastructure as standard Ansible
72+
73+
### Performance Optimizations
74+
75+
The Python fallback includes several performance optimizations:
76+
77+
- **Collection Caching**: Collections are installed once per host and cached for the session
78+
- **Multi-Level Bundle Caching**:
79+
- **Permanent Cache**: Long-term cache at `/tmp/spage-ansible-cached` (when permissions allow)
80+
- **Session Cache**: Per-session cache at `/tmp/spage-ansible-session` for repeated module calls
81+
- **Fresh Transfer**: Only occurs once per session when caches are unavailable
82+
- **Minimal Bundles**: Only essential Ansible core files are transferred, not the entire Ansible installation
83+
- **Smart Collection Detection**: Existing collections are detected before attempting installation
84+
85+
### Usage Examples
86+
87+
```yaml
88+
# Explicit Python fallback
89+
- name: Use Python fallback for ping module
90+
ansible_python:
91+
module_name: ping
92+
args:
93+
data: pong
94+
95+
# Community collection module (automatically uses Python fallback)
96+
- name: Get Python requirements info
97+
community.general.python_requirements_info:
98+
dependencies: []
99+
100+
# Custom namespace module
101+
- name: Use custom module
102+
my_company.custom_collection.special_module:
103+
param1: value1
104+
param2: value2
83105
```
84106
107+
### Local vs Remote Execution
108+
109+
- **Local Execution**: Uses the local Ansible installation directly with `ansible-playbook`
110+
- **Remote Execution**: Transfers minimal Ansible bundle and executes via Python with proper `PYTHONPATH` configuration
111+
112+
### Limitations
113+
114+
- Requires Python 3 and pip on target hosts for collection installation
115+
- Some complex Ansible plugins may have additional dependencies
116+
- Performance is slower than native Go modules but still benefits from caching optimizations
117+
- Network transfers are required for initial bundle deployment per session
118+
119+
## Differences between Spage and Ansible
120+
121+
Spage is a drop-in replacement for Ansible, but with some notable differences:
122+
123+
- Playbooks are allowed to start without `- tasks:`. It assumes `hosts: localhost` and runs locally.
124+
- Tasks are executed in parallel by default based on variable usage.
125+
- New keywords `before`/`after` are available to control the flow of parallel tasks.
126+
- The `shell` module has two new parameters: `execute` and `revert`. If you don't specify these options and just use it as you would with Ansible, it will not do anything on revert.
127+
85128
TODO:
86129

87130
- Add revert conditions `revert_when`
88131
- Should we compile assets (templates, files) along with the code?
89132
- Read `ansible.cfg` variables such as `[defaults] roles_path = roles/:shared_roles/` and `[privilege_escalation] become_flags = -H -S`
90133
- `vars_prompt` on play
91134
- `gather_facts` on play
92-
- `vars` on play
93135
- Logic for `no_log`
94136
- Don't allow interactive commands in `temporal` executor, or define an option that allows for signals/disallows interactivity.
95137
- Plugin support
96138
- Callback support
97-
98-
## Differences between Spage and Ansible
99-
100-
- Playbooks are allowed to start without `- tasks:`. It assumes `hosts: localhost` and runs locally.
101-
- Parallel execution mode and reverts tasks by default.

cmd/cmd.go

Lines changed: 63 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ var (
2121
tags []string
2222
skipTags []string
2323
cfg *config.Config // Store the loaded config
24+
checkMode bool
25+
diffMode bool
2426
)
2527

2628
// LoadConfig loads the configuration and applies settings
@@ -72,65 +74,94 @@ var RootCmd = &cobra.Command{
7274
},
7375
}
7476

75-
func generatePlaybook(cmd *cobra.Command, args []string) {
76-
graph, err := pkg.NewGraphFromFile(playbookFile)
77-
if err != nil {
78-
common.LogError("Failed to generate graph", map[string]interface{}{
79-
"error": err.Error(),
80-
})
81-
os.Exit(1)
82-
}
83-
77+
func GetGraph(playbookFile string, tags, skipTags []string, baseConfig *config.Config) (pkg.Graph, error) {
8478
// Override config with command line flags if provided
8579
if len(tags) > 0 {
86-
cfg.Tags.Tags = tags
80+
baseConfig.Tags.Tags = tags
8781
}
8882
if len(skipTags) > 0 {
89-
cfg.Tags.SkipTags = skipTags
83+
baseConfig.Tags.SkipTags = skipTags
9084
}
9185

92-
// Apply tag filtering to the graph
93-
filteredGraph, err := applyTagFiltering(graph, cfg.Tags)
86+
graph, err := pkg.NewGraphFromFile(playbookFile)
9487
if err != nil {
95-
common.LogError("Failed to apply tag filtering", map[string]interface{}{
96-
"error": err.Error(),
97-
})
98-
os.Exit(1)
88+
return pkg.Graph{}, fmt.Errorf("failed to generate graph from playbook: %w", err)
9989
}
10090

101-
if cfg.Executor == "temporal" {
102-
filteredGraph.SaveToTemporalWorkflowFile(outputFile)
103-
} else {
104-
filteredGraph.SaveToFile(outputFile)
91+
// Apply tag filtering to the graph
92+
filteredGraph, err := applyTagFiltering(graph, baseConfig.Tags)
93+
if err != nil {
94+
return pkg.Graph{}, fmt.Errorf("failed to apply tag filtering: %w", err)
10595
}
106-
common.LogInfo("Compiled binary", map[string]interface{}{
107-
"output_file": outputFile,
108-
})
96+
return filteredGraph, nil
10997
}
11098

11199
var generateCmd = &cobra.Command{
112100
Use: "generate",
113101
Short: "Generate a graph from a playbook and save it as Go code",
114-
Run: generatePlaybook,
102+
RunE: func(cmd *cobra.Command, args []string) error {
103+
graph, err := GetGraph(playbookFile, tags, skipTags, cfg)
104+
if err != nil {
105+
common.LogError("Failed to generate graph", map[string]interface{}{
106+
"error": err.Error(),
107+
})
108+
os.Exit(1)
109+
}
110+
111+
if cfg.Executor == "temporal" {
112+
err = graph.SaveToTemporalWorkflowFile(outputFile)
113+
} else {
114+
err = graph.SaveToFile(outputFile)
115+
}
116+
common.LogInfo("Compiled binary", map[string]interface{}{
117+
"output_file": outputFile,
118+
})
119+
if err != nil {
120+
common.LogError("Failed to generate graph", map[string]interface{}{
121+
"error": err.Error(),
122+
})
123+
os.Exit(1)
124+
}
125+
return nil
126+
},
115127
}
116128

117129
var runCmd = &cobra.Command{
118130
Use: "run",
119131
Short: "Run a playbook by compiling & executing it",
120-
Run: func(cmd *cobra.Command, args []string) {
121-
generatePlaybook(cmd, args)
122-
graph, err := pkg.NewGraphFromFile(outputFile)
132+
RunE: func(cmd *cobra.Command, args []string) error {
133+
graph, err := GetGraph(playbookFile, tags, skipTags, cfg)
123134
if err != nil {
124135
common.LogError("Failed to generate graph", map[string]interface{}{
125136
"error": err.Error(),
126137
})
127138
os.Exit(1)
128139
}
140+
if checkMode {
141+
if cfg.Facts == nil {
142+
cfg.Facts = make(map[string]interface{})
143+
}
144+
cfg.Facts["ansible_check_mode"] = true
145+
}
146+
if diffMode {
147+
if cfg.Facts == nil {
148+
cfg.Facts = make(map[string]interface{})
149+
}
150+
cfg.Facts["ansible_diff"] = true
151+
}
152+
129153
if cfg.Executor == "temporal" {
130-
StartTemporalExecutor(graph)
154+
err = StartTemporalExecutor(graph, inventoryFile, cfg)
131155
} else {
132-
StartLocalExecutor(graph)
156+
err = StartLocalExecutor(graph, inventoryFile, cfg)
157+
}
158+
if err != nil {
159+
common.LogError("Failed to run playbook", map[string]interface{}{
160+
"error": err.Error(),
161+
})
162+
os.Exit(1)
133163
}
164+
return nil
134165
},
135166
}
136167

@@ -150,6 +181,8 @@ func init() {
150181
runCmd.Flags().StringVarP(&outputFile, "output", "o", "generated_tasks.go", "Output file (default: generated_tasks.go)")
151182
runCmd.Flags().StringSliceVarP(&tags, "tags", "t", []string{}, "Only include tasks with these tags (comma-separated)")
152183
runCmd.Flags().StringSliceVar(&skipTags, "skip-tags", []string{}, "Skip tasks with these tags (comma-separated)")
184+
runCmd.Flags().BoolVar(&checkMode, "check", false, "Enable check mode (dry run)")
185+
runCmd.Flags().BoolVar(&diffMode, "diff", false, "Enable diff mode")
153186

154187
runCmd.MarkFlagRequired("playbook")
155188

0 commit comments

Comments
 (0)