provisioning/docs/src/development/providers/provider-development-guide.md
2026-01-14 03:09:18 +00:00

17 KiB

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