Skip to content

Examples

arm-lab

One ARM64 web container. arch: arm64 pins the machine's CPU architecture: the Compose platform, the Vagrant box architecture and the Kubernetes node selector all follow it, so the lab runs the same on an Apple Silicon or a Graviton host. The cloud targets (x86-64 only, for now) refuse an arm64 machine rather than run it on the wrong architecture.

Tested: Shows arch: arm64: the container platform, box architecture and node selector follow it; cloud targets (x86-64 only) refuse it.

version: 1
name: arm-lab

networks:
  lab: { cidr: 10.60.0.0/24 }

machines:
  web:
    networks: { lab: 10 }
    arch: arm64
    privileged: true
    read_only: true
    tmpfs: [/run, /var/cache/nginx]
    shm_size: 128m
    dns:
      servers: [10.60.0.2]
      search: [arm.lab]
      domain: arm.lab
    services: [{ port: 80, http: true, publish: 8085 }]
    docker: { image: nginx:1.27 }

checks:
  - checks/web-answers.sh

Targets

docker, hosted, docker-vm, cloud-docker, kubernetes. See Targets for what each means.

Checks

  • checks/web-answers.sh

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/docker/compose.yml

# 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:  docker compose -f .isoloom/docker/compose.yml up -d --wait
# Checks: docker compose -f .isoloom/docker/compose.yml --profile check run --rm isoloom-check
# Stop:   docker compose -f .isoloom/docker/compose.yml down -v

name: arm-lab
services:
  web:
    image: nginx:1.27
    platform: linux/arm64
    privileged: true
    read_only: true
    shm_size: 128m
    tmpfs:
    - /run
    - /var/cache/nginx
    dns:
    - 10.60.0.2
    dns_search:
    - arm.lab
    domainname: arm.lab
    hostname: web
    networks:
      lab:
        ipv4_address: 10.60.0.10
    ports:
    - ${ISOLOOM_PUBLISH_ADDRESS:-127.0.0.1}:8085:80
    healthcheck:
      test:
      - CMD-SHELL
      - (nc -z 127.0.0.1 80 2>/dev/null || bash -c '</dev/tcp/127.0.0.1/80' 2>/dev/null)
      interval: 5s
      timeout: 3s
      retries: 60
      start_period: 10s
    labels:
      isoloom.service.80: http:80
    restart: unless-stopped
  isoloom-check:
    image: curlimages/curl:8.11.1
    profiles:
    - check
    entrypoint:
    - /bin/sh
    - -c
    - echo '== checks/web-answers.sh' && sh /isoloom/checks/01-web-answers.sh
    volumes:
    - ../../checks/web-answers.sh:/isoloom/checks/01-web-answers.sh:ro
    networks:
      lab: null
    depends_on:
      web:
        condition: service_healthy
networks:
  lab:
    ipam:
      config:
      - subnet: 10.60.0.0/24
        gateway: 10.60.0.1

.isoloom/docker-vm/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/docker-vm && vagrant up
# Checks: cd .isoloom/docker-vm && vagrant provision --provision-with checks
# Stop:   cd .isoloom/docker-vm && vagrant destroy -f

ROOT = File.expand_path("../..", __dir__)
# Copied into the VM: the project, its generated Compose file included.
PROJECT = Dir.children(ROOT).reject { |e| [".git", ".vagrant"].include?(e) }.sort

Vagrant.configure("2") do |config|
  config.vm.box = "bento/debian-12"
  config.vm.hostname = "arm-lab"
  config.vm.synced_folder ".", "/vagrant", disabled: true
  config.vm.boot_timeout = 600
  config.vm.network "forwarded_port", guest: 8085, host: 8085, host_ip: "127.0.0.1"
  config.vm.provider "virtualbox" do |v|
    v.name = "arm-lab · docker"
    v.cpus = 2
    v.memory = 2048
  end
  config.vm.provider "vmware_desktop" do |v|
    v.vmx["displayName"] = "arm-lab · docker"
    v.vmx["numvcpus"] = "2"
    v.vmx["memsize"] = "2048"
  end
  config.vm.provider "parallels" do |v|
    v.name = "arm-lab · docker"
    v.cpus = 2
    v.memory = 2048
  end
  config.vm.provider "libvirt" do |v, o|
    o.vm.box = "generic/debian12"
    v.cpus = 2
    v.memory = 2048
  end
  config.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 = [ENV.fetch("ESXI_VIRTUAL_NETWORK", "VM Network").split(",").first.strip]
    v.guest_name = "arm-lab-docker"
    v.guest_numvcpus = 2
    v.guest_memsize = 2048
  end
  config.vm.provision "shell", name: "docker", inline: "command -v docker >/dev/null || curl -fsSL https://get.docker.com | sh"
  PROJECT.each do |entry|
    config.vm.provision "file", source: File.join(ROOT, entry), destination: "/tmp/isoloom-project/#{entry}"
  end
  config.vm.provision "shell", name: "project", inline: "rm -rf /opt/isoloom && mv /tmp/isoloom-project /opt/isoloom"
  config.vm.provision "shell", name: "environment", inline: "cd /opt/isoloom && ISOLOOM_PUBLISH_ADDRESS=0.0.0.0 docker compose -f .isoloom/docker/compose.yml up -d --build --wait --wait-timeout 900 && mkdir -p /var/lib/isoloom && echo ready > /var/lib/isoloom/ready"
  config.vm.provision "shell", name: "checks", run: "never", inline: "cd /opt/isoloom && docker compose -f .isoloom/docker/compose.yml --profile check run --rm isoloom-check"
end

.isoloom/docker-vm/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/docker-vm/proxmox init && terraform -chdir=.isoloom/docker-vm/proxmox apply \
#           -var proxmox_endpoint=https://<server>:8006/ -var proxmox_api_token=… -var ssh_public_key="$(cat ~/.ssh/id_ed25519.pub)" -var ssh_private_key_file=~/.ssh/id_ed25519
# Stop:   terraform -chdir=.isoloom/docker-vm/proxmox destroy (same variables)

terraform {
  required_version = ">= 1.6"
  backend "local" {}
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"
      version = "~> 0.84"
    }
  }
}

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 the cloud-init snippet 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 the VM's disk goes"
}
variable "image_datastore" {
  type        = string
  default     = "local"
  description = "A datastore with 'iso' content, for the cloud image"
}
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 VM gets its address (DHCP) and the internet from"
}
variable "slot" {
  type        = number
  default     = 1
  description = "1 to 99, unique per environment on this server"
}

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 {
  root = abspath("${path.module}/../../..")
  # The VM's address on the uplink, from the guest agent (not the loopback).
  ip = [for a in flatten(proxmox_virtual_environment_vm.env.ipv4_addresses) : a if a != "127.0.0.1"][0]
}

resource "proxmox_download_file" "debian" {
  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"
  file_name           = "iso${var.slot}-docker-debian-12.img"
  overwrite_unmanaged = true
}

resource "proxmox_virtual_environment_file" "env" {
  node_name    = var.node
  datastore_id = var.snippets_datastore
  content_type = "snippets"
  source_raw {
    file_name = "iso${var.slot}-docker.yaml"
    data = "#cloud-config\n${yamlencode({
      hostname = "arm-lab"
      users = [{
        name                = "isoloom"
        sudo                = "ALL=(ALL) NOPASSWD:ALL"
        shell               = "/bin/bash"
        ssh_authorized_keys = [var.ssh_public_key]
      }]
      packages = ["qemu-guest-agent", "curl"]
      runcmd   = [["systemctl", "enable", "--now", "qemu-guest-agent"]]
    })}"
  }
}

resource "proxmox_virtual_environment_vm" "env" {
  name      = "iso${var.slot}-arm-lab"
  node_name = var.node
  tags      = ["isoloom", "arm-lab"]
  on_boot   = false
  agent {
    enabled = true
  }
  cpu {
    cores = 2
    type  = "host"
  }
  memory {
    dedicated = 1536
  }
  disk {
    datastore_id = var.datastore
    file_id      = proxmox_download_file.debian.id
    interface    = "virtio0"
    size         = 30
  }
  network_device {
    bridge = var.uplink_bridge
  }
  initialization {
    datastore_id      = var.datastore
    user_data_file_id = proxmox_virtual_environment_file.env.id
    ip_config {
      ipv4 {
        address = "dhcp"
      }
    }
  }
  operating_system {
    type = "l26"
  }
  serial_device {}
}

variable "ssh_public_key" {
  type = string
}
variable "ssh_private_key_file" {
  type        = string
  description = "The private key of ssh_public_key: Terraform copies the project over SSH"
}
variable "auto_stop_minutes" {
  type        = number
  default     = 0
  description = "Shut the VM down after this many minutes (0: never). Destroy still ends the billing of disks and addresses"
}

# The environment, over SSH: the project, Docker, then the Compose file.
resource "terraform_data" "environment" {
  triggers_replace = [proxmox_virtual_environment_vm.env.id]
  connection {
    type        = "ssh"
    host        = local.ip
    user        = "isoloom"
    private_key = file(pathexpand(var.ssh_private_key_file))
    timeout     = "10m"
  }
  provisioner "remote-exec" {
    inline = [
      "cloud-init status --wait >/dev/null 2>&1 || true",
      var.auto_stop_minutes > 0 ? "sudo shutdown -h +${var.auto_stop_minutes} >/dev/null 2>&1" : "true",
      "sudo mkdir -p /opt/isoloom && sudo chown isoloom /opt/isoloom",
    ]
  }
  # The project as an archive: a plain copy drops the executable bits (entrypoint scripts).
  provisioner "local-exec" {
    command = "tar -czf \"${path.module}/.isoloom-project.tgz\" --exclude=.git --exclude=.vagrant --exclude=.terraform --exclude=.isoloom-project.tgz -C \"${local.root}\" ."
  }
  provisioner "file" {
    source      = "${path.module}/.isoloom-project.tgz"
    destination = "/tmp/isoloom-project.tgz"
  }
  provisioner "remote-exec" {
    inline = [
      "set -e",
      "tar -xzf /tmp/isoloom-project.tgz -C /opt/isoloom && rm -f /tmp/isoloom-project.tgz",
      "command -v docker >/dev/null || curl -fsSL https://get.docker.com | sudo sh",
      "cd /opt/isoloom && sudo -E env ISOLOOM_PUBLISH_ADDRESS=0.0.0.0 docker compose -f .isoloom/docker/compose.yml up -d --build --wait --wait-timeout 900",
      "sudo mkdir -p /var/lib/isoloom && echo ready | sudo tee /var/lib/isoloom/ready >/dev/null",
    ]
  }
}

output "ip" {
  value = local.ip
}
output "ssh_user" {
  value = "isoloom"
}
output "ready_file" {
  value = "/var/lib/isoloom/ready"
}