provisioning/docs/src/development/provider-development-guide.md
2026-01-12 04:42:18 +00:00

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 { } blocks
  • let 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"