
Quand un projet Pulumi quitte le stade de la premiere demo, le premier
probleme n’est pas le cloud. C’est souvent un __main__.py qui concentre
la configuration, les ressources, les outputs et les secrets au meme 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 cote concret du lab KVM/libvirt.
Le scenario a ete rejoue le 1 avril 2026 avec une stack dev, une VM
pulumi-config-vm, un reseau pulumi-config-net, un composant
lab:kvm:LibvirtVmStack, puis la boucle complete 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 deja fonctionnel pour qu’il reste maintenable.
Le resultat vise est simple :
- un point d’entree 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 regression sur la creation 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 probleme commence quand vous ajoutez :
- plusieurs valeurs de config ;
- des secrets ;
- plusieurs ressources liees entre elles ;
- des outputs plus nombreux ;
- l’envie de reutiliser le meme 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 metier 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 reseau, le disque, le cloud-init et la VM.
Etape 1 - Creer le paquet Python du projet
Section intitulée « Etape 1 - Creer le paquet Python du projet »Depuis la racine du projet, creez d’abord le dossier qui recevra le code metier :
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. Creez 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 responsabilite claire : traduire la stack en objet Python lisible par le reste du projet.
Pourquoi c’est utile :
__main__.pyn’a plus besoin de connaitre chaque cle de config ;- le type
StackSettingsdocumente le contrat attendu par le composant ; - les valeurs par defaut et la lecture du secret restent concentrees au meme 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 : creez un ComponentResource qui
regroupe le provider libvirt, le reseau, 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 decoupage. C’est la creation d’un
vrai ComponentResource. Cela donne un bloc coherent, nomme, parent de ses
ressources enfants et reutilisable ensuite dans d’autres stacks ou d’autres
fichiers.
Etape 4 - Garder un point d’entree minimal
Section intitulée « Etape 4 - Garder un point d’entree 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 apres restructuration
Section intitulée « Etape 5 - Relire le preview apres restructuration »La validation ne s’arrete pas au rangement des fichiers. Il faut verifier que la nouvelle structure continue a produire le meme resultat.
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 reseau, le disque, le cloud-init et le domaine.
Autrement dit, la structure change, mais la stack reste lisible et toujours coherente.
Etape 6 - Appliquer, lire les outputs et verifier la VM
Section intitulée « Etape 6 - Appliquer, lire les outputs et verifier 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 etatrunning.
Cette etape montre que la restructuration du projet n’a pas casse le resultat 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 reseau ;
- le provider enfant du composant ;
- le composant lui-meme.
La boucle reste donc complete, ce qui est la seule maniere serieuse 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 separation rend le projet
plus lisible et facilite les tests ou les evolutions futures.
Pourquoi un ComponentResource
Section intitulée « Pourquoi un ComponentResource »Un ComponentResource vous permet de regrouper plusieurs ressources sous
une meme abstraction logique. Ici, le lecteur ne manipule plus quatre blocs
isolés, mais un composant LibvirtVmStack qui decrit une VM complete avec son
reseau 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’entree principale, parce qu’ils servent a presenter ce que la stack publie. Cela evite 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
hierarchie du projet.
Pieges frequents
Section intitulée « Pieges frequents »| Symptome | Cause probable | Solution |
|---|---|---|
ModuleNotFoundError sur pulumi_kvm_local | Le paquet Python ou __init__.py manque | Creez le dossier et le fichier __init__.py, puis relancez pulumi preview |
| Le secret n’est plus masque | La lecture a ete 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 | Verifiez 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 tres fin. - Centraliser la config dans un module dedie 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 regression.