
Quand un projet Pulumi quitte le stade de la première demo, le premier
problème n'est pas le cloud. C'est souvent un __main__.py qui concentre
la configuration, les ressources, les outputs et les secrets au même endroit.
Ce guide vous montre comment passer a une structure plus propre en Python
avec un module de configuration et un ComponentResource reutilisable,
sans perdre le côté concret du lab KVM/libvirt.
Le scenario a été rejoue le 1 avril 2026 avec une stack dev, une VM
pulumi-config-vm, un réseau pulumi-config-net, un composant
lab:kvm:LibvirtVmStack, puis la boucle complète preview -> up -> virsh -> destroy.
Objectif
Section intitulée « Objectif »Ici, vous n'apprenez pas un nouveau provider. Vous apprenez a mieux ranger un projet Pulumi déjà fonctionnel pour qu'il reste maintenable.
Le résultat vise est simple :
- un point d'entrée qui charge la configuration et exporte les sorties ;
- un fichier
config.pyqui centralise les valeurs de stack ; - un fichier
component.pyqui porte la logique libvirt ; - un workflow valide sans régression sur la création de la VM.
Quand il faut restructurer un projet Pulumi
Section intitulée « Quand il faut restructurer un projet Pulumi »Tant qu'une stack tient en quelques lignes, un fichier unique peut rester acceptable. Le problème commence quand vous ajoutez :
- plusieurs valeurs de config ;
- des secrets ;
- plusieurs ressources liées entre elles ;
- des outputs plus nombreux ;
- l'envie de réutiliser le même bloc dans une autre stack.
Si tout cela vit encore dans __main__.py, la lecture devient lente et le
debug plus fragile. On ne sait plus ce qui releve du contexte de stack, du
provider ou de la logique métier du projet.
Structure cible du projet
Section intitulée « Structure cible du projet »Le lab valide utilise maintenant cette structure :
pulumi-kvm-local/|-- __main__.py|-- Pulumi.dev.yaml|-- Pulumi.yaml|-- requirements.txt`-- pulumi_kvm_local/ |-- __init__.py |-- component.py `-- config.pyCette arborescence a deux avantages immediats :
- vous savez ou chercher une valeur de stack ;
- vous savez ou se trouve la logique qui cree le réseau, le disque, le cloud-init et la VM.
Etape 1 - Créer le paquet Python du projet
Section intitulée « Etape 1 - Créer le paquet Python du projet »Depuis la racine du projet, créez d'abord le dossier qui recevra le code métier :
mkdir -p pulumi_kvm_localtouch pulumi_kvm_local/__init__.pyLe nom du dossier n'est pas anodin. Il sert de paquet Python que vous
pourrez importer ensuite depuis __main__.py.
Le fichier __init__.py minimal peut ressembler a ceci :
"""Composants Python pour le projet Pulumi local."""
from .component import LibvirtVmStackfrom .config import StackSettings, load_stack_settings
__all__ = ["LibvirtVmStack", "StackSettings", "load_stack_settings"]Verification : a ce stade, le projet n'est pas encore fonctionnel, mais le paquet Python existe et peut maintenant accueillir votre structure.
Etape 2 - Isoler la configuration de stack
Section intitulée « Etape 2 - Isoler la configuration de stack »Le premier fichier a sortir de __main__.py est la lecture de la
configuration. Créez pulumi_kvm_local/config.py avec ce contenu :
from dataclasses import dataclass
import pulumi
@dataclass(frozen=True)class StackSettings: network_name: str vm_name: str vm_memory_mib: int vm_vcpu: int admin_user: str admin_password: pulumi.Output[str]
def load_stack_settings() -> StackSettings: config = pulumi.Config()
return StackSettings( network_name=config.get("networkName") or "pulumi-lab-net", vm_name=config.get("vmName") or "pulumi-lab-vm", vm_memory_mib=config.get_int("vmMemoryMiB") or 2048, vm_vcpu=config.get_int("vmVcpu") or 2, admin_user=config.get("adminUser") or "devops", admin_password=config.require_secret("adminPassword"), )Ce fichier a une responsabilité claire : traduire la stack en objet Python lisible par le reste du projet.
Pourquoi c'est utile :
__main__.pyn'a plus besoin de connaître chaque clé de config ;- le type
StackSettingsdocumente le contrat attendu par le composant ; - les valeurs par défaut et la lecture du secret restent concentrees au même endroit.
Etape 3 - Encapsuler les ressources dans un composant
Section intitulée « Etape 3 - Encapsuler les ressources dans un composant »Le coeur de la restructuration est ici : créez un ComponentResource qui
regroupe le provider libvirt, le réseau, le disque, le cloud-init et la
VM.
Créez pulumi_kvm_local/component.py :
import pulumiimport pulumi_libvirt as libvirt
from .config import StackSettings
class LibvirtVmStack(pulumi.ComponentResource): def __init__(self, name: str, settings: StackSettings, opts: pulumi.ResourceOptions | None = None): super().__init__("lab:kvm:LibvirtVmStack", name, None, opts)
provider = libvirt.Provider( f"{name}-provider", uri="qemu:///system", opts=pulumi.ResourceOptions(parent=self), ) child_opts = pulumi.ResourceOptions(parent=self, provider=provider)
network = libvirt.Network( f"{name}-network", name=settings.network_name, autostart=True, domain=libvirt.NetworkDomainArgs(name="pulumi.lab"), forward=libvirt.NetworkForwardArgs(mode="nat"), ips=[ libvirt.NetworkIpArgs( address="192.168.151.1", prefix=24, dhcp=libvirt.NetworkIpDhcpArgs( ranges=[ libvirt.NetworkIpDhcpRangeArgs( start="192.168.151.50", end="192.168.151.200", ) ] ), ) ], opts=child_opts, )
disk = libvirt.Volume( f"{name}-disk", name="pulumi-lab-vm.qcow2", pool="default", capacity=20, capacity_unit="GiB", backing_store=libvirt.VolumeBackingStoreArgs( path="/var/lib/libvirt/images/ubuntu-22.04-cloudimg.qcow2", format=libvirt.VolumeBackingStoreFormatArgs(type="qcow2"), ), target=libvirt.VolumeTargetArgs( format=libvirt.VolumeTargetFormatArgs(type="qcow2"), ), opts=child_opts, )
cloudinit_disk = libvirt.CloudinitDisk( f"{name}-cloudinit", name=f"{settings.vm_name}-cloudinit.iso", meta_data=f"""instance-id: {settings.vm_name}local-hostname: {settings.vm_name}""", user_data=pulumi.Output.format( """#cloud-confighostname: {0}manage_etc_hosts: trueusers: - name: {1} groups: sudo shell: /bin/bash sudo: ALL=(ALL) NOPASSWD:ALLchpasswd: list: | {1}:{2} expire: falsessh_pwauth: truepackage_update: truepackages: - qemu-guest-agentruncmd: - systemctl enable --now qemu-guest-agent""", settings.vm_name, settings.admin_user, settings.admin_password, ), opts=child_opts, )
vm = libvirt.Domain( f"{name}-vm", type="kvm", name=settings.vm_name, memory=settings.vm_memory_mib, memory_unit="MiB", vcpu=settings.vm_vcpu, running=True, os=libvirt.DomainOsArgs( type="hvm", type_arch="x86_64", ), devices=libvirt.DomainDevicesArgs( disks=[ libvirt.DomainDevicesDiskArgs( device="disk", source=libvirt.DomainDevicesDiskSourceArgs( volume=libvirt.DomainDevicesDiskSourceVolumeArgs( pool="default", volume=disk.name, ), ), target=libvirt.DomainDevicesDiskTargetArgs( dev="vda", bus="virtio", ), ), libvirt.DomainDevicesDiskArgs( device="cdrom", read_only=True, source=libvirt.DomainDevicesDiskSourceArgs( file=libvirt.DomainDevicesDiskSourceFileArgs( file=cloudinit_disk.path, ), ), target=libvirt.DomainDevicesDiskTargetArgs( dev="hda", bus="ide", ), ), ], interfaces=[ libvirt.DomainDevicesInterfaceArgs( source=libvirt.DomainDevicesInterfaceSourceArgs( network=libvirt.DomainDevicesInterfaceSourceNetworkArgs( network=network.name, ), ), model=libvirt.DomainDevicesInterfaceModelArgs(type="virtio"), ), ], ), opts=child_opts, )
self.network_name = network.name self.network_id = network.id self.network_uuid = network.uuid self.vm_name = vm.name self.vm_disk_name = disk.name self.admin_user = pulumi.Output.from_input(settings.admin_user) self.vm_memory_mib = pulumi.Output.from_input(settings.vm_memory_mib)
self.register_outputs( { "network_name": self.network_name, "network_id": self.network_id, "network_uuid": self.network_uuid, "vm_name": self.vm_name, "vm_disk_name": self.vm_disk_name, "admin_user": self.admin_user, "vm_memory_mib": self.vm_memory_mib, } )Le point important n'est pas seulement le découpage. C'est la création d'un
vrai ComponentResource. Cela donne un bloc cohérent, nomme, parent de ses
ressources enfants et reutilisable ensuite dans d'autres stacks ou d'autres
fichiers.
Etape 4 - Garder un point d'entrée minimal
Section intitulée « Etape 4 - Garder un point d'entrée minimal »Votre __main__.py devient alors beaucoup plus simple :
import pulumi
from pulumi_kvm_local.component import LibvirtVmStackfrom pulumi_kvm_local.config import load_stack_settings
settings = load_stack_settings()stack = LibvirtVmStack("lab", settings=settings)
pulumi.export("network_name", stack.network_name)pulumi.export("network_id", stack.network_id)pulumi.export("network_uuid", stack.network_uuid)pulumi.export("vm_name", stack.vm_name)pulumi.export("vm_disk_name", stack.vm_disk_name)pulumi.export("admin_user", stack.admin_user)pulumi.export("vm_memory_mib", stack.vm_memory_mib)Ici, __main__.py joue bien son role :
- charger la configuration courante ;
- instancier le composant ;
- publier les outputs de la stack.
Il ne porte plus tout le detail de la logique libvirt.
Etape 5 - Relire le preview après restructuration
Section intitulée « Etape 5 - Relire le preview après restructuration »La validation ne s'arrête pas au rangement des fichiers. Il faut vérifier que la nouvelle structure continue a produire le même résultat.
source venv/bin/activatepulumi stack select devpulumi preview --stack devDans le lab rejoue, le preview annonce maintenant :
- la stack Pulumi ;
- le composant
lab:kvm:LibvirtVmStack; - le provider
pulumi:providers:libvirt; - le réseau, le disque, le cloud-init et le domaine.
Autrement dit, la structure change, mais la stack reste lisible et toujours cohérente.
Etape 6 - Appliquer, lire les outputs et vérifier la VM
Section intitulée « Etape 6 - Appliquer, lire les outputs et vérifier la VM »pulumi up --stack dev --yespulumi stack output vm_name --stack devpulumi stack output admin_user --stack devpulumi stack output vm_memory_mib --stack devvirsh list --all | grep pulumi-config-vmvirsh domstate pulumi-config-vmDans le scenario valide, vous devez retrouver :
vm_name = pulumi-config-vm;admin_user = devops;vm_memory_mib = 3072;- une VM
pulumi-config-vmen étatrunning.
Cette etape montre que la restructuration du projet n'a pas casse le résultat visible sur libvirt.
Etape 7 - Detruire proprement
Section intitulée « Etape 7 - Detruire proprement »pulumi destroy --stack dev --yesDans le lab rejoue, Pulumi supprime proprement :
- le domaine libvirt ;
- le disque cloud-init ;
- le disque clone ;
- le réseau ;
- le provider enfant du composant ;
- le composant lui-même.
La boucle reste donc complète, ce qui est la seule manière sérieuse de valider qu'une nouvelle structure de projet est saine.
Comment lire cette organisation
Section intitulée « Comment lire cette organisation »Pourquoi un fichier config.py
Section intitulée « Pourquoi un fichier config.py »Le module config.py se charge d'une seule chose : convertir la
configuration de stack en objet Python. Cette séparation rend le projet
plus lisible et facilite les tests ou les évolutions futures.
Pourquoi un ComponentResource
Section intitulée « Pourquoi un ComponentResource »Un ComponentResource vous permet de regrouper plusieurs ressources sous
une même abstraction logique. Ici, le lecteur ne manipule plus quatre blocs
isolés, mais un composant LibvirtVmStack qui décrit une VM complète avec son
réseau et son bootstrap.
Pourquoi garder les exports dans __main__.py
Section intitulée « Pourquoi garder les exports dans __main__.py »Les outputs restent au niveau de l'entrée principale, parce qu'ils servent a présenter ce que la stack publie. Cela évite d'enfouir la lecture publique du run au plus profond du composant.
Pourquoi utiliser parent=self et child_opts
Section intitulée « Pourquoi utiliser parent=self et child_opts »Ces options rattachent le provider et les ressources libvirt au composant.
Le graphe de ressources reste plus clair et le preview affiche mieux la
hiérarchie du projet.
Pieges fréquents
Section intitulée « Pieges fréquents »| Symptôme | Cause probable | Solution |
|---|---|---|
ModuleNotFoundError sur pulumi_kvm_local | Le paquet Python ou __init__.py manque | Créez le dossier et le fichier __init__.py, puis relancez pulumi preview |
| Le secret n'est plus masque | La lecture a été recopiee hors de require_secret() | Relisez config.py et gardez config.require_secret("adminPassword") |
Le preview n'affiche plus le composant | La classe n'herite pas de ComponentResource ou n'est pas instanciee | Vérifiez class LibvirtVmStack(pulumi.ComponentResource) et l'appel dans __main__.py |
| Les ressources ne sont pas rattachees au composant | parent=self et child_opts sont absents | Reappliquez les ResourceOptions du composant |
A retenir
Section intitulée « A retenir »- Un projet Pulumi reste plus lisible quand
__main__.pyreste très fin. - Centraliser la config dans un module dédié clarifie le contrat de la stack.
- Un
ComponentResourcedonne une abstraction reutilisable au lieu d'un simple gros fichier. - La seule bonne restructuration est celle qui repasse
preview -> up -> verification -> destroysans régression.