Skip to content

Latest commit

 

History

History
1060 lines (871 loc) · 27.4 KB

usage.md

File metadata and controls

1060 lines (871 loc) · 27.4 KB

Usage Guide

Table of Contents


Quick Start

# Install dependencies
apt-get install -y python3-yaml genisoimage   # Debian/Ubuntu/Proxmox
dnf install -y python3-pyyaml genisoimage       # RHEL/Rocky/Alma
pacman -S python-yaml cdrtools                # Arch

# Create main.json (see examples below)
# Place provisioning scripts in samples/{profile}/

# Build everything
python3 build.py -a

# Build specific preset
python3 build.py --preset my-router

# Build multiple presets
python3 build.py --preset my-router,web-server

# Preview without generating
python3 build.py -a --dry-run

# Inline build without any config file
python3 build.py --no-config     --profile ubuntu --ids default     --hostname myvm --ip 10.0.0.10/24 --gateway 10.0.0.1

Project Structure

cibuilder/
├── build.py              # CLI frontend
├── lib/                  # Core library
│   ├── __init__.py
│   ├── builder.py        # Build orchestration
│   ├── config.py         # Config loading + conf.d merging
│   ├── scripts.py        # Script resolution + includes
│   ├── network.py        # Network config generation
│   └── iso.py            # ISO creation + YAML writing
├── main.json             # Orchestrator: global + builds
├── conf.d/               # Modular build definitions
│   └── *.json
├── samples/              # Provisioning scripts + includes
│   ├── ubuntu/           # Profile: Ubuntu-based machines
│   │   ├── 01-base_default.sh
│   │   ├── 10-network_default.sh
│   │   ├── 10-network_router.sh
│   │   ├── 30-packages_default.sh
│   │   └── include/      # Include files: {id}_{name}
│   │       ├── default_motd
│   │       └── router_iptables.conf
│   ├── debian/           # Profile: Debian-based machines
│   ├── alpine/           # Profile: Alpine-based machines
│   ├── rhel/             # Profile: RHEL-based machines
│   └── common/           # Shared across all profiles
├── postbuild/
│   └── deploy-to-proxmox.sh
└── output/               # Generated ISOs and files
    ├── {preset-name}/
    │   ├── user-data
    │   ├── meta-data
    │   ├── network-config
    │   └── scripts/
    │       ├── 01-base.sh
    │       ├── 10-network.sh
    │       └── include/
    │           ├── motd
    │           └── iptables.conf
    └── {preset-name}.iso

Configuration

main.json

Central orchestrator. Defines global defaults and a builds array.

{
  "global": {
    "output_dir": "./output",
    "iso_label": "cidata",
    "default_ssh_key": "~/.ssh/id_rsa.pub",
    "auto_clean": true,
    "postbuild": "postbuild/deploy-all.sh",
    "default_include_permissions": {
      "*": "0644",
      ".sh": "0755",
      ".env": "0600"
    },
    "params": {
      "timezone": "UTC",
      "dns": "8.8.8.8",
      "domain": "local"
    }
  },
  "builds": [
    {
      "preset": "ubuntu-router",
      "profile": "ubuntu",
      "ids": ["default", "router"],
      "default_include_permissions": {
        "*": "0644",
        ".conf": "0640"
      },
      "includes": [
        "router_iptables.rules",
        {
          "src": "motd",
          "dest": "/etc/update-motd.d/99-custom",
          "mode": "0755"
        }
      ],
      "params": {
        "hostname": "router1",
        "ip": "dhcp",
        "interface": "ens18"
      },
      "ignore_missing_ids": false
    },
    {
      "preset": "ubuntu-web",
      "profile": "ubuntu",
      "ids": ["default"],
      "includes": [
        {
          "src": ".env",
          "dest": "/etc/app/config.env",
          "mode": "0600",
          "owner": "1000:1000"
        },
        {
          "src": "scripts",
          "dest": "/usr/local/bin",
          "tree": true,
          "mode": "0755"
        }
      ],
      "params": {
        "hostname": "web-01",
        "ciuser": "admin",
        "password": "changeme123",
        "ip": "10.0.0.10/24",
        "gateway": "10.0.0.1",
        "interface": "ens18"
      }
    }
  ]
}

conf.d/

Split builds across files. Files are sorted alphabetically and merged after main.json.

// conf.d/routers.json
{
  "builds": [
    {
      "preset": "router-east",
      "profile": "ubuntu",
      "ids": ["default", "router"],
      "includes": [
        {
          "src": "iptables",
          "dest": "/etc/iptables/rules.v4",
          "mode": "0644"
        }
      ],
      "params": {
        "hostname": "router-east",
        "ip": "dhcp"
      }
    },
    {
      "preset": "router-west",
      "profile": "ubuntu",
      "ids": ["default", "router"],
      "params": {
        "hostname": "router-west",
        "ip": "dhcp"
      }
    }
  ]
}

Each file can contain a single build, an array of builds, or a dict with global and builds.

Global Override in conf.d

// conf.d/99-production.json
{
  "global": {
    "postbuild": "postbuild/deploy-to-prod.sh",
    "params": {
      "timezone": "Europe/Moscow",
      "dns": "1.1.1.1"
    },
    "default_include_permissions": {
      "*.key": "0400"
    }
  }
}

Merge rules:

  • params and default_include_permissions are deep-merged
  • All other global keys are shallow-merged
  • Build definitions are appended after main.json builds

Global Parameters

Field Type Default Description
output_dir string ./output Output directory for ISOs
iso_label string cidata ISO volume label
default_ssh_key string ~/.ssh/id_rsa.pub Path to SSH public key
auto_clean bool true Clean output dir before build
postbuild string — Global postbuild script (fallback for all builds)
default_include_permissions object {"*": "0644"} Default mode/owner for includes
params object {} Default params for all builds

Build Parameters

Field Type Description
preset string Required. Name of output ISO
profile string Required. Subdirectory in samples/
ids array Required. Script IDs to include
params object Variables for {{key}} substitution
include_dest string Destination for includes (default: /opt/provision/include)
includes array Explicit include manifest (strings or objects)
default_include_permissions object Default mode/owner for this build's includes
overrides.disable array Script names to skip
overrides.extra_files array Additional script paths
ignore_missing_ids bool Skip build if false and ID has no scripts and no includes
postbuild string Per-build postbuild script (overrides global)

Params

Param Type Description
hostname string System hostname
ciuser string Create user (triggers users block)
password string Plain-text password for ciuser
ssh_pwauth bool Enable password auth
disable_root bool Disable root login
growpart bool Auto-expand root partition
package_update bool Run package update
package_upgrade bool Run package upgrade
packages array|string Packages to install
timezone string System timezone
ntp object NTP configuration
network_version int 1 (default) or 2 (Netplan)
interfaces array Network interfaces
ip string Single IP with CIDR or dhcp (v1)
gateway string Default gateway (v1)
interface string Interface name, default eth0 (v1)
dns string DNS servers, comma-separated
domain string DNS search domain

Parameter Priority

global.params  →  conf.d global.params  →  build.params  →  CLI overrides

Provisioning Scripts

Naming Convention

{priority}-{name}_{id}.sh
Component Description Example
priority Execution order (numeric, zero-padded) 01, 10, 50
name Script purpose disk, network, packages
id Configuration set ID default, router, web

Examples:

  • 01-disk_default.sh → priority=1, name=disk, id=default
  • 10-network_router.sh → priority=10, name=network, id=router
  • 50-nginx_web.sh → priority=50, name=nginx, id=web

Scripts live in samples/{profile}/ and samples/common/.

Conflict Resolution

When multiple scripts share the same priority, the one whose id appears later in ids wins.

"ids": ["default", "router"]
samples/ubuntu/
  01-disk_default.sh       → included
  10-network_default.sh    → conflict with 10-network_router.sh
  10-network_router.sh     → wins (router is later in ids)
  30-packages_default.sh   → included

Missing ID Handling

When an ID is specified but no scripts are found, the builder checks for includes. If includes exist, build continues with [INFO]. If neither scripts nor includes are found, build is blocked (unless ignore_missing_ids=true).

Writing Scripts

Scripts are standard bash. Use {{key}} for parameter substitution.

#!/bin/bash
# samples/ubuntu/30-packages_default.sh

apt-get update
apt-get install -y nginx curl vim

echo "Hostname: {{hostname}}"
echo "Timezone: {{timezone}}"

Best practices:

  • Use set -e for fail-fast (builder wraps execution automatically)
  • Use || true for commands that may legitimately fail
  • Keep scripts focused on one task
  • Use apt-get instead of apt for stable CLI

Include System

Include files are static assets copied into the VM via write_files.

Naming

samples/{profile}/include/{id}_{name}
  • {id} — matches IDs from the build's ids array
  • {name} — source filename (preserved, any extension or none)
  • First underscore is the separator: private_.env → id=private, name=.env

Examples:

samples/ubuntu/include/
  default_motd              → /opt/provision/include/motd      (mode=0644)
  router_iptables.rules     → /opt/provision/include/iptables.rules
  private_.env              → /opt/provision/include/.env      (mode=0600)

Auto-Discovery

Without an includes manifest, the builder auto-discovers all matching files.

Destination defaults to include_dest (/opt/provision/include). Last ID wins on conflicts.

Default Permissions

{
  "global": {
    "default_include_permissions": {
      "*": "0644",
      ".sh": "0755",
      ".env": "0600",
      ".key": {"mode": "0400", "owner": "0:0"},
      "nginx.conf": "0644"
    }
  },
  "builds": [
    {
      "preset": "app-server",
      "default_include_permissions": {
        "*": {"mode": "0640", "owner": "1000:1000"}
      }
    }
  ]
}

Priority (first match wins):

  1. Explicit mode/owner in includes[] entry
  2. preserve_mode: true — mode from source filesystem
  3. Exact filename match in default_include_permissions
  4. Extension match (.sh, .env)
  5. * fallback

Owner format: uid:gid (e.g., "0:0", "1000:1000"). Numeric IDs are safest.

Explicit Manifest

{
  "preset": "ubuntu-router",
  "profile": "ubuntu",
  "ids": ["default", "router", "private"],
  "includes": [
    "router_iptables.rules",
    {
      "src": "motd",
      "dest": "/etc/update-motd.d/99-custom",
      "mode": "0755"
    },
    {
      "src": ".env",
      "dest": "/etc/app/config.env",
      "mode": "0600",
      "owner": "1000:1000"
    },
    {
      "src": "scripts",
      "dest": "/usr/local/bin",
      "tree": true,
      "mode": "0755"
    },
    {
      "src": "bin",
      "dest": "/opt/app/bin",
      "tree": true,
      "preserve_mode": true,
      "owner": "1000:1000"
    }
  ]
}

Object fields:

Field Type Default Description
src string Required Source name in include/ (without {id}_)
dest string include_dest Destination path in VM
mode string from defaults File permissions (octal string)
owner string from defaults uid:gid or user:group
tree bool false Recursively copy directory
preserve_mode bool false Use mode from source filesystem

Tree Copy

When tree: true, src is a directory. Structure is preserved:

samples/ubuntu/include/private_scripts/
  backup.sh
  restore.sh
  config/settings.json
{"src": "scripts", "dest": "/opt/app/scripts", "tree": true, "mode": "0755"}

Result:

/opt/app/scripts/backup.sh
/opt/app/scripts/restore.sh
/opt/app/scripts/config/settings.json

Preserve Mode

preserve_mode: true copies mode from source filesystem. Explicit mode or owner still override:

{
  "src": "bin",
  "dest": "/opt/app/bin",
  "tree": true,
  "preserve_mode": true,
  "owner": "1000:1000"
}

Binary Files

Include files are always base64-encoded in user-data. This ensures binary files (SSH keys, compiled binaries, archives) are reproduced exactly.


Network Configuration

Both v1 and v2 use the same unified interfaces format. The builder translates it automatically. Only network_version (default: 1) determines the output format.

Unified Interface Format

"interfaces": [
  {
    "name": "ens18",
    "type": "dhcp",
    "dhcp4-overrides": {"use-routes": false}
  },
  {
    "name": "ens19",
    "type": "static",
    "address": "10.0.0.10",
    "prefix": 24,
    "gateway": "10.0.0.1"
  }
]

Common fields:

Field Description
name Interface name (required)
type dhcp, static, physical, bond, bridge, vlan
address IP address (for static)
prefix CIDR prefix (for static, default: 24)
netmask Dotted netmask (for static v1, default: 255.255.255.0)
gateway Default gateway (for static)
dns DNS servers (interface-level)
domain Search domain (interface-level)

Type behavior:

  • dhcp → v1: subnets: [{type: dhcp}]; v2: dhcp4: true
  • static → v1: subnets: [{type: static, address, netmask, gateway}]; v2: addresses, gateway4
  • physical/bond/bridge/vlan → passthrough as-is

All other fields are copied verbatim.

v1 — Universal

Works on all distros.

DHCP:

"params": {
  "network_version": 1,
  "interfaces": [
    {
      "name": "ens18",
      "type": "dhcp",
      "post-up": "ip route del default dev ens18 || true"
    }
  ]
}

Static:

"params": {
  "network_version": 1,
  "interfaces": [
    {
      "name": "ens18",
      "type": "static",
      "address": "10.0.0.10",
      "netmask": "255.255.255.0",
      "gateway": "10.0.0.1"
    }
  ]
}

Bond:

"params": {
  "network_version": 1,
  "interfaces": [
    {"name": "eth0", "type": "physical"},
    {"name": "eth1", "type": "physical"},
    {
      "name": "bond0",
      "type": "bond",
      "bond_interfaces": ["eth0", "eth1"],
      "params": {"mode": "active-backup"},
      "subnets": [
        {"type": "static", "address": "10.0.0.10", "netmask": "255.255.255.0"}
      ]
    }
  ]
}

Top-level routes:

"params": {
  "network_version": 1,
  "interfaces": [
    {"name": "ens18", "type": "static", "address": "10.0.0.10"}
  ],
  "routes": [
    {"network": "0.0.0.0", "netmask": "0.0.0.0", "gateway": "10.0.0.1", "metric": 100}
  ]
}

Raw passthrough:

"params": {
  "network_config": {
    "version": 1,
    "config": [
      {"type": "physical", "name": "ens18", "subnets": [{"type": "dhcp"}]},
      {"type": "route", "network": "10.0.0.0", "netmask": "255.0.0.0", "gateway": "10.0.0.254"}
    ]
  }
}

Nameserver:

  • Generated only if dns is explicitly set in params.
  • Suppress with "no_auto_nameserver": true.
"params": {
  "dns": "8.8.8.8,1.1.1.1",
  "domain": "example.com",
  "interfaces": [...]
}

Extra config entries:

"params": {
  "interfaces": [...],
  "extra_config_entries": [
    {"type": "route", "network": "192.168.100.0", "netmask": "255.255.255.0", "gateway": "10.0.0.254"}
  ]
}

v2 — Netplan

Full passthrough to Netplan. Any valid Netplan field works.

DHCP with overrides:

"params": {
  "network_version": 2,
  "interfaces": [
    {
      "name": "ens18",
      "type": "dhcp",
      "dhcp4-overrides": {"use-routes": false}
    }
  ]
}

Policy routing (multi-WAN):

"params": {
  "network_version": 2,
  "interfaces": [
    {
      "name": "ens18",
      "type": "dhcp",
      "routes": [
        {"to": "0.0.0.0/0", "via": "192.168.10.1", "table": 200}
      ],
      "routing-policy": [
        {"from": "192.168.10.11", "table": 200}
      ]
    }
  ]
}

Advanced features:

"params": {
  "network_version": 2,
  "interfaces": [
    {
      "name": "ens18",
      "type": "dhcp",
      "mtu": 9000,
      "wakeonlan": true,
      "nameservers": {
        "addresses": ["8.8.8.8", "2001:4860:4860::8888"]
      }
    }
  ]
}

Important: Netplan fields must use correct types. dhcp4-overrides is an object:

// Correct
"dhcp4-overrides": {"use-routes": false}

// Wrong
"dhcp4-overrides": [{"use-routes": false}]

Users and Authentication

To create a user, set ciuser in params:

{
  "preset": "ubuntu-web",
  "profile": "ubuntu",
  "ids": ["default"],
  "params": {
    "hostname": "web-01",
    "ciuser": "admin",
    "password": "changeme123",
    "ssh_pwauth": false
  }
}

Generated user-data:

users:
  - name: admin
    groups: sudo
    shell: /bin/bash
    sudo: ["ALL=(ALL) NOPASSWD:ALL"]
    ssh_authorized_keys: ["ssh-rsa AAAA..."]
    plain_text_passwd: changeme123
    lock_passwd: false
ssh_pwauth: false

Without ciuser — no users block is generated. You control users entirely through scripts.


CLI Reference

python3 build.py [OPTIONS]

Config Modes

Option Description
-a, --all Build all presets from config
--config-json JSON Pass config as JSON string (highest priority)
-c, --config FILE Main config file (default: main.json)
--no-config Ignore main.json even if it exists
--conf-d DIR Config directory to merge (default: conf.d)
--conf-d-build PATH Build only from this conf.d file or directory

Build Selection

Option Description
--preset LIST Build specific preset(s), comma-separated
--profile LIST Filter by profile, comma-separated
--ids LIST Override IDs, comma-separated

Inline Build Params

Option Type Description
--profile PROFILE string Profile subdirectory in samples/
--ids LIST string Script IDs to include, comma-separated
--hostname HOST string System hostname
--ip IP string IP with CIDR or dhcp
--gateway IP string Default gateway
--dns SERVERS string DNS servers, comma-separated
--domain DOMAIN string DNS search domain
--interface IFACE string Interface name (default: eth0)
--ciuser USER string Create user with sudo access
--password PASS string Plain-text password for ciuser
--ssh-key PATH string SSH public key path
--ssh-pwauth BOOL bool Enable SSH password auth (true/false)
--disable-root BOOL bool Disable root login (true/false)
--packages LIST string Packages to install, comma-separated
--timezone TZ string System timezone
--growpart flag Auto-expand root partition
--package-update flag Run package update
--package-upgrade flag Run package upgrade
--network-version VER int Network version: 1 or 2
--interfaces JSON string Network interfaces as JSON string
--script PATH path Additional script (priority 99)
--include PATH path Include file to copy into VM
--include-dest PATH path Destination for --include
--ignore-missing flag Skip build if IDs not found

Output Control

Option Description
--output-dir DIR Override output directory
--no-iso Generate files only, skip ISO
--no-clean Skip auto-clean of output dir
--no-postbuild Skip postbuild scripts
--postbuild SCRIPT Run postbuild script after ISO creation
--dry-run Preview without generating
-l, --list List available presets and exit
-v, --verbose Verbose output
-q, --quiet Quiet output (errors and summary only)

Config Modes Examples

# Show help (default, no args)
python3 build.py

# Build all presets from main.json + conf.d/
python3 build.py -a

# Pass config as JSON string (no file needed)
python3 build.py --config-json '{"builds":[{"preset":"web","profile":"ubuntu","ids":["default"],"params":{"hostname":"web1"}}]}'

# Ignore existing main.json, force inline mode
python3 build.py --no-config --profile ubuntu --ids default --hostname test

# Build from a specific conf.d directory only
python3 build.py -a --conf-d-build conf.d/production

# Build from a single conf.d file
python3 build.py -a --conf-d-build conf.d/routers.json

Build Selection Examples

# Build specific preset
python3 build.py --preset ubuntu-router

# Build multiple presets at once
python3 build.py --preset ubuntu-router,debian-db

# Build only Ubuntu and Debian presets
python3 build.py -a --profile ubuntu,debian

# Override IDs for all matching builds
python3 build.py -a --ids default,router

# Combine filters
python3 build.py -a --profile ubuntu --preset ubuntu-router,ubuntu-web

# Preview what would be built
python3 build.py -a --dry-run

# List all presets without building
python3 build.py --list

Inline Build Examples

# Minimal inline
python3 build.py --profile ubuntu --ids default     --hostname myvm --ip 10.0.0.10/24 --gateway 10.0.0.1

# DHCP
python3 build.py --profile ubuntu --ids default     --hostname myvm --ip dhcp

# With user and password
python3 build.py --profile ubuntu --ids default     --hostname myvm --ciuser admin --password changeme123     --ip 10.0.0.10/24 --gateway 10.0.0.1

# Full inline with packages and custom script
python3 build.py --no-config     --profile ubuntu --ids default     --hostname router --ip dhcp     --ciuser admin --password changeme     --packages nginx,iptables-persistent     --timezone Europe/Moscow     --growpart --package-update     --script ./custom-setup.sh     --include ./motd --include-dest /etc/update-motd.d/99-custom

# Multiple interfaces via JSON
python3 build.py --profile ubuntu --ids default     --hostname router     --interfaces '[{"name":"ens18","type":"dhcp"},{"name":"ens19","type":"static","address":"192.168.50.10","netmask":"255.255.255.0"}]'

# Multiple custom scripts
python3 build.py --no-config     --profile ubuntu --ids default     --hostname test     --script ./01-base.sh     --script ./10-network.sh     --script ./50-app.sh

Output Control Examples

# Verbose: show merged params, resolved scripts/includes
python3 build.py -a -v

# Quiet: only errors, [OK], and summary
python3 build.py -a -q

# Files only, no ISO
python3 build.py -a --no-iso

# Skip auto-clean
python3 build.py -a --no-clean

# Skip postbuild even if configured
python3 build.py -a --no-postbuild

# Custom postbuild
python3 build.py -a --postbuild postbuild/deploy-to-proxmox.sh

Postbuild

Postbuild scripts run after ISO creation with environment variables. Priority: CLI > build > global.

Global postbuild:

{
  "global": {
    "postbuild": "postbuild/deploy-all.sh"
  }
}

Per-build postbuild:

{
  "preset": "ubuntu-router",
  "postbuild": "postbuild/deploy-router.sh"
}

CLI postbuild:

python3 build.py -a --postbuild postbuild/deploy-to-proxmox.sh

Disable postbuild:

python3 build.py -a --no-postbuild

Postbuild script example:

#!/bin/bash
# postbuild/deploy-to-proxmox.sh

echo "Deploying $PRESET_NAME ($PROFILE_NAME)"
echo "ISO: $ISO_PATH"

# scp "$ISO_PATH" root@proxmox:/var/lib/vz/template/iso/
# qm create 9000 --name "$PRESET_NAME" --memory 2048 --net0 virtio,bridge=vmbr0
# qm set 9000 --ide2 local:iso/$(basename "$ISO_PATH"),media=cdrom

Environment variables:

  • ISO_PATH — full path to generated ISO
  • PRESET_NAME — build preset name
  • PROFILE_NAME — profile from build definition
  • OUTPUT_DIR — output directory path

Connecting ISO to Proxmox

The generated ISO is a regular CD-ROM. Proxmox does not know it is cloud-init.

# Attach as IDE CD-ROM
qm set <vmid> --ide2 local:iso/{preset-name}.iso,media=cdrom

# Or SATA
qm set <vmid> --sata0 local:iso/{preset-name}.iso,media=cdrom

Via GUI: Hardware → Add → CD/DVD Drive → select ISO.

Inside the VM, cloud-init detects the cidata label and processes user-data, meta-data, and network-config.


Debugging

Check cloud-init logs inside VM

cat /var/log/cloud-init.log
cat /var/log/cloud-init-output.log
cloud-init status --long
cloud-init single --name scripts_user

Verify ISO contents

mkdir /tmp/iso
mount -o loop {preset-name}.iso /tmp/iso
ls /tmp/iso/
cat /tmp/iso/user-data
cat /tmp/iso/network-config
ls /tmp/iso/scripts/

Manual script execution

bash /opt/provision/01-base.sh
bash /opt/provision/10-network.sh

Test network config (Netplan)

netplan generate
netplan apply

Common issues

Scripts fail silently: Check set -e wrapping in user-data. The builder wraps each script automatically.

Network restart drops SSH: Use nohup or at for network restarts in scripts.

Packages not found: Ensure apt update succeeds before apt install. Use apt-get in scripts.

SSH key not applied: Ensure ciuser is set and default_ssh_key points to an existing file.

Include checksum mismatch: Include files are base64-encoded in user-data to ensure exact byte reproduction. If checksums differ, verify the source file in samples/{profile}/include/.