Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cqfd/docker/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ ENV DEBIAN_FRONTEND=noninteractive \
RUN set -x \
&& apt-get update \
&& apt-get install -y \
cloud-image-utils \
curl \
libarchive-tools \
locales \
Expand Down
82 changes: 82 additions & 0 deletions roles/cloud_init_seed/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Build cloud-init seed Seapath Role

This role builds a [cloud-init NoCloud](https://cloudinit.readthedocs.io/en/latest/reference/datasources/nocloud.html)
seed image for a single VM on the Ansible controller.

The seed image is a small disk holding a filesystem labelled `cidata` and
containing the cloud-init `meta-data`, `user-data` and (optionally)
`network-config` files. When this image is attached to a VM as an extra disk,
cloud-init running inside the guest discovers it (by the `CIDATA` label) and
applies the configuration on first boot.

This role only **builds** the image. Attaching it to the VM is the
responsibility of the calling role (`deploy_vms_cluster` /
`deploy_vms_standalone`), which uploads the seed to the hypervisor and adds it
to the VM disks.

## Requirements

`cloud-localds` (Debian/Ubuntu package `cloud-image-utils`) must be installed on
the Ansible controller.

The base VM image must contain cloud-init for the seed to have any effect.

## Role Variables

| Variable | Required | Type | Default | Comments |
|-------------------------------|----------|--------|-----------------------------|--------------------------------------------------------------------------|
| cloud_init_seed_vm_name | Yes | String | | Name of the VM. Used as default `instance-id` and `local-hostname`. |
| cloud_init_seed_vm | Yes | Dict | | `hostvars` of the VM. Must contain a `cloud_init` mapping (see below). |
| cloud_init_seed_output_dir | No | String | `/tmp/seapath_cloud_init` | Directory on the controller where seed images are built. |
| cloud_init_seed_disk_format | No | String | `qcow2` | Disk format of the generated seed image. |

The role returns the path of the generated seed image (on the controller) in
the `cloud_init_seed_path` fact.

## The `cloud_init` mapping

Define a `cloud_init` mapping on the VM in the inventory to enable cloud-init for
that VM. Every key is passed through as-is to the `#cloud-config` `user-data`,
**except** the following reserved keys:

| Key | Type | Comments |
|-----------------|--------|-------------------------------------------------------------------------------------------------|
| `hostname` | String | Also written to the `meta-data` `local-hostname` (and kept in user-data as a cloud-config key). |
| `instance_id` | String | `meta-data` instance-id. Defaults to the VM name. Changing it makes cloud-init re-run. |
| `network` | Dict | A NoCloud network-config v2 document (without the `version: 2` header, added automatically). |
| `user_data_file`| String | Path on the controller to a complete user-data file. When set, the generated user-data keys are ignored and this file is used verbatim. |

Any other key (`users`, `packages`, `runcmd`, `write_files`, `ansible`, `ssh_authorized_keys`, ...)
is a standard cloud-config key and is rendered directly into the user-data.

### Example

```yaml
myvm:
cloud_init:
hostname: myvm
users:
- name: admin
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- "ssh-ed25519 AAAA..."
packages:
- qemu-guest-agent
runcmd:
- ["systemctl", "enable", "--now", "qemu-guest-agent"]
network:
ethernets:
enp1s0:
addresses: ["10.0.0.10/24"]
routes:
- to: "default"
via: "10.0.0.1"
nameservers:
addresses: ["9.9.9.9"]
# Run Ansible from within the VM at first boot (cloud-init native module)
ansible:
install_method: pip
pull:
url: "https://github.com/myorg/myrepo.git"
playbook_name: site.yml
```
17 changes: 17 additions & 0 deletions roles/cloud_init_seed/defaults/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Copyright (C) 2026, RTE (http://www.rte-france.com)
# SPDX-License-Identifier: Apache-2.0

---
# Directory on the Ansible controller where the cloud-init seed images and
# their intermediate files (meta-data, user-data, network-config) are built.
cloud_init_seed_output_dir: "/tmp/seapath_cloud_init"

# Disk format of the generated seed image. Passed to `cloud-localds --disk-format`.
cloud_init_seed_disk_format: "qcow2"

# Keys of the per-VM `cloud_init` mapping that are NOT valid #cloud-config keys
# and must therefore be stripped before rendering user-data.
cloud_init_seed_reserved_keys:
- network
- user_data_file
- instance_id
9 changes: 9 additions & 0 deletions roles/cloud_init_seed/meta/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Copyright (C) 2026, RTE (http://www.rte-france.com)
# SPDX-License-Identifier: Apache-2.0
---
galaxy_info:
author: "Seapath"
description: Build a cloud-init NoCloud seed image for a VM
license: Apache-2.0
min_ansible_version: 2.9.10
dependencies: []
81 changes: 81 additions & 0 deletions roles/cloud_init_seed/tasks/main.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
# Copyright (C) 2026, RTE (http://www.rte-france.com)
# SPDX-License-Identifier: Apache-2.0

# Build a cloud-init NoCloud seed image on the Ansible controller for a single
# VM. The caller must provide:
# cloud_init_seed_vm_name : name of the VM (used as default instance-id/hostname)
# cloud_init_seed_vm : hostvars of the VM (must contain a `cloud_init` mapping)
# The path of the generated seed image is returned in `cloud_init_seed_path`.

---
- name: "Set cloud-init seed facts for {{ cloud_init_seed_vm_name }}"
ansible.builtin.set_fact:
cloud_init_seed_workdir: "{{ cloud_init_seed_output_dir }}/{{ cloud_init_seed_vm_name }}"
cloud_init_seed_path: "{{ cloud_init_seed_output_dir }}/{{ cloud_init_seed_vm_name }}-seed.{{ cloud_init_seed_disk_format }}"

- name: "Create local cloud-init working directory for {{ cloud_init_seed_vm_name }}"
ansible.builtin.file:
path: "{{ cloud_init_seed_workdir }}"
state: directory
mode: "0700"
delegate_to: localhost
become: false

- name: "Render cloud-init meta-data for {{ cloud_init_seed_vm_name }}"
ansible.builtin.copy:
dest: "{{ cloud_init_seed_workdir }}/meta-data"
mode: "0644"
content: |
instance-id: {{ cloud_init_seed_vm.cloud_init.instance_id | default(cloud_init_seed_vm_name) }}
local-hostname: {{ cloud_init_seed_vm.cloud_init.hostname | default(cloud_init_seed_vm_name) }}
delegate_to: localhost
become: false

- name: "Copy provided cloud-init user-data file for {{ cloud_init_seed_vm_name }}"
ansible.builtin.copy:
src: "{{ cloud_init_seed_vm.cloud_init.user_data_file }}"
dest: "{{ cloud_init_seed_workdir }}/user-data"
mode: "0644"
delegate_to: localhost
become: false
when: cloud_init_seed_vm.cloud_init.user_data_file is defined

- name: "Render cloud-init user-data for {{ cloud_init_seed_vm_name }}"
ansible.builtin.copy:
dest: "{{ cloud_init_seed_workdir }}/user-data"
mode: "0644"
content: |
#cloud-config
{{ cloud_init_seed_vm.cloud_init | dict2items
| rejectattr('key', 'in', cloud_init_seed_reserved_keys)
| items2dict | to_nice_yaml(indent=2) }}
delegate_to: localhost
become: false
when: cloud_init_seed_vm.cloud_init.user_data_file is not defined

- name: "Render cloud-init network-config for {{ cloud_init_seed_vm_name }}"
ansible.builtin.copy:
dest: "{{ cloud_init_seed_workdir }}/network-config"
mode: "0644"
content: |
{{ {'version': 2} | combine(cloud_init_seed_vm.cloud_init.network) | to_nice_yaml(indent=2) }}
delegate_to: localhost
become: false
when: cloud_init_seed_vm.cloud_init.network is defined

- name: "Build cloud-localds command line for {{ cloud_init_seed_vm_name }}"
ansible.builtin.set_fact:
cloud_init_seed_localds_argv: >-
{{ ['cloud-localds', '--disk-format=' + cloud_init_seed_disk_format]
+ (['--network-config=' + cloud_init_seed_workdir + '/network-config']
if cloud_init_seed_vm.cloud_init.network is defined else [])
+ [cloud_init_seed_path,
cloud_init_seed_workdir + '/user-data',
cloud_init_seed_workdir + '/meta-data'] }}

- name: "Build cloud-init seed image for {{ cloud_init_seed_vm_name }}"
ansible.builtin.command:
argv: "{{ cloud_init_seed_localds_argv }}"
delegate_to: localhost
become: false
changed_when: true
17 changes: 17 additions & 0 deletions roles/deploy_vms_cluster/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,23 @@ VMs:
[...]
```

## Configure the guest with cloud-init

A VM can be configured on first boot with [cloud-init](https://cloudinit.readthedocs.io/)
by adding a `cloud_init` mapping to its inventory entry. When present, this role
builds a NoCloud seed image (via the `cloud_init_seed` role) and attaches it to
the VM as an extra disk; cloud-init inside the guest applies it on first boot.

Requirements:
- The base VM image must ship cloud-init.
- `cloud-localds` (Debian/Ubuntu package `cloud-image-utils`) must be installed
on the Ansible controller.

Every key of the `cloud_init` mapping is passed through as a `#cloud-config`
`user-data` key, except the reserved keys `hostname`, `instance_id`, `network`
and `user_data_file`. See the `cloud_init_seed` role README for the full
reference and an example.

## Use a templated libvirt XML file

The `vm_template` variable can point toward a jinja2 templated XML file.
Expand Down
26 changes: 25 additions & 1 deletion roles/deploy_vms_cluster/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,21 @@
loop_var: add_disk
index_var: add_disk_idx
when: deploy_vms_cluster_disk_copy | bool
- name: "Build and upload cloud-init seed for {{ item }}"
when: hostvars[item].cloud_init is defined
block:
- name: "Generate cloud-init seed for {{ item }}"
ansible.builtin.include_role:
name: cloud_init_seed
vars:
cloud_init_seed_vm_name: "{{ item }}"
cloud_init_seed_vm: "{{ hostvars[item] }}"
- name: "Copy cloud-init seed on target for {{ item }}" # noqa: risky-file-permissions
ansible.builtin.copy:
src: "{{ cloud_init_seed_path }}"
dest: "{{ deploy_vms_cluster_qcow2tmpuploadfolder }}/cloudinit-seed.qcow2"
vars:
ansible_remote_tmp: "{{ deploy_vms_cluster_qcow2tmpuploadfolder | default(omit) }}"
- name: "Create {{ item }}"
cluster_vm:
name: "{{ item }}"
Expand All @@ -98,7 +113,11 @@
preferred_host: "{{ hostvars[item].preferred_host | default(omit) }}"
crm_config_cmd: "{{ hostvars[item].crm_config_cmd | default(omit) }}"
disk_bus: "{{ hostvars[item].disk_bus | default(omit) }}"
additional_disks: "{{ range(hostvars[item].additional_disk | default([]) | length) | map('string') | map('regex_replace', '^(.+)$', deploy_vms_cluster_qcow2tmpuploadfolder ~ '/additional_\\1.qcow2') | list }}"
additional_disks: >-
{{ (range(hostvars[item].additional_disk | default([]) | length) | map('string')
| map('regex_replace', '^(.+)$', deploy_vms_cluster_qcow2tmpuploadfolder ~ '/additional_\1.qcow2') | list)
+ ([deploy_vms_cluster_qcow2tmpuploadfolder ~ '/cloudinit-seed.qcow2']
if hostvars[item].cloud_init is defined else []) }}
xml: >-
{{ lookup('file', hostvars[item].xml_path)
if hostvars[item].xml_path is defined
Expand All @@ -118,6 +137,11 @@
loop_control:
loop_var: add_disk
index_var: add_disk_idx
- name: "Remove temporary cloud-init seed file for {{ item }}"
ansible.builtin.file:
path: "{{ deploy_vms_cluster_qcow2tmpuploadfolder }}/cloudinit-seed.qcow2"
state: absent
when: hostvars[item].cloud_init is defined
- name: Wait for VM connections
ansible.builtin.wait_for_connection:
delegate_to: "{{ item }}"
Expand Down
18 changes: 18 additions & 0 deletions roles/deploy_vms_standalone/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,24 @@ VMs:
[...]
```

## Configure the guest with cloud-init

A VM can be configured on first boot with [cloud-init](https://cloudinit.readthedocs.io/)
by adding a `cloud_init` mapping to its inventory entry. When present, this role
builds a NoCloud seed image (via the `cloud_init_seed` role), copies it to the
disk pool and attaches it to the VM (an extra `vdz` disk in `guest.xml.j2`);
cloud-init inside the guest applies it on first boot.

Requirements:
- The base VM image must ship cloud-init.
- `cloud-localds` (Debian/Ubuntu package `cloud-image-utils`) must be installed
on the Ansible controller.

Every key of the `cloud_init` mapping is passed through as a `#cloud-config`
`user-data` key, except the reserved keys `hostname`, `instance_id`, `network`
and `user_data_file`. See the `cloud_init_seed` role README for the full
reference and an example.

## Use a templated libvirt XML file

The `vm_template` variable point toward a jinja2 templated XML file.
Expand Down
24 changes: 24 additions & 0 deletions roles/deploy_vms_standalone/tasks/cloud_init_seed.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Copyright (C) 2026, RTE (http://www.rte-france.com)
# SPDX-License-Identifier: Apache-2.0

---
- name: "Generate cloud-init seed for {{ _vm_name }}"
ansible.builtin.include_role:
name: cloud_init_seed
vars:
cloud_init_seed_vm_name: "{{ _vm_name }}"
cloud_init_seed_vm: "{{ hostvars[_vm_name] }}"

- name: "Copy cloud-init seed on target for {{ _vm_name }}"
ansible.builtin.copy:
src: "{{ cloud_init_seed_path }}"
dest: "{{ deploy_vms_standalone_disk_pool }}/{{ _vm_name }}-seed.qcow2"
mode: "0644"
vars:
ansible_remote_tmp: "{{ deploy_vms_standalone_qcow2tmpuploadfolder | default(omit) }}"

- name: "Register cloud-init seed disk for {{ _vm_name }}"
ansible.builtin.set_fact:
standalone_cloud_init_seed: "{{ _vm_name }}-seed.qcow2" # noqa: var-naming[no-role-prefix]
delegate_to: "{{ _vm_name }}"
delegate_facts: true
9 changes: 9 additions & 0 deletions roles/deploy_vms_standalone/tasks/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,15 @@
- deploy_vms_standalone_disk_copy | bool
- item not in deploy_vms_standalone_all_vms.list_vms or (item in deploy_vms_standalone_all_vms.list_vms and hostvars[item].force is defined and hostvars[item].force)

- name: Build cloud-init seed disks on target
ansible.builtin.include_tasks: cloud_init_seed.yml
vars:
_vm_name: "{{ item }}"
loop: "{{ groups['VMs'] }}"
when:
- hostvars[item].cloud_init is defined
- item not in deploy_vms_standalone_all_vms.list_vms or (item in deploy_vms_standalone_all_vms.list_vms and hostvars[item].force is defined and hostvars[item].force)

- name: Add main disk to disk list
# This is only done in standalone because the disk is handled by vm-manager in the cluster
ansible.builtin.set_fact:
Expand Down
7 changes: 7 additions & 0 deletions templates/vm/guest.xml.j2
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,13 @@
</disk>
{% endfor %}
{% endif %}
{% if vm.standalone_cloud_init_seed is defined %}
<disk type="file" device="disk">
<driver name="qemu" type="qcow2"/>
<source file="/var/lib/libvirt/images/{{ vm.standalone_cloud_init_seed }}"/>
<target dev="vdz" bus="virtio"/>
</disk>
{% endif %}
{% if "dpdk" in vm_features %}
{% for interface in vm.dpdk %}
<interface type='vhostuser'>
Expand Down
Loading