Aller au contenu
Infrastructure as Code medium
🔐 Alerte sécurité — Incident supply chain Trivy : lire mon analyse de l'attaque

Pulumi - structurer un projet et reutiliser des composants

12 min de lecture

logo pulumi

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.

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

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.

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 reseau, le disque, le cloud-init et la VM.

Depuis la racine du projet, creez d’abord le dossier qui recevra le code metier :

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. 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__.py n’a plus besoin de connaitre chaque cle de config ;
  • le type StackSettings documente 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 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 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.

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.

La validation ne s’arrete pas au rangement des fichiers. Il faut verifier que la nouvelle structure continue a produire le meme resultat.

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 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 »
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 etat running.

Cette etape montre que la restructuration du projet n’a pas casse le resultat 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 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.

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.

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.

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.

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.

SymptomeCause probableSolution
ModuleNotFoundError sur pulumi_kvm_localLe paquet Python ou __init__.py manqueCreez le dossier et le fichier __init__.py, puis relancez pulumi preview
Le secret n’est plus masqueLa lecture a ete 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 instancieeVerifiez 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 tres fin.
  • Centraliser la config dans un module dedie 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 regression.

Ce site vous est utile ?

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

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn