nullpoint/build.py
2025-05-13 17:32:06 +02:00

236 lines
7.8 KiB
Python

#!/usr/bin/env python3
import os
import sys
import yaml
import subprocess
import json
import shutil
from pathlib import Path
import tempfile
def load_config(config_file):
"""Load and parse YAML config file."""
with open(config_file, 'r') as f:
return yaml.safe_load(f)
def check_prerequisites():
"""Check if required tools are installed."""
required_tools = ['hcloud', 'hcloud-upload-image', 'jq', 'virt-customize', 'cloud-localds']
for tool in required_tools:
if not shutil.which(tool):
print(f"Error: {tool} not found. Please install it first.")
sys.exit(1)
def check_hetzner_token():
"""Check if HCLOUD_TOKEN is set."""
if not os.environ.get('HCLOUD_TOKEN'):
print("Error: HCLOUD_TOKEN environment variable not set")
sys.exit(1)
def generate_cloud_init(config):
"""Generate cloud-init configuration."""
system = config['system']
cloud_init = system['cloud_init']
tpm_config = system['luks']['tpm']
# Generate TPM config
tpm_json = {
'pcr_bank': tpm_config['pcr_bank'],
'pcr_ids': ','.join(map(str, tpm_config['pcr_ids']))
}
# Base cloud-init config
cloud_config = {
'timezone': cloud_init['timezone'],
'users': cloud_init['users'],
'package_update': True,
'package_upgrade': True,
'packages': cloud_init['packages'],
'write_files': [
{
'path': '/etc/clevis/tang.conf',
'content': f"URL={system['luks']['tang_url']}\nThumbprint={system['luks']['tang_thumbprint']}\n",
'permissions': '0644'
},
{
'path': '/etc/clevis/tpm2.conf',
'content': json.dumps(tpm_json),
'permissions': '0644'
},
{
'path': '/root/update-tpm-bindings.sh',
'content': '''#!/bin/bash
# Script to update TPM bindings after firmware updates
# Usage: sudo ./update-tpm-bindings.sh
set -e
# Check if running as root
if [ "$EUID" -ne 0 ]; then
echo "Please run as root"
exit 1
fi
# Check if TPM is available
if ! tpm2_getcap properties-fixed > /dev/null; then
echo "Error: TPM not available"
exit 1
fi
# Check if Tang server is accessible
TANG_URL=$(grep URL /etc/clevis/tang.conf | cut -d= -f2)
if ! curl -s -f "$TANG_URL/adv" > /dev/null; then
echo "Error: Tang server not accessible"
exit 1
fi
# Get current PCR values
echo "Current PCR values:"
tpm2_pcrread sha256:$(cat /etc/clevis/tpm2.conf | jq -r '.pcr_ids' | tr -d '[]' | tr ',' ' ')
# Ask for confirmation
read -p "Have you updated firmware? Continue with TPM binding update? [y/N] " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Aborted"
exit 1
fi
# Update TPM bindings
echo "Updating TPM bindings..."
for dev in /dev/sda2 /dev/sdb2; do
echo "Processing $dev..."
# Unbind old TPM binding
clevis luks unbind -d "$dev" -s 1 || true
# Create new TPM binding
clevis luks bind -d "$dev" tpm2 -c /etc/clevis/tpm2.conf || {
echo "Error: Failed to bind TPM to $dev"
exit 1
}
# Verify Tang binding
if ! clevis luks list -d "$dev" | grep -q "tang"; then
echo "Error: Tang binding not found on $dev"
exit 1
fi
done
echo "TPM bindings updated successfully!"
echo "Please reboot to verify the changes."
''',
'permissions': '0700'
}
],
'runcmd': [
# Check if passphrase is set
'[ -n "$LUKS_PASSPHRASE" ] || { echo "Error: LUKS passphrase not found"; exit 1; }',
# Verify Tang server is accessible
f'curl -s -f {system["luks"]["tang_url"]}/adv > /dev/null || {{ echo "Tang server not accessible"; exit 1; }}',
# Verify TPM is available
'tpm2_getcap properties-fixed > /dev/null || { echo "TPM not available"; exit 1; }',
# Create filesystem
'mkfs.btrfs -f -d raid1 -m raid1 /dev/sda2 /dev/sdb2',
'mount /dev/sda2 /mnt',
# Create subvolumes
'btrfs subvolume create /mnt/@boot',
'btrfs subvolume create /mnt/@home',
'btrfs subvolume create /mnt/@db',
'chattr +C /mnt/@db',
# Setup LUKS with escaped passphrase
'echo -n "${LUKS_PASSPHRASE}" | tr -d "\n" | cryptsetup luksFormat /dev/sda2 --type luks2 --key-file -',
'echo -n "${LUKS_PASSPHRASE}" | tr -d "\n" | cryptsetup luksFormat /dev/sdb2 --type luks2 --key-file -',
# Setup Clevis with error handling
'clevis luks bind -d /dev/sda2 tpm2 -c /etc/clevis/tpm2.conf || { echo "TPM bind failed"; exit 1; }',
'clevis luks bind -d /dev/sda2 tang -c /etc/clevis/tang.conf || { echo "Tang bind failed"; exit 1; }',
'clevis luks bind -d /dev/sdb2 tpm2 -c /etc/clevis/tpm2.conf || { echo "TPM bind failed"; exit 1; }',
'clevis luks bind -d /dev/sdb2 tang -c /etc/clevis/tang.conf || { echo "Tang bind failed"; exit 1; }',
# Setup fstab
'echo "/dev/mapper/root / btrfs compress=zstd 0 0" >> /etc/fstab',
'echo "/dev/mapper/root /boot btrfs subvol=@boot,compress=zstd 0 0" >> /etc/fstab',
'echo "/dev/mapper/root /home btrfs subvol=@home,compress=zstd 0 0" >> /etc/fstab',
'echo "/dev/mapper/root /db btrfs subvol=@db,nodatacow,noatime,compress=zstd 0 0" >> /etc/fstab',
# Enable services
'systemctl enable clevis-luks-askpass.service',
'systemctl enable clevis-luks-askpass.path'
]
}
return yaml.dump(cloud_config)
def download_fedora_image(config):
"""Download Fedora Server cloud image."""
image = config['image']
url = f"https://download.fedoraproject.org/pub/fedora/linux/releases/{image['version']}/Cloud/x86_64/images/Fedora-Cloud-Base-{image['version']}-1.6.x86_64.qcow2"
cmd = ['curl', '-L', '-o', 'fedora-server.qcow2', url]
subprocess.run(cmd, check=True)
def customize_image(config):
"""Customize the Fedora Server image."""
# Generate cloud-init config
cloud_init = generate_cloud_init(config)
# Create cloud-init ISO
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml') as f:
f.write(cloud_init)
f.flush()
subprocess.run(['cloud-localds', 'cloud-init.iso', f.name], check=True)
# Customize image
cmd = [
'virt-customize',
'-a', 'fedora-server.qcow2',
'--selinux-relabel',
'--run-command', 'dnf clean all',
'--run-command', 'dnf -y update',
'--run-command', 'dnf -y install cloud-init',
'--copy-in', 'cloud-init.iso:/var/lib/cloud/seed/nocloud/'
]
subprocess.run(cmd, check=True)
def create_snapshot(config):
"""Create new snapshot from image."""
image = config['image']
cmd = [
'hcloud-upload-image', 'upload',
'--architecture', image['hetzner_arch'],
'--compression', 'xz',
'--image-path', 'fedora-server.qcow2',
'--name', image['name'],
'--labels', f'os=fedora-server,version={image["version"]}',
'--description', f'Fedora Server {image["version"]} for Nullpoint'
]
subprocess.run(cmd, check=True)
def main():
"""Main entry point."""
# Load config
if not os.path.exists('build-config.yaml'):
print("Error: build-config.yaml not found")
sys.exit(1)
config = load_config('build-config.yaml')
# Check prerequisites
check_prerequisites()
check_hetzner_token()
# Download Fedora Server image
print("Downloading Fedora Server image...")
download_fedora_image(config)
# Customize image
print("Customizing image...")
customize_image(config)
# Create snapshot
print("Creating snapshot...")
create_snapshot(config)
print("Image build complete!")
if __name__ == '__main__':
main()