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"
}