16 KiB
16 KiB
Cloud Provider Development Guide
Version: 2.0 Status: Production Ready Based On: Hetzner, UpCloud, AWS (3 completed providers)
Overview: 4-Task Completion Framework
A cloud provider is production-ready when it completes all 4 tasks:
| Task | Requirements | Reference |
|---|---|---|
| 1. Nushell Compliance | 0 deprecated patterns, full implementations | provisioning/extensions/providers/hetzner/ |
| 2. Test Infrastructure | 51 tests (14 unit + 37 integration, mock-based) | provisioning/extensions/providers/upcloud/tests/ |
| 3. Runtime Templates | 3+ Jinja2/Bash templates for core resources | provisioning/extensions/providers/aws/templates/ |
| 4. Nickel Validation | Schemas pass nickel typecheck |
provisioning/extensions/providers/hetzner/nickel/ |
Execution Sequence
Tarea 4 (5 min) ──────┐
Tarea 1 (main) ───┐ ├──> Tarea 2 (tests)
Tarea 3 (parallel)┘ │
└──> Production Ready ✅
Nushell 0.109.0+ Core Rules
These rules are mandatory for all provider Nushell code:
Rule 1: Module System & Imports
use mod.nu
use api.nu
use servers.nu
Rule 2: Function Signatures
def function_name [param: type, optional: type = default] { }
Rule 3: Return Early, Fail Fast
def operation [resource: record] {
if ($resource | get -o id | is-empty) {
error make {msg: "Resource ID required"}
}
}
Rule 4: Modern Error Handling (CRITICAL)
❌ FORBIDDEN - Deprecated try-catch:
try {
^external_command
} catch {|err|
print $"Error: ($err.msg)"
}
✅ REQUIRED - Modern do/complete pattern:
let result = (do { ^external_command } | complete)
if $result.exit_code != 0 {
error make {msg: $"Command failed: ($result.stderr)"}
}
$result.stdout
Rule 5: Atomic Operations
All operations must fully succeed or fully fail. No partial state changes.
Rule 12: Structured Error Returns
error make {
msg: "Human-readable message",
label: {text: "Error context", span: (metadata error).span}
}
Critical Violations (INSTANT FAIL)
❌ FORBIDDEN:
try { } catch { }blockslet mut variable = value(mutable state)error make {msg: "Not implemented"}(stubs)- Empty function bodies returning ok
- Deprecated error patterns
Nickel IaC: Three-File Pattern
All Nickel schemas follow this pattern:
contracts.ncl: Type Definitions
{
Server = {
id | String,
name | String,
instance_type | String,
zone | String,
},
Volume = {
id | String,
name | String,
size | Number,
type | String,
}
}
defaults.ncl: Default Values
{
Server = {
instance_type = "t3.micro",
zone = "us-east-1a",
},
Volume = {
size = 20,
type = "gp3",
}
}
main.ncl: Public API
let contracts = import "contracts.ncl" in
let defaults = import "defaults.ncl" in
{
make_server = fun config => defaults.Server & config,
make_volume = fun config => defaults.Volume & config,
}
version.ncl: Version Tracking
{
provider_version = "1.0.0",
cli_tools = {
hcloud = "1.47.0+",
},
nickel_version = "1.7.0+",
}
Validation:
nickel typecheck nickel/contracts.ncl
nickel typecheck nickel/defaults.ncl
nickel typecheck nickel/main.ncl
nickel typecheck nickel/version.ncl
nickel export nickel/main.ncl
Tarea 1: Nushell Compliance
Identify Violations
cd provisioning/extensions/providers/{PROVIDER}
grep -r "try {" nulib/ --include="*.nu" | wc -l
grep -r "let mut " nulib/ --include="*.nu" | wc -l
grep -r "not implemented" nulib/ --include="*.nu" | wc -l
All three commands should return 0.
Fix Mutable Loops: Accumulation Pattern
def retry_with_backoff [
closure: closure,
max_attempts: int
]: nothing -> any {
let result = (
0..$max_attempts | reduce --fold {
success: false,
value: null,
delay: 100 ms
} {|attempt, acc|
if $acc.success {
$acc
} else {
let op_result = (do { $closure | call } | complete)
if $op_result.exit_code == 0 {
{success: true, value: $op_result.stdout, delay: $acc.delay}
} else if $attempt >= ($max_attempts - 1) {
$acc
} else {
sleep $acc.delay
{success: false, value: null, delay: ($acc.delay * 2)}
}
}
}
)
if $result.success {
$result.value
} else {
error make {msg: $"Failed after ($max_attempts) attempts"}
}
}
Fix Mutable Loops: Recursive Pattern
def _wait_for_state [
resource_id: string,
target_state: string,
timeout_sec: int,
elapsed: int = 0,
interval: int = 2
]: nothing -> bool {
let current = (^aws ec2 describe-volumes \
--volume-ids $resource_id \
--query "Volumes[0].State" \
--output text)
if ($current | str contains $target_state) {
true
} else if $elapsed > $timeout_sec {
false
} else {
sleep ($"($interval)sec" | into duration)
_wait_for_state $resource_id $target_state $timeout_sec ($elapsed + $interval) $interval
}
}
Fix Error Handling
def create_server [config: record] {
if ($config | get -o name | is-empty) {
error make {msg: "Server name required"}
}
let api_result = (do {
^hcloud server create \
--name $config.name \
--type $config.instance_type \
--format json
} | complete)
if $api_result.exit_code != 0 {
error make {msg: $"Server creation failed: ($api_result.stderr)"}
}
let response = ($api_result.stdout | from json)
{
id: $response.server.id,
name: $response.server.name,
status: "created"
}
}
Validation
cd provisioning/extensions/providers/{PROVIDER}
for file in nulib/*/\*.nu; do
nu --ide-check 100 "$file" 2>&1 | grep -i error && exit 1
done
nu -c "use nulib/{provider}/mod.nu; print 'OK'"
echo "✅ Nushell compliance complete"
Tarea 2: Test Infrastructure
Directory Structure
tests/
├── mocks/
│ └── mock_api_responses.json
├── unit/
│ └── test_utils.nu
├── integration/
│ ├── test_api_client.nu
│ ├── test_server_lifecycle.nu
│ └── test_pricing_cache.nu
└── run_{provider}_tests.nu
Mock API Responses
{
"list_servers": {
"servers": [
{
"id": "srv-123",
"name": "test-server",
"status": "running"
}
]
},
"error_401": {
"error": {"message": "Unauthorized", "code": 401}
},
"error_429": {
"error": {"message": "Rate limited", "code": 429}
}
}
Unit Tests: 14 Tests
def test-result [name: string, result: bool] {
if $result {
print $"✓ ($name)"
} else {
print $"✗ ($name)"
}
$result
}
def test-validate-instance-id [] {
let valid = "i-1234567890abcdef0"
let invalid = "invalid-id"
let test1 = (test-result "Instance ID valid" ($valid | str contains "i-"))
let test2 = (test-result "Instance ID invalid" (($invalid | str contains "i-") == false))
$test1 and $test2
}
def test-validate-ipv4 [] {
let valid = "10.0.1.100"
let parts = ($valid | split row ".")
test-result "IPv4 four octets" (($parts | length) == 4)
}
def test-validate-instance-type [] {
let valid_types = ["t3.micro" "t3.small" "m5.large"]
let invalid = "invalid_type"
let test1 = (test-result "Instance type valid" (($valid_types | contains ["t3.micro"])))
let test2 = (test-result "Instance type invalid" (($valid_types | contains [$invalid]) == false))
$test1 and $test2
}
def test-validate-zone [] {
let valid_zones = ["us-east-1a" "us-east-1b" "eu-west-1a"]
let invalid = "invalid-zone"
let test1 = (test-result "Zone valid" (($valid_zones | contains ["us-east-1a"])))
let test2 = (test-result "Zone invalid" (($valid_zones | contains [$invalid]) == false))
$test1 and $test2
}
def test-validate-volume-id [] {
let valid = "vol-12345678"
let invalid = "invalid-vol"
let test1 = (test-result "Volume ID valid" ($valid | str contains "vol-"))
let test2 = (test-result "Volume ID invalid" (($invalid | str contains "vol-") == false))
$test1 and $test2
}
def test-validate-volume-state [] {
let valid_states = ["available" "in-use" "creating"]
let invalid = "pending"
let test1 = (test-result "Volume state valid" (($valid_states | contains ["available"])))
let test2 = (test-result "Volume state invalid" (($valid_states | contains [$invalid]) == false))
$test1 and $test2
}
def test-validate-cidr [] {
let valid = "10.0.0.0/16"
let invalid = "10.0.0.1"
let test1 = (test-result "CIDR valid" ($valid | str contains "/"))
let test2 = (test-result "CIDR invalid" (($invalid | str contains "/") == false))
$test1 and $test2
}
def test-validate-volume-type [] {
let valid_types = ["gp2" "gp3" "io1" "io2"]
let invalid = "invalid-type"
let test1 = (test-result "Volume type valid" (($valid_types | contains ["gp3"])))
let test2 = (test-result "Volume type invalid" (($valid_types | contains [$invalid]) == false))
$test1 and $test2
}
def test-validate-timestamp [] {
let valid = "2025-01-07T10:00:00.000Z"
let invalid = "not-a-timestamp"
let test1 = (test-result "Timestamp valid" ($valid | str contains "T" and $valid | str contains "Z"))
let test2 = (test-result "Timestamp invalid" (($invalid | str contains "T") == false))
$test1 and $test2
}
def test-validate-server-state [] {
let valid_states = ["running" "stopped" "pending"]
let invalid = "hibernating"
let test1 = (test-result "Server state valid" (($valid_states | contains ["running"])))
let test2 = (test-result "Server state invalid" (($valid_states | contains [$invalid]) == false))
$test1 and $test2
}
def test-validate-security-group [] {
let valid = "sg-12345678"
let invalid = "invalid-sg"
let test1 = (test-result "Security group valid" ($valid | str contains "sg-"))
let test2 = (test-result "Security group invalid" (($invalid | str contains "sg-") == false))
$test1 and $test2
}
def test-validate-memory [] {
let valid_mems = ["512 MB" "1 GB" "2 GB" "4 GB"]
let invalid = "0 GB"
let test1 = (test-result "Memory valid" (($valid_mems | contains ["1 GB"])))
let test2 = (test-result "Memory invalid" (($valid_mems | contains [$invalid]) == false))
$test1 and $test2
}
def test-validate-vcpu [] {
let valid_cpus = [1, 2, 4, 8, 16]
let invalid = 0
let test1 = (test-result "vCPU valid" (($valid_cpus | contains [1])))
let test2 = (test-result "vCPU invalid" (($valid_cpus | contains [$invalid]) == false))
$test1 and $test2
}
def main [] {
print "=== Unit Tests ==="
print ""
let results = [
(test-validate-instance-id),
(test-validate-ipv4),
(test-validate-instance-type),
(test-validate-zone),
(test-validate-volume-id),
(test-validate-volume-state),
(test-validate-cidr),
(test-validate-volume-type),
(test-validate-timestamp),
(test-validate-server-state),
(test-validate-security-group),
(test-validate-memory),
(test-validate-vcpu)
]
let passed = ($results | where {|it| $it == true} | length)
let failed = ($results | where {|it| $it == false} | length)
print ""
print $"Results: ($passed) passed, ($failed) failed"
{
passed: $passed,
failed: $failed,
total: ($passed + $failed)
}
}
main
Integration Tests: 37 Tests across 3 Modules
Module 1: test_api_client.nu (13 tests)
- Response structure validation
- Error handling for 401, 404, 429
- Resource listing operations
- Pricing data validation
Module 2: test_server_lifecycle.nu (12 tests)
- Server creation, listing, state
- Instance type and zone info
- Storage and security attachment
- Server state transitions
Module 3: test_pricing_cache.nu (12 tests)
- Pricing data structure validation
- On-demand vs reserved pricing
- Cost calculations
- Volume pricing operations
Test Orchestrator
def main [] {
print "=== Provider Test Suite ==="
let unit_result = (nu tests/unit/test_utils.nu)
let api_result = (nu tests/integration/test_api_client.nu)
let lifecycle_result = (nu tests/integration/test_server_lifecycle.nu)
let pricing_result = (nu tests/integration/test_pricing_cache.nu)
let total_passed = (
$unit_result.passed +
$api_result.passed +
$lifecycle_result.passed +
$pricing_result.passed
)
let total_failed = (
$unit_result.failed +
$api_result.failed +
$lifecycle_result.failed +
$pricing_result.failed
)
print $"Results: ($total_passed) passed, ($total_failed) failed"
{
passed: $total_passed,
failed: $total_failed,
success: ($total_failed == 0)
}
}
let result = (main)
exit (if $result.success {0} else {1})
Validation
cd provisioning/extensions/providers/{PROVIDER}
nu tests/run_{provider}_tests.nu
Expected: 51 tests passing, exit code 0
Tarea 3: Runtime Templates
Directory Structure
templates/
├── {provider}_servers.j2
├── {provider}_networks.j2
└── {provider}_volumes.j2
Template Example
#!/bin/bash
# {{ provider_name }} Server Provisioning
set -e
{% if debug %}set -x{% endif %}
{%- for server in servers %}
{%- if server.name %}
echo "Creating server: {{ server.name }}"
{%- if server.instance_type %}
INSTANCE_TYPE="{{ server.instance_type }}"
{%- else %}
INSTANCE_TYPE="t3.micro"
{%- endif %}
SERVER_ID=$(^hcloud server create \
--name "{{ server.name }}" \
--type $INSTANCE_TYPE \
--query 'id' \
--output text 2>/dev/null)
if [ -z "$SERVER_ID" ]; then
echo "Failed to create server {{ server.name }}"
exit 1
fi
echo "✓ Server {{ server.name }} created: $SERVER_ID"
{%- endif %}
{%- endfor %}
echo "Server provisioning complete"
Validation
cd provisioning/extensions/providers/{PROVIDER}
for template in templates/*.j2; do
bash -n <(sed 's/{%.*%}//' "$template" | sed 's/{{.*}}/x/g')
done
echo "✅ Templates valid"
Tarea 4: Nickel Schema Validation
cd provisioning/extensions/providers/{PROVIDER}
nickel typecheck nickel/contracts.ncl || exit 1
nickel typecheck nickel/defaults.ncl || exit 1
nickel typecheck nickel/main.ncl || exit 1
nickel typecheck nickel/version.ncl || exit 1
nickel export nickel/main.ncl || exit 1
echo "✅ Nickel schemas validated"
Complete Validation Script
#!/bin/bash
set -e
PROVIDER="hetzner"
PROV="provisioning/extensions/providers/$PROVIDER"
echo "=== Provider Completeness Check: $PROVIDER ==="
echo ""
echo "✓ Tarea 4: Validating Nickel..."
nickel typecheck "$PROV/nickel/main.ncl"
echo "✓ Tarea 1: Checking Nushell..."
[ $(grep -r "try {" "$PROV/nulib" 2>/dev/null | wc -l) -eq 0 ]
[ $(grep -r "let mut " "$PROV/nulib" 2>/dev/null | wc -l) -eq 0 ]
echo " - No deprecated patterns ✓"
echo "✓ Tarea 3: Validating templates..."
for f in "$PROV"/templates/*.j2; do
bash -n <(sed 's/{%.*%}//' "$f" | sed 's/{{.*}}/x/g')
done
echo "✓ Tarea 2: Running tests..."
nu "$PROV/tests/run_${PROVIDER}_tests.nu"
echo ""
echo "╔════════════════════════════════════════╗"
echo "║ ✅ ALL TASKS COMPLETE ║"
echo "║ PRODUCTION READY ║"
echo "╚════════════════════════════════════════╝"
Reference Implementations
- Hetzner:
provisioning/extensions/providers/hetzner/ - UpCloud:
provisioning/extensions/providers/upcloud/ - AWS:
provisioning/extensions/providers/aws/
Use these as templates for new providers.
Quick Start
cd provisioning/extensions/providers/{PROVIDER}
# Validate completeness
nickel typecheck nickel/main.ncl && \
[ $(grep -r "try {" nulib/ 2>/dev/null | wc -l) -eq 0 ] && \
nu tests/run_{provider}_tests.nu && \
for f in templates/*.j2; do bash -n <(sed 's/{%.*%}//' "$f"); done && \
echo "✅ PRODUCTION READY"