Skip to content

Examples

existing-hosts

Machines that already exist: two VMs someone built by hand on a home lab, reached over SSH at the addresses in external:. Isoloom creates nothing; isoloom run external provisions them, isoloom test external runs the checks from each, connect and exec reach them. Networks and reach describe what the lab's own switches and firewall should allow, which the derived checks verify. These machines keep vm: too, so the same spec also builds the lab from scratch.

Tested: Shows the external target: machines that already exist, described with their SSH endpoints; generated and checked in CI.

version: 1
name: existing-hosts

networks:
  lab: { cidr: 192.168.1.0/24 }

machines:
  web:
    networks: { lab: 20 }
    services: [{ port: 80, http: true }]
    vm:
      os: debian-12
      provision: [provision/web.sh]
    external: { address: 192.168.1.20, user: florian, key: ~/.ssh/id_ed25519 }
  cache:
    networks: { lab: 21 }
    services: [{ port: 6379, name: redis }]
    vm:
      os: debian-12
      provision: [provision/cache.sh]
    external: { address: 192.168.1.21, user: florian, key: ~/.ssh/id_ed25519 }

Targets

vagrant, proxmox, cloud-vm, external. See Targets for what each means.

Checks

All derived: Isoloom checks every service from every machine its reach rules let through (and from nowhere else), and that offline networks stay offline. See Checks.

Generated files

What isoloom generate writes for this spec, as committed next to it. Don't edit them: change the spec and generate again (isoloom check fails in CI when they're out of date).

.isoloom/vagrant/Vagrantfile

# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start:  cd .isoloom/vagrant && vagrant up
# Stop:   cd .isoloom/vagrant && vagrant destroy -f

ROOT = File.expand_path("../..", __dir__)
# Copied into each VM: the project, without version control or generated files.
PROJECT = Dir.children(ROOT).reject { |e| [".git", ".vagrant"].include?(e) || e.start_with?(".isoloom") }.sort
# ESXi (vagrant-vmware-esxi): ESXI_VIRTUAL_NETWORK lists port groups, comma-separated: the
# management network first, then one per network, in order (lab); a missing one reuses the last.
ESXI_NETWORKS = ENV.fetch("ESXI_VIRTUAL_NETWORK", "VM Network").split(",").map(&:strip)
LAB_NETWORKS = ["lab"]
def esxi_networks(nets)
  [ESXI_NETWORKS[0]] + nets.map { |n| ESXI_NETWORKS[1 + LAB_NETWORKS.index(n)] || ESXI_NETWORKS[-1] }
end

Vagrant.configure("2") do |config|
  config.vm.synced_folder ".", "/vagrant", disabled: true
  config.vm.boot_timeout = 900

  config.vm.define "web" do |m|
    m.vm.box = "bento/debian-12"
    m.vm.hostname = "web"
    m.vm.network "private_network", ip: "192.168.1.20", netmask: "255.255.255.0", virtualbox__intnet: "isoloom-existing-hosts-lab", libvirt__network_name: "isoloom-existing-hosts-lab", libvirt__dhcp_enabled: false
    m.vm.provider "virtualbox" do |v|
      v.name = "existing-hosts · web"
      v.linked_clone = true
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provider "vmware_desktop" do |v|
      v.vmx["displayName"] = "existing-hosts · web"
      v.vmx["numvcpus"] = "1"
      v.vmx["memsize"] = "1024"
    end
    m.vm.provider "parallels" do |v|
      v.name = "existing-hosts · web"
      v.linked_clone = true
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provider "utm" do |v|
      v.name = "existing-hosts · web"
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provider "qemu" do |v|
      v.smp = "cpus=1"
      v.memory = "1024M"
    end
    m.vm.provider "vmware_esxi" do |v|
      v.esxi_hostname = ENV.fetch("ESXI_HOSTNAME", "")
      v.esxi_hostport = ENV.fetch("ESXI_HOSTPORT", "22").to_i
      v.esxi_username = ENV.fetch("ESXI_USERNAME", "root")
      v.esxi_password = "env:ESXI_PASSWORD"
      v.esxi_disk_store = ENV["ESXI_DATASTORE"] if ENV["ESXI_DATASTORE"]
      v.esxi_virtual_network = esxi_networks(["lab"])
      v.guest_name = "existing-hosts-web"
      v.guest_numvcpus = 1
      v.guest_memsize = 1024
    end
    m.vm.provider "libvirt" do |v, o|
      o.vm.box = "generic/debian12"
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provision "shell", name: "hosts", inline: "for l in '192.168.1.21 cache'; do grep -qxF \"$l\" /etc/hosts || echo \"$l\" >> /etc/hosts; done"
    PROJECT.each do |entry|
      m.vm.provision "file", source: File.join(ROOT, entry), destination: "/tmp/isoloom-project/#{entry}"
    end
    m.vm.provision "shell", name: "project", inline: "rm -rf /opt/isoloom && mv /tmp/isoloom-project /opt/isoloom"
    m.vm.provision "shell", name: "provision/web.sh", inline: "cd /opt/isoloom && sh provision/web.sh"
    m.vm.provision "shell", name: "checks", run: "never", path: "checks/web.sh", env: { "ISOLOOM_DERIVED" => ENV.fetch("ISOLOOM_DERIVED", "1") }
  end

  config.vm.define "cache" do |m|
    m.vm.box = "bento/debian-12"
    m.vm.hostname = "cache"
    m.vm.network "private_network", ip: "192.168.1.21", netmask: "255.255.255.0", virtualbox__intnet: "isoloom-existing-hosts-lab", libvirt__network_name: "isoloom-existing-hosts-lab", libvirt__dhcp_enabled: false
    m.vm.provider "virtualbox" do |v|
      v.name = "existing-hosts · cache"
      v.linked_clone = true
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provider "vmware_desktop" do |v|
      v.vmx["displayName"] = "existing-hosts · cache"
      v.vmx["numvcpus"] = "1"
      v.vmx["memsize"] = "1024"
    end
    m.vm.provider "parallels" do |v|
      v.name = "existing-hosts · cache"
      v.linked_clone = true
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provider "utm" do |v|
      v.name = "existing-hosts · cache"
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provider "qemu" do |v|
      v.smp = "cpus=1"
      v.memory = "1024M"
    end
    m.vm.provider "vmware_esxi" do |v|
      v.esxi_hostname = ENV.fetch("ESXI_HOSTNAME", "")
      v.esxi_hostport = ENV.fetch("ESXI_HOSTPORT", "22").to_i
      v.esxi_username = ENV.fetch("ESXI_USERNAME", "root")
      v.esxi_password = "env:ESXI_PASSWORD"
      v.esxi_disk_store = ENV["ESXI_DATASTORE"] if ENV["ESXI_DATASTORE"]
      v.esxi_virtual_network = esxi_networks(["lab"])
      v.guest_name = "existing-hosts-cache"
      v.guest_numvcpus = 1
      v.guest_memsize = 1024
    end
    m.vm.provider "libvirt" do |v, o|
      o.vm.box = "generic/debian12"
      v.cpus = 1
      v.memory = 1024
    end
    m.vm.provision "shell", name: "hosts", inline: "for l in '192.168.1.20 web'; do grep -qxF \"$l\" /etc/hosts || echo \"$l\" >> /etc/hosts; done"
    PROJECT.each do |entry|
      m.vm.provision "file", source: File.join(ROOT, entry), destination: "/tmp/isoloom-project/#{entry}"
    end
    m.vm.provision "shell", name: "project", inline: "rm -rf /opt/isoloom && mv /tmp/isoloom-project /opt/isoloom"
    m.vm.provision "shell", name: "provision/cache.sh", inline: "cd /opt/isoloom && sh provision/cache.sh"
    m.vm.provision "shell", name: "checks", run: "never", path: "checks/cache.sh", env: { "ISOLOOM_DERIVED" => ENV.fetch("ISOLOOM_DERIVED", "1") }
  end
end

.isoloom/proxmox/main.tf

# Generated by isoloom from isoloom.yml. Don't edit: change isoloom.yml and run
# `isoloom generate`. `isoloom check` fails when this file is out of date.
# Start:  terraform -chdir=.isoloom/proxmox init && terraform -chdir=.isoloom/proxmox apply -var proxmox_endpoint=https://<server>:8006/ -var proxmox_api_token=<user@realm!name=secret>
# Stop:   terraform -chdir=.isoloom/proxmox destroy (same variables)

terraform {
  required_version = ">= 1.6"
  backend "local" {}
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"
      version = "~> 0.115"
    }
    # The controller's SSH key (environment-level provisioning).
    tls = {
      source  = "hashicorp/tls"
      version = "~> 4.0"
    }
  }
}

variable "proxmox_endpoint" {
  type        = string
  description = "https://<server>:8006/"
}
variable "proxmox_api_token" {
  type        = string
  default     = ""
  sensitive   = true
  description = "user@realm!name=secret; or use proxmox_username and proxmox_password"
}
variable "proxmox_username" {
  type    = string
  default = "root@pam"
}
variable "proxmox_password" {
  type      = string
  default   = ""
  sensitive = true
}
variable "proxmox_insecure" {
  type        = bool
  default     = true
  description = "Accept the server's self-signed certificate"
}
variable "proxmox_ssh_username" {
  type        = string
  default     = "root"
  description = "Uploading cloud-init snippets goes over SSH to the node"
}
variable "proxmox_ssh_private_key_file" {
  type    = string
  default = ""
}
variable "proxmox_ssh_address" {
  type        = string
  default     = ""
  description = "The node's SSH address, when the API reports one this machine can't reach"
}
variable "node" {
  type    = string
  default = "pve"
}
variable "datastore" {
  type        = string
  default     = "local-lvm"
  description = "Where VM disks go"
}
variable "image_datastore" {
  type        = string
  default     = "local"
  description = "A datastore with 'iso' content, for cloud images"
}
variable "snippets_datastore" {
  type        = string
  default     = "local"
  description = "A datastore with 'snippets' content, for cloud-init"
}
variable "uplink_bridge" {
  type        = string
  default     = "vmbr0"
  description = "The bridge the router reaches the internet through"
}
variable "slot" {
  type        = number
  default     = 1
  description = "1 to 99, unique per environment on this server (SDN ids are short)"
}
variable "ssh_public_key" {
  type        = string
  default     = ""
  description = "Installed for the user `isoloom` on every VM"
}

provider "proxmox" {
  endpoint  = var.proxmox_endpoint
  api_token = var.proxmox_api_token != "" ? var.proxmox_api_token : null
  username  = var.proxmox_api_token != "" ? null : var.proxmox_username
  password  = var.proxmox_api_token != "" ? null : var.proxmox_password
  insecure  = var.proxmox_insecure
  ssh {
    agent       = false
    username    = var.proxmox_ssh_username
    password    = var.proxmox_ssh_private_key_file != "" ? null : var.proxmox_password
    private_key = var.proxmox_ssh_private_key_file != "" ? file(var.proxmox_ssh_private_key_file) : null
    dynamic "node" {
      for_each = var.proxmox_ssh_address == "" ? [] : [1]
      content {
        name    = var.node
        address = var.proxmox_ssh_address
      }
    }
  }
}

locals {
  zone = "iso${var.slot}"
  # The project, written to /opt/isoloom in each machine that has steps.
  root    = abspath("${path.module}/../..")
  project = [for f in fileset(local.root, "**") : f if !startswith(f, ".git/") && !startswith(f, ".isoloom/") && !startswith(f, ".vagrant/")]
  project_files = [for f in local.project : {
    path     = "/opt/isoloom/${f}"
    encoding = "b64"
    content  = filebase64("${local.root}/${f}")
  }]
  users = var.ssh_public_key == "" ? [] : [{
    name                = "isoloom"
    sudo                = "ALL=(ALL) NOPASSWD:ALL"
    shell               = "/bin/bash"
    ssh_authorized_keys = [var.ssh_public_key]
  }]
}

# The environment's networks: an SDN simple zone, a VNet per network.
resource "proxmox_sdn_zone_simple" "env" {
  id    = local.zone
  nodes = [var.node]
}

# Network `lab`: 192.168.1.0/24
resource "proxmox_sdn_vnet" "lab" {
  id   = "i${var.slot}n0"
  zone = proxmox_sdn_zone_simple.env.id
}

resource "proxmox_sdn_applier" "env" {
  depends_on = [proxmox_sdn_vnet.lab]
}

resource "proxmox_download_file" "debian_12" {
  node_name    = var.node
  datastore_id = var.image_datastore
  content_type = "iso"
  url          = "https://cloud.debian.org/images/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2"
  # Per environment: a shared file would go with whichever environment is destroyed first.
  file_name           = "iso${var.slot}-debian-12.img"
  overwrite_unmanaged = true
}

# The router: forwards between networks with the reach rules, and to the internet.
resource "proxmox_virtual_environment_file" "router" {
  node_name    = var.node
  datastore_id = var.snippets_datastore
  content_type = "snippets"
  source_raw {
    file_name = "iso${var.slot}-router.yaml"
    data = "#cloud-config\n${yamlencode({
      hostname = "isoloom-router"
      users    = local.users
      packages = ["nftables", "qemu-guest-agent"]
      write_files = [
        { path = "/etc/systemd/network/10-wan.network", content = join("\n", ["[Match]", "MACAddress=${format("02:15:%02x:00:00:00", var.slot)}", "", "[Network]", "DHCP=yes"]) },
        { path = "/etc/nftables.conf", content = "flush ruleset\ntable inet isoloom {\n  chain forward {\n    type filter hook forward priority 0; policy drop;\n    ct state established,related accept\n    ip saddr { 192.168.1.0/24 } ip daddr != { 192.168.1.0/24 } accept\n  }\n  chain prerouting {\n    type nat hook prerouting priority -100;\n  }\n  chain postrouting {\n    type nat hook postrouting priority 100;\n    ip saddr { 192.168.1.0/24 } ip daddr != { 192.168.1.0/24 } masquerade\n  }\n}\n" },
        { path = "/etc/sysctl.d/90-isoloom.conf", content = "net.ipv4.ip_forward=1\n" },
        { path = "/etc/systemd/network/20-lab.network", content = join("\n", ["[Match]", "MACAddress=${format("02:15:%02x:01:%02x:00", var.slot, 0)}", "", "[Network]", "Address=192.168.1.254/24", "ConfigureWithoutCarrier=yes"]) }
      ]
      runcmd = [
        ["sysctl", "-p", "/etc/sysctl.d/90-isoloom.conf"],
        ["systemctl", "enable", "systemd-networkd"],
        ["systemctl", "restart", "systemd-networkd"],
        ["systemctl", "enable", "--now", "nftables"],
        ["nft", "-f", "/etc/nftables.conf"],
        ["systemctl", "enable", "--now", "qemu-guest-agent"],
      ]
    })}"
  }
}

resource "proxmox_virtual_environment_vm" "isoloom_router" {
  name      = "iso${var.slot}-router"
  node_name = var.node
  tags      = ["isoloom", "existing-hosts"]
  on_boot   = false
  # Its uplink address (DHCP), for the published ports.
  agent {
    enabled = true
  }
  cpu {
    cores = 1
    type  = "host"
  }
  memory {
    dedicated = 512
  }
  disk {
    datastore_id = var.datastore
    file_id      = proxmox_download_file.debian_12.id
    interface    = "virtio0"
    size         = 8
  }
  network_device {
    bridge      = var.uplink_bridge
    mac_address = upper(format("02:15:%02x:00:00:00", var.slot))
  }
  network_device {
    bridge      = proxmox_sdn_vnet.lab.id
    mac_address = upper(format("02:15:%02x:01:%02x:00", var.slot, 0))
  }
  initialization {
    datastore_id      = var.datastore
    user_data_file_id = proxmox_virtual_environment_file.router.id
    ip_config {
      ipv4 {
        address = "dhcp"
      }
    }
  }
  operating_system {
    type = "l26"
  }
  serial_device {}
  depends_on = [proxmox_sdn_applier.env]
}

# Machine `web`.
resource "proxmox_virtual_environment_file" "web" {
  node_name    = var.node
  datastore_id = var.snippets_datastore
  content_type = "snippets"
  source_raw {
    file_name = "iso${var.slot}-web.yaml"
    data = "#cloud-config\n${yamlencode({
      hostname    = "web"
      users       = local.users
      packages    = ["nftables", "curl", "netcat-openbsd"]
      write_files = local.project_files
      runcmd = [
        ["sh", "-c", "printf '%s\\n' '192.168.1.21 cache' >> /etc/hosts"],
        ["sh", "-c", "cd /opt/isoloom && sh provision/web.sh"],
        ["sh", "-c", "mkdir -p /var/lib/isoloom && echo ready > /var/lib/isoloom/ready"]
      ]
    })}"
  }
}

resource "proxmox_virtual_environment_vm" "web" {
  name      = "iso${var.slot}-web"
  node_name = var.node
  tags      = ["isoloom", "existing-hosts"]
  on_boot   = false
  cpu {
    cores = 1
    type  = "host"
  }
  memory {
    dedicated = 1024
  }
  disk {
    datastore_id = var.datastore
    file_id      = proxmox_download_file.debian_12.id
    interface    = "virtio0"
    size         = 20
  }
  network_device {
    bridge = proxmox_sdn_vnet.lab.id
  }
  initialization {
    datastore_id      = var.datastore
    user_data_file_id = proxmox_virtual_environment_file.web.id
    dns {
      servers = ["1.1.1.1"]
    }
    ip_config {
      ipv4 {
        address = "192.168.1.20/24"
        gateway = "192.168.1.254"
      }
    }
  }
  operating_system {
    type = "l26"
  }
  serial_device {}
  depends_on = [proxmox_virtual_environment_vm.isoloom_router]
}

# Machine `cache`.
resource "proxmox_virtual_environment_file" "cache" {
  node_name    = var.node
  datastore_id = var.snippets_datastore
  content_type = "snippets"
  source_raw {
    file_name = "iso${var.slot}-cache.yaml"
    data = "#cloud-config\n${yamlencode({
      hostname    = "cache"
      users       = local.users
      packages    = ["nftables", "curl", "netcat-openbsd"]
      write_files = local.project_files
      runcmd = [
        ["sh", "-c", "printf '%s\\n' '192.168.1.20 web' >> /etc/hosts"],
        ["sh", "-c", "cd /opt/isoloom && sh provision/cache.sh"],
        ["sh", "-c", "mkdir -p /var/lib/isoloom && echo ready > /var/lib/isoloom/ready"]
      ]
    })}"
  }
}

resource "proxmox_virtual_environment_vm" "cache" {
  name      = "iso${var.slot}-cache"
  node_name = var.node
  tags      = ["isoloom", "existing-hosts"]
  on_boot   = false
  cpu {
    cores = 1
    type  = "host"
  }
  memory {
    dedicated = 1024
  }
  disk {
    datastore_id = var.datastore
    file_id      = proxmox_download_file.debian_12.id
    interface    = "virtio0"
    size         = 20
  }
  network_device {
    bridge = proxmox_sdn_vnet.lab.id
  }
  initialization {
    datastore_id      = var.datastore
    user_data_file_id = proxmox_virtual_environment_file.cache.id
    dns {
      servers = ["1.1.1.1"]
    }
    ip_config {
      ipv4 {
        address = "192.168.1.21/24"
        gateway = "192.168.1.254"
      }
    }
  }
  operating_system {
    type = "l26"
  }
  serial_device {}
  depends_on = [proxmox_virtual_environment_vm.isoloom_router]
}

locals {
  router_address = [for a in flatten(proxmox_virtual_environment_vm.isoloom_router.ipv4_addresses) : a if a != "127.0.0.1" && !contains(["192.168.1.254"], a)][0]
}

output "address" {
  value = local.router_address
}

# The machines, through the router: ssh -J isoloom@<address> isoloom@<machine>.
output "machines" {
  value = {
    "web"   = "192.168.1.20"
    "cache" = "192.168.1.21"
  }
}

output "ssh_user" {
  value = "isoloom"
}

# The checks: each runner piped to its machine (ssh -J isoloom@<address> isoloom@<host> sh -s < <script>), or `isoloom test proxmox`.
output "checks" {
  value = [
    { position = "web", machine = "web", host = "192.168.1.20", user = "isoloom", script = ".isoloom/proxmox/checks/web.sh" },
    { position = "cache", machine = "cache", host = "192.168.1.21", user = "isoloom", script = ".isoloom/proxmox/checks/cache.sh" }
  ]
}