Aller au contenu
English
English
Infrastructure as Code medium

Pulumi - structurer un projet et réutiliser des composants

12 min de lecture

logo pulumi

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.

Structure d'un projet Pulumi Python avec main.py, config.py, component.py, la stack et les ressources libvirt

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.py qui centralise les valeurs de stack ;
  • un fichier component.py qui porte la logique libvirt ;
  • un workflow valide sans régression sur la création de la VM.

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.

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.py

Cette 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.

Depuis la racine du projet, créez d'abord le dossier qui recevra le code métier :

Fenêtre de terminal
mkdir -p pulumi_kvm_local
touch pulumi_kvm_local/__init__.py

Le 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 LibvirtVmStack
from .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.

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__.py n'a plus besoin de connaître chaque clé de config ;
  • le type StackSettings documente 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 pulumi
import 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-config
hostname: {0}
manage_etc_hosts: true
users:
- name: {1}
groups: sudo
shell: /bin/bash
sudo: ALL=(ALL) NOPASSWD:ALL
chpasswd:
list: |
{1}:{2}
expire: false
ssh_pwauth: true
package_update: true
packages:
- qemu-guest-agent
runcmd:
- 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.

Votre __main__.py devient alors beaucoup plus simple :

import pulumi
from pulumi_kvm_local.component import LibvirtVmStack
from 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.

Fenêtre de terminal
source venv/bin/activate
pulumi stack select dev
pulumi preview --stack dev

Dans 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 »
Fenêtre de terminal
pulumi up --stack dev --yes
pulumi stack output vm_name --stack dev
pulumi stack output admin_user --stack dev
pulumi stack output vm_memory_mib --stack dev
virsh list --all | grep pulumi-config-vm
virsh domstate pulumi-config-vm

Dans le scenario valide, vous devez retrouver :

  • vm_name = pulumi-config-vm ;
  • admin_user = devops ;
  • vm_memory_mib = 3072 ;
  • une VM pulumi-config-vm en état running.

Cette etape montre que la restructuration du projet n'a pas casse le résultat visible sur libvirt.

Fenêtre de terminal
pulumi destroy --stack dev --yes

Dans 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.

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.

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.

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.

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.

SymptômeCause probableSolution
ModuleNotFoundError sur pulumi_kvm_localLe paquet Python ou __init__.py manqueCréez le dossier et le fichier __init__.py, puis relancez pulumi preview
Le secret n'est plus masqueLa lecture a été recopiee hors de require_secret()Relisez config.py et gardez config.require_secret("adminPassword")
Le preview n'affiche plus le composantLa classe n'herite pas de ComponentResource ou n'est pas instancieeVérifiez class LibvirtVmStack(pulumi.ComponentResource) et l'appel dans __main__.py
Les ressources ne sont pas rattachees au composantparent=self et child_opts sont absentsReappliquez les ResourceOptions du composant
  • Un projet Pulumi reste plus lisible quand __main__.py reste très fin.
  • Centraliser la config dans un module dédié clarifie le contrat de la stack.
  • Un ComponentResource donne une abstraction reutilisable au lieu d'un simple gros fichier.
  • La seule bonne restructuration est celle qui repasse preview -> up -> verification -> destroy sans régression.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens ce site gratuitement, sans publicité, sans profilage et sans compte à créer. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn