ADR-002: Preset Expansion System¶
Status: Accepted Date: 2026-02-05 Deciders: Engineering Team Technical Story: Extensible preset system for reusable configuration patterns
Context¶
As mooncake matured, users frequently requested support for common deployment patterns (Ollama, Docker, PostgreSQL, Nginx, etc.). The initial approach was to implement each as a native Go action (e.g., ollama action with ~1,400 lines of code). This approach had several problems:
- Maintenance Burden: Each new tool required ~1,000+ lines of Go code, tests, documentation
- Release Cycle Dependency: Adding/updating tool support required code releases
- Limited User Extensibility: Users couldn't create their own "actions" without Go knowledge
- Feature Bloat: Core binary size grew with each tool integration
- Tight Coupling: Tool-specific logic mixed with mooncake core
The Ollama action exemplified these issues:
- 672 lines in
ollama_step.go+ 646 lines of tests - Platform detection logic (apt/dnf/yum/brew)
- Service configuration (systemd/launchd)
- Model management
- Installation/uninstallation workflows
Most of this logic could be expressed in YAML using existing mooncake actions (shell, service, file, etc.), but no mechanism existed for packaging reusable workflows.
Decision¶
We adopted a preset system that allows packaging reusable workflows as YAML files. Presets expand into constituent steps at execution time with parameter injection.
Benefits:
- Extensible: Users can create presets without Go knowledge
- Maintainable: Update workflows in YAML, no code releases needed
- Smaller Binary: Tool-specific code moved out of core
- Faster Iteration: Presets can be updated without recompilation
1. Preset Structure¶
Presets are YAML files defining:
- Name: Unique identifier
- Description: Human-readable summary
- Version: Semantic version
- Parameters: Typed parameter definitions (string, bool, array, object)
- Steps: Mooncake steps using existing actions
Example:
name: ollama
description: Install and configure Ollama AI runtime
version: 1.0.0
parameters:
- name: state
type: string
default: present
enum: [present, absent, running, stopped]
- name: models
type: array
required: false
steps:
- name: Install Ollama
shell: curl -fsSL https://ollama.com/install.sh | sh
when: "{{ parameters.state != 'absent' }}"
- name: Configure service
service:
name: ollama
state: "{{ parameters.state }}"
2. Key Architectural Decisions¶
Flat Presets Only (No Nesting)¶
- Presets CANNOT invoke other presets
- Prevents circular dependencies
- Simpler mental model and execution flow
- Easier to debug and trace
# NOT ALLOWED
name: my-preset
steps:
- preset: base-setup # Would fail validation - presets cannot call other presets
Parameters Namespace¶
- Parameters accessible via
parameters.namein templates - Clear separation from variables and facts
- Prevents naming collisions
- shell: echo "{{ parameters.state }}" # Explicit namespace
- shell: echo "{{ state }}" # Would look in variables
Register at Preset Level¶
- Preset returns aggregate result (changed = any step changed)
- Users get
preset_result.changed,preset_result.stdout - Individual step results not exposed (encapsulation)
- preset: ollama
with:
state: present
register: install_result
- print: "Ollama changed: {{ install_result.changed }}"
Discovery Paths (Priority Order)¶
./presets/- Playbook-local presets~/.mooncake/presets/- User presets/usr/local/share/mooncake/presets/- Local installation/usr/share/mooncake/presets/- System installation
Two File Formats¶
- Flat:
<name>.yml(e.g.,presets/ollama.yml) - Directory:
<name>/preset.yml(e.g.,presets/ollama/preset.yml) - Directory format supports bundling templates/files with preset
3. Execution Flow¶
- User invokes preset:
preset: {name: ollama, with: {state: present}} - Loader searches discovery paths for preset definition
- Validator checks parameters (types, required, enum constraints)
- Expander creates
parametersnamespace with validated params - Expander clones preset steps
- Planner expands includes, loops, templates (with parameters injected)
- Executor runs expanded steps sequentially
- Handler aggregates results (changed = any step changed)
- Result registered to user's variable if requested
4. Integration with Planner¶
Presets integrate with the planner's expansion system:
- Preset steps may contain
includedirectives → expanded by planner - Preset steps may contain
with_itemsloops → expanded by planner - Preset steps may use relative paths → resolved from preset base directory
- Parameters injected into variable context before planner expansion
This ensures presets work seamlessly with all mooncake features.
Alternatives Considered¶
Alternative 1: Nested Presets¶
Approach: Allow presets to invoke other presets
Pros:
- Better composition and reuse
- DRY principle for common patterns
Cons:
- Circular dependency complexity
- Harder to debug (deep nesting)
- Parameter passing complexity
- Execution order ambiguity
Rejected: Simplicity and debuggability more important than composition
Alternative 2: Global Variables Instead of Parameters Namespace¶
Approach: Inject parameters directly into global variable context
Pros:
- Simpler template syntax:
{{ state }}vs{{ parameters.state }}
Cons:
- Name collisions with user variables
- Unclear where values come from
- Harder to track parameter usage
Rejected: Explicit namespace prevents subtle bugs and improves clarity
Alternative 3: Keep Tool-Specific Actions¶
Approach: Continue implementing tools as Go actions
Pros:
- No new concepts for users
- Potentially better performance
- Compile-time validation
Cons:
- Doesn't solve maintenance burden
- Users can't extend without Go knowledge
- Binary bloat
- Slow feature iteration
Rejected: Doesn't address core extensibility problem
Alternative 4: Expose Individual Step Results¶
Approach: Return array of results instead of aggregate
Pros:
- More granular control for users
- Can inspect each step individually
Cons:
- Breaks encapsulation
- Implementation details leak
- API changes when preset internals change
- More complex for users
Rejected: Aggregate result better matches abstraction level
Consequences¶
Positive¶
- Code Reduction
- Ollama: ~1,400 lines Go → 250 lines YAML
- Removed:
ollama_step.go,ollama_step_test.go -
Net: -1,400 lines code, +250 lines YAML
-
User Extensibility
- Users can create presets without Go knowledge
- Share presets via git/files
-
Community can contribute presets
-
Faster Iteration
- Update presets without recompiling
- No release cycle for preset changes
-
Users can hotfix/customize locally
-
Smaller Binary
- Tool-specific code moved to YAML
-
Core stays focused and minimal
-
Better Separation of Concerns
- Core: execution engine
- Presets: tool workflows
-
Clear boundary
-
Validation and Safety
- Parameter type checking
- Required parameter enforcement
- Enum constraint validation
Negative¶
- Two Ways to Do Things
- Users might be confused: action vs preset?
-
Mitigation: Clear documentation, use presets for tools
-
Runtime Errors Instead of Compile-Time
- YAML typos discovered at runtime
-
Mitigation: JSON schema validation, dry-run mode
-
Performance Overhead
- YAML parsing and expansion at runtime
-
Mitigation: Overhead negligible for typical workloads
-
Limited Type Safety
- No compile-time checks for parameter usage
- Mitigation: Parameter validation catches most issues
Risks¶
- Preset Quality
- Risk: Community presets may be buggy/insecure
- Mitigation: Discovery path priority (local overrides system)
-
Status: Low risk with good docs
-
Breaking Changes
- Risk: Preset API changes break users
- Mitigation: Semantic versioning for presets
-
Status: Medium risk, needs version checking
-
Performance
- Risk: Complex presets might be slow
- Mitigation: Benchmarking, optimization if needed
- Status: Low risk, not observed in practice
Implementation Details¶
File Organization¶
internal/
├── presets/
│ ├── loader.go # Preset discovery and loading
│ ├── validator.go # Parameter validation
│ └── expander.go # Step expansion with parameters
├── actions/
│ └── preset/
│ └── handler.go # Preset action handler
presets/
├── ollama.yml # Flat format example
└── complex-app/ # Directory format example
├── preset.yml
├── templates/
│ └── config.j2
└── files/
└── default.conf
Parameter Validation¶
// ValidateParameters checks user-provided parameters against definition
func ValidateParameters(def *PresetDefinition, userParams map[string]interface{}) (map[string]interface{}, error) {
validated := make(map[string]interface{})
// Check each defined parameter
for _, param := range def.Parameters {
value, provided := userParams[param.Name]
// Apply defaults
if !provided && param.Default != nil {
value = param.Default
}
// Check required
if !provided && param.Required {
return nil, fmt.Errorf("required parameter '%s' not provided", param.Name)
}
// Type checking
if err := validateType(param.Type, value); err != nil {
return nil, fmt.Errorf("parameter '%s': %w", param.Name, err)
}
// Enum constraints
if len(param.Enum) > 0 && !contains(param.Enum, value) {
return nil, fmt.Errorf("parameter '%s' must be one of %v", param.Name, param.Enum)
}
validated[param.Name] = value
}
// Check for unknown parameters
for name := range userParams {
if !isDefined(def, name) {
return nil, fmt.Errorf("unknown parameter '%s'", name)
}
}
return validated, nil
}
Context Isolation¶
Preset execution preserves caller's variable context:
// Save context before execution
saved := captureContext(ec)
defer saved.restore(ec, parametersNamespace)
// Inject parameters
for k, v := range parametersNamespace {
ec.Variables[k] = v
}
// Execute steps
ExecuteSteps(expandedSteps, ec)
// Context automatically restored by defer
Relative Path Resolution¶
Preset base directory used for relative paths:
# In presets/myapp/preset.yml
steps:
- template:
src: templates/config.j2 # Resolved to presets/myapp/templates/config.j2
dest: /etc/myapp/config.conf
- copy:
src: files/default.conf # Resolved to presets/myapp/files/default.conf
dest: /etc/myapp/default.conf
Validation of Approach¶
Ollama Migration Success¶
The Ollama action was successfully migrated to a preset:
- Before: 1,400 lines Go (action + tests)
- After: 250 lines YAML
- Functionality: Identical (all features preserved)
- Test Coverage: Manual testing + examples
- Breaking Changes: Zero (users updated config)
This validates that presets can handle complex, multi-platform workflows.
Preset vs Action Guidelines¶
Use Presets For:
- Tool installation/configuration (Ollama, Docker, PostgreSQL)
- Multi-step workflows (deploy webapp, setup dev environment)
- Platform-specific logic (apt vs dnf vs brew)
- Service management patterns
Use Actions For:
- Primitive operations (file, shell, template)
- Performance-critical paths
- Complex logic requiring Go
- Core mooncake features
Compliance¶
This ADR complies with:
- YAML specification for preset format
- JSON Schema for parameter validation
- Mooncake code style guidelines
- Security best practices (no code execution from presets)
References¶
- Preset User Guide - How to use presets
- Preset Authoring Guide - How to create presets
- Preset Loader - Discovery and loading
- Preset Handler - Execution
- Ollama Preset - Real-world example
Related Decisions¶
- ADR-000: Planner and Execution Model - How presets integrate with planner expansion
- ADR-001: Handler-Based Action Architecture - Preset is implemented as an action handler
Future Considerations¶
- Preset Versioning: Add version checking and compatibility validation
- Preset Registry: Central repository for community presets
- Preset Testing: Framework for testing presets (like molecule for Ansible)
- Preset Documentation: Auto-generate docs from preset definitions
- Preset Dependencies: Allow declaring required system packages/tools
- Conditional Parameters: Parameter visibility based on other parameter values
- Preset Composition: Explore safe nesting patterns if user demand arises
Appendix: Migration Statistics¶
Ollama Action Removal¶
- Files Deleted: 2 files (1,318 lines)
internal/executor/ollama_step.go(672 lines)internal/executor/ollama_step_test.go(646 lines)- Files Created: 1 file (250 lines)
presets/ollama.yml- Net Change: -1,068 lines (-81% code reduction)
- Breaking Changes: None (config syntax equivalent)
- User Migration: Update
ollama:topreset: {name: ollama, with: {...}}
Preset System Implementation¶
- Core Files: 4 files (715 lines)
internal/presets/loader.go(120 lines)internal/presets/validator.go(180 lines)internal/presets/expander.go(50 lines)internal/actions/preset/handler.go(205 lines)- Tests (160 lines)
- Documentation: 3 guides (1,400+ lines)
- User guide (600 lines)
- Authoring guide (800 lines)
- Reference updates
- Examples: 30+ examples in
examples/ollama/
Overall Impact¶
- Code: -1,068 lines (action removal) + 715 lines (preset system) = -353 net lines
- Extensibility: Users can now create presets without Go knowledge
- Maintenance: Preset updates = YAML edits (no releases needed)
- Performance: Negligible overhead measured