- Architecture Overview
- Design Decisions
- Module Reference
- Data Flow
- Cloud-init Integration
- Cross-Profile Strategy
- Security Considerations
- Extending the Builder
┌─────────────────────────────────────────┐
│ CLI Frontend (build.py) │ ← Argument parsing, orchestration
├─────────────────────────────────────────┤
│ Config Layer (lib/config.py) │ ← JSON loading, conf.d merging
├─────────────────────────────────────────┤
│ Build Engine (lib/builder.py) │ ← Preset processing, cloud-init generation
├─────────────────────────────────────────┤
│ Resolution Layer (lib/scripts.py) │ ← Script matching, include handling
│ Network Layer (lib/network.py) │ ← v1/v2 config generation
├─────────────────────────────────────────┤
│ Output Layer (lib/iso.py) │ ← YAML writing, ISO creation
└─────────────────────────────────────────┘
Key principle: The CLI frontend is thin. All logic lives in lib/ for testability and reuse.
Proxmox's built-in cloud-init only supports: user, password, DNS, IP, SSH key, package upgrade. Real provisioning needs custom packages, files, commands, routing policies, etc.
NoCloud ISO is a regular CD-ROM with cidata label. Cloud-init auto-detects it. Proxmox sees it as a normal ISO.
Trade-offs:
- ✅ Works offline, no network dependencies
- ✅ Compatible with any hypervisor
- ✅ Full control over cloud-init modules
- ❌ No dynamic metadata (instance identity tokens)
- ❌ Static files only
Cloud-init modules vary across distros:
packagesmodule: works on apt/dnf, broken on apk (Alpine)runcmdwith inline commands: universal but unreadable for complex logicwrite_files+runcmd: universal, readable, debuggable
Decision: Write scripts to /opt/provision/ via write_files, then execute via runcmd.
Benefits:
- Cross-distro compatibility (bash works everywhere)
- Scripts exist on disk after provisioning — easy debugging
- No dependency on cloud-init module availability
- Version-controllable provisioning logic
| Format | Supported by | Features |
|---|---|---|
| v1 | All distros | Static, DHCP, bonds, bridges, VLANs, routes, passthrough |
| v2 (Netplan) | Ubuntu/Debian only | Policy routing, tables, bonds, VLANs |
v1 is translated by cloud-init to NetworkManager, ifupdown, or sysconfig depending on the distro. v2 requires Netplan.
Decision: v1 is default for universal compatibility. v2 is opt-in via "network_version": 2.
Unified interface format: Both v1 and v2 accept the same interfaces list. The builder translates type: "dhcp" and type: "static" to the appropriate format, while passing all other fields verbatim.
YAML has implicit type conversion: yes → True, 10:30 → time object, off → False. JSON is explicit and universally parseable.
Trade-off: Slightly more verbosity, zero ambiguity.
Scenario: base setup (default) + network override (2nets). Instead of duplicating all scripts, specify ids: ["default", "2nets"]. The builder collects all matching scripts and resolves same-priority conflicts by ID position (later wins).
This enables composition over inheritance.
As the number of presets grows, main.json becomes unwieldy. Splitting into conf.d/*.json allows:
- Team members to own different preset files
- Git-friendly diffs
- Easy environment-specific overrides
Files are sorted alphabetically and appended after main.json builds.
Environment-specific settings (timezone, DNS, postbuild) often need to be applied across all builds in a conf.d file. Instead of duplicating them in every build definition, a global section in conf.d/*.json can override or extend the main global config.
Merge strategy:
paramsanddefault_include_permissions: deep-merge (later files override earlier ones)- All other keys: shallow merge (later files overwrite earlier ones)
Netplan is actively developed. New features appear regularly. Instead of maintaining a mapping of every Netplan field, the builder copies interface definitions verbatim (with minimal conversion for name and type).
This means users can use any Netplan feature immediately, without waiting for a builder update.
Early versions hardcoded users, ssh_pwauth, disable_root, growpart, package_update, package_upgrade into every user-data. This caused issues:
- Unwanted user creation when the admin wanted custom user management
package_updaterunning beforeruncmdand breaking dpkg state- Bloated configs for minimal deployments
Decision: Only include cloud-init modules when explicitly configured in params.
Not every environment has a persistent main.json. CI/CD pipelines, one-off tests, and quick provisioning often need ad-hoc ISO generation. The builder supports full inline parameter passing via CLI, including:
- All cloud-init modules (users, packages, timezone, growpart, etc.)
- Network configuration (v1 and v2)
- Custom scripts and includes
- JSON config via
--config-jsonfor ephemeral environments
This makes the builder usable as a standalone tool without any configuration files.
Role: CLI frontend and orchestrator.
Responsibilities:
- Parse CLI arguments
- Load
main.jsonvialib.config.load_config() - Merge
conf.d/vialib.config.merge_conf_d() - Instantiate
lib.builder.Builder - Iterate over builds, apply filters (
--preset,--profile,--conf-d-build) - Handle inline builds (no config)
- Handle
--config-json(ephemeral config) - Auto-clean output directory
- Print summary
No build logic here. All heavy lifting is delegated to lib/builder.py.
# Entry point flow
main()
├── print_help() # if no args
├── argparse # parse CLI
├── load_config() # load main.json (unless --no-config or --config-json)
├── merge_conf_d() # merge conf.d/*.json (or single file for --conf-d-build)
├── Builder() # create builder instance
├── builder.build_preset() # for each build
└── print summaryCLI modes:
build.py(no args) → helpbuild.py -a→ build all from configbuild.py --preset X,Y→ build specific preset(s)build.py --conf-d-build PATH→ build from specific conf.d file/dirbuild.py --config-json '{...}'→ ephemeral configbuild.py --no-config --profile X --ids Y ...→ pure inline build
Inline parameter parsing:
_parse_inline_params(args)converts CLI flags toparamsdict- Supports all cloud-init modules: hostname, ip, gateway, dns, domain, interface, ciuser, password, ssh_key, ssh_pwauth, disable_root, packages, timezone, growpart, package_update, package_upgrade, network_version, interfaces
--scriptcan be specified multiple times for extra scripts--includecan be specified multiple times for extra includes
Role: Configuration loading and merging.
Functions:
Loads main.json and returns (global_cfg, builds_list).
global_cfg, builds = load_config('main.json')
# global_cfg: dict with output_dir, iso_label, etc.
# builds: list of build definitionsMerges JSON files from conf.d/ (or a single file) into the global config and builds list.
- Accepts a directory or a single file path
- Files are sorted alphabetically
- Each file can contain:
- A list of build definitions
- A single build definition (dict)
- A dict with
globaland/orbuildskeys
globalsections are merged: deep-merge forparamsanddefault_include_permissions, shallow for othersbuildsare appended aftermain.jsonbuilds
# Merge entire conf.d directory
global_cfg, builds = merge_conf_d('conf.d', global_cfg, builds)
# Merge a single file (for --conf-d-build)
global_cfg, builds = merge_conf_d('conf.d/production.json', global_cfg, builds)Role: Script resolution and include handling.
Parses {priority}-{name}_{id}.sh into a dict:
{
'priority': 10,
'name': 'network',
'id': 'router',
'filename': '10-network_router.sh'
}Returns None for invalid filenames.
Finds scripts matching any of the given ids.
Search order:
samples/{profile_name}/samples/common/
Conflict resolution:
- Group by
priority - If multiple scripts share a priority, the one whose
idappears later inidswins - Prints
[CONFLICT]message when resolving
Returns: (resolved_scripts, missing_ids)
scripts, missing = resolve_scripts('samples', 'ubuntu', ['default', 'router'])Resolves include files based on manifest and/or auto-discovery.
Parameters:
samples_dir— path to samples directoryprofile_name— profile subdirectory nameids— list of IDs to matchinclude_manifest— list of include directives (strings or dicts), optionaldefault_perms— dict ofdefault_include_permissions, optionalinclude_dest— default destination path, default/opt/provision/include
Returns: (entries, missing_ids)
entries: list of dicts forwrite_filesentriesmissing_ids: set of IDs with no includes found
Resolution flow:
- Parse filenames via
parse_include_filename()— first_is separator - Process manifest entries in order:
- String: find source by
src_nameacross IDs (last ID wins), copy toinclude_dest - Object: extract
src,dest,mode,owner,tree,preserve_mode - If
tree=Trueor source is directory: recurse via_collect_tree_files()
- String: find source by
- Auto-discover remaining files not in manifest, applying conflict resolution (last ID wins)
- Resolve permissions per file via
_resolve_include_permissions():- Explicit
mode/ownerfrom manifest entry preserve_mode=True→ mode from filesystem- Exact filename match in
default_perms - Extension match in
default_perms *fallback
- Explicit
Permission format:
- String value:
"0755"→ mode only - Dict value:
{"mode": "0755", "owner": "0:0"}→ both - Owner is always
uid:gidstring, passed to cloud-initwrite_files.owner
Role: Network configuration generation for cloud-init.
Key design: Both v1 and v2 use the same unified interfaces list. The builder translates type: "dhcp" and type: "static" to the appropriate format, while passing all other fields verbatim.
Converts CIDR prefix (e.g., 24) to dotted netmask (255.255.255.0).
cidr_to_netmask(24) # -> '255.255.255.0'Parses and normalizes the unified interfaces list from params. Returns a list of dicts.
Converts a unified interface dict to a v1 config entry.
Translation rules:
type: "dhcp"→type: "physical",subnets: [{"type": "dhcp"}]type: "static"→type: "physical",subnets: [{"type": "static", "address", "netmask", "gateway"}]type: "physical"/"bond"/"bridge"/"vlan"→ passthroughtypeas-is- All other fields copied verbatim
routesinside interface: wrapped intosubnets[0]withnetwork/netmask/gatewaynormalization
Converts a unified interface dict to a v2 (Netplan) ethernet config.
Translation rules:
type: "dhcp"→dhcp4: truetype: "static"→addresses,gateway4(ifaddress/gatewayprovided)type: "physical"/"bond"/"bridge"/"vlan"→ passthrough as-is- All other fields copied verbatim
dns/domain→nameservers.addresses/nameservers.search
Generates network-config version 1 (universal format).
Modes:
- Raw passthrough:
params['network_config']withversion: 1→ returned as-is. - Unified interfaces:
params['interfaces']list → translated via_v1_entry_from_interface(). - Legacy single interface:
params['ip']/params['interface']/params['gateway'].
Additional features:
params['routes']→ top-leveltype: routeentriesparams['extra_config_entries']→ appended verbatim- Nameserver generated only if
params['dns']is explicitly set (andno_auto_nameserveris not True)
Output:
{
'version': 1,
'config': [
{'type': 'physical', 'name': 'ens18', 'subnets': [...]},
{'type': 'route', 'network': '0.0.0.0', 'netmask': '0.0.0.0', 'gateway': '10.0.0.1'},
{'type': 'nameserver', 'address': ['8.8.8.8'], 'search': ['local']}
]
}Generates network-config version 2 (Netplan, Ubuntu/Debian only).
Modes:
- Unified interfaces:
params['interfaces']list → translated via_v2_entry_from_interface(). - Raw Netplan dict:
params['interfaces']as dict withethernetskey → returned as-is. - Fallback: no interfaces → delegates to
generate_network_config_v1().
Output:
{
'version': 2,
'ethernets': {
'ens18': {'dhcp4': True, ...},
'ens19': {'addresses': ['10.0.0.10/24'], ...}
}
}Role: YAML writing and ISO creation.
Writes a Python dict to YAML.
- Uses
yaml.dump()withdefault_flow_style=Falsefor readability allow_unicode=Truefor non-ASCII supportsort_keys=Falseto preserve insertion order- Optional header (e.g.,
#cloud-config)
create_iso(preset_name, user_data, meta_data, network_config, rendered_scripts, includes, output_dir, iso_label)
Creates the output directory structure and ISO file.
Steps:
- Create
output/{preset}/directory - Write
user-data,meta-data,network-configas YAML - Write rendered scripts to
output/{preset}/scripts/ - Write includes to
output/{preset}/scripts/include/ - Generate ISO using
genisoimageormkisofs
ISO command:
genisoimage \
-output output/{preset}.iso \
-volid cidata \
-joliet -rock \
-input-charset utf-8 \
-graft-points \
user-data=output/{preset}/user-data \
meta-data=output/{preset}/meta-data \
network-config=output/{preset}/network-config \
scripts/=output/{preset}/scripts/Fallback: If genisoimage/mkisofs is not found, files are still written to disk with instructions for manual ISO creation.
Role: Core build engine.
Constructor:
Builder(samples_dir, output_dir, iso_label, default_ssh_key, global_cfg=None)Methods:
Replaces {{key}} placeholders in script content with values from params.
content = "echo {{hostname}}"
render_template(content, {'hostname': 'web-01'})
# -> "echo web-01"Loads SSH public key from file. Expands ~ to home directory.
Executes a postbuild script with environment variables set.
Generates all cloud-init artifacts.
Steps:
- Render each script with
render_template() - Build
write_filesentries for scripts and includes- Includes are base64-encoded to preserve binary data exactly
- Build
runcmdentries withset -ewrapping and logging - Construct
user_datadict dynamically:usersblock only ifciuseris setpasswordattached to user entry if sethostname/fqdn/manage_etc_hostsifhostnameis setgrowpart,package_update,package_upgrade,timezone,packages,ntponly if explicitly configuredwrite_filesandruncmdalways included
- Construct
meta_data:instance-id:{preset_name}(no prefix)local-hostname:params.hostnameorvm-{preset_name}
- Generate
network_configvia v1 or v2
Returns: (user_data, meta_data, network_config, rendered_scripts)
Orchestrates a single preset build.
Steps:
- Extract
preset,profile,idsfrompreset_def - Resolve scripts via
resolve_scripts()withignore_missing=True(always) - Resolve includes via
resolve_includes() - Handle missing IDs:
- IDs with includes but no scripts →
[INFO]log, build continues - IDs with neither scripts nor includes →
[BLOCK]ifignore_missing_ids=false
- IDs with includes but no scripts →
- Apply overrides (
disable,extra_files) - Merge
default_include_permissions:global→build(build wins) - Resolve includes via
resolve_includes()with manifest and default permissions - Merge params:
global.params→build.params - Load SSH key if
ciuseris set - Generate cloud-init configs via
generate_cloudinit() - Write files or create ISO via
create_iso() - Run postbuild if configured and not disabled by
--no-postbuild
Postbuild priority: CLI --postbuild > build.postbuild > global.postbuild. --no-postbuild disables all.
Returns: True on success, False on failure.
main.json + conf.d/*.json
│
▼
┌─────────────────┐
│ load_config() │ → (global_cfg, builds)
└─────────────────┘
│
▼
┌─────────────────┐
│ merge_conf_d() │ → (global_cfg, builds) extended
└─────────────────┘
│
▼
┌─────────────────┐ ┌──────────────────┐
│ build_preset() │────→│ resolve_scripts()│ → scripts[]
│ │ └──────────────────┘
│ │ ┌──────────────────┐
│ │────→│ resolve_includes()│ → includes[]
│ │ └──────────────────┘
│ │ ┌──────────────────────┐
│ │────→│ generate_cloudinit() │ → user_data, meta_data, network_config
│ │ └──────────────────────┘
│ │ ┌──────────────────┐
│ │────→│ create_iso() │ → .iso file
│ │ └──────────────────┘
└─────────────────┘
- VM boots with ISO mounted as CD-ROM
- Cloud-init scans for volume label
cidata - Reads
user-data,meta-data,network-configfrom ISO root - Processes modules in order:
write_files→ creates files on diskruncmd→ executes commands
#cloud-config
# [optional] users block (only if ciuser set)
users:
- name: admin
groups: sudo
shell: /bin/bash
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
ssh_authorized_keys: ["ssh-rsa ..."]
# [optional] hostname block (only if hostname set)
hostname: router1
fqdn: router1
manage_etc_hosts: true
# [optional] cloud-init modules (only if explicitly set)
timezone: UTC
package_update: true
package_upgrade: true
# [always] scripts and includes
write_files:
- path: /opt/provision/01-disk.sh
content: "#!/bin/bash\n..."
permissions: '0755'
- path: /opt/provision/include/motd
content: "V2VsY29tZSEK"
encoding: b64
permissions: '0644'
# [always] execution
runcmd:
- bash -c 'set -e; echo "[cloud-init] Running 01-disk.sh"; bash /opt/provision/01-disk.sh; echo "[cloud-init] 01-disk.sh done"'Without set -e, bash continues after errors. A failed apt install might leave the system in an inconsistent state. With set -e, the script stops on first error, and cloud-init logs the failure.
The wrapper pattern:
bash -c 'set -e; echo "[cloud-init] Running {script}"; bash {path}; echo "[cloud-init] {script} done"'Provides:
- Fail-fast behavior
- Console logging for traceability
- Clear error attribution
Scripts execute in priority order (numeric). The builder sorts by priority before generating runcmd.
Include files are base64-encoded in write_files to ensure exact byte reproduction. This prevents corruption of:
- SSH private keys (PEM format with specific line endings)
- Compiled binaries
- Archives (tar, zip)
- Any file with non-ASCII or non-UTF-8 content
Cloud-init decodes base64 content before writing to disk, preserving the original file exactly.
| Profile | Init | Packages | Network Manager | cloud-init Support |
|---|---|---|---|---|
| Ubuntu/Debian | systemd | apt | systemd-networkd / Netplan | v1 + v2 |
| Rocky/Alma/RHEL | systemd | dnf/yum | NetworkManager | v1 only |
| Alpine | OpenRC | apk | ifupdown | v1 only (limited modules) |
| Arch | systemd | pacman | systemd-networkd | v1 + v2 |
Key insight: Shell scripts in runcmd work on all distros. Network v1 works on all distros. v2 requires Netplan (Ubuntu/Debian only).
Profile-specific adaptations:
sudogroup iswheelon Alpine,sudoeverywhere elseapt-getpreferred overaptin scripts (stable CLI)|| trueused for service commands that may fail on non-systemd systems
Passwords are stored in main.json as plain text. This is intentional — the builder generates plain-text cloud-init configs, and the ISO is not encrypted.
Mitigations:
- Use SSH keys instead of passwords when possible
- Restrict file permissions on
main.json(chmod 600) - Do not commit
main.jsonwith real passwords to version control - Use environment-specific
conf.d/files for sensitive data
The builder reads the SSH public key from default_ssh_key path and embeds it in user-data. The private key never leaves your machine.
The generated ISO contains:
user-datawith all configuration (including passwords if set)meta-datawith instance ID and hostnamenetwork-configwith network settingsscripts/with provisioning scripts
Treat the ISO as sensitive. Do not distribute it publicly if it contains passwords.
Setting disable_root: true in params prevents root login. Recommended for production.
Setting ssh_pwauth: false disables password authentication for SSH. Recommended when using SSH keys.
- Create
samples/{profile_name}/ - Add scripts with appropriate
{id}values - Add builds to
main.jsonorconf.d/
Edit lib/builder.py, method generate_cloudinit(). Add a conditional block:
if 'my_module' in params:
user_data['my_module'] = params['my_module']Edit lib/iso.py, function create_iso(). The ISO is created via genisoimage with -graft-points for flexible file placement.
- Edit
build.py, add argument toargparse - Pass through to
Builderorbuild_preset() - Document in
docs/usage.md
The builder is designed for unit testing:
from lib.scripts import resolve_scripts, parse_script_filename
from lib.network import generate_network_config_v1, generate_network_config_v2
from lib.builder import Builder
# Test script resolution
scripts, missing = resolve_scripts('samples', 'ubuntu', ['default'])
assert len(scripts) > 0
# Test network generation
config = generate_network_config_v1({'ip': '10.0.0.10/24', 'gateway': '10.0.0.1'})
assert config['version'] == 1
# Test builder
builder = Builder('samples', './output', 'cidata', '~/.ssh/id_rsa.pub')
# ...