provisioning/docs/src/infrastructure/nickel-guide.md
2026-01-17 03:58:28 +00:00

12 KiB

Nickel Guide

Comprehensive guide to using Nickel as the infrastructure-as-code language for the Provisioning platform.

Critical Principle: Nickel is Source of Truth

TYPE-SAFETY ALWAYS REQUIRED: ALL configurations MUST be type-safe and validated via Nickel. TOML is NOT acceptable as source of truth. Validation is NOT optional, NOT "progressive", NOT "production-only". This applies to ALL profiles (developer, production, cicd).

Nickel is the PRIMARY IaC language. TOML files are GENERATED OUTPUT ONLY, never the source.

Why Nickel

Nickel provides:

  • Type Safety: Static type checking catches errors before deployment
  • Lazy Evaluation: Efficient configuration composition and merging
  • Contract System: Schema validation with gradual typing
  • Record Merging: Powerful composition without duplication
  • LSP Support: IDE integration for autocomplete and validation
  • Human-Readable: Clear syntax for infrastructure definition

Installation

# macOS (Homebrew)
brew install nickel

# Linux (Cargo)
cargo install nickel-lang-cli

# Verify installation
nickel --version  # 1.15.1+

Core Concepts

Records and Fields

Records are the fundamental data structure in Nickel:

{
  name = "my-server"
  plan = "medium"
  zone = "de-fra1"
}

Type Annotations

Add type safety with contracts:

{
  name : String = "my-server"
  plan : String = "medium"
  cpu_count : Number = 4
  enabled : Bool = true
}

Record Merging

Compose configurations by merging records:

let base_config = {
  provider = "upcloud"
  region = "de-fra1"
} in

let server_config = base_config & {
  name = "web-01"
  plan = "medium"
} in

server_config

Result:

{
  provider = "upcloud"
  region = "de-fra1"
  name = "web-01"
  plan = "medium"
}

Contracts (Schema Validation)

Define contracts to validate structure:

let ServerContract = {
  name | String
  plan | String | default = "small"
  zone | String | default = "de-fra1"
  cpu | Number | optional
} in

{
  name = "my-server"
  plan = "large"
} | ServerContract

Three-File Pattern (Provisioning Standard)

The platform uses a standardized three-file pattern for all schemas:

1. contracts.ncl - Type Definitions

Defines the schema contracts:

# contracts.ncl
{
  Server = {
    name | String
    plan | String | default = "small"
    zone | String | default = "de-fra1"
    disk_size_gb | Number | default = 25
    backup_enabled | Bool | default = false
    role | | [ 'control, 'worker, 'standalone | ] | optional
  }

  Infrastructure = {
    servers | Array Server
    provider | String
    environment | | [ 'development, 'staging, 'production | ]
  }
}

2. defaults.ncl - Default Values

Provides sensible defaults:

# defaults.ncl
{
  server = {
    name = "unnamed-server"
    plan = "small"
    zone = "de-fra1"
    disk_size_gb = 25
    backup_enabled = false
  }

  infrastructure = {
    servers = []
    provider = "local"
    environment = 'development
  }
}

3. main.ncl - Entry Point

Combines contracts and defaults, provides makers:

# main.ncl
let contracts_lib = import "./contracts.ncl" in
let defaults_lib = import "./defaults.ncl" in

{
  # Direct access to defaults (for inspection)
  defaults = defaults_lib

  # Convenience makers (90% of use cases)
  make_server | not_exported = fun overrides =>
    defaults_lib.server & overrides

  make_infrastructure | not_exported = fun overrides =>
    defaults_lib.infrastructure & overrides

  # Default instances (bare defaults)
  DefaultServer = defaults_lib.server
  DefaultInfrastructure = defaults_lib.infrastructure
}

Usage Example

# user-infra.ncl
let infra_lib = import "provisioning/schemas/infrastructure/main.ncl" in

infra_lib.make_infrastructure {
  provider = "upcloud"
  environment = 'production
  servers = [
    infra_lib.make_server {
      name = "web-01"
      plan = "medium"
      backup_enabled = true
    }
    infra_lib.make_server {
      name = "web-02"
      plan = "medium"
      backup_enabled = true
    }
  ]
}

Hybrid Interface Pattern

Records can be used both as functions (makers) and as plain data:

let config_lib = import "./config.ncl" in

# Use as function (with overrides)
let custom_config = config_lib.make_server { name = "custom" } in

# Use as plain data (defaults)
let default_config = config_lib.DefaultServer in

{
  custom = custom_config
  default = default_config
}

Record Merging Strategies

Priority Merging (Default)

let base = { a = 1, b = 2 } in
let override = { b = 3, c = 4 } in
base & override
# Result: { a = 1, b = 3, c = 4 }

Recursive Merging

let base = {
  server = { cpu = 2, ram = 4 }
} in

let override = {
  server = { ram = 8, disk = 100 }
} in

std.record.merge_all [base, override]
# Result: { server = { cpu = 2, ram = 8, disk = 100 } }

Lazy Evaluation

Nickel evaluates expressions lazily, only when needed:

let expensive_computation = std.string.join " " ["a", "b", "c"] in

{
  # Only evaluated when accessed
  computed_field = expensive_computation

  # Conditional evaluation
  conditional = if environment == 'production then
    expensive_computation
  else
    "dev-value"
}

Schema Organization

The platform organizes Nickel schemas by domain:

provisioning/schemas/
├── main.ncl                  # Top-level entry point
├── config/                   # Configuration schemas
│   ├── settings/
│   │   ├── main.ncl
│   │   ├── contracts.ncl
│   │   └── defaults.ncl
│   └── defaults/
│       ├── main.ncl
│       ├── contracts.ncl
│       └── defaults.ncl
├── infrastructure/           # Infrastructure definitions
│   ├── servers/
│   ├── networks/
│   └── storage/
├── deployment/               # Deployment schemas
├── services/                 # Service configurations
├── operations/               # Operational schemas
└── generator/                # Runtime schema generation

Type System

Primitive Types

{
  string_field : String = "text"
  number_field : Number = 42
  bool_field : Bool = true
}

Array Types

{
  names : Array String = ["alice", "bob", "charlie"]
  ports : Array Number = [80, 443, 8080]
}

Enum Types

{
  environment : | [ 'development, 'staging, 'production | ] = 'production
  role : | [ 'control, 'worker, 'standalone | ] = 'worker
}

Optional Fields

{
  required_field : String = "value"
  optional_field : String | optional
}

Default Values

{
  with_default : String | default = "default-value"
}

Validation Patterns

Runtime Validation

let validate_plan = fun plan =>
  if plan == "small" | | plan == "medium" | | plan == "large" then
    plan
  else
    std.fail "Invalid plan: must be small, medium, or large"
in

{
  plan = validate_plan "medium"
}

Contract-Based Validation

let PlanContract = | [ 'small, 'medium, 'large | ] in

{
  plan | PlanContract = 'medium
}

Real-World Examples

Simple Server Configuration

{
  metadata = {
    name = "demo-server"
    provider = "upcloud"
    environment = 'development
  }

  infrastructure = {
    servers = [
      {
        name = "web-01"
        plan = "medium"
        zone = "de-fra1"
        disk_size_gb = 50
        backup_enabled = true
        role = 'standalone
      }
    ]
  }

  services = {
    taskservs = ["containerd", "docker"]
  }
}

Kubernetes Cluster Configuration

{
  metadata = {
    name = "k8s-prod"
    provider = "upcloud"
    environment = 'production
  }

  infrastructure = {
    servers = [
      {
        name = "k8s-control-01"
        plan = "medium"
        role = 'control
        zone = "de-fra1"
        disk_size_gb = 50
        backup_enabled = true
      }
      {
        name = "k8s-worker-01"
        plan = "large"
        role = 'worker
        zone = "de-fra1"
        disk_size_gb = 100
        backup_enabled = true
      }
      {
        name = "k8s-worker-02"
        plan = "large"
        role = 'worker
        zone = "de-fra1"
        disk_size_gb = 100
        backup_enabled = true
      }
    ]
  }

  services = {
    taskservs = ["containerd", "etcd", "kubernetes", "cilium", "rook-ceph"]
  }

  kubernetes = {
    version = "1.28.0"
    pod_cidr = "10.244.0.0/16"
    service_cidr = "10.96.0.0/12"
    container_runtime = "containerd"
    cri_socket = "/run/containerd/containerd.sock"
  }
}

Multi-Provider Batch Workflow

{
  batch_workflow = {
    operations = [
      {
        id = "aws-cluster"
        provider = "aws"
        region = "us-east-1"
        servers = [
          { name = "aws-web-01", plan = "t3.medium" }
        ]
      }
      {
        id = "upcloud-cluster"
        provider = "upcloud"
        region = "de-fra1"
        servers = [
          { name = "upcloud-web-01", plan = "medium" }
        ]
        dependencies = ["aws-cluster"]
      }
    ]
    parallel_limit = 2
  }
}

Validation Workflow

Type-Check Schema

# Check syntax and types
nickel typecheck infra/my-cluster.ncl

# Export to JSON (validates during export)
nickel export infra/my-cluster.ncl

# Export to TOML (generated output only)
nickel export --format toml infra/my-cluster.ncl > config.toml

Platform Validation

# Validate against platform contracts
provisioning validate config --infra my-cluster

# Verbose validation
provisioning validate config --verbose

IDE Integration

Language Server (nickel-lang-lsp)

Install LSP for IDE support:

# Install LSP server
cargo install nickel-lang-lsp

# Configure your editor (VS Code example)
# Install "Nickel" extension from marketplace

Features:

  • Syntax highlighting
  • Type checking on save
  • Autocomplete
  • Hover documentation
  • Go to definition

VS Code Configuration

{
  "nickel.lsp.command": "nickel-lang-lsp",
  "nickel.lsp.args": ["--stdio"],
  "nickel.format.onSave": true
}

Common Patterns

Environment-Specific Configuration

let env_configs = {
  development = {
    plan = "small"
    backup_enabled = false
  }
  production = {
    plan = "large"
    backup_enabled = true
  }
} in

let environment = 'production in

{
  servers = [
    env_configs.%{std.string.from_enum environment} & {
      name = "server-01"
    }
  ]
}

Configuration Composition

let base_server = {
  zone = "de-fra1"
  backup_enabled = false
} in

let prod_overrides = {
  backup_enabled = true
  disk_size_gb = 100
} in

{
  servers = [
    base_server & { name = "dev-01" }
    base_server & prod_overrides & { name = "prod-01" }
  ]
}

Migration from TOML

TOML is ONLY for generated output. Source is always Nickel.

# Generate TOML from Nickel (if needed for external tools)
nickel export --format toml infra/cluster.ncl > cluster.toml

# NEVER edit cluster.toml directly - edit cluster.ncl instead

Best Practices

  1. Use Three-File Pattern: Separate contracts, defaults, and main entry
  2. Type Everything: Add type annotations for all fields
  3. Validate Early: Run nickel typecheck before deployment
  4. Use Makers: Leverage maker functions for composition
  5. Document Contracts: Add comments explaining schema requirements
  6. Avoid Duplication: Use record merging and defaults
  7. Test Locally: Export and verify before deploying
  8. Version Schemas: Track schema changes in version control

Debugging

Type Errors

# Detailed type error messages
nickel typecheck --color always infra/cluster.ncl

Schema Inspection

# Export to JSON for inspection
nickel export infra/cluster.ncl | jq '.'

# Check specific field
nickel export infra/cluster.ncl | jq '.metadata'

Format Code

# Auto-format Nickel files
nickel fmt infra/cluster.ncl

# Check formatting without modifying
nickel fmt --check infra/cluster.ncl

Next Steps