From 2fbfdbb4b905346b5bb2add52720d9f7167f0729 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Fri, 6 Sep 2024 13:02:42 +0200
Subject: [PATCH 01/41] pool: add a log for better understanding of INFO level

This in INFO level makes user think the test is blocked on
xapi-wait-init-complete while the problem is with reaching pool members:

 Sep 06 12:50:28.791 INFO Wait for XAPI init to be complete on 172.16.210.12
 Sep 06 12:50:28.792 DEBUG [172.16.210.12] xapi-wait-init-complete 60
 Sep 06 12:50:29.221 DEBUG [172.16.210.12] xe host-list --minimal
 ...
 Sep 06 12:50:30.503 DEBUG [172.16.210.12] xe host-param-get uuid=0aea61f4-c9d1-4060-94e8-4eb2024d082c param-name=address
 Sep 06 12:50:30.926 DEBUG > 172.16.210.11
 Sep 06 12:50:30.929 DEBUG [172.16.210.11] cat /etc/xensource-inventory
 Sep 06 12:52:47.216 DEBUG > ssh: connect to host 172.16.210.11 port 22: Connection timed out

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 lib/pool.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/lib/pool.py b/lib/pool.py
index 790ac4f9..baa6686b 100644
--- a/lib/pool.py
+++ b/lib/pool.py
@@ -26,6 +26,7 @@ def __init__(self, master_hostname_or_ip: str) -> None:
                  f"Wait for XAPI init to be complete on {master_hostname_or_ip}",
                  timeout_secs=30 * 60)
 
+        logging.info("Getting Pool info for %r", master_hostname_or_ip)
         for host_uuid in self.hosts_uuids():
             if host_uuid != self.hosts[0].uuid:
                 host = Host(self, self.host_ip(host_uuid))

From 7c431189e638dbaaf2effefd2f7151f3d6a0bb6e Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Wed, 7 May 2025 14:58:19 +0200
Subject: [PATCH 02/41] ci: pull additional typing data for mypy

This is notably required for the `requests` module.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 .github/workflows/code-checkers.yml | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/.github/workflows/code-checkers.yml b/.github/workflows/code-checkers.yml
index 4f2573dc..a1a2abf3 100644
--- a/.github/workflows/code-checkers.yml
+++ b/.github/workflows/code-checkers.yml
@@ -18,9 +18,9 @@ jobs:
         pip install mypy
     - name: Create a dummy data.py
       run: cp data.py-dist data.py
-    - name: Check with mypy
-      run: mypy lib/ tests/
-      
+    - name: Install additional typing data and check with mypy
+      run: mypy --install-types --non-interactive lib/ tests/
+
   pyright:
     runs-on: ubuntu-latest
     steps:

From e5ae82308ab08f07dbe6446fbf9857f74b704788 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Fri, 21 Mar 2025 17:15:42 +0100
Subject: [PATCH 03/41] pxe: improve and simplify error logging

The original exception should be enough to understand and more
precise, and even "raise from" does not give that precision back.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 lib/pxe.py | 7 +------
 1 file changed, 1 insertion(+), 6 deletions(-)

diff --git a/lib/pxe.py b/lib/pxe.py
index e1596587..ef8da2dd 100644
--- a/lib/pxe.py
+++ b/lib/pxe.py
@@ -1,13 +1,8 @@
 from lib.commands import ssh, scp
+from data import PXE_CONFIG_SERVER
 
 PXE_CONFIG_DIR = "/pxe/configs/custom"
 
-try:
-    from data import PXE_CONFIG_SERVER
-    assert PXE_CONFIG_SERVER
-except ImportError:
-    raise Exception('No address for the PXE server found in data.py (`PXE_CONFIG_SERVER`)')
-
 def generate_boot_conf(directory, installer, action):
     # in case of restore, we disable the text ui from the installer completely,
     # to workaround a bug that leaves us stuck on a confirmation dialog at the end of the operation.

From ebd57d1f80bf6ff7631a4c2c5624885f578096fc Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Fri, 6 Sep 2024 15:13:41 +0200
Subject: [PATCH 04/41] Host: on VM cache miss print a key pastable into
 IMAGE_*EQUIVS

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 lib/host.py | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/lib/host.py b/lib/host.py
index 29ffd9b0..cbb1ade6 100644
--- a/lib/host.py
+++ b/lib/host.py
@@ -291,7 +291,7 @@ def cached_vm(self, uri, sr_uuid):
             if not vm.vdi_uuids() or vm.get_sr().uuid == sr_uuid:
                 logging.info(f"Reusing cached VM {vm.uuid} for {uri}")
                 return vm
-        logging.info("Could not find a VM in cache with key %r", cache_key)
+        logging.info("Could not find a VM in cache for %r", uri)
 
     def import_vm(self, uri, sr_uuid=None, use_cache=False):
         if use_cache:

From 2c0db3a4a23afe01002a5383f107b1c1236d3a68 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Tue, 7 May 2024 15:58:16 +0200
Subject: [PATCH 05/41] install 1/n: fixture to create VMs from template

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 conftest.py               | 115 ++++++++++++++++++++++++++++++++++++++
 data.py-dist              |   4 ++
 lib/common.py             |  21 +++++++
 lib/host.py               |   9 +++
 lib/pool.py               |   3 +
 lib/vm.py                 |  34 +++++++++++
 pytest.ini                |   3 +
 tests/install/__init__.py |   0
 tests/install/test.py     |  32 +++++++++++
 9 files changed, 221 insertions(+)
 create mode 100644 tests/install/__init__.py
 create mode 100644 tests/install/test.py

diff --git a/conftest.py b/conftest.py
index e4d9f5ef..b0bc02c9 100644
--- a/conftest.py
+++ b/conftest.py
@@ -8,10 +8,12 @@
 
 import lib.config as global_config
 
+from lib.common import callable_marker
 from lib.common import wait_for, vm_image, is_uuid
 from lib.common import setup_formatted_and_mounted_disk, teardown_formatted_and_mounted_disk
 from lib.netutil import is_ipv6
 from lib.pool import Pool
+from lib.sr import SR
 from lib.vm import VM
 from lib.xo import xo_cli
 
@@ -410,6 +412,119 @@ def imported_vm(host, vm_ref):
         logging.info("<< Destroy VM")
         vm.destroy(verify=True)
 
+@pytest.fixture(scope="function")
+def create_vms(request, host):
+    """
+    Returns list of VM objects created from `vm_definitions` marker.
+
+    `vm_definitions` marker test author to specify one or more VMs, by
+    giving for each VM one `dict`, or a callable taking fixtures as
+    arguments and returning such a `dict`.
+
+    Mandatory keys:
+    - `name`: name of the VM to create (str)
+    - `template`: name (or UUID) of template to use (str)
+
+    Optional keys: see example below
+
+    Example:
+    -------
+    > @pytest.mark.vm_definitions(
+    >     dict(name="vm1", template="Other install media"),
+    >     dict(name="vm2",
+    >          template="CentOS 7",
+    >          params=(
+    >              dict(param_name="memory-static-max", value="4GiB"),
+    >              dict(param_name="HVM-boot-params", key="order", value="dcn"),
+    >          ),
+    >          vdis=[dict(name="vm 2 system disk",
+    >                     size="100GiB",
+    >                     device="xvda",
+    >                     userdevice="0",
+    >                     )],
+    >          cd_vbd=dict(device="xvdd", userdevice="3"),
+    >          vifs=(dict(index=0, network_name=NETWORKS["MGMT"]),
+    >                dict(index=1, network_uuid=NETWORKS["MYNET_UUID"]),
+    >          ),
+    >     ))
+    > def test_foo(create_vms):
+    >    ...
+
+    """
+    marker = request.node.get_closest_marker("vm_definitions")
+    if marker is None:
+        raise Exception("No vm_definitions marker specified.")
+
+    vm_defs = []
+    for vm_def in marker.args:
+        vm_def = callable_marker(vm_def, request)
+        assert "name" in vm_def
+        assert "template" in vm_def
+        # FIXME should check optional vdis contents
+        # FIXME should check for extra args
+        vm_defs.append(vm_def)
+
+    try:
+        vms = []
+        vdis = []
+        vbds = []
+        for vm_def in vm_defs:
+            _create_vm(request, vm_def, host, vms, vdis, vbds)
+        yield vms
+
+    except Exception:
+        logging.error("exception caught...")
+        raise
+
+    finally:
+        for vbd in vbds:
+            logging.info("<< Destroy VBD %s", vbd.uuid)
+            vbd.destroy()
+        for vdi in vdis:
+            logging.info("<< Destroy VDI %s", vdi.uuid)
+            vdi.destroy()
+        for vm in vms:
+            logging.info("<< Destroy VM %s", vm.uuid)
+            vm.destroy(verify=True)
+
+def _vm_name(request, vm_def):
+    return f"{vm_def['name']} in {request.node.nodeid}"
+
+def _create_vm(request, vm_def, host, vms, vdis, vbds):
+    vm_name = _vm_name(request, vm_def)
+    vm_template = vm_def["template"]
+
+    logging.info("Installing VM %r from template %r", vm_name, vm_template)
+
+    vm = host.vm_from_template(vm_name, vm_template)
+
+    # VM is now created, make sure we clean it up on any subsequent failure
+    vms.append(vm)
+
+    if "vdis" in vm_def:
+        for vdi_def in vm_def["vdis"]:
+            sr = SR(host.main_sr_uuid(), host.pool)
+            vdi = sr.create_vdi(vdi_def["name"], vdi_def["size"])
+            vdis.append(vdi)
+            # connect to VM
+            vbd = vm.create_vbd(vdi_def["device"], vdi.uuid)
+            vbds.append(vbd)
+            vbd.param_set(param_name="userdevice", value=vdi_def["userdevice"])
+
+    if "cd_vbd" in vm_def:
+        vm.create_cd_vbd(**vm_def["cd_vbd"])
+
+    if "vifs" in vm_def:
+        for vif_def in vm_def["vifs"]:
+            vm.create_vif(vif_def["index"],
+                          network_uuid=vif_def.get("network_uuid", None),
+                          network_name=vif_def.get("network_name", None))
+
+    if "params" in vm_def:
+        for param_def in vm_def["params"]:
+            logging.info("Setting param %s", param_def)
+            vm.param_set(**param_def)
+
 @pytest.fixture(scope="module")
 def started_vm(imported_vm):
     vm = imported_vm
diff --git a/data.py-dist b/data.py-dist
index 89d74da1..dd3bee9d 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -23,6 +23,10 @@ HOSTS: Dict[str, Dict[str, Any]] = {
 #    "testhost1": {"user": "root", "password": "", 'skip_xo_config': True},
 }
 
+NETWORKS = {
+    "MGMT": "Pool-wide network associated with eth0",
+}
+
 # PXE config server for automated XCP-ng installation
 PXE_CONFIG_SERVER = 'pxe'
 
diff --git a/lib/common.py b/lib/common.py
index 6c3e1068..064786b0 100644
--- a/lib/common.py
+++ b/lib/common.py
@@ -8,6 +8,8 @@
 from typing import Dict, Literal, Optional, overload, TYPE_CHECKING, Union
 from uuid import UUID
 
+import pytest
+
 import lib.commands as commands
 if TYPE_CHECKING:
     import lib.host
@@ -36,6 +38,25 @@ def prefix_object_name(label):
         name_prefix = f"[{getpass.getuser()}]"
     return f"{name_prefix} {label}"
 
+def callable_marker(value, request):
+    """
+    Process value optionally generated by fixture-dependent callable.
+
+    Typically useful for fixtures using pytest markers on parametrized tests.
+
+    Such markers as parameter one value, or a callable that will
+    return a value.  The callable may take as parameters any subset of
+    the fixture names the test itself uses.
+    """
+    if callable(value):
+        try:
+            params = {arg_name: request.getfixturevalue(arg_name)
+                      for arg_name in inspect.getfullargspec(value).args}
+        except pytest.FixtureLookupError as e:
+            raise RuntimeError("fixture in mapping not found on test") from e
+        value = value(**params)
+    return value
+
 def wait_for(fn, msg=None, timeout_secs=2 * 60, retry_delay_secs=2, invert=False):
     if msg is not None:
         logging.info(msg)
diff --git a/lib/host.py b/lib/host.py
index cbb1ade6..c9fe7866 100644
--- a/lib/host.py
+++ b/lib/host.py
@@ -353,6 +353,15 @@ def import_iso(self, uri, sr: SR):
 
         return VDI(vdi_uuid, sr=sr)
 
+    def vm_from_template(self, name, template):
+        params = {
+            "new-name-label": prefix_object_name(name),
+            "template": template,
+            "sr-uuid": self.main_sr_uuid(),
+        }
+        vm_uuid = self.xe('vm-install', params)
+        return VM(vm_uuid, self)
+
     def pool_has_vm(self, vm_uuid, vm_type='vm'):
         if vm_type == 'snapshot':
             return self.xe('snapshot-list', {'uuid': vm_uuid}, minimal=True) == vm_uuid
diff --git a/lib/pool.py b/lib/pool.py
index baa6686b..3d05e159 100644
--- a/lib/pool.py
+++ b/lib/pool.py
@@ -256,3 +256,6 @@ def eject_host(self, host):
         wait_for_not(lambda: host.uuid in self.hosts_uuids(), f"Wait for host {host} to be ejected of pool {master}.")
         self.hosts = [h for h in self.hosts if h.uuid != host.uuid]
         wait_for(host.is_enabled, f"Wait for host {host} to restart in its own pool.", timeout_secs=10 * 60)
+
+    def network_named(self, network_name):
+        return self.master.xe('network-list', {'name-label': network_name}, minimal=True)
diff --git a/lib/vm.py b/lib/vm.py
index fa16d682..ba93882e 100644
--- a/lib/vm.py
+++ b/lib/vm.py
@@ -12,6 +12,7 @@
 from lib.basevm import BaseVM
 from lib.common import PackageManagerEnum, parse_xe_dict, safe_split, strtobool, wait_for, wait_for_not
 from lib.snapshot import Snapshot
+from lib.vbd import VBD
 from lib.vif import VIF
 
 if TYPE_CHECKING:
@@ -280,6 +281,18 @@ def vifs(self):
             _vifs.append(VIF(vif_uuid, self))
         return _vifs
 
+    def create_vif(self, vif_num, *, network_uuid=None, network_name=None):
+        assert bool(network_uuid) != bool(network_name), \
+            "create_vif needs network_uuid XOR network_name"
+        if network_name:
+            network_uuid = self.host.pool.network_named(network_name)
+        assert network_uuid, f"No UUID given, and network name {network_name!r} not found"
+        logging.info("Create VIF %d to network %r on VM %s", vif_num, network_uuid, self.uuid)
+        self.host.xe('vif-create', {'vm-uuid': self.uuid,
+                                    'device': str(vif_num),
+                                    'network-uuid': network_uuid,
+                                    })
+
     def is_running_on_host(self, host):
         return self.is_running() and self.param_get('resident-on') == host.uuid
 
@@ -508,6 +521,27 @@ def destroy_vtpm(self):
         logging.info("Destroying vTPM %s" % vtpm_uuid)
         return self.host.xe('vtpm-destroy', {'uuid': vtpm_uuid}, force=True)
 
+    def create_vbd(self, device, vdi_uuid):
+        logging.info("Create VBD %r for VDI %r on VM %s", device, vdi_uuid, self.uuid)
+        vbd_uuid = self.host.xe('vbd-create', {'vm-uuid': self.uuid,
+                                               'device': device,
+                                               'vdi-uuid': vdi_uuid,
+                                               })
+        logging.info("New VBD %s", vbd_uuid)
+        return VBD(vbd_uuid, self, device)
+
+    def create_cd_vbd(self, device, userdevice):
+        logging.info("Create CD VBD %r on VM %s", device, self.uuid)
+        vbd_uuid = self.host.xe('vbd-create', {'vm-uuid': self.uuid,
+                                               'device': device,
+                                               'type': 'CD',
+                                               'mode': 'RO',
+                                               })
+        vbd = VBD(vbd_uuid, self, device)
+        vbd.param_set(param_name="userdevice", value=userdevice)
+        logging.info("New VBD %s", vbd_uuid)
+        return vbd
+
     def clone(self, *, name=None):
         if name is None:
             name = self.name() + '_clone_for_tests'
diff --git a/pytest.ini b/pytest.ini
index fe93b353..30219e1e 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -18,6 +18,9 @@ markers =
     unix_vm: tests that require a unix/linux VM to run.
     windows_vm: tests that require a Windows VM to run.
 
+    # * VM-related markers to give parameters to fixtures
+    vm_definitions: dicts of VM defs for create_vms fixture.
+
     # * Test targets related to VMs
     small_vm: tests that it is enough to run just once, using the smallest possible VM.
     big_vm: tests that it would be good to run with a big VM.
diff --git a/tests/install/__init__.py b/tests/install/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/tests/install/test.py b/tests/install/test.py
new file mode 100644
index 00000000..9e3790a5
--- /dev/null
+++ b/tests/install/test.py
@@ -0,0 +1,32 @@
+import logging
+import pytest
+
+from data import NETWORKS
+assert "MGMT" in NETWORKS
+
+# Requirements:
+# - one XCP-ng host capable of nested virt, with an ISO SR, and a default SR
+
+class TestNested:
+    @pytest.mark.vm_definitions(
+        dict(
+            name="vm1",
+            template="Other install media",
+            params=(
+                # dict(param_name="", value=""),
+                dict(param_name="memory-static-max", value="4GiB"),
+                dict(param_name="memory-dynamic-max", value="4GiB"),
+                dict(param_name="memory-dynamic-min", value="4GiB"),
+                dict(param_name="VCPUs-max", value="2"),
+                dict(param_name="VCPUs-at-startup", value="2"),
+                dict(param_name="platform", key="exp-nested-hvm", value="true"), # FIXME < 8.3 host?
+                dict(param_name="HVM-boot-params", key="firmware", value="uefi"),
+                dict(param_name="HVM-boot-params", key="order", value="dc"),
+                dict(param_name="platform", key="device-model", value="qemu-upstream-uefi"),
+            ),
+            vdis=[dict(name="vm1 system disk", size="100GiB", device="xvda", userdevice="0")],
+            cd_vbd=dict(device="xvdd", userdevice="3"),
+            vifs=[dict(index=0, network_name=NETWORKS["MGMT"])],
+        ))
+    def test_install(self, create_vms):
+        pass

From 341e5b87b1893476b0797c29bdca2af76001b071 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Fri, 9 Aug 2024 15:51:16 +0200
Subject: [PATCH 06/41] install 2/n: insert ISO in host VM

Getting a vm_booted_with_installer starts with inserting a CD, will be
progressively enhanced.

ISOs are specified through an iso_version key, matched to a filename,
or to a download URL used through a local cache, in data.py.

The ISO is uploaded to the SR through the pool master's mountpoint,
which will lead to extra network traffic but avoids having to
(mis)record the server's location.

The iso_version value is temporarily specified using a pytest marker.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist              | 22 +++++++++++++++++++--
 lib/common.py             | 11 +++++++++++
 lib/pool.py               | 28 ++++++++++++++++++++++++++
 lib/typing.py             |  6 ++++++
 pytest.ini                |  3 +++
 requirements/base.txt     |  1 +
 requirements/dev.txt      |  2 ++
 tests/install/conftest.py | 41 +++++++++++++++++++++++++++++++++++++++
 tests/install/test.py     |  3 ++-
 9 files changed, 114 insertions(+), 3 deletions(-)
 create mode 100644 lib/typing.py
 create mode 100644 tests/install/conftest.py

diff --git a/data.py-dist b/data.py-dist
index dd3bee9d..14dedc17 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -1,7 +1,9 @@
-from typing import Any, Dict
-
 # Configuration file, to be adapted to one's needs
 
+from typing import Any, Dict, TYPE_CHECKING
+if TYPE_CHECKING:
+    from lib.typing import IsoImageDef
+
 # Default user and password to connect to a host through XAPI
 # Note: this won't be used for SSH.
 # You need to have an SSH key into the hosts' /root/.ssh/authorized_keys.
@@ -90,6 +92,22 @@ VM_IMAGES = {
     'mini-linux-x86_64-uefi': 'alpine-uefi-minimal-3.12.0.xva'
 }
 
+ISO_IMAGES_BASE = "https://updates.xcp-ng.org/isos/"
+ISO_IMAGES_CACHE = "/home/user/iso"
+# ISO_IMAGES path can be:
+# - absolute filename
+# - absolute URL
+# - path relative to ISO_IMAGES_BASE URL
+# Note the dirname part is ignored when looking in ISO_IMAGES_CACHE, abuse this
+# for local-only ISO with things like "locally-built/my.iso" or "xs/8.3.iso"
+ISO_IMAGES: Dict[str, "IsoImageDef"] = {
+    '83b2': {'path': "8.3/xcp-ng-8.3.0-beta2.iso"},
+    '83b1': {'path': "8.3/xcp-ng-8.3.0-beta1.iso"},
+    '821.1': {'path': "8.2/xcp-ng-8.2.1-20231130.iso"},
+    '821': {'path': "8.2/xcp-ng-8.2.1.iso"},
+    '820': {'path': "8.2/xcp-ng-8.2.0.iso"},
+}
+
 # In some cases, we may prefer to favour a local SR to store test VM disks,
 # to avoid latency or unstabilities related to network or shared file servers.
 # However it's not good practice to make a local SR the default SR for a pool of several hosts.
diff --git a/lib/common.py b/lib/common.py
index 064786b0..1496ac12 100644
--- a/lib/common.py
+++ b/lib/common.py
@@ -1,6 +1,7 @@
 import getpass
 import inspect
 import logging
+import os
 import sys
 import time
 import traceback
@@ -9,6 +10,7 @@
 from uuid import UUID
 
 import pytest
+import requests
 
 import lib.commands as commands
 if TYPE_CHECKING:
@@ -181,6 +183,15 @@ def strtobool(str):
         return False
     raise ValueError("invalid truth value '{}'".format(str))
 
+def url_download(url: str, filename: str) -> None:
+    r = requests.get(url, stream=True)
+    r.raise_for_status()
+    tempfilename = filename + ".part"
+    with open(tempfilename, 'wb') as fd:
+        for chunk in r.iter_content(chunk_size=128):
+            fd.write(chunk)
+    os.rename(tempfilename, filename)
+
 @overload
 def _param_get(host: 'lib.host.Host', xe_prefix: str, uuid: str, param_name: str, key: Optional[str] = ...,
                accept_unknown_key: Literal[False] = ...) -> str:
diff --git a/lib/pool.py b/lib/pool.py
index 3d05e159..c223397a 100644
--- a/lib/pool.py
+++ b/lib/pool.py
@@ -1,4 +1,5 @@
 import logging
+import os
 import traceback
 from typing import Any, Dict, Optional, cast
 
@@ -119,6 +120,33 @@ def first_shared_sr(self) -> Optional[SR]:
     def get_vdi_sr_uuid(self, vdi_uuid):
         return self.master.xe('vdi-param-get', {'uuid': vdi_uuid, 'param-name': 'sr-uuid'})
 
+    def get_iso_sr(self):
+        uuids = safe_split(self.master.xe('sr-list', {'type': 'iso',
+                                                      'content-type': 'iso',
+                                                      'is-tools-sr': False},
+                                          minimal=True))
+        assert len(uuids) == 1  # we may need to allow finer selection if this triggers
+        return SR(uuids[0], self)
+
+    def push_iso(self, local_file, remote_filename=None):
+        iso_sr = self.get_iso_sr()
+        mountpoint = f"/run/sr-mount/{iso_sr.uuid}"
+        if remote_filename is None:
+            # needs only work on XCP-ng 8.2+
+            remote_filename = self.master.ssh(["mktemp --suffix=.iso -p", mountpoint])
+            self.master.ssh(["chmod 644", remote_filename])
+
+        logging.info("Uploading to ISO-SR %s as %s", local_file, remote_filename)
+        self.master.scp(local_file, remote_filename)
+        iso_sr.scan()
+        return os.path.basename(remote_filename)
+
+    def remove_iso(self, remote_filename):
+        iso_sr = self.get_iso_sr()
+        fullpath = f"/run/sr-mount/{iso_sr.uuid}/{remote_filename}"
+        logging.info("Removing %s from ISO-SR server", remote_filename)
+        self.master.ssh(["rm", fullpath])
+
     def save_uefi_certs(self) -> None:
         """
         Save UEFI certificates in order to restore them later. XCP-ng 8.2 only.
diff --git a/lib/typing.py b/lib/typing.py
new file mode 100644
index 00000000..6d494273
--- /dev/null
+++ b/lib/typing.py
@@ -0,0 +1,6 @@
+from typing import TypedDict
+from typing_extensions import NotRequired
+
+IsoImageDef = TypedDict('IsoImageDef',
+                        {'path': str,
+                         })
diff --git a/pytest.ini b/pytest.ini
index 30219e1e..cd178e0f 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -21,6 +21,9 @@ markers =
     # * VM-related markers to give parameters to fixtures
     vm_definitions: dicts of VM defs for create_vms fixture.
 
+    # * installation-related markers to customize installer run
+    iso_version: key of data.ISO_IMAGES identifying an installer ISO
+
     # * Test targets related to VMs
     small_vm: tests that it is enough to run just once, using the smallest possible VM.
     big_vm: tests that it would be good to run with a big VM.
diff --git a/requirements/base.txt b/requirements/base.txt
index 3df8b072..ec0db963 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -2,3 +2,4 @@ cryptography>=3.3.1
 packaging>=20.7
 pytest>=8.0.0
 pluggy>=1.1.0
+requests
diff --git a/requirements/dev.txt b/requirements/dev.txt
index 3b8d7bb1..d9fbd913 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -3,4 +3,6 @@ ansible>=5.0.1
 bs4>=0.0.1
 pycodestyle>=2.6.0
 PyYAML>=6.0
+mypy
+typing_extensions
 -r base.txt
diff --git a/tests/install/conftest.py b/tests/install/conftest.py
new file mode 100644
index 00000000..ef9e3308
--- /dev/null
+++ b/tests/install/conftest.py
@@ -0,0 +1,41 @@
+import logging
+import os
+import pytest
+
+from lib.common import callable_marker, url_download
+
+from data import ISO_IMAGES, ISO_IMAGES_BASE, ISO_IMAGES_CACHE
+
+@pytest.fixture(scope='function')
+def installer_iso(request):
+    iso_key = request.node.get_closest_marker("iso_version").args[0]
+    assert iso_key in ISO_IMAGES, f"ISO_IMAGES does not have a value for {iso_key}"
+    iso = ISO_IMAGES[iso_key]['path']
+    if iso.startswith("/"):
+        assert os.path.exists(iso), f"file not found: {iso}"
+        local_iso = iso
+    else:
+        cached_iso = os.path.join(ISO_IMAGES_CACHE, os.path.basename(iso))
+        if not os.path.exists(cached_iso):
+            url = iso if ":/" in iso else (ISO_IMAGES_BASE + iso)
+            logging.info("installer_iso: downloading %r into %r", url, cached_iso)
+            url_download(url, cached_iso)
+        local_iso = cached_iso
+    logging.info("installer_iso: using %r", local_iso)
+    return dict(iso=local_iso,
+                )
+
+@pytest.fixture(scope='function')
+def vm_booted_with_installer(host, create_vms, installer_iso):
+    host_vm, = create_vms # one single VM
+    iso = installer_iso['iso']
+
+    remote_iso = None
+    try:
+        remote_iso = host.pool.push_iso(iso)
+        host_vm.insert_cd(os.path.basename(remote_iso))
+        yield host_vm
+        host_vm.eject_cd()
+    finally:
+        if remote_iso:
+            host.pool.remove_iso(remote_iso)
diff --git a/tests/install/test.py b/tests/install/test.py
index 9e3790a5..48cb52f0 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -8,6 +8,7 @@
 # - one XCP-ng host capable of nested virt, with an ISO SR, and a default SR
 
 class TestNested:
+    @pytest.mark.iso_version("821.1")
     @pytest.mark.vm_definitions(
         dict(
             name="vm1",
@@ -28,5 +29,5 @@ class TestNested:
             cd_vbd=dict(device="xvdd", userdevice="3"),
             vifs=[dict(index=0, network_name=NETWORKS["MGMT"])],
         ))
-    def test_install(self, create_vms):
+    def test_install(self, vm_booted_with_installer):
         pass

From d93274b247a8072e981cb0e017bcc9893b1f9313 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Fri, 9 Aug 2024 16:11:54 +0200
Subject: [PATCH 07/41] install 3/n: use iso-remaster to plug an hardcoded
 answerfile

This is a first step for answerfile handling, to be able to run a
first real installation.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist              |  7 ++++++
 tests/install/conftest.py | 52 ++++++++++++++++++++++++++++++++++++---
 2 files changed, 56 insertions(+), 3 deletions(-)

diff --git a/data.py-dist b/data.py-dist
index 14dedc17..27590f57 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -86,6 +86,13 @@ OTHER_GUEST_TOOLS = {
     },
 }
 
+ANSWERFILE_URL = f"http://{PXE_CONFIG_SERVER}/configs/custom/ydi/install-8.2-uefi-iso-ext.xml" # FIXME
+
+# Tools
+TOOLS: Dict[str, str] = {
+#    "iso-remaster": "/home/user/src/xcpng/xcp/scripts/iso-remaster/iso-remaster.sh",
+}
+
 # Values can be either full URLs or only partial URLs that will be automatically appended to DEF_VM_URL
 VM_IMAGES = {
     'mini-linux-x86_64-bios': 'alpine-minimal-3.12.0.xva',
diff --git a/tests/install/conftest.py b/tests/install/conftest.py
index ef9e3308..861f1332 100644
--- a/tests/install/conftest.py
+++ b/tests/install/conftest.py
@@ -1,10 +1,13 @@
 import logging
 import os
 import pytest
+import tempfile
 
 from lib.common import callable_marker, url_download
+from lib.commands import local_cmd
 
-from data import ISO_IMAGES, ISO_IMAGES_BASE, ISO_IMAGES_CACHE
+from data import (ANSWERFILE_URL, ISO_IMAGES, ISO_IMAGES_BASE, ISO_IMAGES_CACHE,
+                  TOOLS)
 
 @pytest.fixture(scope='function')
 def installer_iso(request):
@@ -25,10 +28,53 @@ def installer_iso(request):
     return dict(iso=local_iso,
                 )
 
+# Remasters the ISO sepecified by `installer_iso` mark, with:
+# - network and ssh support activated so tests can go probe installation process
+# - atexit=shell to prevent the system from spontaneously rebooting
+# - an answerfile to make the install process non-interactive (downloaded from URL)
 @pytest.fixture(scope='function')
-def vm_booted_with_installer(host, create_vms, installer_iso):
+def remastered_iso(installer_iso):
+    iso_file = installer_iso['iso']
+    assert "iso-remaster" in TOOLS
+    iso_remaster = TOOLS["iso-remaster"]
+    assert os.access(iso_remaster, os.X_OK)
+
+    with tempfile.TemporaryDirectory() as isotmp:
+        remastered_iso = os.path.join(isotmp, "image.iso")
+        iso_patcher_script = os.path.join(isotmp, "iso-patcher")
+
+        logging.info("Remastering %s to %s", iso_file, remastered_iso)
+
+        # generate iso-patcher script
+        with open(iso_patcher_script, "xt") as patcher_fd:
+            passwd = "passw0rd" # FIXME use invalid hash?
+            script_contents = f"""#!/bin/bash
+set -ex
+ISODIR="$1"
+SED_COMMANDS=(-e "s@/vmlinuz@/vmlinuz network_device=all sshpassword={passwd} atexit=shell@")
+SED_COMMANDS+=(-e "s@/vmlinuz@/vmlinuz install answerfile={ANSWERFILE_URL}@")
+
+
+shopt -s nullglob # there may be no grub config, eg for XS 6.5 and earlier
+sed -i "${{SED_COMMANDS[@]}}" \
+    "$ISODIR"/*/*/grub*.cfg \
+    "$ISODIR"/boot/isolinux/isolinux.cfg
+"""
+            print(script_contents, file=patcher_fd)
+            os.chmod(patcher_fd.fileno(), 0o755)
+
+        # do remaster
+        local_cmd([iso_remaster,
+                   "--iso-patcher", iso_patcher_script,
+                   iso_file, remastered_iso
+                   ])
+
+        yield remastered_iso
+
+@pytest.fixture(scope='function')
+def vm_booted_with_installer(host, create_vms, remastered_iso):
     host_vm, = create_vms # one single VM
-    iso = installer_iso['iso']
+    iso = remastered_iso
 
     remote_iso = None
     try:

From e6285caeda10c5646bcafaf93b59c79054c37b44 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Fri, 9 Aug 2024 16:26:00 +0200
Subject: [PATCH 08/41] install 4/n: boot and monitor installer

Currently relies on the installer fetching the answerfile from the PXE
server, to ensure the latter's ARP tables are populated with the
obtained DHCP address.

Monitoring the install progress is done by repeatedly polling with grep,
which is far from ideal performance-wise, but avoids adding too much
complexity.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist              |  6 ++++
 lib/installer.py          | 38 ++++++++++++++++++++++
 tests/install/conftest.py | 68 ++++++++++++++++++++++++++++++++++++---
 tests/install/test.py     |  5 ++-
 4 files changed, 112 insertions(+), 5 deletions(-)
 create mode 100644 lib/installer.py

diff --git a/data.py-dist b/data.py-dist
index 27590f57..091b37d9 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -10,6 +10,12 @@ if TYPE_CHECKING:
 HOST_DEFAULT_USER = "root"
 HOST_DEFAULT_PASSWORD = ""
 
+# Public key for a private key available to the test runner
+TEST_SSH_PUBKEY = """
+ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMnN/wVdQqHA8KsndfrLS7fktH/IEgxoa533efuXR6rw XCP-ng CI
+ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDKz9uQOoxq6Q0SQ0XTzQHhDolvuo/7EyrDZsYQbRELhcPJG8MT/o5u3HyJFhIP2+HqBSXXgmqRPJUkwz9wUwb2sUwf44qZm/pyPUWOoxyVtrDXzokU/uiaNKUMhbnfaXMz6Ogovtjua63qld2+ZRXnIgrVtYKtYBeu/qKGVSnf4FTOUKl1w3uKkr59IUwwAO8ay3wVnxXIHI/iJgq6JBgQNHbn3C/SpYU++nqL9G7dMyqGD36QPFuqH/cayL8TjNZ67TgAzsPX8OvmRSqjrv3KFbeSlpS/R4enHkSemhgfc8Z2f49tE7qxWZ6x4Uyp5E6ur37FsRf/tEtKIUJGMRXN XCP-ng CI
+"""
+
 # The following prefix will be added to the `name-label` parameter of XAPI objects
 # that the tests will create or import, such as VMs and SRs.
 # Default value: [your login/user]
diff --git a/lib/installer.py b/lib/installer.py
new file mode 100644
index 00000000..2da97e9e
--- /dev/null
+++ b/lib/installer.py
@@ -0,0 +1,38 @@
+import logging
+from lib.commands import ssh, SSHCommandFailed
+from lib.common import wait_for
+
+def poweroff(ip):
+    try:
+        ssh(ip, ["poweroff"])
+    except SSHCommandFailed as e:
+        # ignore connection closed by reboot
+        if e.returncode == 255 and "closed by remote host" in e.stdout:
+            logging.info("sshd closed the connection")
+            pass
+        else:
+            raise
+
+def monitor_install(*, ip):
+    # wait for "yum install" phase to finish
+    wait_for(lambda: ssh(ip, ["grep",
+                              "'DISPATCH: NEW PHASE: Completing installation'",
+                              "/tmp/install-log"],
+                         check=False, simple_output=False,
+                         ).returncode == 0,
+             "Wait for rpm installation to succeed",
+             timeout_secs=40 * 60) # FIXME too big
+
+    # wait for install to finish
+    wait_for(lambda: ssh(ip, ["grep",
+                              "'The installation completed successfully'",
+                              "/tmp/install-log"],
+                         check=False, simple_output=False,
+                         ).returncode == 0,
+             "Wait for system installation to succeed",
+             timeout_secs=40 * 60) # FIXME too big
+
+    wait_for(lambda: ssh(ip, ["ps a|grep '[0-9]. python /opt/xensource/installer/init'"],
+                         check=False, simple_output=False,
+                         ).returncode == 1,
+             "Wait for installer to terminate")
diff --git a/tests/install/conftest.py b/tests/install/conftest.py
index 861f1332..3829e3a8 100644
--- a/tests/install/conftest.py
+++ b/tests/install/conftest.py
@@ -3,11 +3,12 @@
 import pytest
 import tempfile
 
-from lib.common import callable_marker, url_download
+from lib import installer, pxe
+from lib.common import callable_marker, url_download, wait_for
 from lib.commands import local_cmd
 
 from data import (ANSWERFILE_URL, ISO_IMAGES, ISO_IMAGES_BASE, ISO_IMAGES_CACHE,
-                  TOOLS)
+                  TEST_SSH_PUBKEY, TOOLS)
 
 @pytest.fixture(scope='function')
 def installer_iso(request):
@@ -29,9 +30,12 @@ def installer_iso(request):
                 )
 
 # Remasters the ISO sepecified by `installer_iso` mark, with:
-# - network and ssh support activated so tests can go probe installation process
+# - network and ssh support activated, and .ssh/authorized_key so tests can
+#   go probe installation process
 # - atexit=shell to prevent the system from spontaneously rebooting
 # - an answerfile to make the install process non-interactive (downloaded from URL)
+# - a postinstall script to modify the installed system with:
+#   - the same .ssh/authorized_key
 @pytest.fixture(scope='function')
 def remastered_iso(installer_iso):
     iso_file = installer_iso['iso']
@@ -41,10 +45,33 @@ def remastered_iso(installer_iso):
 
     with tempfile.TemporaryDirectory() as isotmp:
         remastered_iso = os.path.join(isotmp, "image.iso")
+        img_patcher_script = os.path.join(isotmp, "img-patcher")
         iso_patcher_script = os.path.join(isotmp, "iso-patcher")
 
         logging.info("Remastering %s to %s", iso_file, remastered_iso)
 
+        # generate install.img-patcher script
+        with open(img_patcher_script, "xt") as patcher_fd:
+            script_contents = f"""#!/bin/bash
+set -ex
+INSTALLIMG="$1"
+
+mkdir -p "$INSTALLIMG/root/.ssh"
+echo "{TEST_SSH_PUBKEY}" > "$INSTALLIMG/root/.ssh/authorized_keys"
+
+cat > "$INSTALLIMG/root/postinstall.sh" <<'EOF'
+#!/bin/sh
+set -ex
+
+ROOT="$1"
+
+mkdir -p "$ROOT/root/.ssh"
+echo "{TEST_SSH_PUBKEY}" >> "$ROOT/root/.ssh/authorized_keys"
+EOF
+"""
+            print(script_contents, file=patcher_fd)
+            os.chmod(patcher_fd.fileno(), 0o755)
+
         # generate iso-patcher script
         with open(iso_patcher_script, "xt") as patcher_fd:
             passwd = "passw0rd" # FIXME use invalid hash?
@@ -65,6 +92,7 @@ def remastered_iso(installer_iso):
 
         # do remaster
         local_cmd([iso_remaster,
+                   "--install-patcher", img_patcher_script,
                    "--iso-patcher", iso_patcher_script,
                    iso_file, remastered_iso
                    ])
@@ -76,11 +104,43 @@ def vm_booted_with_installer(host, create_vms, remastered_iso):
     host_vm, = create_vms # one single VM
     iso = remastered_iso
 
+    vif = host_vm.vifs()[0]
+    mac_address = vif.param_get('MAC')
+    logging.info("Host VM has MAC %s", mac_address)
+
     remote_iso = None
     try:
         remote_iso = host.pool.push_iso(iso)
         host_vm.insert_cd(os.path.basename(remote_iso))
-        yield host_vm
+
+        try:
+            host_vm.start()
+            wait_for(host_vm.is_running, "Wait for host VM running")
+
+            # catch host-vm IP address
+            wait_for(lambda: pxe.arp_addresses_for(mac_address),
+                     "Wait for DHCP server to see Host VM in ARP tables",
+                     timeout_secs=10 * 60)
+            ips = pxe.arp_addresses_for(mac_address)
+            logging.info("Host VM has IPs %s", ips)
+            assert len(ips) == 1
+            host_vm.ip = ips[0]
+
+            yield host_vm
+
+            logging.info("Shutting down Host VM")
+            installer.poweroff(host_vm.ip)
+            wait_for(host_vm.is_halted, "Wait for host VM halted")
+
+        except Exception as e:
+            logging.critical("caught exception %s", e)
+            host_vm.shutdown(force=True)
+            raise
+        except KeyboardInterrupt:
+            logging.warning("keyboard interrupt")
+            host_vm.shutdown(force=True)
+            raise
+
         host_vm.eject_cd()
     finally:
         if remote_iso:
diff --git a/tests/install/test.py b/tests/install/test.py
index 48cb52f0..2c9b8eec 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -1,6 +1,8 @@
 import logging
 import pytest
 
+from lib import installer
+
 from data import NETWORKS
 assert "MGMT" in NETWORKS
 
@@ -30,4 +32,5 @@ class TestNested:
             vifs=[dict(index=0, network_name=NETWORKS["MGMT"])],
         ))
     def test_install(self, vm_booted_with_installer):
-        pass
+        host_vm = vm_booted_with_installer
+        installer.monitor_install(ip=host_vm.ip)

From 39bc80bf17165ca602a4076bd1782341ec5e8211 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Tue, 25 Jun 2024 13:04:16 +0200
Subject: [PATCH 09/41] install 5/n: make sure host running installer appears
 in PXE ARP tables

Detection of host IP till now relies on the fact we download the
answerfile from PXE server.  Once we take this file from the ISO this
network traffic won't happen so we need some other mechanism to fill the
server's ARP tables.

test-pingpxe.service is installed in install.img by iso-remaster.

Since it is difficult to wait until the IP has been assigned before
launching the service, make it ping continuously until we can reach
the PXE server.

One the installed host we will set a static IP instead.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 tests/install/conftest.py | 47 ++++++++++++++++++++++++++++++++++++++-
 1 file changed, 46 insertions(+), 1 deletion(-)

diff --git a/tests/install/conftest.py b/tests/install/conftest.py
index 3829e3a8..38e18813 100644
--- a/tests/install/conftest.py
+++ b/tests/install/conftest.py
@@ -8,7 +8,7 @@
 from lib.commands import local_cmd
 
 from data import (ANSWERFILE_URL, ISO_IMAGES, ISO_IMAGES_BASE, ISO_IMAGES_CACHE,
-                  TEST_SSH_PUBKEY, TOOLS)
+                  PXE_CONFIG_SERVER, TEST_SSH_PUBKEY, TOOLS)
 
 @pytest.fixture(scope='function')
 def installer_iso(request):
@@ -32,6 +32,8 @@ def installer_iso(request):
 # Remasters the ISO sepecified by `installer_iso` mark, with:
 # - network and ssh support activated, and .ssh/authorized_key so tests can
 #   go probe installation process
+# - a test-pingpxe.service running in installer system, to make it possible
+#   for the test to determine the dynamic IP obtained during installation
 # - atexit=shell to prevent the system from spontaneously rebooting
 # - an answerfile to make the install process non-interactive (downloaded from URL)
 # - a postinstall script to modify the installed system with:
@@ -59,6 +61,49 @@ def remastered_iso(installer_iso):
 mkdir -p "$INSTALLIMG/root/.ssh"
 echo "{TEST_SSH_PUBKEY}" > "$INSTALLIMG/root/.ssh/authorized_keys"
 
+mkdir -p "$INSTALLIMG/usr/local/sbin"
+cat > "$INSTALLIMG/usr/local/sbin/test-pingpxe.sh" << 'EOF'
+#! /bin/bash
+set -eE
+set -o pipefail
+
+if [ $(readlink "/bin/ping") = busybox ]; then
+    # XS before 7.0
+    PINGARGS=""
+else
+    PINGARGS="-c1"
+fi
+
+ping $PINGARGS "$1"
+EOF
+chmod +x "$INSTALLIMG/usr/local/sbin/test-pingpxe.sh"
+
+if [ -d "$INSTALLIMG/etc/systemd/system" ]; then
+    cat > "$INSTALLIMG/etc/systemd/system/test-pingpxe.service" <<EOF
+[Unit]
+Description=Ping pxe server to populate its ARP table
+After=network-online.target
+[Service]
+Type=oneshot
+ExecStart=/bin/sh -c 'while ! /usr/local/sbin/test-pingpxe.sh "{PXE_CONFIG_SERVER}"; do sleep 1 ; done'
+[Install]
+WantedBy=default.target
+EOF
+
+    systemctl --root="$INSTALLIMG" enable test-pingpxe.service
+else # sysv scripts for before XS 7.x
+    cat > "$INSTALLIMG/etc/init.d/S12test-pingpxe" <<'EOF'
+#!/bin/sh
+case "$1" in
+  start)
+    sh -c 'while ! /usr/local/sbin/test-pingpxe.sh "{PXE_CONFIG_SERVER}"; do sleep 1 ; done' & ;;
+  stop) ;;
+esac
+EOF
+
+    chmod +x "$INSTALLIMG/etc/init.d/S12test-pingpxe"
+fi
+
 cat > "$INSTALLIMG/root/postinstall.sh" <<'EOF'
 #!/bin/sh
 set -ex

From 4fefd93b84e2861bae7b621bdaa964d1c323f401 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Fri, 2 Aug 2024 14:18:50 +0200
Subject: [PATCH 10/41] install 6/n: install test-pingpxe service on host

This is necessary to get rid of old ARP cache entries that would match
our IP to the MAC used by the VM clone in a previous test.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 tests/install/conftest.py | 26 ++++++++++++++++++++++++++
 1 file changed, 26 insertions(+)

diff --git a/tests/install/conftest.py b/tests/install/conftest.py
index 38e18813..bfdd9b62 100644
--- a/tests/install/conftest.py
+++ b/tests/install/conftest.py
@@ -38,6 +38,9 @@ def installer_iso(request):
 # - an answerfile to make the install process non-interactive (downloaded from URL)
 # - a postinstall script to modify the installed system with:
 #   - the same .ssh/authorized_key
+#   - the same test-pingpxe.service, which is also useful even with static IP,
+#     in contexts where the same IP is reused by successively different MACs
+#     (when cloning VMs from cache)
 @pytest.fixture(scope='function')
 def remastered_iso(installer_iso):
     iso_file = installer_iso['iso']
@@ -67,6 +70,19 @@ def remastered_iso(installer_iso):
 set -eE
 set -o pipefail
 
+ether_of () {{
+    ifconfig "$1" | grep ether | sed 's/.*ether \\([^ ]*\\).*/\\1/'
+}}
+
+# on installed system, avoid xapi-project/xen-api#5799
+if ! [ -e /opt/xensource/installer ]; then
+    eth_mac=$(ether_of eth0)
+    br_mac=$(ether_of xenbr0)
+
+    # wait for bridge MAC to be fixed
+    test "$eth_mac" = "$br_mac"
+fi
+
 if [ $(readlink "/bin/ping") = busybox ]; then
     # XS before 7.0
     PINGARGS=""
@@ -110,6 +126,16 @@ def remastered_iso(installer_iso):
 
 ROOT="$1"
 
+mkdir -p "$ROOT/usr/local/sbin"
+cp /usr/local/sbin/test-pingpxe.sh "$ROOT/usr/local/sbin/test-pingpxe.sh"
+if [ -d "$ROOT/etc/systemd/system" ]; then
+    cp /etc/systemd/system/test-pingpxe.service "$ROOT/etc/systemd/system/test-pingpxe.service"
+    systemctl --root="$ROOT" enable test-pingpxe.service
+else
+    cp /etc/init.d/S12test-pingpxe "$ROOT/etc/init.d/test-pingpxe"
+    ln -s ../init.d/test-pingpxe "$ROOT/etc/rc3.d/S11test-pingpxe"
+fi
+
 mkdir -p "$ROOT/root/.ssh"
 echo "{TEST_SSH_PUBKEY}" >> "$ROOT/root/.ssh/authorized_keys"
 EOF

From e62b2d01ed0c83bd1d93ecf1ca335d0ab08a7ccc Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Mon, 7 Oct 2024 17:55:49 +0200
Subject: [PATCH 11/41] install 7/n: answerfile generation
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

This process has several steps:
- building of a data structure holding all of the answerfile data,
  from a customizable base in data.py and from tests-specific items
- serialization as XML to be read by host-installer
- necessary changes to the ISO for host-installer to use it

We now have to explicitly enable the network during access (was implied by
the use of a remote answerfile).  Similarly we now rely on the test-pingpxe
service, as nothing else would otherwise populate the server's ARP table.

This is needed so:
- different tests can use different parameters without the need for
  provisionning every answerfile to be used
- tests can dynamically add contents for their own needs, before the
  XML gets actualy written

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>

use legacycrypt in place of crypt

crypt has been deprecated in python 3.11 and removed in 3.13

replacing crypt with another lib is not that easy. See

  https://eighty-twenty.org/2024/01/13/python-crypt-shacrypt

as an example. crypt_r is an alternative to legacycrypt that exports
the crypt module, but doesn't come with a binary installable package.

  https://pypi.org/project/crypt-r/

Signed-off-by: Gaƫtan Lehmann <gaetan.lehmann@vates.tech>
---
 data.py-dist                   | 27 ++++++++++++--
 jobs.py                        |  1 +
 lib/installer.py               | 57 ++++++++++++++++++++++++++++++
 pytest.ini                     |  1 +
 requirements/base.txt          |  1 +
 tests/install/conftest.py      | 64 +++++++++++++++++++++++++++++++---
 tests/install/test.py          |  6 ++++
 tests/install/test_fixtures.py | 20 +++++++++++
 8 files changed, 171 insertions(+), 6 deletions(-)
 create mode 100644 tests/install/test_fixtures.py

diff --git a/data.py-dist b/data.py-dist
index 091b37d9..9ca1ae08 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -1,6 +1,9 @@
 # Configuration file, to be adapted to one's needs
 
 from typing import Any, Dict, TYPE_CHECKING
+
+import legacycrypt as crypt     # type: ignore
+
 if TYPE_CHECKING:
     from lib.typing import IsoImageDef
 
@@ -10,6 +13,13 @@ if TYPE_CHECKING:
 HOST_DEFAULT_USER = "root"
 HOST_DEFAULT_PASSWORD = ""
 
+def hash_password(password):
+    """Hash password for /etc/password."""
+    salt = crypt.mksalt(crypt.METHOD_SHA512)
+    return crypt.crypt(password, salt)
+
+HOST_DEFAULT_PASSWORD_HASH = hash_password(HOST_DEFAULT_PASSWORD)
+
 # Public key for a private key available to the test runner
 TEST_SSH_PUBKEY = """
 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIMnN/wVdQqHA8KsndfrLS7fktH/IEgxoa533efuXR6rw XCP-ng CI
@@ -92,8 +102,6 @@ OTHER_GUEST_TOOLS = {
     },
 }
 
-ANSWERFILE_URL = f"http://{PXE_CONFIG_SERVER}/configs/custom/ydi/install-8.2-uefi-iso-ext.xml" # FIXME
-
 # Tools
 TOOLS: Dict[str, str] = {
 #    "iso-remaster": "/home/user/src/xcpng/xcp/scripts/iso-remaster/iso-remaster.sh",
@@ -188,6 +196,21 @@ LVMOISCSI_DEVICE_CONFIG: Dict[str, Dict[str, str]] = {
 #    'SCSIid': 'id'
 }
 
+BASE_ANSWERFILES = dict(
+    INSTALL={
+        "TAG": "installation",
+        "CONTENTS": (
+            {"TAG": "root-password",
+             "type": "hash",
+             "CONTENTS": HOST_DEFAULT_PASSWORD_HASH},
+            {"TAG": "timezone",
+             "CONTENTS": "Europe/Paris"},
+            {"TAG": "keymap",
+             "CONTENTS": "us"},
+        ),
+    },
+)
+
 # compatibility settings for older tests
 DEFAULT_NFS_DEVICE_CONFIG = NFS_DEVICE_CONFIG
 DEFAULT_NFS4_DEVICE_CONFIG = NFS4_DEVICE_CONFIG
diff --git a/jobs.py b/jobs.py
index c6f5f52d..7264d410 100755
--- a/jobs.py
+++ b/jobs.py
@@ -29,6 +29,7 @@
             "tests/system",
             "tests/xapi",
             "tests/xapi_plugins",
+            "tests/install/test_fixtures.py",
         ],
         "markers": "(small_vm or no_vm) and not flaky and not reboot and not complex_prerequisites",
     },
diff --git a/lib/installer.py b/lib/installer.py
index 2da97e9e..4eebbe94 100644
--- a/lib/installer.py
+++ b/lib/installer.py
@@ -1,7 +1,64 @@
 import logging
+import xml.etree.ElementTree as ET
+
 from lib.commands import ssh, SSHCommandFailed
 from lib.common import wait_for
 
+class AnswerFile:
+    def __init__(self, kind, /):
+        from data import BASE_ANSWERFILES
+        defn = BASE_ANSWERFILES[kind]
+        self.defn = self._normalize_structure(defn)
+
+    def write_xml(self, filename):
+        etree = ET.ElementTree(self._defn_to_xml_et(self.defn))
+        etree.write(filename)
+
+    # chainable mutators for lambdas
+
+    def top_append(self, *defs):
+        for defn in defs:
+            self.defn['CONTENTS'].append(self._normalize_structure(defn))
+        return self
+
+    def top_setattr(self, attrs):
+        assert 'CONTENTS' not in attrs
+        self.defn.update(attrs)
+        return self
+
+    # makes a mutable deep copy of all `contents`
+    @staticmethod
+    def _normalize_structure(defn):
+        assert isinstance(defn, dict)
+        assert 'TAG' in defn
+        defn = dict(defn)
+        if 'CONTENTS' not in defn:
+            defn['CONTENTS'] = []
+        if not isinstance(defn['CONTENTS'], str):
+            defn['CONTENTS'] = [AnswerFile._normalize_structure(item)
+                                for item in defn['CONTENTS']]
+        return defn
+
+    # convert to a ElementTree.Element tree suitable for further
+    # modification before we serialize it to XML
+    @staticmethod
+    def _defn_to_xml_et(defn, /, *, parent=None):
+        assert isinstance(defn, dict)
+        defn = dict(defn)
+        name = defn.pop('TAG')
+        assert isinstance(name, str)
+        contents = defn.pop('CONTENTS', ())
+        assert isinstance(contents, (str, list))
+        element = ET.Element(name, **defn)
+        if parent is not None:
+            parent.append(element)
+        if isinstance(contents, str):
+            element.text = contents
+        else:
+            for contents in contents:
+                AnswerFile._defn_to_xml_et(contents, parent=element)
+        return element
+
 def poweroff(ip):
     try:
         ssh(ip, ["poweroff"])
diff --git a/pytest.ini b/pytest.ini
index cd178e0f..cef49f76 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -22,6 +22,7 @@ markers =
     vm_definitions: dicts of VM defs for create_vms fixture.
 
     # * installation-related markers to customize installer run
+    answerfile: dict defining an answerfile
     iso_version: key of data.ISO_IMAGES identifying an installer ISO
 
     # * Test targets related to VMs
diff --git a/requirements/base.txt b/requirements/base.txt
index ec0db963..aa0e20d8 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -3,3 +3,4 @@ packaging>=20.7
 pytest>=8.0.0
 pluggy>=1.1.0
 requests
+legacycrypt
diff --git a/tests/install/conftest.py b/tests/install/conftest.py
index bfdd9b62..bc19d792 100644
--- a/tests/install/conftest.py
+++ b/tests/install/conftest.py
@@ -2,14 +2,57 @@
 import os
 import pytest
 import tempfile
+import xml.etree.ElementTree as ET
 
 from lib import installer, pxe
 from lib.common import callable_marker, url_download, wait_for
+from lib.installer import AnswerFile
 from lib.commands import local_cmd
 
-from data import (ANSWERFILE_URL, ISO_IMAGES, ISO_IMAGES_BASE, ISO_IMAGES_CACHE,
+from data import (ISO_IMAGES, ISO_IMAGES_BASE, ISO_IMAGES_CACHE,
                   PXE_CONFIG_SERVER, TEST_SSH_PUBKEY, TOOLS)
 
+@pytest.fixture(scope='function')
+def answerfile(request):
+    """
+    Makes an AnswerFile object available to test and other fixtures.
+
+    AnswerFile object are typically generated from a template
+    customizable in `data.py` specified to the ctor, and extended by:
+    - adding attributes to the top element
+    - appending new elements to the top element's children
+
+    > @pytest.mark.answerfile(lambda firmware: AnswerFile("INSTALL")
+    >                         .top_setattr({"sr-type": local_sr})
+    >                         .top_append(
+    >                             {"TAG": "source", "type": "local"},
+    >                             {"TAG": "primary-disk",
+    >                              "guest-storage": "yes",
+    >                              "CONTENTS": {"uefi": "nvme0n1", "bios": "sda"}[firmware]},
+    >                         ))
+    > def test_install(answerfile):
+    >     answerfile.write_xml("my-answers.xml")
+    """
+    marker = request.node.get_closest_marker("answerfile")
+
+    if marker is None:
+        yield None              # no answerfile to generate
+        return
+
+    # construct answerfile definition from option "base", and explicit bits
+    answerfile_def = callable_marker(marker.args[0], request)
+    assert isinstance(answerfile_def, AnswerFile)
+
+    answerfile_def.top_append(
+        dict(TAG="admin-interface",
+             name="eth0",
+             proto="dhcp",
+             ),
+    )
+
+    yield answerfile_def
+
+
 @pytest.fixture(scope='function')
 def installer_iso(request):
     iso_key = request.node.get_closest_marker("iso_version").args[0]
@@ -35,14 +78,14 @@ def installer_iso(request):
 # - a test-pingpxe.service running in installer system, to make it possible
 #   for the test to determine the dynamic IP obtained during installation
 # - atexit=shell to prevent the system from spontaneously rebooting
-# - an answerfile to make the install process non-interactive (downloaded from URL)
+# - a generated answerfile to make the install process non-interactive
 # - a postinstall script to modify the installed system with:
 #   - the same .ssh/authorized_key
 #   - the same test-pingpxe.service, which is also useful even with static IP,
 #     in contexts where the same IP is reused by successively different MACs
 #     (when cloning VMs from cache)
 @pytest.fixture(scope='function')
-def remastered_iso(installer_iso):
+def remastered_iso(installer_iso, answerfile):
     iso_file = installer_iso['iso']
     assert "iso-remaster" in TOOLS
     iso_remaster = TOOLS["iso-remaster"]
@@ -52,6 +95,15 @@ def remastered_iso(installer_iso):
         remastered_iso = os.path.join(isotmp, "image.iso")
         img_patcher_script = os.path.join(isotmp, "img-patcher")
         iso_patcher_script = os.path.join(isotmp, "iso-patcher")
+        answerfile_xml = os.path.join(isotmp, "answerfile.xml")
+
+        if answerfile:
+            logging.info("generating answerfile %s", answerfile_xml)
+            answerfile.top_append(dict(TAG="script", stage="filesystem-populated",
+                                       type="url", CONTENTS="file:///root/postinstall.sh"))
+            answerfile.write_xml(answerfile_xml)
+        else:
+            logging.info("no answerfile")
 
         logging.info("Remastering %s to %s", iso_file, remastered_iso)
 
@@ -64,6 +116,9 @@ def remastered_iso(installer_iso):
 mkdir -p "$INSTALLIMG/root/.ssh"
 echo "{TEST_SSH_PUBKEY}" > "$INSTALLIMG/root/.ssh/authorized_keys"
 
+test ! -e "{answerfile_xml}" ||
+    cp "{answerfile_xml}" "$INSTALLIMG/root/answerfile.xml"
+
 mkdir -p "$INSTALLIMG/usr/local/sbin"
 cat > "$INSTALLIMG/usr/local/sbin/test-pingpxe.sh" << 'EOF'
 #! /bin/bash
@@ -150,7 +205,8 @@ def remastered_iso(installer_iso):
 set -ex
 ISODIR="$1"
 SED_COMMANDS=(-e "s@/vmlinuz@/vmlinuz network_device=all sshpassword={passwd} atexit=shell@")
-SED_COMMANDS+=(-e "s@/vmlinuz@/vmlinuz install answerfile={ANSWERFILE_URL}@")
+test ! -e "{answerfile_xml}" ||
+    SED_COMMANDS+=(-e "s@/vmlinuz@/vmlinuz install answerfile=file:///root/answerfile.xml@")
 
 
 shopt -s nullglob # there may be no grub config, eg for XS 6.5 and earlier
diff --git a/tests/install/test.py b/tests/install/test.py
index 2c9b8eec..2a0a7b8e 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -2,6 +2,7 @@
 import pytest
 
 from lib import installer
+from lib.installer import AnswerFile
 
 from data import NETWORKS
 assert "MGMT" in NETWORKS
@@ -31,6 +32,11 @@ class TestNested:
             cd_vbd=dict(device="xvdd", userdevice="3"),
             vifs=[dict(index=0, network_name=NETWORKS["MGMT"])],
         ))
+    @pytest.mark.answerfile(
+        lambda: AnswerFile("INSTALL").top_append(
+            {"TAG": "source", "type": "local"},
+            {"TAG": "primary-disk", "CONTENTS": "nvme0n1"},
+        ))
     def test_install(self, vm_booted_with_installer):
         host_vm = vm_booted_with_installer
         installer.monitor_install(ip=host_vm.ip)
diff --git a/tests/install/test_fixtures.py b/tests/install/test_fixtures.py
new file mode 100644
index 00000000..486ba9cc
--- /dev/null
+++ b/tests/install/test_fixtures.py
@@ -0,0 +1,20 @@
+import logging
+import pytest
+
+from lib.installer import AnswerFile
+
+# test the answerfile fixture can run on 2 parametrized instances
+# of the test in one run
+@pytest.mark.answerfile(lambda: AnswerFile("INSTALL").top_append(
+    {"TAG": "source", "type": "local"},
+    {"TAG": "primary-disk", "text": "nvme0n1"},
+))
+@pytest.mark.parametrize("parm", [
+    1,
+    pytest.param(2, marks=[
+        pytest.mark.dependency(depends=["TestFixtures::test_parametrized_answerfile[1]"]),
+    ]),
+])
+@pytest.mark.dependency
+def test_parametrized_answerfile(answerfile, parm):
+    logging.debug("test_parametrized_answerfile with parm=%s", parm)

From c5e59dd4b66dd01599afea2badc1db24a334e774 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Tue, 25 Jun 2024 09:49:13 +0200
Subject: [PATCH 12/41] install 8/n: use VM cache to chain tests

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 conftest.py                    | 49 ++++++++++++++++++++++++++++++----
 lib/basevm.py                  |  2 +-
 lib/common.py                  | 27 +++++++++++++++++++
 lib/vm.py                      | 24 +++++++++++++++++
 requirements/base.txt          |  1 +
 tests/install/test.py          |  1 +
 tests/install/test_fixtures.py | 11 ++++++++
 7 files changed, 109 insertions(+), 6 deletions(-)

diff --git a/conftest.py b/conftest.py
index b0bc02c9..3e7249bf 100644
--- a/conftest.py
+++ b/conftest.py
@@ -8,13 +8,13 @@
 
 import lib.config as global_config
 
-from lib.common import callable_marker
+from lib.common import callable_marker, shortened_nodeid, prefix_object_name
 from lib.common import wait_for, vm_image, is_uuid
 from lib.common import setup_formatted_and_mounted_disk, teardown_formatted_and_mounted_disk
 from lib.netutil import is_ipv6
 from lib.pool import Pool
 from lib.sr import SR
-from lib.vm import VM
+from lib.vm import VM, vm_cache_key_from_def
 from lib.xo import xo_cli
 
 # Import package-scoped fixtures. Although we need to define them in a separate file so that we can
@@ -450,6 +450,13 @@ def create_vms(request, host):
     > def test_foo(create_vms):
     >    ...
 
+    Example:
+    -------
+    > @pytest.mark.dependency(depends=["test_foo"])
+    > @pytest.mark.vm_definitions(dict(name="vm1", image_test="test_foo", image_vm="vm2"))
+    > def test_bar(create_vms):
+    >    ...
+
     """
     marker = request.node.get_closest_marker("vm_definitions")
     if marker is None:
@@ -459,8 +466,10 @@ def create_vms(request, host):
     for vm_def in marker.args:
         vm_def = callable_marker(vm_def, request)
         assert "name" in vm_def
-        assert "template" in vm_def
-        # FIXME should check optional vdis contents
+        assert "template" in vm_def or "image_test" in vm_def
+        if "template" in vm_def:
+            assert "image_test" not in vm_def
+            # FIXME should check optional vdis contents
         # FIXME should check for extra args
         vm_defs.append(vm_def)
 
@@ -469,9 +478,27 @@ def create_vms(request, host):
         vdis = []
         vbds = []
         for vm_def in vm_defs:
-            _create_vm(request, vm_def, host, vms, vdis, vbds)
+            if "template" in vm_def:
+                _create_vm(request, vm_def, host, vms, vdis, vbds)
+            elif "image_test" in vm_def:
+                _vm_from_cache(request, vm_def, host, vms)
         yield vms
 
+        # request.node is an "item" because this fixture has "function" scope
+        report = request.node.stash.get(PHASE_REPORT_KEY, None)
+        if report is None:
+            # user interruption during setup
+            logging.warning("test setup result not available: not exporting VMs")
+        elif report["setup"].failed:
+            logging.warning("setting up a test failed or skipped: not exporting VMs")
+        elif ("call" not in report) or report["call"].failed:
+            logging.warning("executing test failed or skipped: not exporting VMs")
+        else:
+            # record this state
+            for vm_def, vm in zip(vm_defs, vms):
+                nodeid = shortened_nodeid(request.node.nodeid)
+                vm.save_to_cache(f"{nodeid}-{vm_def['name']}")
+
     except Exception:
         logging.error("exception caught...")
         raise
@@ -525,6 +552,18 @@ def _create_vm(request, vm_def, host, vms, vdis, vbds):
             logging.info("Setting param %s", param_def)
             vm.param_set(**param_def)
 
+def _vm_from_cache(request, vm_def, host, vms):
+    base_vm = host.cached_vm(vm_cache_key_from_def(vm_def, request.node.nodeid),
+                             sr_uuid=host.main_sr_uuid())
+
+    # Clone the VM before running tests, so that the original VM remains untouched
+    logging.info("Cloning VM from cache")
+    vm = base_vm.clone(name=prefix_object_name(_vm_name(request, vm_def)))
+    # Remove the description, which may contain a cache identifier
+    vm.param_set('name-description', "")
+
+    vms.append(vm)
+
 @pytest.fixture(scope="module")
 def started_vm(imported_vm):
     vm = imported_vm
diff --git a/lib/basevm.py b/lib/basevm.py
index e5a84c5a..31c25703 100644
--- a/lib/basevm.py
+++ b/lib/basevm.py
@@ -100,7 +100,7 @@ def get_sr(self):
         assert sr.attached_to_host(self.host)
         return sr
 
-    def export(self, filepath, compress='none') -> None:
+    def export(self, filepath, compress='none', use_cache=False) -> None:
         logging.info("Export VM %s to %s with compress=%s" % (self.uuid, filepath, compress))
         params = {
             'uuid': self.uuid,
diff --git a/lib/common.py b/lib/common.py
index 1496ac12..2c8b36e9 100644
--- a/lib/common.py
+++ b/lib/common.py
@@ -1,5 +1,6 @@
 import getpass
 import inspect
+import itertools
 import logging
 import os
 import sys
@@ -40,6 +41,32 @@ def prefix_object_name(label):
         name_prefix = f"[{getpass.getuser()}]"
     return f"{name_prefix} {label}"
 
+def shortened_nodeid(nodeid):
+    components = nodeid.split("::")
+    # module
+    components[0] = strip_prefix(components[0], "tests/")
+    components[0] = strip_suffix(components[0], ".py")
+    components[0] = components[0].replace("/", ".")
+    # function
+    components[-1] = strip_prefix(components[-1], "test_")
+    # class
+    if len(components) > 2:
+        components[1] = strip_prefix(components[1], "Test")
+
+    return "::".join(components)
+
+def expand_scope_relative_nodeid(scoped_nodeid, scope, ref_nodeid):
+    if scope == 'session' or scope == 'package':
+        base = ()
+    elif scope == 'module':
+        base = ref_nodeid.split("::", 1)[:1]
+    elif scope == 'class':
+        base = ref_nodeid.split("::", 2)[:2]
+    else:
+        raise RuntimeError(f"Internal error: invalid scope {scope!r}")
+    logging.debug("scope: %r base: %r relative: %r", scope, base, scoped_nodeid)
+    return "::".join(itertools.chain(base, (scoped_nodeid,)))
+
 def callable_marker(value, request):
     """
     Process value optionally generated by fixture-dependent callable.
diff --git a/lib/vm.py b/lib/vm.py
index ba93882e..49f47575 100644
--- a/lib/vm.py
+++ b/lib/vm.py
@@ -11,6 +11,7 @@
 
 from lib.basevm import BaseVM
 from lib.common import PackageManagerEnum, parse_xe_dict, safe_split, strtobool, wait_for, wait_for_not
+from lib.common import shortened_nodeid, expand_scope_relative_nodeid
 from lib.snapshot import Snapshot
 from lib.vbd import VBD
 from lib.vif import VIF
@@ -755,3 +756,26 @@ def are_windows_tools_uninstalled(self):
             and not self.are_windows_services_present()
             and not self.are_windows_drivers_present()
         )
+
+    def save_to_cache(self, cache_id):
+        logging.info("Save VM %s to cache for %r as a clone" % (self.uuid, cache_id))
+
+        while True:
+            old_vm = self.host.cached_vm(cache_id, sr_uuid=self.host.main_sr_uuid())
+            if old_vm is None:
+                break
+            logging.info("Destroying old cache %s first", old_vm.uuid)
+            old_vm.destroy()
+
+        clone = self.clone(name=f"{self.name()} cache")
+        logging.info(f"Marking VM {clone.uuid} as cached")
+        clone.param_set('name-description', self.host.vm_cache_key(cache_id))
+
+
+def vm_cache_key_from_def(vm_def, ref_nodeid):
+    vm_name = vm_def["name"]
+    image_test = vm_def["image_test"]
+    image_vm = vm_def.get("image_vm", vm_name)
+    image_scope = vm_def.get("image_scope", "module")
+    nodeid = shortened_nodeid(expand_scope_relative_nodeid(image_test, image_scope, ref_nodeid))
+    return f"{nodeid}-{image_vm}"
diff --git a/requirements/base.txt b/requirements/base.txt
index aa0e20d8..1d0496d2 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -4,3 +4,4 @@ pytest>=8.0.0
 pluggy>=1.1.0
 requests
 legacycrypt
+pytest-dependency
diff --git a/tests/install/test.py b/tests/install/test.py
index 2a0a7b8e..4a199ce2 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -10,6 +10,7 @@
 # Requirements:
 # - one XCP-ng host capable of nested virt, with an ISO SR, and a default SR
 
+@pytest.mark.dependency()
 class TestNested:
     @pytest.mark.iso_version("821.1")
     @pytest.mark.vm_definitions(
diff --git a/tests/install/test_fixtures.py b/tests/install/test_fixtures.py
index 486ba9cc..64d21c3c 100644
--- a/tests/install/test_fixtures.py
+++ b/tests/install/test_fixtures.py
@@ -18,3 +18,14 @@
 @pytest.mark.dependency
 def test_parametrized_answerfile(answerfile, parm):
     logging.debug("test_parametrized_answerfile with parm=%s", parm)
+
+@pytest.mark.dependency
+class TestManualChaining:
+    @pytest.mark.vm_definitions(dict(name="vm1", template="Other install media"))
+    def test_create(self, create_vms):
+        logging.debug("TestManualChaining::test_create: %s", create_vms)
+
+    @pytest.mark.dependency(depends=["TestManualChaining::test_create"])
+    @pytest.mark.vm_definitions(dict(name="vm1", image_test="TestManualChaining::test_create"))
+    def test_chain(self, create_vms):
+        logging.debug("TestManualChaining::test_chain")

From a7d71b35ebdc7ab8da80b8174c7aef518df64077 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Thu, 25 Jul 2024 17:25:43 +0200
Subject: [PATCH 13/41] install 9/n: add firstboot test

This is not ideal, as we would need to encapsulate machine boot in a
fixture - that will be fixed when we switch away from
pytest-dependency.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 tests/install/test.py | 89 ++++++++++++++++++++++++++++++++++++++++++-
 1 file changed, 88 insertions(+), 1 deletion(-)

diff --git a/tests/install/test.py b/tests/install/test.py
index 4a199ce2..c41b3905 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -1,8 +1,10 @@
 import logging
 import pytest
 
-from lib import installer
+from lib import commands, installer, pxe
+from lib.common import wait_for
 from lib.installer import AnswerFile
+from lib.pool import Pool
 
 from data import NETWORKS
 assert "MGMT" in NETWORKS
@@ -41,3 +43,88 @@ class TestNested:
     def test_install(self, vm_booted_with_installer):
         host_vm = vm_booted_with_installer
         installer.monitor_install(ip=host_vm.ip)
+
+    def _test_firstboot(self, create_vms):
+        host_vm = create_vms[0]
+        vif = host_vm.vifs()[0]
+        mac_address = vif.param_get('MAC')
+        logging.info("Host VM has MAC %s", mac_address)
+
+        try:
+            # FIXME: evict MAC from ARP cache first?
+            host_vm.start()
+            wait_for(host_vm.is_running, "Wait for host VM running")
+
+            # catch host-vm IP address
+            wait_for(lambda: pxe.arp_addresses_for(mac_address),
+                     "Wait for DHCP server to see Host VM in ARP tables",
+                     timeout_secs=10 * 60)
+            ips = pxe.arp_addresses_for(mac_address)
+            logging.info("Host VM has IPs %s", ips)
+            assert len(ips) == 1
+            host_vm.ip = ips[0]
+
+            wait_for(
+                lambda: commands.local_cmd(
+                    ["nc", "-zw5", host_vm.ip, "22"], check=False).returncode == 0,
+                "Wait for ssh back up on Host VM", retry_delay_secs=5, timeout_secs=4 * 60)
+
+            # pool master must be reachable here
+            pool = Pool(host_vm.ip)
+
+            # wait for XAPI
+            wait_for(pool.master.is_enabled, "Wait for XAPI to be ready", timeout_secs=30 * 60)
+
+            # check for firstboot issues
+            # FIXME: flaky, must check logs extraction on failure
+            for service in ["control-domain-params-init",
+                            "network-init",
+                            "storage-init",
+                            "generate-iscsi-iqn",
+                            "create-guest-templates",
+                            ]:
+                try:
+                    wait_for(lambda: pool.master.ssh(["test", "-e", f"/var/lib/misc/ran-{service}"],
+                                                     check=False, simple_output=False,
+                                                     ).returncode == 0,
+                             f"Wait for ran-{service} stamp")
+                except TimeoutError:
+                    logging.warning("investigating lack of %s service stamp", service)
+                    out = pool.master.ssh(["systemctl", "status", service], check=False)
+                    logging.warning("service status: %s", out)
+                    out = pool.master.ssh(["grep", "-r", service, "/var/log"], check=False)
+                    logging.warning("in logs: %s", out)
+                    raise
+
+            logging.info("Powering off pool master")
+            try:
+                # use "poweroff" because "reboot" would cause ARP and
+                # SSH to be checked before host is down, and require
+                # ssh retries
+                pool.master.ssh(["poweroff"])
+            except commands.SSHCommandFailed as e:
+                # ignore connection closed by reboot
+                if e.returncode == 255 and "closed by remote host" in e.stdout:
+                    logging.info("sshd closed the connection")
+                    pass
+                else:
+                    raise
+
+            wait_for(host_vm.is_halted, "Wait for host VM halted")
+
+        except Exception as e:
+            logging.critical("caught exception %s", e)
+            # wait_for(lambda: False, 'Wait "forever"', timeout_secs=100 * 60)
+            host_vm.shutdown(force=True)
+            raise
+        except KeyboardInterrupt:
+            logging.warning("keyboard interrupt")
+            # wait_for(lambda: False, 'Wait "forever"', timeout_secs=100 * 60)
+            host_vm.shutdown(force=True)
+            raise
+
+    @pytest.mark.dependency(depends=["TestNested::test_install"])
+    @pytest.mark.vm_definitions(
+        dict(name="vm1", image_test="TestNested::test_install"))
+    def test_boot_inst(self, create_vms):
+        self._test_firstboot(create_vms)

From f1138aa196738e99b2853ea64b8b8541b2353363 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Wed, 4 Sep 2024 11:43:50 +0200
Subject: [PATCH 14/41] Image caching: include commit hash in caching key

This protects against using results from incompatible test by mistake.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 conftest.py           | 25 ++++++++++++++++++++-----
 lib/vm.py             |  4 ++--
 requirements/base.txt |  1 +
 3 files changed, 23 insertions(+), 7 deletions(-)

diff --git a/conftest.py b/conftest.py
index 3e7249bf..c82731f5 100644
--- a/conftest.py
+++ b/conftest.py
@@ -1,4 +1,5 @@
 import itertools
+import git
 import logging
 import pytest
 import tempfile
@@ -412,8 +413,20 @@ def imported_vm(host, vm_ref):
         logging.info("<< Destroy VM")
         vm.destroy(verify=True)
 
+@pytest.fixture(scope="session")
+def tests_git_revision():
+    """
+    Get the git revision string for this tests repo.
+
+    Use of this fixture means impacted tests cannot run unless all
+    modifications are commited.
+    """
+    test_repo = git.Repo(".")
+    assert not test_repo.is_dirty(), "test repo must not be dirty"
+    yield test_repo.head.commit.hexsha
+
 @pytest.fixture(scope="function")
-def create_vms(request, host):
+def create_vms(request, host, tests_git_revision):
     """
     Returns list of VM objects created from `vm_definitions` marker.
 
@@ -481,7 +494,7 @@ def create_vms(request, host):
             if "template" in vm_def:
                 _create_vm(request, vm_def, host, vms, vdis, vbds)
             elif "image_test" in vm_def:
-                _vm_from_cache(request, vm_def, host, vms)
+                _vm_from_cache(request, vm_def, host, vms, tests_git_revision)
         yield vms
 
         # request.node is an "item" because this fixture has "function" scope
@@ -497,7 +510,7 @@ def create_vms(request, host):
             # record this state
             for vm_def, vm in zip(vm_defs, vms):
                 nodeid = shortened_nodeid(request.node.nodeid)
-                vm.save_to_cache(f"{nodeid}-{vm_def['name']}")
+                vm.save_to_cache(f"{nodeid}-{vm_def['name']}-{tests_git_revision}")
 
     except Exception:
         logging.error("exception caught...")
@@ -552,9 +565,11 @@ def _create_vm(request, vm_def, host, vms, vdis, vbds):
             logging.info("Setting param %s", param_def)
             vm.param_set(**param_def)
 
-def _vm_from_cache(request, vm_def, host, vms):
-    base_vm = host.cached_vm(vm_cache_key_from_def(vm_def, request.node.nodeid),
+def _vm_from_cache(request, vm_def, host, vms, tests_hexsha):
+    base_vm = host.cached_vm(vm_cache_key_from_def(vm_def, request.node.nodeid, tests_hexsha),
                              sr_uuid=host.main_sr_uuid())
+    if base_vm is None:
+        raise RuntimeError("No cache found")
 
     # Clone the VM before running tests, so that the original VM remains untouched
     logging.info("Cloning VM from cache")
diff --git a/lib/vm.py b/lib/vm.py
index 49f47575..beadf775 100644
--- a/lib/vm.py
+++ b/lib/vm.py
@@ -772,10 +772,10 @@ def save_to_cache(self, cache_id):
         clone.param_set('name-description', self.host.vm_cache_key(cache_id))
 
 
-def vm_cache_key_from_def(vm_def, ref_nodeid):
+def vm_cache_key_from_def(vm_def, ref_nodeid, test_gitref):
     vm_name = vm_def["name"]
     image_test = vm_def["image_test"]
     image_vm = vm_def.get("image_vm", vm_name)
     image_scope = vm_def.get("image_scope", "module")
     nodeid = shortened_nodeid(expand_scope_relative_nodeid(image_test, image_scope, ref_nodeid))
-    return f"{nodeid}-{image_vm}"
+    return f"{nodeid}-{image_vm}-{test_gitref}"
diff --git a/requirements/base.txt b/requirements/base.txt
index 1d0496d2..ecfd49e5 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -1,4 +1,5 @@
 cryptography>=3.3.1
+GitPython
 packaging>=20.7
 pytest>=8.0.0
 pluggy>=1.1.0

From fd2f31b1028495a39fe36a376977062dc65c150f Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Tue, 9 Jul 2024 09:23:08 +0200
Subject: [PATCH 15/41] Image caching: allow to declare image equivalence

The key used to locate a VM image in the VM cache depends on the test repo
commit hash, to protect against using results from incompatible test by
mistake.  But the commit hash can change for many reasons that do not
influence the parent tests, so this provides a way to use known-equivalent
test outputs.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist | 5 +++++
 lib/vm.py    | 5 ++++-
 2 files changed, 9 insertions(+), 1 deletion(-)

diff --git a/data.py-dist b/data.py-dist
index 9ca1ae08..8690c4b4 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -211,6 +211,11 @@ BASE_ANSWERFILES = dict(
     },
 )
 
+IMAGE_EQUIVS: Dict[str, str] = {
+#    'install.test::Nested::install[bios-83rc1-ext]-vm1-607cea0c825a4d578fa5fab56978627d8b2e28bb':
+#    'install.test::Nested::install[bios-83rc1-ext]-vm1-addb4ead4da49856e1d2fb3ddf4e31027c6b693b',
+}
+
 # compatibility settings for older tests
 DEFAULT_NFS_DEVICE_CONFIG = NFS_DEVICE_CONFIG
 DEFAULT_NFS4_DEVICE_CONFIG = NFS4_DEVICE_CONFIG
diff --git a/lib/vm.py b/lib/vm.py
index beadf775..8f9541c8 100644
--- a/lib/vm.py
+++ b/lib/vm.py
@@ -778,4 +778,7 @@ def vm_cache_key_from_def(vm_def, ref_nodeid, test_gitref):
     image_vm = vm_def.get("image_vm", vm_name)
     image_scope = vm_def.get("image_scope", "module")
     nodeid = shortened_nodeid(expand_scope_relative_nodeid(image_test, image_scope, ref_nodeid))
-    return f"{nodeid}-{image_vm}-{test_gitref}"
+    image_key = f"{nodeid}-{image_vm}-{test_gitref}"
+
+    from data import IMAGE_EQUIVS
+    return IMAGE_EQUIVS.get(image_key, image_key)

From acdf56a1a964acbfe6146ff74215541738fdeb78 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Fri, 21 Jun 2024 18:06:25 +0200
Subject: [PATCH 16/41] install: use xcpng_chained/continuation_of to simplify
 dependency spec

This will avoid duplication of logic for selecting parent test.

Since dependencies are now generated by fixtures, they are not taken
into account by pytest-order any more.  Looks like some heavy surgery
would be necessary to get that back.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 pytest.ini                |  3 ++-
 tests/install/conftest.py | 19 +++++++++++++++++++
 tests/install/test.py     |  6 +++---
 3 files changed, 24 insertions(+), 4 deletions(-)

diff --git a/pytest.ini b/pytest.ini
index cef49f76..77a6feb2 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -19,7 +19,8 @@ markers =
     windows_vm: tests that require a Windows VM to run.
 
     # * VM-related markers to give parameters to fixtures
-    vm_definitions: dicts of VM defs for create_vms fixture.
+    vm_definitions: dicts of VM nick to VM defs for create_vms fixture.
+    continuation_of: dicts of VM nick to test (and soon VM nick) from which to start
 
     # * installation-related markers to customize installer run
     answerfile: dict defining an answerfile
diff --git a/tests/install/conftest.py b/tests/install/conftest.py
index bc19d792..437afde2 100644
--- a/tests/install/conftest.py
+++ b/tests/install/conftest.py
@@ -1,6 +1,7 @@
 import logging
 import os
 import pytest
+import pytest_dependency        # type: ignore
 import tempfile
 import xml.etree.ElementTree as ET
 
@@ -272,3 +273,21 @@ def vm_booted_with_installer(host, create_vms, remastered_iso):
     finally:
         if remote_iso:
             host.pool.remove_iso(remote_iso)
+
+@pytest.fixture(scope='function')
+def xcpng_chained(request):
+    # take test name from mark
+    marker = request.node.get_closest_marker("continuation_of")
+    assert marker is not None, "xcpng_chained fixture requires 'continuation_of' marker"
+    continuation_of = callable_marker(marker.args[0], request)
+
+    vm_defs = [dict(name=vm_spec['vm'],
+                    image_test=vm_spec['image_test'],
+                    image_vm=vm_spec.get("image_vm", vm_spec['vm']),
+                    image_scope=vm_spec.get("scope", "module"),
+                    )
+               for vm_spec in continuation_of]
+
+    depends = [vm_spec['image_test'] for vm_spec in continuation_of]
+    pytest_dependency.depends(request, depends)
+    request.applymarker(pytest.mark.vm_definitions(*vm_defs))
diff --git a/tests/install/test.py b/tests/install/test.py
index c41b3905..317870d7 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -123,8 +123,8 @@ def _test_firstboot(self, create_vms):
             host_vm.shutdown(force=True)
             raise
 
-    @pytest.mark.dependency(depends=["TestNested::test_install"])
-    @pytest.mark.vm_definitions(
-        dict(name="vm1", image_test="TestNested::test_install"))
+    @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.continuation_of([dict(vm="vm1",
+                                       image_test="TestNested::test_install")])
     def test_boot_inst(self, create_vms):
         self._test_firstboot(create_vms)

From a5f6d54d79bf858e8bdc4b32cb4b036f593c08b4 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Wed, 4 Sep 2024 18:03:47 +0200
Subject: [PATCH 17/41] remastered_iso: support for unsigned ISOs

Especially useful for images out of CI, which today are not signed yet.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 lib/typing.py             |  1 +
 tests/install/conftest.py | 10 ++++++++++
 2 files changed, 11 insertions(+)

diff --git a/lib/typing.py b/lib/typing.py
index 6d494273..68e9adb0 100644
--- a/lib/typing.py
+++ b/lib/typing.py
@@ -3,4 +3,5 @@
 
 IsoImageDef = TypedDict('IsoImageDef',
                         {'path': str,
+                         'unsigned': NotRequired[bool],
                          })
diff --git a/tests/install/conftest.py b/tests/install/conftest.py
index 437afde2..e6db396f 100644
--- a/tests/install/conftest.py
+++ b/tests/install/conftest.py
@@ -71,6 +71,7 @@ def installer_iso(request):
         local_iso = cached_iso
     logging.info("installer_iso: using %r", local_iso)
     return dict(iso=local_iso,
+                unsigned=ISO_IMAGES[iso_key].get('unsigned', False),
                 )
 
 # Remasters the ISO sepecified by `installer_iso` mark, with:
@@ -88,6 +89,8 @@ def installer_iso(request):
 @pytest.fixture(scope='function')
 def remastered_iso(installer_iso, answerfile):
     iso_file = installer_iso['iso']
+    unsigned = installer_iso['unsigned']
+
     assert "iso-remaster" in TOOLS
     iso_remaster = TOOLS["iso-remaster"]
     assert os.access(iso_remaster, os.X_OK)
@@ -102,6 +105,10 @@ def remastered_iso(installer_iso, answerfile):
             logging.info("generating answerfile %s", answerfile_xml)
             answerfile.top_append(dict(TAG="script", stage="filesystem-populated",
                                        type="url", CONTENTS="file:///root/postinstall.sh"))
+            if unsigned:
+                answerfile.top_setattr({'gpgcheck': "false",
+                                        'repo-gpgcheck': "false",
+                                        })
             answerfile.write_xml(answerfile_xml)
         else:
             logging.info("no answerfile")
@@ -208,6 +215,9 @@ def remastered_iso(installer_iso, answerfile):
 SED_COMMANDS=(-e "s@/vmlinuz@/vmlinuz network_device=all sshpassword={passwd} atexit=shell@")
 test ! -e "{answerfile_xml}" ||
     SED_COMMANDS+=(-e "s@/vmlinuz@/vmlinuz install answerfile=file:///root/answerfile.xml@")
+# assuming *gpgcheck only appear within unsigned ISO
+test "{unsigned}" = False ||
+    SED_COMMANDS+=(-e "s@ no-gpgcheck\\>@@" -e "s@ no-repo-gpgcheck\\>@@")
 
 
 shopt -s nullglob # there may be no grub config, eg for XS 6.5 and earlier

From 68c702cf51e75df2b3306ea186de337cfdd86729 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Mon, 26 Aug 2024 18:15:09 +0200
Subject: [PATCH 18/41] install: add "version" test parameter and
 test-sequences

Changing test parameters may require changes to hardcoded test sequences.

Adding only a single test sequence here to server as example for quick
testing, as further commits will make many changes to test names.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 .github/workflows/test-sequences.yml  | 26 ++++++++++++++++++++++++++
 data.py-dist                          |  5 +++++
 pytest.ini                            |  1 -
 tests/install/conftest.py             |  2 +-
 tests/install/test-sequences/inst.lst |  2 ++
 tests/install/test.py                 | 26 +++++++++++++++++++-------
 6 files changed, 53 insertions(+), 9 deletions(-)
 create mode 100644 .github/workflows/test-sequences.yml
 create mode 100644 tests/install/test-sequences/inst.lst

diff --git a/.github/workflows/test-sequences.yml b/.github/workflows/test-sequences.yml
new file mode 100644
index 00000000..3761c6af
--- /dev/null
+++ b/.github/workflows/test-sequences.yml
@@ -0,0 +1,26 @@
+name: Check test-sequences consistency
+
+on: [push]
+
+jobs:
+  jobs-check:
+    runs-on: ubuntu-latest
+    steps:
+    - uses: actions/checkout@v2
+    - name: Set up Python
+      uses: actions/setup-python@v4
+    - name: Install dependencies
+      run: |
+        python -m pip install --upgrade pip
+        pip install -r requirements/base.txt
+    - name: Create a dummy data.py
+      run: cp data.py-dist data.py
+    - name: jobs-check
+      run: |
+        FAILURES=""
+        for seq in $(find -name "*.lst"); do
+          if ! pytest @$seq --collect-only --quiet; then
+            FAILURES="$FAILURES $seq"
+          fi
+        done
+        [ -z "$FAILURES" ] || { echo >&2 "ERROR: test sequences failed consistency check: $FAILURES"; exit 1; }
diff --git a/data.py-dist b/data.py-dist
index 8690c4b4..cc3ae416 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -3,6 +3,7 @@
 from typing import Any, Dict, TYPE_CHECKING
 
 import legacycrypt as crypt     # type: ignore
+import os
 
 if TYPE_CHECKING:
     from lib.typing import IsoImageDef
@@ -122,6 +123,10 @@ ISO_IMAGES_CACHE = "/home/user/iso"
 # Note the dirname part is ignored when looking in ISO_IMAGES_CACHE, abuse this
 # for local-only ISO with things like "locally-built/my.iso" or "xs/8.3.iso"
 ISO_IMAGES: Dict[str, "IsoImageDef"] = {
+    '83nightly': {'path': os.environ.get("XCPNG83_NIGHTLY",
+                                         "http://unconfigured.iso"),
+                  'unsigned': True},
+    '83rc1': {'path': "8.3/xcp-ng-8.3.0-rc1.iso"},
     '83b2': {'path': "8.3/xcp-ng-8.3.0-beta2.iso"},
     '83b1': {'path': "8.3/xcp-ng-8.3.0-beta1.iso"},
     '821.1': {'path': "8.2/xcp-ng-8.2.1-20231130.iso"},
diff --git a/pytest.ini b/pytest.ini
index 77a6feb2..fe2c9214 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -24,7 +24,6 @@ markers =
 
     # * installation-related markers to customize installer run
     answerfile: dict defining an answerfile
-    iso_version: key of data.ISO_IMAGES identifying an installer ISO
 
     # * Test targets related to VMs
     small_vm: tests that it is enough to run just once, using the smallest possible VM.
diff --git a/tests/install/conftest.py b/tests/install/conftest.py
index e6db396f..46750d22 100644
--- a/tests/install/conftest.py
+++ b/tests/install/conftest.py
@@ -56,7 +56,7 @@ def answerfile(request):
 
 @pytest.fixture(scope='function')
 def installer_iso(request):
-    iso_key = request.node.get_closest_marker("iso_version").args[0]
+    iso_key = request.getfixturevalue("iso_version")
     assert iso_key in ISO_IMAGES, f"ISO_IMAGES does not have a value for {iso_key}"
     iso = ISO_IMAGES[iso_key]['path']
     if iso.startswith("/"):
diff --git a/tests/install/test-sequences/inst.lst b/tests/install/test-sequences/inst.lst
new file mode 100644
index 00000000..7ea2a852
--- /dev/null
+++ b/tests/install/test-sequences/inst.lst
@@ -0,0 +1,2 @@
+tests/install/test.py::TestNested::test_install[83nightly]
+tests/install/test.py::TestNested::test_boot_inst[83nightly]
diff --git a/tests/install/test.py b/tests/install/test.py
index 317870d7..eb1ba80d 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -14,7 +14,11 @@
 
 @pytest.mark.dependency()
 class TestNested:
-    @pytest.mark.iso_version("821.1")
+    @pytest.mark.parametrize("iso_version", (
+        "83nightly",
+        "83rc1", "83b2", "83b1",
+        "821.1",
+    ))
     @pytest.mark.vm_definitions(
         dict(
             name="vm1",
@@ -40,11 +44,12 @@ class TestNested:
             {"TAG": "source", "type": "local"},
             {"TAG": "primary-disk", "CONTENTS": "nvme0n1"},
         ))
-    def test_install(self, vm_booted_with_installer):
+    def test_install(self, vm_booted_with_installer,
+                     iso_version):
         host_vm = vm_booted_with_installer
         installer.monitor_install(ip=host_vm.ip)
 
-    def _test_firstboot(self, create_vms):
+    def _test_firstboot(self, create_vms, mode):
         host_vm = create_vms[0]
         vif = host_vm.vifs()[0]
         mac_address = vif.param_get('MAC')
@@ -124,7 +129,14 @@ def _test_firstboot(self, create_vms):
             raise
 
     @pytest.mark.usefixtures("xcpng_chained")
-    @pytest.mark.continuation_of([dict(vm="vm1",
-                                       image_test="TestNested::test_install")])
-    def test_boot_inst(self, create_vms):
-        self._test_firstboot(create_vms)
+    @pytest.mark.parametrize("version", (
+        "83nightly",
+        "83rc1", "83b2", "83b1",
+        "821.1",
+    ))
+    @pytest.mark.continuation_of(
+        lambda version: [
+            dict(vm="vm1", image_test=f"TestNested::test_install[{version}]")])
+    def test_boot_inst(self, create_vms,
+                       version):
+        self._test_firstboot(create_vms, version)

From c97736244ecbb9f2504965858f3236d66a492d46 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Wed, 4 Sep 2024 18:31:38 +0200
Subject: [PATCH 19/41] Add upgrade test

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist                              |  4 +++
 lib/installer.py                          | 33 +++++++++++++++++++
 tests/install/conftest.py                 |  6 ++++
 tests/install/test-sequences/inst+upg.lst |  2 ++
 tests/install/test.py                     | 39 +++++++++++++++++++++++
 5 files changed, 84 insertions(+)
 create mode 100644 tests/install/test-sequences/inst+upg.lst

diff --git a/data.py-dist b/data.py-dist
index cc3ae416..4c3e0f22 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -214,6 +214,10 @@ BASE_ANSWERFILES = dict(
              "CONTENTS": "us"},
         ),
     },
+    UPGRADE={
+        "TAG": "installation",
+        "mode": "upgrade",
+    },
 )
 
 IMAGE_EQUIVS: Dict[str, str] = {
diff --git a/lib/installer.py b/lib/installer.py
index 4eebbe94..475c7f5d 100644
--- a/lib/installer.py
+++ b/lib/installer.py
@@ -93,3 +93,36 @@ def monitor_install(*, ip):
                          check=False, simple_output=False,
                          ).returncode == 1,
              "Wait for installer to terminate")
+
+def monitor_upgrade(*, ip):
+    # wait for "yum install" phase to start
+    wait_for(lambda: ssh(ip, ["grep",
+                              "'DISPATCH: NEW PHASE: Reading package information'",
+                              "/tmp/install-log"],
+                         check=False, simple_output=False,
+                         ).returncode == 0,
+             "Wait for upgrade preparations to finish",
+             timeout_secs=40 * 60) # FIXME too big
+
+    # wait for "yum install" phase to finish
+    wait_for(lambda: ssh(ip, ["grep",
+                              "'DISPATCH: NEW PHASE: Completing installation'",
+                              "/tmp/install-log"],
+                         check=False, simple_output=False,
+                         ).returncode == 0,
+             "Wait for rpm installation to succeed",
+             timeout_secs=40 * 60) # FIXME too big
+
+    # wait for install to finish
+    wait_for(lambda: ssh(ip, ["grep",
+                              "'The installation completed successfully'",
+                              "/tmp/install-log"],
+                         check=False, simple_output=False,
+                         ).returncode == 0,
+             "Wait for system installation to succeed",
+             timeout_secs=40 * 60) # FIXME too big
+
+    wait_for(lambda: ssh(ip, ["ps a|grep '[0-9]. python /opt/xensource/installer/init'"],
+                         check=False, simple_output=False,
+                         ).returncode == 1,
+             "Wait for installer to terminate")
diff --git a/tests/install/conftest.py b/tests/install/conftest.py
index 46750d22..7cb14d44 100644
--- a/tests/install/conftest.py
+++ b/tests/install/conftest.py
@@ -264,6 +264,12 @@ def vm_booted_with_installer(host, create_vms, remastered_iso):
             assert len(ips) == 1
             host_vm.ip = ips[0]
 
+            # host may not be up if ARP cache was filled
+            wait_for(lambda: local_cmd(["ping", "-c1", host_vm.ip], check=False),
+                     "Wait for host up", timeout_secs=10 * 60, retry_delay_secs=10)
+            wait_for(lambda: local_cmd(["nc", "-zw5", host_vm.ip, "22"], check=False),
+                     "Wait for ssh up on host", timeout_secs=10 * 60, retry_delay_secs=5)
+
             yield host_vm
 
             logging.info("Shutting down Host VM")
diff --git a/tests/install/test-sequences/inst+upg.lst b/tests/install/test-sequences/inst+upg.lst
new file mode 100644
index 00000000..95bc9219
--- /dev/null
+++ b/tests/install/test-sequences/inst+upg.lst
@@ -0,0 +1,2 @@
+tests/install/test.py::TestNested::test_upgrade[83nightly-83nightly]
+tests/install/test.py::TestNested::test_boot_upg[83nightly-83nightly]
diff --git a/tests/install/test.py b/tests/install/test.py
index eb1ba80d..21f979f2 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -140,3 +140,42 @@ def _test_firstboot(self, create_vms, mode):
     def test_boot_inst(self, create_vms,
                        version):
         self._test_firstboot(create_vms, version)
+
+    @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("mode", (
+        "83nightly-83nightly",
+        "83rc1-83nightly",
+        "83b2-83nightly",
+        "83b1-83nightly",
+        "821.1-83nightly",
+        "821.1-821.1",
+    ))
+    @pytest.mark.continuation_of(
+        lambda mode: [dict(
+            vm="vm1",
+            image_test=(f"TestNested::test_upgrade[{mode}]"))])
+    def test_boot_upg(self, create_vms,
+                      mode):
+        self._test_firstboot(create_vms, mode)
+
+    @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize(("orig_version", "iso_version"), [
+        ("83nightly", "83nightly"),
+        ("83rc1", "83nightly"),
+        ("83b2", "83nightly"),
+        ("83b1", "83nightly"),
+        ("821.1", "83nightly"),
+        ("821.1", "821.1"),
+    ])
+    @pytest.mark.continuation_of(
+        lambda orig_version: [dict(vm="vm1",
+                                   image_test=f"TestNested::test_boot_inst[{orig_version}]")])
+    @pytest.mark.answerfile(
+        lambda: AnswerFile("UPGRADE").top_append(
+            {"TAG": "source", "type": "local"},
+            {"TAG": "existing-installation", "CONTENTS": "nvme0n1"},
+        ))
+    def test_upgrade(self, vm_booted_with_installer,
+                     orig_version, iso_version):
+        host_vm = vm_booted_with_installer
+        installer.monitor_upgrade(ip=host_vm.ip)

From cb2af06c598fdf792f579f19de6d1bd87dca52f8 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Wed, 9 Oct 2024 11:14:29 +0200
Subject: [PATCH 20/41] install: add a "firmware" parameter

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 tests/install/conftest.py                 |  5 +++
 tests/install/test-sequences/inst+upg.lst |  4 +-
 tests/install/test-sequences/inst.lst     |  4 +-
 tests/install/test.py                     | 50 ++++++++++++++---------
 4 files changed, 39 insertions(+), 24 deletions(-)

diff --git a/tests/install/conftest.py b/tests/install/conftest.py
index 7cb14d44..57e0120e 100644
--- a/tests/install/conftest.py
+++ b/tests/install/conftest.py
@@ -74,6 +74,11 @@ def installer_iso(request):
                 unsigned=ISO_IMAGES[iso_key].get('unsigned', False),
                 )
 
+@pytest.fixture(scope='function')
+def install_disk(request):
+    firmware = request.getfixturevalue("firmware")
+    yield {"uefi": "nvme0n1", "bios": "sda"}[firmware]
+
 # Remasters the ISO sepecified by `installer_iso` mark, with:
 # - network and ssh support activated, and .ssh/authorized_key so tests can
 #   go probe installation process
diff --git a/tests/install/test-sequences/inst+upg.lst b/tests/install/test-sequences/inst+upg.lst
index 95bc9219..895d93a0 100644
--- a/tests/install/test-sequences/inst+upg.lst
+++ b/tests/install/test-sequences/inst+upg.lst
@@ -1,2 +1,2 @@
-tests/install/test.py::TestNested::test_upgrade[83nightly-83nightly]
-tests/install/test.py::TestNested::test_boot_upg[83nightly-83nightly]
+tests/install/test.py::TestNested::test_upgrade[uefi-83nightly-83nightly]
+tests/install/test.py::TestNested::test_boot_upg[uefi-83nightly-83nightly]
diff --git a/tests/install/test-sequences/inst.lst b/tests/install/test-sequences/inst.lst
index 7ea2a852..74689208 100644
--- a/tests/install/test-sequences/inst.lst
+++ b/tests/install/test-sequences/inst.lst
@@ -1,2 +1,2 @@
-tests/install/test.py::TestNested::test_install[83nightly]
-tests/install/test.py::TestNested::test_boot_inst[83nightly]
+tests/install/test.py::TestNested::test_install[uefi-83nightly]
+tests/install/test.py::TestNested::test_boot_inst[uefi-83nightly]
diff --git a/tests/install/test.py b/tests/install/test.py
index 21f979f2..3721447a 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -19,8 +19,9 @@ class TestNested:
         "83rc1", "83b2", "83b1",
         "821.1",
     ))
+    @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.vm_definitions(
-        dict(
+        lambda firmware: dict(
             name="vm1",
             template="Other install media",
             params=(
@@ -31,21 +32,25 @@ class TestNested:
                 dict(param_name="VCPUs-max", value="2"),
                 dict(param_name="VCPUs-at-startup", value="2"),
                 dict(param_name="platform", key="exp-nested-hvm", value="true"), # FIXME < 8.3 host?
-                dict(param_name="HVM-boot-params", key="firmware", value="uefi"),
                 dict(param_name="HVM-boot-params", key="order", value="dc"),
-                dict(param_name="platform", key="device-model", value="qemu-upstream-uefi"),
-            ),
+            ) + {
+                "uefi": (
+                    dict(param_name="HVM-boot-params", key="firmware", value="uefi"),
+                    dict(param_name="platform", key="device-model", value="qemu-upstream-uefi"),
+                ),
+                "bios": (),
+            }[firmware],
             vdis=[dict(name="vm1 system disk", size="100GiB", device="xvda", userdevice="0")],
             cd_vbd=dict(device="xvdd", userdevice="3"),
             vifs=[dict(index=0, network_name=NETWORKS["MGMT"])],
         ))
     @pytest.mark.answerfile(
-        lambda: AnswerFile("INSTALL").top_append(
+        lambda install_disk: AnswerFile("INSTALL").top_append(
             {"TAG": "source", "type": "local"},
-            {"TAG": "primary-disk", "CONTENTS": "nvme0n1"},
+            {"TAG": "primary-disk", "CONTENTS": install_disk},
         ))
-    def test_install(self, vm_booted_with_installer,
-                     iso_version):
+    def test_install(self, vm_booted_with_installer, install_disk,
+                     firmware, iso_version):
         host_vm = vm_booted_with_installer
         installer.monitor_install(ip=host_vm.ip)
 
@@ -134,11 +139,12 @@ def _test_firstboot(self, create_vms, mode):
         "83rc1", "83b2", "83b1",
         "821.1",
     ))
+    @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda version: [
-            dict(vm="vm1", image_test=f"TestNested::test_install[{version}]")])
+        lambda firmware, version: [
+            dict(vm="vm1", image_test=f"TestNested::test_install[{firmware}-{version}]")])
     def test_boot_inst(self, create_vms,
-                       version):
+                       firmware, version):
         self._test_firstboot(create_vms, version)
 
     @pytest.mark.usefixtures("xcpng_chained")
@@ -150,12 +156,13 @@ def test_boot_inst(self, create_vms,
         "821.1-83nightly",
         "821.1-821.1",
     ))
+    @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda mode: [dict(
+        lambda firmware, mode: [dict(
             vm="vm1",
-            image_test=(f"TestNested::test_upgrade[{mode}]"))])
+            image_test=(f"TestNested::test_upgrade[{firmware}-{mode}]"))])
     def test_boot_upg(self, create_vms,
-                      mode):
+                      firmware, mode):
         self._test_firstboot(create_vms, mode)
 
     @pytest.mark.usefixtures("xcpng_chained")
@@ -167,15 +174,18 @@ def test_boot_upg(self, create_vms,
         ("821.1", "83nightly"),
         ("821.1", "821.1"),
     ])
+    @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda orig_version: [dict(vm="vm1",
-                                   image_test=f"TestNested::test_boot_inst[{orig_version}]")])
+        lambda firmware, orig_version: [dict(
+            vm="vm1",
+            image_test=f"TestNested::test_boot_inst[{firmware}-{orig_version}]")])
     @pytest.mark.answerfile(
-        lambda: AnswerFile("UPGRADE").top_append(
+        lambda install_disk: AnswerFile("UPGRADE").top_append(
             {"TAG": "source", "type": "local"},
-            {"TAG": "existing-installation", "CONTENTS": "nvme0n1"},
+            {"TAG": "existing-installation",
+             "CONTENTS": install_disk},
         ))
-    def test_upgrade(self, vm_booted_with_installer,
-                     orig_version, iso_version):
+    def test_upgrade(self, vm_booted_with_installer, install_disk,
+                     firmware, orig_version, iso_version):
         host_vm = vm_booted_with_installer
         installer.monitor_upgrade(ip=host_vm.ip)

From 1e9f6a9d95edf1d665fc472b197b1d8ab708c63f Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Thu, 5 Sep 2024 17:16:57 +0200
Subject: [PATCH 21/41] install: add "restore" test using 8.3 ISO

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist                                  |  3 ++
 lib/installer.py                              | 36 ++++++++++++++++
 tests/install/test-sequences/inst+upg+rst.lst |  2 +
 tests/install/test.py                         | 42 +++++++++++++++++++
 4 files changed, 83 insertions(+)
 create mode 100644 tests/install/test-sequences/inst+upg+rst.lst

diff --git a/data.py-dist b/data.py-dist
index 4c3e0f22..0ca096a0 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -218,6 +218,9 @@ BASE_ANSWERFILES = dict(
         "TAG": "installation",
         "mode": "upgrade",
     },
+    RESTORE={
+        "TAG": "restore",
+    },
 )
 
 IMAGE_EQUIVS: Dict[str, str] = {
diff --git a/lib/installer.py b/lib/installer.py
index 475c7f5d..b2e00e3b 100644
--- a/lib/installer.py
+++ b/lib/installer.py
@@ -1,4 +1,5 @@
 import logging
+import time
 import xml.etree.ElementTree as ET
 
 from lib.commands import ssh, SSHCommandFailed
@@ -126,3 +127,38 @@ def monitor_upgrade(*, ip):
                          check=False, simple_output=False,
                          ).returncode == 1,
              "Wait for installer to terminate")
+
+def monitor_restore(*, ip):
+    # wait for "yum install" phase to start
+    wait_for(lambda: ssh(ip, ["grep",
+                              "'Restoring backup'",
+                              "/tmp/install-log"],
+                         check=False, simple_output=False,
+                         ).returncode == 0,
+             "Wait for data restoration to start",
+             timeout_secs=40 * 60) # FIXME too big
+
+    # wait for "yum install" phase to finish
+    wait_for(lambda: ssh(ip, ["grep",
+                              "'Data restoration complete.  About to re-install bootloader.'",
+                              "/tmp/install-log"],
+                         check=False, simple_output=False,
+                         ).returncode == 0,
+             "Wait for data restoration to complete",
+             timeout_secs=40 * 60) # FIXME too big
+
+    # The installer will not terminate in restore mode, it
+    # requires human interaction and does not even log it, so
+    # wait for last known action log (tested with 8.3b2)
+    wait_for(lambda: ssh(ip, ["grep",
+                              "'ran .*swaplabel.*rc 0'",
+                              "/tmp/install-log"],
+                         check=False, simple_output=False,
+                         ).returncode == 0,
+             "Wait for installer to hopefully finish",
+             timeout_secs=40 * 60) # FIXME too big
+
+    # "wait a bit to be extra sure".  Yuck.
+    time.sleep(30)
+
+    logging.info("Shutting down Host VM after successful restore")
diff --git a/tests/install/test-sequences/inst+upg+rst.lst b/tests/install/test-sequences/inst+upg+rst.lst
new file mode 100644
index 00000000..96418249
--- /dev/null
+++ b/tests/install/test-sequences/inst+upg+rst.lst
@@ -0,0 +1,2 @@
+tests/install/test.py::TestNested::test_restore[uefi-83nightly-83nightly-83nightly]
+tests/install/test.py::TestNested::test_boot_rst[uefi-83nightly-83nightly-83nightly]
diff --git a/tests/install/test.py b/tests/install/test.py
index 3721447a..849989e3 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -165,6 +165,24 @@ def test_boot_upg(self, create_vms,
                       firmware, mode):
         self._test_firstboot(create_vms, mode)
 
+    @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("mode", (
+        "83nightly-83nightly-83nightly",
+        "83rc1-83nightly-83nightly",
+        "83b2-83nightly-83nightly",
+        "83b1-83nightly-83nightly",
+        "821.1-83nightly-83nightly",
+        "821.1-821.1-821.1",
+    ))
+    @pytest.mark.parametrize("firmware", ("uefi", "bios"))
+    @pytest.mark.continuation_of(
+        lambda firmware, mode: [dict(
+            vm="vm1",
+            image_test=(f"TestNested::test_restore[{firmware}-{mode}]"))])
+    def test_boot_rst(self, create_vms,
+                      firmware, mode):
+        self._test_firstboot(create_vms, mode)
+
     @pytest.mark.usefixtures("xcpng_chained")
     @pytest.mark.parametrize(("orig_version", "iso_version"), [
         ("83nightly", "83nightly"),
@@ -189,3 +207,27 @@ def test_upgrade(self, vm_booted_with_installer, install_disk,
                      firmware, orig_version, iso_version):
         host_vm = vm_booted_with_installer
         installer.monitor_upgrade(ip=host_vm.ip)
+
+    @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize(("orig_version", "iso_version"), [
+        ("83nightly-83nightly", "83nightly"),
+        ("83rc1-83nightly", "83nightly"),
+        ("83b2-83nightly", "83nightly"),
+        ("83b1-83nightly", "83nightly"),
+        ("821.1-83nightly", "83nightly"),
+        ("821.1-821.1", "821.1"),
+    ])
+    @pytest.mark.parametrize("firmware", ("uefi", "bios"))
+    @pytest.mark.continuation_of(
+        lambda firmware, orig_version: [dict(
+            vm="vm1",
+            image_test=f"TestNested::test_boot_upg[{firmware}-{orig_version}]")])
+    @pytest.mark.answerfile(
+        lambda install_disk: AnswerFile("RESTORE").top_append(
+            {"TAG": "backup-disk",
+             "CONTENTS": install_disk},
+        ))
+    def test_restore(self, vm_booted_with_installer, install_disk,
+                     firmware, orig_version, iso_version):
+        host_vm = vm_booted_with_installer
+        installer.monitor_restore(ip=host_vm.ip)

From a6f73ef267f756ca61265d35addf7a846e5b9466 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Mon, 17 Jun 2024 14:43:13 +0200
Subject: [PATCH 22/41] install/firstboot: check installed version

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 tests/install/test.py | 28 ++++++++++++++++++++++++++--
 1 file changed, 26 insertions(+), 2 deletions(-)

diff --git a/tests/install/test.py b/tests/install/test.py
index 849989e3..4359b52e 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -54,12 +54,30 @@ def test_install(self, vm_booted_with_installer, install_disk,
         host_vm = vm_booted_with_installer
         installer.monitor_install(ip=host_vm.ip)
 
-    def _test_firstboot(self, create_vms, mode):
+    def _test_firstboot(self, create_vms, mode, is_restore=False):
         host_vm = create_vms[0]
         vif = host_vm.vifs()[0]
         mac_address = vif.param_get('MAC')
         logging.info("Host VM has MAC %s", mac_address)
 
+        # succession of insta/upg/rst operations
+        split_mode = mode.split("-")
+        if is_restore:
+            # restore: back to previous installed version
+            expected_rel_id = split_mode[-3]
+        else:
+            expected_rel_id = split_mode[-1]
+        expected_rel = {
+            "821.1": "8.2.1",
+            "83b1": "8.3.0",
+            "83b2": "8.3.0",
+            "83rc1": "8.3.0",
+            "83nightly": "8.3.0",
+        }[expected_rel_id]
+
+        # determine version info from `mode`
+        expected_dist = "XCP-ng"
+
         try:
             # FIXME: evict MAC from ARP cache first?
             host_vm.start()
@@ -79,6 +97,12 @@ def _test_firstboot(self, create_vms, mode):
                     ["nc", "-zw5", host_vm.ip, "22"], check=False).returncode == 0,
                 "Wait for ssh back up on Host VM", retry_delay_secs=5, timeout_secs=4 * 60)
 
+            logging.info("Checking installed version (expecting %r %r)",
+                         expected_dist, expected_rel)
+            lsb_dist = commands.ssh(host_vm.ip, ["lsb_release", "-si"])
+            lsb_rel = commands.ssh(host_vm.ip, ["lsb_release", "-sr"])
+            assert (lsb_dist, lsb_rel) == (expected_dist, expected_rel)
+
             # pool master must be reachable here
             pool = Pool(host_vm.ip)
 
@@ -181,7 +205,7 @@ def test_boot_upg(self, create_vms,
             image_test=(f"TestNested::test_restore[{firmware}-{mode}]"))])
     def test_boot_rst(self, create_vms,
                       firmware, mode):
-        self._test_firstboot(create_vms, mode)
+        self._test_firstboot(create_vms, mode, is_restore=True)
 
     @pytest.mark.usefixtures("xcpng_chained")
     @pytest.mark.parametrize(("orig_version", "iso_version"), [

From 5f8ce2bfc00d2bb268179a4594fdf318f5e7bcb3 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Thu, 5 Sep 2024 16:58:00 +0200
Subject: [PATCH 23/41] install: add XS/CH support

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist          |  3 +++
 tests/install/test.py | 19 ++++++++++++++++++-
 2 files changed, 21 insertions(+), 1 deletion(-)

diff --git a/data.py-dist b/data.py-dist
index 0ca096a0..bdca7cc3 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -132,6 +132,9 @@ ISO_IMAGES: Dict[str, "IsoImageDef"] = {
     '821.1': {'path': "8.2/xcp-ng-8.2.1-20231130.iso"},
     '821': {'path': "8.2/xcp-ng-8.2.1.iso"},
     '820': {'path': "8.2/xcp-ng-8.2.0.iso"},
+    'xs8': {'path': "XenServer8_2024-03-18.iso"},
+    'ch821.1': {'path': "CitrixHypervisor-8.2.1-2306-install-cd.iso"},
+    'ch821': {'path': "CitrixHypervisor-8.2.1-install-cd.iso"},
 }
 
 # In some cases, we may prefer to favour a local SR to store test VM disks,
diff --git a/tests/install/test.py b/tests/install/test.py
index 4359b52e..01e30ab6 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -18,6 +18,7 @@ class TestNested:
         "83nightly",
         "83rc1", "83b2", "83b1",
         "821.1",
+        "xs8", "ch821.1",
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.vm_definitions(
@@ -68,6 +69,8 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
         else:
             expected_rel_id = split_mode[-1]
         expected_rel = {
+            "ch821.1": "8.2.1",
+            "xs8": "8.4.0",
             "821.1": "8.2.1",
             "83b1": "8.3.0",
             "83b2": "8.3.0",
@@ -76,7 +79,12 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
         }[expected_rel_id]
 
         # determine version info from `mode`
-        expected_dist = "XCP-ng"
+        if expected_rel_id.startswith("xs"):
+            expected_dist = "XenServer"
+        elif expected_rel_id.startswith("ch"):
+            expected_dist = "CitrixHypervisor"
+        else:
+            expected_dist = "XCP-ng"
 
         try:
             # FIXME: evict MAC from ARP cache first?
@@ -162,6 +170,7 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
         "83nightly",
         "83rc1", "83b2", "83b1",
         "821.1",
+        "xs8", "ch821.1",
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
@@ -178,6 +187,8 @@ def test_boot_inst(self, create_vms,
         "83b2-83nightly",
         "83b1-83nightly",
         "821.1-83nightly",
+        "xs8-83nightly",
+        "ch821.1-83nightly",
         "821.1-821.1",
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
@@ -196,6 +207,8 @@ def test_boot_upg(self, create_vms,
         "83b2-83nightly-83nightly",
         "83b1-83nightly-83nightly",
         "821.1-83nightly-83nightly",
+        "xs8-83nightly-83nightly",
+        "ch821.1-83nightly-83nightly",
         "821.1-821.1-821.1",
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
@@ -214,6 +227,8 @@ def test_boot_rst(self, create_vms,
         ("83b2", "83nightly"),
         ("83b1", "83nightly"),
         ("821.1", "83nightly"),
+        ("xs8", "83nightly"),
+        ("ch821.1", "83nightly"),
         ("821.1", "821.1"),
     ])
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
@@ -239,6 +254,8 @@ def test_upgrade(self, vm_booted_with_installer, install_disk,
         ("83b2-83nightly", "83nightly"),
         ("83b1-83nightly", "83nightly"),
         ("821.1-83nightly", "83nightly"),
+        ("xs8-83nightly", "83nightly"),
+        ("ch821.1-83nightly", "83nightly"),
         ("821.1-821.1", "821.1"),
     ])
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))

From a29cb1e8b0ef5a09bd498efe74c10c193ba8446c Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Thu, 29 Aug 2024 12:07:29 +0200
Subject: [PATCH 24/41] install: add installation of xcp-ng 8.0 and 8.1,
 upgrades to 8.3

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist          |  2 ++
 tests/install/test.py | 58 +++++++++++++++++++++++++++++++++++--------
 2 files changed, 50 insertions(+), 10 deletions(-)

diff --git a/data.py-dist b/data.py-dist
index bdca7cc3..89640285 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -132,6 +132,8 @@ ISO_IMAGES: Dict[str, "IsoImageDef"] = {
     '821.1': {'path': "8.2/xcp-ng-8.2.1-20231130.iso"},
     '821': {'path': "8.2/xcp-ng-8.2.1.iso"},
     '820': {'path': "8.2/xcp-ng-8.2.0.iso"},
+    '81': {'path': "8.1/xcp-ng-8.1.0-2.iso"},
+    '80': {'path': "8.0/xcp-ng-8.0.0.iso"},
     'xs8': {'path': "XenServer8_2024-03-18.iso"},
     'ch821.1': {'path': "CitrixHypervisor-8.2.1-2306-install-cd.iso"},
     'ch821': {'path': "CitrixHypervisor-8.2.1-install-cd.iso"},
diff --git a/tests/install/test.py b/tests/install/test.py
index 01e30ab6..bc8a0fec 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -18,6 +18,7 @@ class TestNested:
         "83nightly",
         "83rc1", "83b2", "83b1",
         "821.1",
+        "81", "80",
         "xs8", "ch821.1",
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
@@ -71,6 +72,8 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
         expected_rel = {
             "ch821.1": "8.2.1",
             "xs8": "8.4.0",
+            "80": "8.0.0",
+            "81": "8.1.0",
             "821.1": "8.2.1",
             "83b1": "8.3.0",
             "83b2": "8.3.0",
@@ -117,26 +120,52 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
             # wait for XAPI
             wait_for(pool.master.is_enabled, "Wait for XAPI to be ready", timeout_secs=30 * 60)
 
-            # check for firstboot issues
-            # FIXME: flaky, must check logs extraction on failure
-            for service in ["control-domain-params-init",
+            if lsb_rel in ["8.2.1", "8.3.0", "8.4.0"]:
+                SERVICES = ["control-domain-params-init",
                             "network-init",
                             "storage-init",
                             "generate-iscsi-iqn",
                             "create-guest-templates",
-                            ]:
-                try:
-                    wait_for(lambda: pool.master.ssh(["test", "-e", f"/var/lib/misc/ran-{service}"],
+                            ]
+                STAMPS_DIR = "/var/lib/misc"
+                STAMPS = [f"ran-{service}" for service in SERVICES]
+            elif lsb_rel in ["8.0.0", "8.1.0"]:
+                SERVICES = ["xs-firstboot"]
+                STAMPS_DIR = "/etc/firstboot.d/state"
+                STAMPS = [
+                    "05-prepare-networking",
+                    "10-prepare-storage",
+                    "15-set-default-storage",
+                    "20-udev-storage",
+                    "25-multipath",
+                    "40-generate-iscsi-iqn",
+                    "50-prepare-control-domain-params",
+                    "60-import-keys",
+                    "60-upgrade-likewise-to-pbis",
+                    "62-create-guest-templates",
+                    "80-common-criteria",
+                    "90-flush-pool-db",
+                    "95-legacy-logrotate",
+                    "99-remove-firstboot-flag",
+                ]
+            else:
+                raise AssertionError(f"Unhandled LSB release {lsb_rel!r}")
+            # check for firstboot issues
+            # FIXME: flaky, must check logs extraction on failure
+            try:
+                for stamp in sorted(STAMPS):
+                    wait_for(lambda: pool.master.ssh(["test", "-e", f"{STAMPS_DIR}/{stamp}"],
                                                      check=False, simple_output=False,
                                                      ).returncode == 0,
-                             f"Wait for ran-{service} stamp")
-                except TimeoutError:
-                    logging.warning("investigating lack of %s service stamp", service)
+                             f"Wait for {stamp} stamp")
+            except TimeoutError:
+                logging.warning("investigating lack of %s service stamp", stamp)
+                for service in SERVICES:
                     out = pool.master.ssh(["systemctl", "status", service], check=False)
                     logging.warning("service status: %s", out)
                     out = pool.master.ssh(["grep", "-r", service, "/var/log"], check=False)
                     logging.warning("in logs: %s", out)
-                    raise
+                raise
 
             logging.info("Powering off pool master")
             try:
@@ -170,6 +199,7 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
         "83nightly",
         "83rc1", "83b2", "83b1",
         "821.1",
+        "81", "80",
         "xs8", "ch821.1",
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
@@ -187,6 +217,8 @@ def test_boot_inst(self, create_vms,
         "83b2-83nightly",
         "83b1-83nightly",
         "821.1-83nightly",
+        "81-83nightly",
+        "80-83nightly",
         "xs8-83nightly",
         "ch821.1-83nightly",
         "821.1-821.1",
@@ -207,6 +239,8 @@ def test_boot_upg(self, create_vms,
         "83b2-83nightly-83nightly",
         "83b1-83nightly-83nightly",
         "821.1-83nightly-83nightly",
+        "81-83nightly-83nightly",
+        "80-83nightly-83nightly",
         "xs8-83nightly-83nightly",
         "ch821.1-83nightly-83nightly",
         "821.1-821.1-821.1",
@@ -227,6 +261,8 @@ def test_boot_rst(self, create_vms,
         ("83b2", "83nightly"),
         ("83b1", "83nightly"),
         ("821.1", "83nightly"),
+        ("81", "83nightly"),
+        ("80", "83nightly"),
         ("xs8", "83nightly"),
         ("ch821.1", "83nightly"),
         ("821.1", "821.1"),
@@ -254,6 +290,8 @@ def test_upgrade(self, vm_booted_with_installer, install_disk,
         ("83b2-83nightly", "83nightly"),
         ("83b1-83nightly", "83nightly"),
         ("821.1-83nightly", "83nightly"),
+        ("81-83nightly", "83nightly"),
+        ("80-83nightly", "83nightly"),
         ("xs8-83nightly", "83nightly"),
         ("ch821.1-83nightly", "83nightly"),
         ("821.1-821.1", "821.1"),

From 0653682f03039f8fd1aa3d67faaea78fee911114 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Fri, 14 Jun 2024 13:57:10 +0200
Subject: [PATCH 25/41] install: 7.5, 7.6, and XS 7.0

No direct upgrade to 8.3 for those versions.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist          |  2 ++
 tests/install/test.py | 32 ++++++++++++++++++++++++++------
 2 files changed, 28 insertions(+), 6 deletions(-)

diff --git a/data.py-dist b/data.py-dist
index 89640285..d05d374f 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -134,6 +134,8 @@ ISO_IMAGES: Dict[str, "IsoImageDef"] = {
     '820': {'path': "8.2/xcp-ng-8.2.0.iso"},
     '81': {'path': "8.1/xcp-ng-8.1.0-2.iso"},
     '80': {'path': "8.0/xcp-ng-8.0.0.iso"},
+    '76': {'path': "7.6/xcp-ng-7.6.0.iso"},
+    '75': {'path': "7.5/xcp-ng-7.5.0-2.iso"},
     'xs8': {'path': "XenServer8_2024-03-18.iso"},
     'ch821.1': {'path': "CitrixHypervisor-8.2.1-2306-install-cd.iso"},
     'ch821': {'path': "CitrixHypervisor-8.2.1-install-cd.iso"},
diff --git a/tests/install/test.py b/tests/install/test.py
index bc8a0fec..c53f4427 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -11,6 +11,10 @@
 
 # Requirements:
 # - one XCP-ng host capable of nested virt, with an ISO SR, and a default SR
+# - the "small_vm" ISO must have in authorized_keys a SSH key accepted by the
+#   ssh server in the installed host version (7.x and earlier reject current
+#   ssh-rsa keys, a public ssh-ed25519 key listed in TEST_SSH_PUBKEY should be
+#   there)
 
 @pytest.mark.dependency()
 class TestNested:
@@ -18,8 +22,9 @@ class TestNested:
         "83nightly",
         "83rc1", "83b2", "83b1",
         "821.1",
-        "81", "80",
+        "81", "80", "76", "75",
         "xs8", "ch821.1",
+        "xs70",
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.vm_definitions(
@@ -70,8 +75,11 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
         else:
             expected_rel_id = split_mode[-1]
         expected_rel = {
+            "xs70": "7.0.0-125380c",
             "ch821.1": "8.2.1",
             "xs8": "8.4.0",
+            "75": "7.5.0",
+            "76": "7.6.0",
             "80": "8.0.0",
             "81": "8.1.0",
             "821.1": "8.2.1",
@@ -129,25 +137,35 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
                             ]
                 STAMPS_DIR = "/var/lib/misc"
                 STAMPS = [f"ran-{service}" for service in SERVICES]
-            elif lsb_rel in ["8.0.0", "8.1.0"]:
+            elif lsb_rel in ["7.0.0-125380c", "7.5.0", "7.6.0", "8.0.0", "8.1.0"]:
                 SERVICES = ["xs-firstboot"]
                 STAMPS_DIR = "/etc/firstboot.d/state"
                 STAMPS = [
-                    "05-prepare-networking",
                     "10-prepare-storage",
                     "15-set-default-storage",
                     "20-udev-storage",
                     "25-multipath",
                     "40-generate-iscsi-iqn",
                     "50-prepare-control-domain-params",
-                    "60-import-keys",
                     "60-upgrade-likewise-to-pbis",
-                    "62-create-guest-templates",
-                    "80-common-criteria",
                     "90-flush-pool-db",
                     "95-legacy-logrotate",
                     "99-remove-firstboot-flag",
                 ]
+                if lsb_rel in ["7.0.0-125380c"]:
+                    STAMPS += [
+                        "61-regenerate-old-templates",
+                    ]
+                if lsb_rel in ["7.5.0", "7.6.0", "8.0.0", "8.1.0"]:
+                    STAMPS += [
+                        "05-prepare-networking",
+                        "60-import-keys",
+                        "62-create-guest-templates",
+                    ]
+                if lsb_rel in ["8.0.0", "8.1.0"]:
+                    STAMPS += [
+                        "80-common-criteria",
+                    ]
             else:
                 raise AssertionError(f"Unhandled LSB release {lsb_rel!r}")
             # check for firstboot issues
@@ -200,7 +218,9 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
         "83rc1", "83b2", "83b1",
         "821.1",
         "81", "80",
+        "76", "75",
         "xs8", "ch821.1",
+        "xs70",
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(

From 916c4531524ea0274de2e7d033e9733dd31ba64c Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Tue, 8 Oct 2024 11:25:45 +0200
Subject: [PATCH 26/41] install: produce several hosts from single install

This is just base infra, different machines have not differences yet.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 tests/install/test-sequences/inst+upg.lst |  4 ++--
 tests/install/test-sequences/inst.lst     |  2 +-
 tests/install/test.py                     | 19 +++++++++++--------
 3 files changed, 14 insertions(+), 11 deletions(-)

diff --git a/tests/install/test-sequences/inst+upg.lst b/tests/install/test-sequences/inst+upg.lst
index 895d93a0..5eb9b6d7 100644
--- a/tests/install/test-sequences/inst+upg.lst
+++ b/tests/install/test-sequences/inst+upg.lst
@@ -1,2 +1,2 @@
-tests/install/test.py::TestNested::test_upgrade[uefi-83nightly-83nightly]
-tests/install/test.py::TestNested::test_boot_upg[uefi-83nightly-83nightly]
+tests/install/test.py::TestNested::test_upgrade[uefi-83nightly-83nightly-host1]
+tests/install/test.py::TestNested::test_boot_upg[uefi-83nightly-83nightly-host1]
diff --git a/tests/install/test-sequences/inst.lst b/tests/install/test-sequences/inst.lst
index 74689208..0521f715 100644
--- a/tests/install/test-sequences/inst.lst
+++ b/tests/install/test-sequences/inst.lst
@@ -1,2 +1,2 @@
 tests/install/test.py::TestNested::test_install[uefi-83nightly]
-tests/install/test.py::TestNested::test_boot_inst[uefi-83nightly]
+tests/install/test.py::TestNested::test_boot_inst[uefi-83nightly-host1]
diff --git a/tests/install/test.py b/tests/install/test.py
index c53f4427..5e0ad7b8 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -213,6 +213,7 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
             raise
 
     @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize("version", (
         "83nightly",
         "83rc1", "83b2", "83b1",
@@ -227,10 +228,11 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
         lambda firmware, version: [
             dict(vm="vm1", image_test=f"TestNested::test_install[{firmware}-{version}]")])
     def test_boot_inst(self, create_vms,
-                       firmware, version):
+                       firmware, version, machine):
         self._test_firstboot(create_vms, version)
 
     @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize("mode", (
         "83nightly-83nightly",
         "83rc1-83nightly",
@@ -245,11 +247,11 @@ def test_boot_inst(self, create_vms,
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, mode: [dict(
+        lambda firmware, mode, machine: [dict(
             vm="vm1",
-            image_test=(f"TestNested::test_upgrade[{firmware}-{mode}]"))])
+            image_test=(f"TestNested::test_upgrade[{firmware}-{mode}-{machine}]"))])
     def test_boot_upg(self, create_vms,
-                      firmware, mode):
+                      firmware, mode, machine):
         self._test_firstboot(create_vms, mode)
 
     @pytest.mark.usefixtures("xcpng_chained")
@@ -275,6 +277,7 @@ def test_boot_rst(self, create_vms,
         self._test_firstboot(create_vms, mode, is_restore=True)
 
     @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize(("orig_version", "iso_version"), [
         ("83nightly", "83nightly"),
         ("83rc1", "83nightly"),
@@ -289,9 +292,9 @@ def test_boot_rst(self, create_vms,
     ])
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, orig_version: [dict(
+        lambda firmware, orig_version, machine: [dict(
             vm="vm1",
-            image_test=f"TestNested::test_boot_inst[{firmware}-{orig_version}]")])
+            image_test=f"TestNested::test_boot_inst[{firmware}-{orig_version}-{machine}]")])
     @pytest.mark.answerfile(
         lambda install_disk: AnswerFile("UPGRADE").top_append(
             {"TAG": "source", "type": "local"},
@@ -299,7 +302,7 @@ def test_boot_rst(self, create_vms,
              "CONTENTS": install_disk},
         ))
     def test_upgrade(self, vm_booted_with_installer, install_disk,
-                     firmware, orig_version, iso_version):
+                     firmware, orig_version, iso_version, machine):
         host_vm = vm_booted_with_installer
         installer.monitor_upgrade(ip=host_vm.ip)
 
@@ -320,7 +323,7 @@ def test_upgrade(self, vm_booted_with_installer, install_disk,
     @pytest.mark.continuation_of(
         lambda firmware, orig_version: [dict(
             vm="vm1",
-            image_test=f"TestNested::test_boot_upg[{firmware}-{orig_version}]")])
+            image_test=f"TestNested::test_boot_upg[{firmware}-{orig_version}-host1]")])
     @pytest.mark.answerfile(
         lambda install_disk: AnswerFile("RESTORE").top_append(
             {"TAG": "backup-disk",

From 618b0dcee00c5b49591727e2574aee66e59a7b1f Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Mon, 9 Sep 2024 18:58:58 +0200
Subject: [PATCH 27/41] install: adjust host IP, name, UUIDs in firstboot data
 before booting

Uses a helper VM to modify firstboot data in installed disk

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 tests/install/test-sequences/inst.lst |  1 +
 tests/install/test.py                 | 73 ++++++++++++++++++++++++---
 2 files changed, 67 insertions(+), 7 deletions(-)

diff --git a/tests/install/test-sequences/inst.lst b/tests/install/test-sequences/inst.lst
index 0521f715..ec092ad7 100644
--- a/tests/install/test-sequences/inst.lst
+++ b/tests/install/test-sequences/inst.lst
@@ -1,2 +1,3 @@
 tests/install/test.py::TestNested::test_install[uefi-83nightly]
+tests/install/test.py::TestNested::test_tune_firstboot[None-uefi-83nightly-host1]
 tests/install/test.py::TestNested::test_boot_inst[uefi-83nightly-host1]
diff --git a/tests/install/test.py b/tests/install/test.py
index 5e0ad7b8..c5dec537 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -1,10 +1,13 @@
 import logging
 import pytest
+from uuid import uuid4
 
 from lib import commands, installer, pxe
-from lib.common import wait_for
+from lib.common import safe_split, wait_for
 from lib.installer import AnswerFile
+from lib.pif import PIF
 from lib.pool import Pool
+from lib.vdi import VDI
 
 from data import NETWORKS
 assert "MGMT" in NETWORKS
@@ -16,6 +19,25 @@
 #   ssh-rsa keys, a public ssh-ed25519 key listed in TEST_SSH_PUBKEY should be
 #   there)
 
+@pytest.fixture
+def helper_vm_with_plugged_disk(running_vm, create_vms):
+    helper_vm = running_vm
+    host_vm, = create_vms
+
+    all_vdis = [VDI(uuid, host=host_vm.host) for uuid in host_vm.vdi_uuids()]
+    disk_vdis = [vdi for vdi in all_vdis if not vdi.readonly()]
+    vdi, = disk_vdis
+
+    vbd = helper_vm.create_vbd("1", vdi.uuid)
+    try:
+        vbd.plug()
+
+        yield helper_vm
+
+    finally:
+        vbd.unplug()
+        vbd.destroy()
+
 @pytest.mark.dependency()
 class TestNested:
     @pytest.mark.parametrize("iso_version", (
@@ -61,7 +83,44 @@ def test_install(self, vm_booted_with_installer, install_disk,
         host_vm = vm_booted_with_installer
         installer.monitor_install(ip=host_vm.ip)
 
-    def _test_firstboot(self, create_vms, mode, is_restore=False):
+    @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("machine", ("host1", "host2"))
+    @pytest.mark.parametrize("version", (
+        "83nightly",
+        "83rc1", "83b2", "83b1",
+        "821.1",
+        "81", "80",
+        "76", "75",
+        "xs8", "ch821.1",
+        "xs70",
+    ))
+    @pytest.mark.parametrize("firmware", ("uefi", "bios"))
+    @pytest.mark.continuation_of(
+        lambda version, firmware: [dict(
+            vm="vm1",
+            image_test=f"TestNested::test_install[{firmware}-{version}]")])
+    @pytest.mark.small_vm
+    def test_tune_firstboot(self, create_vms, helper_vm_with_plugged_disk,
+                            firmware, version, machine):
+        helper_vm = helper_vm_with_plugged_disk
+
+        helper_vm.ssh(["mount /dev/xvdb1 /mnt"])
+        try:
+            # hostname
+            logging.info("Setting hostname to %r", machine)
+            helper_vm.ssh(["echo > /mnt/etc/hostname", machine])
+            # UUIDs
+            logging.info("Randomizing UUIDs")
+            helper_vm.ssh(
+                ['sed -i',
+                 f'''-e "/^INSTALLATION_UUID=/ s/.*/INSTALLATION_UUID='{uuid4()}'/"''',
+                 f'''-e "/^CONTROL_DOMAIN_UUID=/ s/.*/CONTROL_DOMAIN_UUID='{uuid4()}'/"''',
+                 '/mnt/etc/xensource-inventory'])
+            helper_vm.ssh(["grep UUID /mnt/etc/xensource-inventory"])
+        finally:
+            helper_vm.ssh(["umount /dev/xvdb1"])
+
+    def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=False):
         host_vm = create_vms[0]
         vif = host_vm.vifs()[0]
         mac_address = vif.param_get('MAC')
@@ -98,7 +157,6 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
             expected_dist = "XCP-ng"
 
         try:
-            # FIXME: evict MAC from ARP cache first?
             host_vm.start()
             wait_for(host_vm.is_running, "Wait for host VM running")
 
@@ -225,11 +283,12 @@ def _test_firstboot(self, create_vms, mode, is_restore=False):
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, version: [
-            dict(vm="vm1", image_test=f"TestNested::test_install[{firmware}-{version}]")])
+        lambda firmware, version, machine: [
+            dict(vm="vm1",
+                 image_test=f"TestNested::test_tune_firstboot[None-{firmware}-{version}-{machine}]")])
     def test_boot_inst(self, create_vms,
                        firmware, version, machine):
-        self._test_firstboot(create_vms, version)
+        self._test_firstboot(create_vms, version, machine=machine)
 
     @pytest.mark.usefixtures("xcpng_chained")
     @pytest.mark.parametrize("machine", ("host1", "host2"))
@@ -252,7 +311,7 @@ def test_boot_inst(self, create_vms,
             image_test=(f"TestNested::test_upgrade[{firmware}-{mode}-{machine}]"))])
     def test_boot_upg(self, create_vms,
                       firmware, mode, machine):
-        self._test_firstboot(create_vms, mode)
+        self._test_firstboot(create_vms, mode, machine=machine)
 
     @pytest.mark.usefixtures("xcpng_chained")
     @pytest.mark.parametrize("mode", (

From 288594746a3ef0fdc6e517f9246db86267010265 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Wed, 9 Oct 2024 11:32:03 +0200
Subject: [PATCH 28/41] Add local_sr parameter to test_install

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 tests/install/test-sequences/inst+upg+rst.lst |  4 +-
 tests/install/test-sequences/inst+upg.lst     |  4 +-
 tests/install/test-sequences/inst.lst         |  6 +--
 tests/install/test.py                         | 53 +++++++++++--------
 4 files changed, 39 insertions(+), 28 deletions(-)

diff --git a/tests/install/test-sequences/inst+upg+rst.lst b/tests/install/test-sequences/inst+upg+rst.lst
index 96418249..f1e5f53d 100644
--- a/tests/install/test-sequences/inst+upg+rst.lst
+++ b/tests/install/test-sequences/inst+upg+rst.lst
@@ -1,2 +1,2 @@
-tests/install/test.py::TestNested::test_restore[uefi-83nightly-83nightly-83nightly]
-tests/install/test.py::TestNested::test_boot_rst[uefi-83nightly-83nightly-83nightly]
+tests/install/test.py::TestNested::test_restore[uefi-83nightly-83nightly-83nightly-ext]
+tests/install/test.py::TestNested::test_boot_rst[uefi-83nightly-83nightly-83nightly-ext]
diff --git a/tests/install/test-sequences/inst+upg.lst b/tests/install/test-sequences/inst+upg.lst
index 5eb9b6d7..1f040973 100644
--- a/tests/install/test-sequences/inst+upg.lst
+++ b/tests/install/test-sequences/inst+upg.lst
@@ -1,2 +1,2 @@
-tests/install/test.py::TestNested::test_upgrade[uefi-83nightly-83nightly-host1]
-tests/install/test.py::TestNested::test_boot_upg[uefi-83nightly-83nightly-host1]
+tests/install/test.py::TestNested::test_upgrade[uefi-83nightly-83nightly-host1-ext]
+tests/install/test.py::TestNested::test_boot_upg[uefi-83nightly-83nightly-host1-ext]
diff --git a/tests/install/test-sequences/inst.lst b/tests/install/test-sequences/inst.lst
index ec092ad7..6b18695b 100644
--- a/tests/install/test-sequences/inst.lst
+++ b/tests/install/test-sequences/inst.lst
@@ -1,3 +1,3 @@
-tests/install/test.py::TestNested::test_install[uefi-83nightly]
-tests/install/test.py::TestNested::test_tune_firstboot[None-uefi-83nightly-host1]
-tests/install/test.py::TestNested::test_boot_inst[uefi-83nightly-host1]
+tests/install/test.py::TestNested::test_install[uefi-83nightly-ext]
+tests/install/test.py::TestNested::test_tune_firstboot[None-uefi-83nightly-host1-ext]
+tests/install/test.py::TestNested::test_boot_inst[uefi-83nightly-host1-ext]
diff --git a/tests/install/test.py b/tests/install/test.py
index c5dec537..6f26236f 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -40,6 +40,7 @@ def helper_vm_with_plugged_disk(running_vm, create_vms):
 
 @pytest.mark.dependency()
 class TestNested:
+    @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
     @pytest.mark.parametrize("iso_version", (
         "83nightly",
         "83rc1", "83b2", "83b1",
@@ -74,16 +75,21 @@ class TestNested:
             vifs=[dict(index=0, network_name=NETWORKS["MGMT"])],
         ))
     @pytest.mark.answerfile(
-        lambda install_disk: AnswerFile("INSTALL").top_append(
+        lambda install_disk, local_sr: AnswerFile("INSTALL")
+        .top_setattr({} if local_sr == "nosr" else {"sr-type": local_sr})
+        .top_append(
             {"TAG": "source", "type": "local"},
-            {"TAG": "primary-disk", "CONTENTS": install_disk},
+            {"TAG": "primary-disk",
+             "guest-storage": "no" if local_sr == "nosr" else "yes",
+             "CONTENTS": install_disk},
         ))
     def test_install(self, vm_booted_with_installer, install_disk,
-                     firmware, iso_version):
+                     firmware, iso_version, local_sr):
         host_vm = vm_booted_with_installer
         installer.monitor_install(ip=host_vm.ip)
 
     @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
     @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize("version", (
         "83nightly",
@@ -96,12 +102,12 @@ def test_install(self, vm_booted_with_installer, install_disk,
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda version, firmware: [dict(
+        lambda version, firmware, local_sr: [dict(
             vm="vm1",
-            image_test=f"TestNested::test_install[{firmware}-{version}]")])
+            image_test=f"TestNested::test_install[{firmware}-{version}-{local_sr}]")])
     @pytest.mark.small_vm
     def test_tune_firstboot(self, create_vms, helper_vm_with_plugged_disk,
-                            firmware, version, machine):
+                            firmware, version, machine, local_sr):
         helper_vm = helper_vm_with_plugged_disk
 
         helper_vm.ssh(["mount /dev/xvdb1 /mnt"])
@@ -271,6 +277,7 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal
             raise
 
     @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
     @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize("version", (
         "83nightly",
@@ -283,14 +290,15 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, version, machine: [
+        lambda firmware, version, machine, local_sr: [
             dict(vm="vm1",
-                 image_test=f"TestNested::test_tune_firstboot[None-{firmware}-{version}-{machine}]")])
+                 image_test=f"TestNested::test_tune_firstboot[None-{firmware}-{version}-{machine}-{local_sr}]")])
     def test_boot_inst(self, create_vms,
-                       firmware, version, machine):
+                       firmware, version, machine, local_sr):
         self._test_firstboot(create_vms, version, machine=machine)
 
     @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
     @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize("mode", (
         "83nightly-83nightly",
@@ -306,14 +314,15 @@ def test_boot_inst(self, create_vms,
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, mode, machine: [dict(
+        lambda firmware, mode, machine, local_sr: [dict(
             vm="vm1",
-            image_test=(f"TestNested::test_upgrade[{firmware}-{mode}-{machine}]"))])
+            image_test=(f"TestNested::test_upgrade[{firmware}-{mode}-{machine}-{local_sr}]"))])
     def test_boot_upg(self, create_vms,
-                      firmware, mode, machine):
+                      firmware, mode, machine, local_sr):
         self._test_firstboot(create_vms, mode, machine=machine)
 
     @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
     @pytest.mark.parametrize("mode", (
         "83nightly-83nightly-83nightly",
         "83rc1-83nightly-83nightly",
@@ -328,14 +337,15 @@ def test_boot_upg(self, create_vms,
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, mode: [dict(
+        lambda firmware, mode, local_sr: [dict(
             vm="vm1",
-            image_test=(f"TestNested::test_restore[{firmware}-{mode}]"))])
+            image_test=(f"TestNested::test_restore[{firmware}-{mode}-{local_sr}]"))])
     def test_boot_rst(self, create_vms,
-                      firmware, mode):
+                      firmware, mode, local_sr):
         self._test_firstboot(create_vms, mode, is_restore=True)
 
     @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
     @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize(("orig_version", "iso_version"), [
         ("83nightly", "83nightly"),
@@ -351,9 +361,9 @@ def test_boot_rst(self, create_vms,
     ])
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, orig_version, machine: [dict(
+        lambda firmware, orig_version, machine, local_sr: [dict(
             vm="vm1",
-            image_test=f"TestNested::test_boot_inst[{firmware}-{orig_version}-{machine}]")])
+            image_test=f"TestNested::test_boot_inst[{firmware}-{orig_version}-{machine}-{local_sr}]")])
     @pytest.mark.answerfile(
         lambda install_disk: AnswerFile("UPGRADE").top_append(
             {"TAG": "source", "type": "local"},
@@ -361,11 +371,12 @@ def test_boot_rst(self, create_vms,
              "CONTENTS": install_disk},
         ))
     def test_upgrade(self, vm_booted_with_installer, install_disk,
-                     firmware, orig_version, iso_version, machine):
+                     firmware, orig_version, iso_version, machine, local_sr):
         host_vm = vm_booted_with_installer
         installer.monitor_upgrade(ip=host_vm.ip)
 
     @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
     @pytest.mark.parametrize(("orig_version", "iso_version"), [
         ("83nightly-83nightly", "83nightly"),
         ("83rc1-83nightly", "83nightly"),
@@ -380,15 +391,15 @@ def test_upgrade(self, vm_booted_with_installer, install_disk,
     ])
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, orig_version: [dict(
+        lambda firmware, orig_version, local_sr: [dict(
             vm="vm1",
-            image_test=f"TestNested::test_boot_upg[{firmware}-{orig_version}-host1]")])
+            image_test=f"TestNested::test_boot_upg[{firmware}-{orig_version}-host1-{local_sr}]")])
     @pytest.mark.answerfile(
         lambda install_disk: AnswerFile("RESTORE").top_append(
             {"TAG": "backup-disk",
              "CONTENTS": install_disk},
         ))
     def test_restore(self, vm_booted_with_installer, install_disk,
-                     firmware, orig_version, iso_version):
+                     firmware, orig_version, iso_version, local_sr):
         host_vm = vm_booted_with_installer
         installer.monitor_restore(ip=host_vm.ip)

From 18321a484c0362955c04c84d418093824a43e2f2 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Mon, 15 Jul 2024 12:10:13 +0200
Subject: [PATCH 29/41] import_vm: add clone:// and clone+start:// URIs

clone+start:// will be used to implement--hosts=cache://...

clone:// itself is not yet used directly, but as the "base" protocol upon
which clone+start build, it seems logical (and basically free) to implement.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 lib/host.py | 17 ++++++++++++++++-
 1 file changed, 16 insertions(+), 1 deletion(-)

diff --git a/lib/host.py b/lib/host.py
index c9fe7866..a30f0157 100644
--- a/lib/host.py
+++ b/lib/host.py
@@ -294,10 +294,25 @@ def cached_vm(self, uri, sr_uuid):
         logging.info("Could not find a VM in cache for %r", uri)
 
     def import_vm(self, uri, sr_uuid=None, use_cache=False):
+        vm = None
         if use_cache:
-            vm = self.cached_vm(uri, sr_uuid)
+            if '://' in uri and uri.startswith("clone"):
+                protocol, rest = uri.split(":", 1)
+                assert rest.startswith("//")
+                filename = rest[2:] # strip "//"
+                base_vm = self.cached_vm(filename, sr_uuid)
+                if base_vm:
+                    vm = base_vm.clone()
+                    vm.param_clear('name-description')
+                    if uri.startswith("clone+start"):
+                        vm.start()
+                        wait_for(vm.is_running, "Wait for VM running")
+            else:
+                vm = self.cached_vm(uri, sr_uuid)
             if vm:
                 return vm
+        else:
+            assert not ('://' in uri and uri.startswith("clone")), "clone URIs require cache enabled"
 
         params = {}
         msg = "Import VM %s" % uri

From d8307229e7b64e61e1318f9ebb4fb62ee7555d20 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Thu, 19 Sep 2024 15:07:52 +0200
Subject: [PATCH 30/41] hosts: make setup_host a nested func

Will allow it to access host_list through closure.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 conftest.py | 10 +++++-----
 1 file changed, 5 insertions(+), 5 deletions(-)

diff --git a/conftest.py b/conftest.py
index c82731f5..4861cfed 100644
--- a/conftest.py
+++ b/conftest.py
@@ -140,13 +140,13 @@ def pytest_runtest_makereport(item, call):
 
 # fixtures
 
-def setup_host(hostname_or_ip):
-    pool = Pool(hostname_or_ip)
-    h = pool.master
-    return h
-
 @pytest.fixture(scope='session')
 def hosts(pytestconfig):
+    def setup_host(hostname_or_ip):
+        pool = Pool(hostname_or_ip)
+        h = pool.master
+        return h
+
     # a list of master hosts, each from a different pool
     hosts_args = pytestconfig.getoption("hosts")
     hosts_split = [hostlist.split(',') for hostlist in hosts_args]

From 1773806a43641a9df345c7fe91cac3099bf5c730 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Tue, 23 Jul 2024 11:12:57 +0200
Subject: [PATCH 31/41] Add support for --nest=... --hosts=cache://...

Special care taken for making sure a created VM is destroyed if
setup_host fails further down the line.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 conftest.py | 63 +++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 file changed, 61 insertions(+), 2 deletions(-)

diff --git a/conftest.py b/conftest.py
index 4861cfed..03ec108e 100644
--- a/conftest.py
+++ b/conftest.py
@@ -1,6 +1,7 @@
 import itertools
 import git
 import logging
+import os
 import pytest
 import tempfile
 
@@ -9,6 +10,7 @@
 
 import lib.config as global_config
 
+from lib import pxe
 from lib.common import callable_marker, shortened_nodeid, prefix_object_name
 from lib.common import wait_for, vm_image, is_uuid
 from lib.common import setup_formatted_and_mounted_disk, teardown_formatted_and_mounted_disk
@@ -33,6 +35,12 @@
 # pytest hooks
 
 def pytest_addoption(parser):
+    parser.addoption(
+        "--nest",
+        action="store",
+        default=None,
+        help="XCP-ng or XS master of pool to use for nesting hosts under test",
+    )
     parser.addoption(
         "--hosts",
         action="append",
@@ -142,20 +150,71 @@ def pytest_runtest_makereport(item, call):
 
 @pytest.fixture(scope='session')
 def hosts(pytestconfig):
-    def setup_host(hostname_or_ip):
+    nested_list = []
+
+    def setup_host(hostname_or_ip, *, config=None):
+        host_vm = None
+        if hostname_or_ip.startswith("cache://"):
+            if config is None:
+                raise RuntimeError("setup_host: a cache:// host requires --nest")
+            nest_hostname = config.getoption("nest")
+            if not nest_hostname:
+                pytest.fail("--hosts=cache://... requires --nest parameter")
+            nest = Pool(nest_hostname).master
+
+            protocol, rest = hostname_or_ip.split(":", 1)
+            host_vm = nest.import_vm(f"clone:{rest}", nest.main_sr_uuid(),
+                                     use_cache=True)
+            nested_list.append(host_vm)
+
+            vif = host_vm.vifs()[0]
+            mac_address = vif.param_get('MAC')
+            logging.info("Nested host has MAC %s", mac_address)
+
+            host_vm.start()
+            wait_for(host_vm.is_running, "Wait for nested host VM running")
+
+            # catch host-vm IP address
+            wait_for(lambda: pxe.arp_addresses_for(mac_address),
+                     "Wait for DHCP server to see nested host in ARP tables",
+                     timeout_secs=10 * 60)
+            ips = pxe.arp_addresses_for(mac_address)
+            logging.info("Nested host has IPs %s", ips)
+            assert len(ips) == 1
+            host_vm.ip = ips[0]
+
+            wait_for(lambda: not os.system(f"nc -zw5 {host_vm.ip} 22"),
+                     "Wait for ssh up on nested host", retry_delay_secs=5)
+
+            hostname_or_ip = host_vm.ip
+
         pool = Pool(hostname_or_ip)
         h = pool.master
         return h
 
+    def cleanup_hosts():
+        for vm in nested_list:
+            logging.info("Destroying nested host VM %s", vm.uuid)
+            vm.destroy(verify=True)
+
     # a list of master hosts, each from a different pool
     hosts_args = pytestconfig.getoption("hosts")
     hosts_split = [hostlist.split(',') for hostlist in hosts_args]
     hostname_list = list(itertools.chain(*hosts_split))
-    host_list = [setup_host(hostname_or_ip) for hostname_or_ip in hostname_list]
+
+    try:
+        host_list = [setup_host(hostname_or_ip, config=pytestconfig)
+                     for hostname_or_ip in hostname_list]
+    except Exception:
+        cleanup_hosts()
+        raise
+
     if not host_list:
         pytest.fail("This test requires at least one --hosts parameter")
     yield host_list
 
+    cleanup_hosts()
+
 @pytest.fixture(scope='session')
 def registered_xo_cli():
     # The fixture is not responsible for establishing the connection.

From 1e06dd44da2011ce5efebb12d20ecdde5d9d206a Mon Sep 17 00:00:00 2001
From: Guillaume <guillaume.thouvenin@vates.tech>
Date: Mon, 9 Sep 2024 19:11:40 +0200
Subject: [PATCH 32/41] Add support for netinstall

Add a new parameter to allow booting using netinstall. If an ISO only
supports netinstall, it is specified it in the data.py by setting the
`net-only`option to True. This option is set to False by default.

Signed-off-by: Guillaume <guillaume.thouvenin@vates.tech>
Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist                                  | 28 +++++--
 lib/typing.py                                 |  2 +
 pytest.ini                                    |  1 +
 tests/install/conftest.py                     | 28 +++++++
 tests/install/test-sequences/inst+upg+rst.lst |  4 +-
 tests/install/test-sequences/inst+upg.lst     |  4 +-
 tests/install/test-sequences/inst.lst         |  6 +-
 tests/install/test.py                         | 73 ++++++++++++-------
 8 files changed, 107 insertions(+), 39 deletions(-)

diff --git a/data.py-dist b/data.py-dist
index d05d374f..fd8d2efd 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -121,15 +121,33 @@ ISO_IMAGES_CACHE = "/home/user/iso"
 # - absolute URL
 # - path relative to ISO_IMAGES_BASE URL
 # Note the dirname part is ignored when looking in ISO_IMAGES_CACHE, abuse this
-# for local-only ISO with things like "locally-built/my.iso" or "xs/8.3.iso"
+# for local-only ISO with things like "locally-built/my.iso" or "xs/8.3.iso".
+# If 'net-only' is set to 'True' only source of type URL will be possible.
+# By default the parameter is set to False.
 ISO_IMAGES: Dict[str, "IsoImageDef"] = {
     '83nightly': {'path': os.environ.get("XCPNG83_NIGHTLY",
                                          "http://unconfigured.iso"),
                   'unsigned': True},
-    '83rc1': {'path': "8.3/xcp-ng-8.3.0-rc1.iso"},
-    '83b2': {'path': "8.3/xcp-ng-8.3.0-beta2.iso"},
-    '83b1': {'path': "8.3/xcp-ng-8.3.0-beta1.iso"},
-    '821.1': {'path': "8.2/xcp-ng-8.2.1-20231130.iso"},
+    # FIXME: no such symlimk + useless without 'net-url'
+    #'83nightlynet': {'path': "http://pxe/isos/xcp-ng-8.3-ci-netinstall-latest"},
+    #                 'net-url': 'fake",
+    #                 'net-only': True},
+    '83rc1': {'path': "8.3/xcp-ng-8.3.0-rc1.iso",
+              #'net-url': "http://server/installers/xcp-ng/8.3-rc1",
+              },
+    ## FIXME: only a compensation for the lack of 83nightlynet
+    #'83rcnet': {'path': "8.3/xcp-ng-8.3.0-rc1-netinstall.iso",
+    #            'net-url': "http://server/installers/xcp-ng/8.3-rc1",
+    #            'net-only': True},
+    '83b2': {'path': "8.3/xcp-ng-8.3.0-beta2.iso",
+             #'net-url': "http://server/installers/xcp-ng/8.3-beta2",
+             },
+    '83b1': {'path': "8.3/xcp-ng-8.3.0-beta1.iso",
+             #'net-url': "http://server/installers/xcp-ng/8.3-beta1",
+             },
+    '821.1': {'path': "8.2/xcp-ng-8.2.1-20231130.iso",
+              #'net-url': f"http://{PXE_CONFIG_SERVER}/installers/xcp-ng/8.2.1-refreshed/",
+              },
     '821': {'path': "8.2/xcp-ng-8.2.1.iso"},
     '820': {'path': "8.2/xcp-ng-8.2.0.iso"},
     '81': {'path': "8.1/xcp-ng-8.1.0-2.iso"},
diff --git a/lib/typing.py b/lib/typing.py
index 68e9adb0..6b2a7d49 100644
--- a/lib/typing.py
+++ b/lib/typing.py
@@ -3,5 +3,7 @@
 
 IsoImageDef = TypedDict('IsoImageDef',
                         {'path': str,
+                         'net-url': NotRequired[str],
+                         'net-only': NotRequired[bool],
                          'unsigned': NotRequired[bool],
                          })
diff --git a/pytest.ini b/pytest.ini
index fe2c9214..6886cce3 100644
--- a/pytest.ini
+++ b/pytest.ini
@@ -24,6 +24,7 @@ markers =
 
     # * installation-related markers to customize installer run
     answerfile: dict defining an answerfile
+    package_source: source of packages during installation.
 
     # * Test targets related to VMs
     small_vm: tests that it is enough to run just once, using the smallest possible VM.
diff --git a/tests/install/conftest.py b/tests/install/conftest.py
index 57e0120e..c3d16f4f 100644
--- a/tests/install/conftest.py
+++ b/tests/install/conftest.py
@@ -13,6 +13,30 @@
 from data import (ISO_IMAGES, ISO_IMAGES_BASE, ISO_IMAGES_CACHE,
                   PXE_CONFIG_SERVER, TEST_SSH_PUBKEY, TOOLS)
 
+# Return true if the version of the ISO doesn't support the source type.
+# Note: this is a quick-win hack, to avoid explicit enumeration of supported
+# package_source values for each ISO.
+def skip_package_source(version, package_source):
+    if version not in ISO_IMAGES:
+        return True, "version of ISO {} is unknown".format(version)
+
+    if package_source == "iso":
+        if ISO_IMAGES[version].get('net-only', False):
+            return True, "ISO image is net-only while package_source is local"
+
+        return False, "do not skip"
+
+    if package_source == "net":
+        # Net install is not valid if there is no netinstall URL
+        # FIXME: ISO includes a default URL so we should be able to omit net-url
+        if 'net-url' not in ISO_IMAGES[version].keys():
+            return True, "net-url required for netinstall was not found for {}".format(version)
+
+        return False, "do not skip"
+
+    # If we don't know the source type then it is invalid
+    return True, "unknown source type {}".format(package_source)
+
 @pytest.fixture(scope='function')
 def answerfile(request):
     """
@@ -57,6 +81,10 @@ def answerfile(request):
 @pytest.fixture(scope='function')
 def installer_iso(request):
     iso_key = request.getfixturevalue("iso_version")
+    package_source = request.getfixturevalue("package_source")
+    skip, reason = skip_package_source(iso_key, package_source)
+    if skip:
+        pytest.skip(reason)
     assert iso_key in ISO_IMAGES, f"ISO_IMAGES does not have a value for {iso_key}"
     iso = ISO_IMAGES[iso_key]['path']
     if iso.startswith("/"):
diff --git a/tests/install/test-sequences/inst+upg+rst.lst b/tests/install/test-sequences/inst+upg+rst.lst
index f1e5f53d..c23878e4 100644
--- a/tests/install/test-sequences/inst+upg+rst.lst
+++ b/tests/install/test-sequences/inst+upg+rst.lst
@@ -1,2 +1,2 @@
-tests/install/test.py::TestNested::test_restore[uefi-83nightly-83nightly-83nightly-ext]
-tests/install/test.py::TestNested::test_boot_rst[uefi-83nightly-83nightly-83nightly-ext]
+tests/install/test.py::TestNested::test_restore[uefi-83nightly-83nightly-83nightly-iso-ext]
+tests/install/test.py::TestNested::test_boot_rst[uefi-83nightly-83nightly-83nightly-iso-ext]
diff --git a/tests/install/test-sequences/inst+upg.lst b/tests/install/test-sequences/inst+upg.lst
index 1f040973..100e5359 100644
--- a/tests/install/test-sequences/inst+upg.lst
+++ b/tests/install/test-sequences/inst+upg.lst
@@ -1,2 +1,2 @@
-tests/install/test.py::TestNested::test_upgrade[uefi-83nightly-83nightly-host1-ext]
-tests/install/test.py::TestNested::test_boot_upg[uefi-83nightly-83nightly-host1-ext]
+tests/install/test.py::TestNested::test_upgrade[uefi-83nightly-83nightly-host1-iso-ext]
+tests/install/test.py::TestNested::test_boot_upg[uefi-83nightly-83nightly-host1-iso-ext]
diff --git a/tests/install/test-sequences/inst.lst b/tests/install/test-sequences/inst.lst
index 6b18695b..9b92eea3 100644
--- a/tests/install/test-sequences/inst.lst
+++ b/tests/install/test-sequences/inst.lst
@@ -1,3 +1,3 @@
-tests/install/test.py::TestNested::test_install[uefi-83nightly-ext]
-tests/install/test.py::TestNested::test_tune_firstboot[None-uefi-83nightly-host1-ext]
-tests/install/test.py::TestNested::test_boot_inst[uefi-83nightly-host1-ext]
+tests/install/test.py::TestNested::test_install[uefi-83nightly-iso-ext]
+tests/install/test.py::TestNested::test_tune_firstboot[None-uefi-83nightly-host1-iso-ext]
+tests/install/test.py::TestNested::test_boot_inst[uefi-83nightly-host1-iso-ext]
diff --git a/tests/install/test.py b/tests/install/test.py
index 6f26236f..4860ab04 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -9,7 +9,7 @@
 from lib.pool import Pool
 from lib.vdi import VDI
 
-from data import NETWORKS
+from data import ISO_IMAGES, NETWORKS
 assert "MGMT" in NETWORKS
 
 # Requirements:
@@ -41,8 +41,9 @@ def helper_vm_with_plugged_disk(running_vm, create_vms):
 @pytest.mark.dependency()
 class TestNested:
     @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
+    @pytest.mark.parametrize("package_source", ("iso", "net"))
     @pytest.mark.parametrize("iso_version", (
-        "83nightly",
+        "83nightly", "83rcnet",
         "83rc1", "83b2", "83b1",
         "821.1",
         "81", "80", "76", "75",
@@ -75,24 +76,28 @@ class TestNested:
             vifs=[dict(index=0, network_name=NETWORKS["MGMT"])],
         ))
     @pytest.mark.answerfile(
-        lambda install_disk, local_sr: AnswerFile("INSTALL")
+        lambda install_disk, local_sr, package_source, iso_version: AnswerFile("INSTALL")
         .top_setattr({} if local_sr == "nosr" else {"sr-type": local_sr})
         .top_append(
-            {"TAG": "source", "type": "local"},
+            {"TAG": "source", "type": "local"} if package_source == "iso"
+            else {"TAG": "source", "type": "url",
+                  "CONTENTS": ISO_IMAGES[iso_version]['net-url']} if package_source == "net"
+            else {},
             {"TAG": "primary-disk",
              "guest-storage": "no" if local_sr == "nosr" else "yes",
              "CONTENTS": install_disk},
         ))
     def test_install(self, vm_booted_with_installer, install_disk,
-                     firmware, iso_version, local_sr):
+                     firmware, iso_version, package_source, local_sr):
         host_vm = vm_booted_with_installer
         installer.monitor_install(ip=host_vm.ip)
 
     @pytest.mark.usefixtures("xcpng_chained")
     @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
+    @pytest.mark.parametrize("package_source", ("iso", "net"))
     @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize("version", (
-        "83nightly",
+        "83nightly", "83rcnet",
         "83rc1", "83b2", "83b1",
         "821.1",
         "81", "80",
@@ -102,12 +107,12 @@ def test_install(self, vm_booted_with_installer, install_disk,
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda version, firmware, local_sr: [dict(
+        lambda version, firmware, local_sr, package_source: [dict(
             vm="vm1",
-            image_test=f"TestNested::test_install[{firmware}-{version}-{local_sr}]")])
+            image_test=f"TestNested::test_install[{firmware}-{version}-{package_source}-{local_sr}]")])
     @pytest.mark.small_vm
     def test_tune_firstboot(self, create_vms, helper_vm_with_plugged_disk,
-                            firmware, version, machine, local_sr):
+                            firmware, version, machine, local_sr, package_source):
         helper_vm = helper_vm_with_plugged_disk
 
         helper_vm.ssh(["mount /dev/xvdb1 /mnt"])
@@ -151,6 +156,7 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal
             "83b1": "8.3.0",
             "83b2": "8.3.0",
             "83rc1": "8.3.0",
+            "83rcnet": "8.3.0",
             "83nightly": "8.3.0",
         }[expected_rel_id]
 
@@ -278,9 +284,10 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal
 
     @pytest.mark.usefixtures("xcpng_chained")
     @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
+    @pytest.mark.parametrize("package_source", ("iso", "net"))
     @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize("version", (
-        "83nightly",
+        "83nightly", "83rcnet",
         "83rc1", "83b2", "83b1",
         "821.1",
         "81", "80",
@@ -290,15 +297,17 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, version, machine, local_sr: [
+        lambda firmware, version, machine, local_sr, package_source: [
             dict(vm="vm1",
-                 image_test=f"TestNested::test_tune_firstboot[None-{firmware}-{version}-{machine}-{local_sr}]")])
+                 image_test=("TestNested::test_tune_firstboot"
+                             f"[None-{firmware}-{version}-{machine}-{package_source}-{local_sr}]"))])
     def test_boot_inst(self, create_vms,
-                       firmware, version, machine, local_sr):
+                       firmware, version, machine, package_source, local_sr):
         self._test_firstboot(create_vms, version, machine=machine)
 
     @pytest.mark.usefixtures("xcpng_chained")
     @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
+    @pytest.mark.parametrize("package_source", ("iso", "net"))
     @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize("mode", (
         "83nightly-83nightly",
@@ -310,19 +319,21 @@ def test_boot_inst(self, create_vms,
         "80-83nightly",
         "xs8-83nightly",
         "ch821.1-83nightly",
+        "83rcnet-83rcnet",
         "821.1-821.1",
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, mode, machine, local_sr: [dict(
+        lambda firmware, mode, machine, package_source, local_sr: [dict(
             vm="vm1",
-            image_test=(f"TestNested::test_upgrade[{firmware}-{mode}-{machine}-{local_sr}]"))])
+            image_test=(f"TestNested::test_upgrade[{firmware}-{mode}-{machine}-{package_source}-{local_sr}]"))])
     def test_boot_upg(self, create_vms,
-                      firmware, mode, machine, local_sr):
+                      firmware, mode, machine, package_source, local_sr):
         self._test_firstboot(create_vms, mode, machine=machine)
 
     @pytest.mark.usefixtures("xcpng_chained")
     @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
+    @pytest.mark.parametrize("package_source", ("iso", "net"))
     @pytest.mark.parametrize("mode", (
         "83nightly-83nightly-83nightly",
         "83rc1-83nightly-83nightly",
@@ -333,19 +344,21 @@ def test_boot_upg(self, create_vms,
         "80-83nightly-83nightly",
         "xs8-83nightly-83nightly",
         "ch821.1-83nightly-83nightly",
+        "83rcnet-83rcnet-83rcnet", # FIXME
         "821.1-821.1-821.1",
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, mode, local_sr: [dict(
+        lambda firmware, mode, package_source, local_sr: [dict(
             vm="vm1",
-            image_test=(f"TestNested::test_restore[{firmware}-{mode}-{local_sr}]"))])
+            image_test=(f"TestNested::test_restore[{firmware}-{mode}-{package_source}-{local_sr}]"))])
     def test_boot_rst(self, create_vms,
-                      firmware, mode, local_sr):
+                      firmware, mode, package_source, local_sr):
         self._test_firstboot(create_vms, mode, is_restore=True)
 
     @pytest.mark.usefixtures("xcpng_chained")
     @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
+    @pytest.mark.parametrize("package_source", ("iso", "net"))
     @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize(("orig_version", "iso_version"), [
         ("83nightly", "83nightly"),
@@ -357,26 +370,31 @@ def test_boot_rst(self, create_vms,
         ("80", "83nightly"),
         ("xs8", "83nightly"),
         ("ch821.1", "83nightly"),
+        ("83rcnet", "83rcnet"), # FIXME
         ("821.1", "821.1"),
     ])
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, orig_version, machine, local_sr: [dict(
+        lambda firmware, orig_version, machine, package_source, local_sr: [dict(
             vm="vm1",
-            image_test=f"TestNested::test_boot_inst[{firmware}-{orig_version}-{machine}-{local_sr}]")])
+            image_test=f"TestNested::test_boot_inst[{firmware}-{orig_version}-{machine}-{package_source}-{local_sr}]")])
     @pytest.mark.answerfile(
-        lambda install_disk: AnswerFile("UPGRADE").top_append(
-            {"TAG": "source", "type": "local"},
+        lambda install_disk, package_source, iso_version: AnswerFile("UPGRADE").top_append(
+            {"TAG": "source", "type": "local"} if package_source == "iso"
+            else {"TAG": "source", "type": "url",
+                  "CONTENTS": ISO_IMAGES[iso_version]['net-url']} if package_source == "net"
+            else {},
             {"TAG": "existing-installation",
              "CONTENTS": install_disk},
         ))
     def test_upgrade(self, vm_booted_with_installer, install_disk,
-                     firmware, orig_version, iso_version, machine, local_sr):
+                     firmware, orig_version, iso_version, machine, package_source, local_sr):
         host_vm = vm_booted_with_installer
         installer.monitor_upgrade(ip=host_vm.ip)
 
     @pytest.mark.usefixtures("xcpng_chained")
     @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
+    @pytest.mark.parametrize("package_source", ("iso", "net"))
     @pytest.mark.parametrize(("orig_version", "iso_version"), [
         ("83nightly-83nightly", "83nightly"),
         ("83rc1-83nightly", "83nightly"),
@@ -387,19 +405,20 @@ def test_upgrade(self, vm_booted_with_installer, install_disk,
         ("80-83nightly", "83nightly"),
         ("xs8-83nightly", "83nightly"),
         ("ch821.1-83nightly", "83nightly"),
+        ("83rcnet-83rcnet", "83rcnet"), # FIXME
         ("821.1-821.1", "821.1"),
     ])
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
     @pytest.mark.continuation_of(
-        lambda firmware, orig_version, local_sr: [dict(
+        lambda firmware, orig_version, local_sr, package_source: [dict(
             vm="vm1",
-            image_test=f"TestNested::test_boot_upg[{firmware}-{orig_version}-host1-{local_sr}]")])
+            image_test=f"TestNested::test_boot_upg[{firmware}-{orig_version}-host1-{package_source}-{local_sr}]")])
     @pytest.mark.answerfile(
         lambda install_disk: AnswerFile("RESTORE").top_append(
             {"TAG": "backup-disk",
              "CONTENTS": install_disk},
         ))
     def test_restore(self, vm_booted_with_installer, install_disk,
-                     firmware, orig_version, iso_version, local_sr):
+                     firmware, orig_version, iso_version, package_source, local_sr):
         host_vm = vm_booted_with_installer
         installer.monitor_restore(ip=host_vm.ip)

From 6b11e96ce02ac4dd20de8ac5f59a163d11a33d34 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Fri, 30 Aug 2024 15:13:06 +0200
Subject: [PATCH 33/41] New test: pool_join

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 jobs.py                 |  9 +++++++++
 tests/misc/test_pool.py | 15 +++++++++++++++
 2 files changed, 24 insertions(+)
 create mode 100644 tests/misc/test_pool.py

diff --git a/jobs.py b/jobs.py
index 7264d410..7dc231c3 100755
--- a/jobs.py
+++ b/jobs.py
@@ -433,6 +433,15 @@
         "params": {},
         "paths": ["tests/fs_diff"],
     },
+    "pool-reboot": {
+        "description": "Tests centered on pools with join/eject causing reboots",
+        "requirements": [
+            "1 XCP-ng pool and an additionnal host >= 8.2"
+        ],
+        "nb_pools": 2,
+        "params": {},
+        "paths": ["tests/misc/test_pool.py"],
+    }
 }
 
 # List used by the 'check' action: tests listed here will not raise a check error
diff --git a/tests/misc/test_pool.py b/tests/misc/test_pool.py
new file mode 100644
index 00000000..c11f575b
--- /dev/null
+++ b/tests/misc/test_pool.py
@@ -0,0 +1,15 @@
+import pytest
+
+# Requirements:
+# From --hosts parameter:
+# - host(A1): first XCP-ng host >= 8.2.
+# - hostB1: Master of a second pool, same version as hostA1.
+#   This host will be joined and ejected from pool A, it means its state will be completely reinitialized from scratch
+
+@pytest.mark.reboot
+def test_pool_join(hostA1, hostB1):
+    hostB1.join_pool(hostA1.pool)
+    # FIXME: is this gymnastics necessary?
+    # If no, fix TestPoolToDiskCertInheritanceOnPoolJoin too
+    joined_host = hostA1.pool.get_host_by_uuid(hostB1.uuid)
+    hostA1.pool.eject(joined_host)

From 1c096993b84ad250c5034e57907868885bbb1f42 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Tue, 8 Oct 2024 16:46:17 +0200
Subject: [PATCH 34/41] firstboot: reordering cleanup

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 tests/install/test.py | 25 +++++++++++++------------
 1 file changed, 13 insertions(+), 12 deletions(-)

diff --git a/tests/install/test.py b/tests/install/test.py
index 4860ab04..a411a647 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -145,19 +145,20 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal
         else:
             expected_rel_id = split_mode[-1]
         expected_rel = {
-            "xs70": "7.0.0-125380c",
-            "ch821.1": "8.2.1",
-            "xs8": "8.4.0",
-            "75": "7.5.0",
-            "76": "7.6.0",
-            "80": "8.0.0",
-            "81": "8.1.0",
-            "821.1": "8.2.1",
-            "83b1": "8.3.0",
-            "83b2": "8.3.0",
-            "83rc1": "8.3.0",
-            "83rcnet": "8.3.0",
             "83nightly": "8.3.0",
+            "83rcnet": "8.3.0",
+            "83rc1": "8.3.0",
+            "83b2": "8.3.0",
+            "83b1": "8.3.0",
+            "821.1": "8.2.1",
+            "81": "8.1.0",
+            "80": "8.0.0",
+            "76": "7.6.0",
+            "75": "7.5.0",
+            #
+            "xs8": "8.4.0",
+            "ch821.1": "8.2.1",
+            "xs70": "7.0.0-125380c",
         }[expected_rel_id]
 
         # determine version info from `mode`

From a8e5cdf1ef008fd9423d218c2b653b32068dc789 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Tue, 6 May 2025 11:08:07 +0200
Subject: [PATCH 35/41] firstboot: move _upg and _rst tests to group them with
 upgrade and restore

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 tests/install/test.py | 102 +++++++++++++++++++++---------------------
 1 file changed, 51 insertions(+), 51 deletions(-)

diff --git a/tests/install/test.py b/tests/install/test.py
index a411a647..cc150c94 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -306,57 +306,6 @@ def test_boot_inst(self, create_vms,
                        firmware, version, machine, package_source, local_sr):
         self._test_firstboot(create_vms, version, machine=machine)
 
-    @pytest.mark.usefixtures("xcpng_chained")
-    @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
-    @pytest.mark.parametrize("package_source", ("iso", "net"))
-    @pytest.mark.parametrize("machine", ("host1", "host2"))
-    @pytest.mark.parametrize("mode", (
-        "83nightly-83nightly",
-        "83rc1-83nightly",
-        "83b2-83nightly",
-        "83b1-83nightly",
-        "821.1-83nightly",
-        "81-83nightly",
-        "80-83nightly",
-        "xs8-83nightly",
-        "ch821.1-83nightly",
-        "83rcnet-83rcnet",
-        "821.1-821.1",
-    ))
-    @pytest.mark.parametrize("firmware", ("uefi", "bios"))
-    @pytest.mark.continuation_of(
-        lambda firmware, mode, machine, package_source, local_sr: [dict(
-            vm="vm1",
-            image_test=(f"TestNested::test_upgrade[{firmware}-{mode}-{machine}-{package_source}-{local_sr}]"))])
-    def test_boot_upg(self, create_vms,
-                      firmware, mode, machine, package_source, local_sr):
-        self._test_firstboot(create_vms, mode, machine=machine)
-
-    @pytest.mark.usefixtures("xcpng_chained")
-    @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
-    @pytest.mark.parametrize("package_source", ("iso", "net"))
-    @pytest.mark.parametrize("mode", (
-        "83nightly-83nightly-83nightly",
-        "83rc1-83nightly-83nightly",
-        "83b2-83nightly-83nightly",
-        "83b1-83nightly-83nightly",
-        "821.1-83nightly-83nightly",
-        "81-83nightly-83nightly",
-        "80-83nightly-83nightly",
-        "xs8-83nightly-83nightly",
-        "ch821.1-83nightly-83nightly",
-        "83rcnet-83rcnet-83rcnet", # FIXME
-        "821.1-821.1-821.1",
-    ))
-    @pytest.mark.parametrize("firmware", ("uefi", "bios"))
-    @pytest.mark.continuation_of(
-        lambda firmware, mode, package_source, local_sr: [dict(
-            vm="vm1",
-            image_test=(f"TestNested::test_restore[{firmware}-{mode}-{package_source}-{local_sr}]"))])
-    def test_boot_rst(self, create_vms,
-                      firmware, mode, package_source, local_sr):
-        self._test_firstboot(create_vms, mode, is_restore=True)
-
     @pytest.mark.usefixtures("xcpng_chained")
     @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
     @pytest.mark.parametrize("package_source", ("iso", "net"))
@@ -393,6 +342,32 @@ def test_upgrade(self, vm_booted_with_installer, install_disk,
         host_vm = vm_booted_with_installer
         installer.monitor_upgrade(ip=host_vm.ip)
 
+    @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
+    @pytest.mark.parametrize("package_source", ("iso", "net"))
+    @pytest.mark.parametrize("machine", ("host1", "host2"))
+    @pytest.mark.parametrize("mode", (
+        "83nightly-83nightly",
+        "83rc1-83nightly",
+        "83b2-83nightly",
+        "83b1-83nightly",
+        "821.1-83nightly",
+        "81-83nightly",
+        "80-83nightly",
+        "xs8-83nightly",
+        "ch821.1-83nightly",
+        "83rcnet-83rcnet",
+        "821.1-821.1",
+    ))
+    @pytest.mark.parametrize("firmware", ("uefi", "bios"))
+    @pytest.mark.continuation_of(
+        lambda firmware, mode, machine, package_source, local_sr: [dict(
+            vm="vm1",
+            image_test=(f"TestNested::test_upgrade[{firmware}-{mode}-{machine}-{package_source}-{local_sr}]"))])
+    def test_boot_upg(self, create_vms,
+                      firmware, mode, machine, package_source, local_sr):
+        self._test_firstboot(create_vms, mode, machine=machine)
+
     @pytest.mark.usefixtures("xcpng_chained")
     @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
     @pytest.mark.parametrize("package_source", ("iso", "net"))
@@ -423,3 +398,28 @@ def test_restore(self, vm_booted_with_installer, install_disk,
                      firmware, orig_version, iso_version, package_source, local_sr):
         host_vm = vm_booted_with_installer
         installer.monitor_restore(ip=host_vm.ip)
+
+    @pytest.mark.usefixtures("xcpng_chained")
+    @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
+    @pytest.mark.parametrize("package_source", ("iso", "net"))
+    @pytest.mark.parametrize("mode", (
+        "83nightly-83nightly-83nightly",
+        "83rc1-83nightly-83nightly",
+        "83b2-83nightly-83nightly",
+        "83b1-83nightly-83nightly",
+        "821.1-83nightly-83nightly",
+        "81-83nightly-83nightly",
+        "80-83nightly-83nightly",
+        "xs8-83nightly-83nightly",
+        "ch821.1-83nightly-83nightly",
+        "83rcnet-83rcnet-83rcnet", # FIXME
+        "821.1-821.1-821.1",
+    ))
+    @pytest.mark.parametrize("firmware", ("uefi", "bios"))
+    @pytest.mark.continuation_of(
+        lambda firmware, mode, package_source, local_sr: [dict(
+            vm="vm1",
+            image_test=(f"TestNested::test_restore[{firmware}-{mode}-{package_source}-{local_sr}]"))])
+    def test_boot_rst(self, create_vms,
+                      firmware, mode, package_source, local_sr):
+        self._test_firstboot(create_vms, mode, is_restore=True)

From c931ccc49da9bea6a4dd41f097258795725a50da Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Mon, 17 Mar 2025 10:46:59 +0100
Subject: [PATCH 36/41] install: add 82nightly configuration for CI

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist          |  3 +++
 tests/install/test.py | 12 ++++++++++++
 2 files changed, 15 insertions(+)

diff --git a/data.py-dist b/data.py-dist
index fd8d2efd..6aaf1933 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -145,6 +145,9 @@ ISO_IMAGES: Dict[str, "IsoImageDef"] = {
     '83b1': {'path': "8.3/xcp-ng-8.3.0-beta1.iso",
              #'net-url': "http://server/installers/xcp-ng/8.3-beta1",
              },
+    '82nightly': {'path': os.environ.get("XCPNG82_NIGHTLY",
+                                         "http://unconfigured.iso"),
+                  'unsigned': True},
     '821.1': {'path': "8.2/xcp-ng-8.2.1-20231130.iso",
               #'net-url': f"http://{PXE_CONFIG_SERVER}/installers/xcp-ng/8.2.1-refreshed/",
               },
diff --git a/tests/install/test.py b/tests/install/test.py
index cc150c94..319ecffc 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -45,6 +45,7 @@ class TestNested:
     @pytest.mark.parametrize("iso_version", (
         "83nightly", "83rcnet",
         "83rc1", "83b2", "83b1",
+        "82nightly",
         "821.1",
         "81", "80", "76", "75",
         "xs8", "ch821.1",
@@ -99,6 +100,7 @@ def test_install(self, vm_booted_with_installer, install_disk,
     @pytest.mark.parametrize("version", (
         "83nightly", "83rcnet",
         "83rc1", "83b2", "83b1",
+        "82nightly",
         "821.1",
         "81", "80",
         "76", "75",
@@ -150,6 +152,7 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal
             "83rc1": "8.3.0",
             "83b2": "8.3.0",
             "83b1": "8.3.0",
+            "82nightly": "8.2.1",
             "821.1": "8.2.1",
             "81": "8.1.0",
             "80": "8.0.0",
@@ -290,6 +293,7 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal
     @pytest.mark.parametrize("version", (
         "83nightly", "83rcnet",
         "83rc1", "83b2", "83b1",
+        "82nightly",
         "821.1",
         "81", "80",
         "76", "75",
@@ -321,6 +325,8 @@ def test_boot_inst(self, create_vms,
         ("xs8", "83nightly"),
         ("ch821.1", "83nightly"),
         ("83rcnet", "83rcnet"), # FIXME
+        ("82nightly", "82nightly"),
+        ("821.1", "82nightly"),
         ("821.1", "821.1"),
     ])
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
@@ -357,6 +363,8 @@ def test_upgrade(self, vm_booted_with_installer, install_disk,
         "xs8-83nightly",
         "ch821.1-83nightly",
         "83rcnet-83rcnet",
+        "82nightly-82nightly",
+        "821.1-82nightly",
         "821.1-821.1",
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
@@ -382,6 +390,8 @@ def test_boot_upg(self, create_vms,
         ("xs8-83nightly", "83nightly"),
         ("ch821.1-83nightly", "83nightly"),
         ("83rcnet-83rcnet", "83rcnet"), # FIXME
+        ("82nightly-82nightly", "82nightly"),
+        ("821.1-82nightly", "82nightly"),
         ("821.1-821.1", "821.1"),
     ])
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))
@@ -413,6 +423,8 @@ def test_restore(self, vm_booted_with_installer, install_disk,
         "xs8-83nightly-83nightly",
         "ch821.1-83nightly-83nightly",
         "83rcnet-83rcnet-83rcnet", # FIXME
+        "82nightly-82nightly-82nightly",
+        "821.1-82nightly-82nightly",
         "821.1-821.1-821.1",
     ))
     @pytest.mark.parametrize("firmware", ("uefi", "bios"))

From f70fa1b97ce1568b5f9a0aa73ab2b4ac6f96f3d9 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Mon, 17 Mar 2025 16:25:07 +0100
Subject: [PATCH 37/41] install: replace 8.3 prereleases with 8.3.0 as base
 version for upgrades

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist          | 22 ++++++++--------------
 tests/install/test.py | 42 ++++++++++++++++--------------------------
 2 files changed, 24 insertions(+), 40 deletions(-)

diff --git a/data.py-dist b/data.py-dist
index 6aaf1933..5b631dbb 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -132,19 +132,13 @@ ISO_IMAGES: Dict[str, "IsoImageDef"] = {
     #'83nightlynet': {'path': "http://pxe/isos/xcp-ng-8.3-ci-netinstall-latest"},
     #                 'net-url': 'fake",
     #                 'net-only': True},
-    '83rc1': {'path': "8.3/xcp-ng-8.3.0-rc1.iso",
-              #'net-url': "http://server/installers/xcp-ng/8.3-rc1",
-              },
+    '830': {'path': "8.3/xcp-ng-8.3.0.iso",
+            #'net-url': "http://server/installers/xcp-ng/8.3.0",
+            },
     ## FIXME: only a compensation for the lack of 83nightlynet
-    #'83rcnet': {'path': "8.3/xcp-ng-8.3.0-rc1-netinstall.iso",
-    #            'net-url': "http://server/installers/xcp-ng/8.3-rc1",
-    #            'net-only': True},
-    '83b2': {'path': "8.3/xcp-ng-8.3.0-beta2.iso",
-             #'net-url': "http://server/installers/xcp-ng/8.3-beta2",
-             },
-    '83b1': {'path': "8.3/xcp-ng-8.3.0-beta1.iso",
-             #'net-url': "http://server/installers/xcp-ng/8.3-beta1",
-             },
+    #'830net': {'path': "8.3/xcp-ng-8.3.0-netinstall.iso",
+    #           'net-url': "http://server/installers/xcp-ng/8.3.0",
+    #           'net-only': True},
     '82nightly': {'path': os.environ.get("XCPNG82_NIGHTLY",
                                          "http://unconfigured.iso"),
                   'unsigned': True},
@@ -252,8 +246,8 @@ BASE_ANSWERFILES = dict(
 )
 
 IMAGE_EQUIVS: Dict[str, str] = {
-#    'install.test::Nested::install[bios-83rc1-ext]-vm1-607cea0c825a4d578fa5fab56978627d8b2e28bb':
-#    'install.test::Nested::install[bios-83rc1-ext]-vm1-addb4ead4da49856e1d2fb3ddf4e31027c6b693b',
+#    'install.test::Nested::install[bios-830-ext]-vm1-607cea0c825a4d578fa5fab56978627d8b2e28bb':
+#    'install.test::Nested::install[bios-830-ext]-vm1-addb4ead4da49856e1d2fb3ddf4e31027c6b693b',
 }
 
 # compatibility settings for older tests
diff --git a/tests/install/test.py b/tests/install/test.py
index 319ecffc..2c3ae09c 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -43,8 +43,8 @@ class TestNested:
     @pytest.mark.parametrize("local_sr", ("nosr", "ext", "lvm"))
     @pytest.mark.parametrize("package_source", ("iso", "net"))
     @pytest.mark.parametrize("iso_version", (
-        "83nightly", "83rcnet",
-        "83rc1", "83b2", "83b1",
+        "83nightly", "830net",
+        "830",
         "82nightly",
         "821.1",
         "81", "80", "76", "75",
@@ -98,8 +98,8 @@ def test_install(self, vm_booted_with_installer, install_disk,
     @pytest.mark.parametrize("package_source", ("iso", "net"))
     @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize("version", (
-        "83nightly", "83rcnet",
-        "83rc1", "83b2", "83b1",
+        "83nightly", "830net",
+        "830",
         "82nightly",
         "821.1",
         "81", "80",
@@ -148,10 +148,8 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal
             expected_rel_id = split_mode[-1]
         expected_rel = {
             "83nightly": "8.3.0",
-            "83rcnet": "8.3.0",
-            "83rc1": "8.3.0",
-            "83b2": "8.3.0",
-            "83b1": "8.3.0",
+            "830net": "8.3.0",
+            "830": "8.3.0",
             "82nightly": "8.2.1",
             "821.1": "8.2.1",
             "81": "8.1.0",
@@ -291,8 +289,8 @@ def _test_firstboot(self, create_vms, mode, *, machine='DEFAULT', is_restore=Fal
     @pytest.mark.parametrize("package_source", ("iso", "net"))
     @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize("version", (
-        "83nightly", "83rcnet",
-        "83rc1", "83b2", "83b1",
+        "83nightly", "830net",
+        "830",
         "82nightly",
         "821.1",
         "81", "80",
@@ -316,15 +314,13 @@ def test_boot_inst(self, create_vms,
     @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize(("orig_version", "iso_version"), [
         ("83nightly", "83nightly"),
-        ("83rc1", "83nightly"),
-        ("83b2", "83nightly"),
-        ("83b1", "83nightly"),
+        ("830", "83nightly"),
         ("821.1", "83nightly"),
         ("81", "83nightly"),
         ("80", "83nightly"),
         ("xs8", "83nightly"),
         ("ch821.1", "83nightly"),
-        ("83rcnet", "83rcnet"), # FIXME
+        ("830net", "830net"), # FIXME
         ("82nightly", "82nightly"),
         ("821.1", "82nightly"),
         ("821.1", "821.1"),
@@ -354,15 +350,13 @@ def test_upgrade(self, vm_booted_with_installer, install_disk,
     @pytest.mark.parametrize("machine", ("host1", "host2"))
     @pytest.mark.parametrize("mode", (
         "83nightly-83nightly",
-        "83rc1-83nightly",
-        "83b2-83nightly",
-        "83b1-83nightly",
+        "830-83nightly",
         "821.1-83nightly",
         "81-83nightly",
         "80-83nightly",
         "xs8-83nightly",
         "ch821.1-83nightly",
-        "83rcnet-83rcnet",
+        "830net-830net",
         "82nightly-82nightly",
         "821.1-82nightly",
         "821.1-821.1",
@@ -381,15 +375,13 @@ def test_boot_upg(self, create_vms,
     @pytest.mark.parametrize("package_source", ("iso", "net"))
     @pytest.mark.parametrize(("orig_version", "iso_version"), [
         ("83nightly-83nightly", "83nightly"),
-        ("83rc1-83nightly", "83nightly"),
-        ("83b2-83nightly", "83nightly"),
-        ("83b1-83nightly", "83nightly"),
+        ("830-83nightly", "83nightly"),
         ("821.1-83nightly", "83nightly"),
         ("81-83nightly", "83nightly"),
         ("80-83nightly", "83nightly"),
         ("xs8-83nightly", "83nightly"),
         ("ch821.1-83nightly", "83nightly"),
-        ("83rcnet-83rcnet", "83rcnet"), # FIXME
+        ("830net-830net", "830net"), # FIXME
         ("82nightly-82nightly", "82nightly"),
         ("821.1-82nightly", "82nightly"),
         ("821.1-821.1", "821.1"),
@@ -414,15 +406,13 @@ def test_restore(self, vm_booted_with_installer, install_disk,
     @pytest.mark.parametrize("package_source", ("iso", "net"))
     @pytest.mark.parametrize("mode", (
         "83nightly-83nightly-83nightly",
-        "83rc1-83nightly-83nightly",
-        "83b2-83nightly-83nightly",
-        "83b1-83nightly-83nightly",
+        "830-83nightly-83nightly",
         "821.1-83nightly-83nightly",
         "81-83nightly-83nightly",
         "80-83nightly-83nightly",
         "xs8-83nightly-83nightly",
         "ch821.1-83nightly-83nightly",
-        "83rcnet-83rcnet-83rcnet", # FIXME
+        "830net-830net-830net", # FIXME
         "82nightly-82nightly-82nightly",
         "821.1-82nightly-82nightly",
         "821.1-821.1-821.1",

From 490e68e221001a84f418e83025c2a74338070753 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Mon, 3 Feb 2025 18:20:35 +0100
Subject: [PATCH 38/41] install: support the new platform setting for nesting
 in 8.3+

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 tests/install/test.py | 1 +
 1 file changed, 1 insertion(+)

diff --git a/tests/install/test.py b/tests/install/test.py
index 2c3ae09c..daacab63 100644
--- a/tests/install/test.py
+++ b/tests/install/test.py
@@ -64,6 +64,7 @@ class TestNested:
                 dict(param_name="VCPUs-max", value="2"),
                 dict(param_name="VCPUs-at-startup", value="2"),
                 dict(param_name="platform", key="exp-nested-hvm", value="true"), # FIXME < 8.3 host?
+                dict(param_name="platform", key="nested-virt", value="true"), # FIXME >= 8.3 host?
                 dict(param_name="HVM-boot-params", key="order", value="dc"),
             ) + {
                 "uefi": (

From 6b4f75c5b569c631c0621e3b77d02ef93bc1db70 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Tue, 4 Feb 2025 16:05:51 +0100
Subject: [PATCH 39/41] pxe.arp_addresses_for: use ARP_SERVER not
 PXE_CONFIG_SERVER

In some network we might not even have a PXE server, or no ssh access to
it.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 data.py-dist              | 3 +++
 lib/pxe.py                | 4 ++--
 tests/install/conftest.py | 6 +++---
 3 files changed, 8 insertions(+), 5 deletions(-)

diff --git a/data.py-dist b/data.py-dist
index 5b631dbb..8399d8ed 100644
--- a/data.py-dist
+++ b/data.py-dist
@@ -49,6 +49,9 @@ NETWORKS = {
 # PXE config server for automated XCP-ng installation
 PXE_CONFIG_SERVER = 'pxe'
 
+# server on MGMT network, where ARP tables can reveal the MACs
+ARP_SERVER = PXE_CONFIG_SERVER
+
 # Default VM images location
 DEF_VM_URL = 'http://pxe/images/'
 
diff --git a/lib/pxe.py b/lib/pxe.py
index ef8da2dd..1009f322 100644
--- a/lib/pxe.py
+++ b/lib/pxe.py
@@ -1,5 +1,5 @@
 from lib.commands import ssh, scp
-from data import PXE_CONFIG_SERVER
+from data import ARP_SERVER, PXE_CONFIG_SERVER
 
 PXE_CONFIG_DIR = "/pxe/configs/custom"
 
@@ -35,7 +35,7 @@ def server_remove_bootconf(mac_address):
 
 def arp_addresses_for(mac_address):
     output = ssh(
-        PXE_CONFIG_SERVER,
+        ARP_SERVER,
         ['arp', '-n', '|', 'grep', mac_address, '|', 'awk', '\'{ print $1 }\'']
     )
     candidate_ips = output.splitlines()
diff --git a/tests/install/conftest.py b/tests/install/conftest.py
index c3d16f4f..03357c61 100644
--- a/tests/install/conftest.py
+++ b/tests/install/conftest.py
@@ -11,7 +11,7 @@
 from lib.commands import local_cmd
 
 from data import (ISO_IMAGES, ISO_IMAGES_BASE, ISO_IMAGES_CACHE,
-                  PXE_CONFIG_SERVER, TEST_SSH_PUBKEY, TOOLS)
+                  ARP_SERVER, TEST_SSH_PUBKEY, TOOLS)
 
 # Return true if the version of the ISO doesn't support the source type.
 # Note: this is a quick-win hack, to avoid explicit enumeration of supported
@@ -197,7 +197,7 @@ def remastered_iso(installer_iso, answerfile):
 After=network-online.target
 [Service]
 Type=oneshot
-ExecStart=/bin/sh -c 'while ! /usr/local/sbin/test-pingpxe.sh "{PXE_CONFIG_SERVER}"; do sleep 1 ; done'
+ExecStart=/bin/sh -c 'while ! /usr/local/sbin/test-pingpxe.sh "{ARP_SERVER}"; do sleep 1 ; done'
 [Install]
 WantedBy=default.target
 EOF
@@ -208,7 +208,7 @@ def remastered_iso(installer_iso, answerfile):
 #!/bin/sh
 case "$1" in
   start)
-    sh -c 'while ! /usr/local/sbin/test-pingpxe.sh "{PXE_CONFIG_SERVER}"; do sleep 1 ; done' & ;;
+    sh -c 'while ! /usr/local/sbin/test-pingpxe.sh "{ARP_SERVER}"; do sleep 1 ; done' & ;;
   stop) ;;
 esac
 EOF

From 7c6f9b8192c0a42627e9704efdec6f6ab4719908 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Tue, 4 Feb 2025 17:01:15 +0100
Subject: [PATCH 40/41] pxe.arp_addresses_for: use iproute2

The former code relies on output from the net-tools implementation of
the arp tool.  At least the busybox implementation, found in Alpine
Linux, has a different output.

The `ip neigh` OTOH is designed as machine-readable and works both
with iproute2 and busybox.

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 lib/pxe.py | 4 +++-
 1 file changed, 3 insertions(+), 1 deletion(-)

diff --git a/lib/pxe.py b/lib/pxe.py
index 1009f322..4e3491ae 100644
--- a/lib/pxe.py
+++ b/lib/pxe.py
@@ -36,7 +36,9 @@ def server_remove_bootconf(mac_address):
 def arp_addresses_for(mac_address):
     output = ssh(
         ARP_SERVER,
-        ['arp', '-n', '|', 'grep', mac_address, '|', 'awk', '\'{ print $1 }\'']
+        ['ip', 'neigh', 'show', 'nud', 'reachable',
+         '|', 'grep', mac_address,
+         '|', 'awk', '\'{ print $1 }\'']
     )
     candidate_ips = output.splitlines()
     return candidate_ips

From 32cc03085a6a8fb53cf7487eff6bc86e69758173 Mon Sep 17 00:00:00 2001
From: Yann Dirson <yann.dirson@vates.tech>
Date: Mon, 17 Mar 2025 15:24:03 +0100
Subject: [PATCH 41/41] install: add a first README for automated installs

Signed-off-by: Yann Dirson <yann.dirson@vates.tech>
---
 tests/install/README.md | 192 ++++++++++++++++++++++++++++++++++++++++
 1 file changed, 192 insertions(+)
 create mode 100644 tests/install/README.md

diff --git a/tests/install/README.md b/tests/install/README.md
new file mode 100644
index 00000000..46003bd6
--- /dev/null
+++ b/tests/install/README.md
@@ -0,0 +1,192 @@
+# Automated XCP-ng installation tests
+
+Note: this is a first iteration, which is bound to evolve a lot, when
+we find the time.
+
+Those tests cover installation, upgrade, and restore, using the
+official installation ISO.  There is a small number of actual tests,
+but a large number of parameters to apply the same testing steps to
+various situations (UEFI or BIOS, choice of a release to upgrade from,
+install purely from ISO or from a network pacakge repository, local SR
+type to create).
+
+## Terminology
+
+In their current state, those tests can only install XCP-ng nested
+inside a pre-existing XCP-ng pool.  In standard terminology of
+hypervisor nesting, we have:
+
+* L0: the pre-existing XCP-ng pool
+* L1: the XCP-ng host(s) exercised by the tests
+* L2: VMs launched by the hosts under test
+
+As far as those install tests are concerned, L2 is out of scope, L0 is
+simply refered to as "host" and L1 as "guest".
+
+When it comes to running tests against our L1, the perspective is
+shifting, with L1 being the "host", and L0 is then refered to as the
+"nest" (which for the test itself is a hidden detail of the execution
+environment).
+
+## Prerequisites
+
+L0 host must have
+- a default SR (where the disk images for L1 hosts will be stored)
+- one ISO SR (where the install images will be copied during install)
+
+## Quick start
+
+Use `data.py-dist` as usual as a reference to craft your `data.py`.
+Especially important here are:
+* `NETWORKS['MGMT']`, which should match the name of the network in
+  your L0, which you want to use for the L1's management networks
+* `TEST_SSH_PUBKEY`, a multiline string to be used as a
+  `.ssh/authorized_keys` in the hosts to be installed (you must have
+  access to one of the matching private keys)
+* `ARP_SERVER`, a machine on the `NETWORKS['MGMT']` local network,
+  with root access for one of the keys in `TEST_SSH_PUBKEY`, for use
+  to determine the IP address of guests
+* `TOOLS['iso-remaster']`, as local path to
+  `scripts/iso-remaster/iso-remaster.sh` from the `xcp` repo
+* `ISO_IMAGES_CACHE`, to specify where to cache the official ISO
+  images
+* `ISO_IMAGES_BASE`, to specify the base URL under which the images in
+  `IMAGES_ISO` can be downloaded (defaults to the official XCP-ng
+  public download server)
+* `OBJECTS_NAME_PREFIX` (optional) will allow you to use a unique
+  prefix to easily filter out *your* VMs in a shared lab
+
+```
+  XCPNG83_NIGHTLY=xcp-ng-8.3-ci-nightly-20250311.iso \
+    pytest \
+    --log-file-level=DEBUG --log-file=test-install.log \
+    @tests/install/test-sequences/inst{,+upg,+upg+rst}.lst \
+    --hosts=<L0-host-IP>
+```
+
+The above command instructs `pytest` to:
+* run the test sequences as defined by the specified `.lst` files
+  (which were specially written to chain an installation, an upgrade
+  to same version, and a restore, all using a single nightly image)
+* specify 
+* to save detailed logs in a file, while during execution only
+  high-level progress messages are shown to avoid flooding
+
+Tests to be executed can always be listed by adding to `pytest`
+options `--co -q`, which for the above command should show:
+
+```
+tests/install/test.py::TestNested::test_install[uefi-83nightly-iso-ext]
+tests/install/test.py::TestNested::test_tune_firstboot[None-uefi-83nightly-host1-iso-ext]
+tests/install/test.py::TestNested::test_boot_inst[uefi-83nightly-host1-iso-ext]
+tests/install/test.py::TestNested::test_upgrade[uefi-83nightly-83nightly-host1-iso-ext]
+tests/install/test.py::TestNested::test_boot_upg[uefi-83nightly-83nightly-host1-iso-ext]
+tests/install/test.py::TestNested::test_restore[uefi-83nightly-83nightly-83nightly-iso-ext]
+tests/install/test.py::TestNested::test_boot_rst[uefi-83nightly-83nightly-83nightly-iso-ext]
+```
+
+In order those are:
+* running an XCP-ng installation in a UEFI guest, using a "8.3
+  nightly" ISO (which when using the provided `data.py-dist` will take
+  a path or URL to an ISO from envvar `XCPNG83_NIGHTLY`), using the
+  ISO itself as RPM source, and creating an `EXT` local SR.  The ISO
+  is first remastered to include an answerfile, as well as enable ssh
+  access using the test key, and to shutdown instead of rebooting once
+  installation is done.
+* running a helper VM to modify the firstboot configuration set by the
+  installer, so we can select hostname, and set unique UUIDs for our
+  L1 host
+* booting the installed host for the first time, check that XAPI
+  properly starts up, that all firstboot services are starting OK, and
+  that host is running the expected product version
+* running an upgrade, similarly to install test, also specifying in
+  parameters the version of the installed host we're upgrading
+* booting the upgraded host for the first time, similarly to install test
+* running a restore, similarly to install test, also specifying in
+  parameters the version of the upgraded host we're upgrading
+  ("version" which includes the version it was upgraded from, which
+  still lives in the backup partition, and which is the one getting
+  restored)
+* booting the restored host for the first time, similarly to install test
+
+Note: the selected management network configuration uses DHCP.
+
+## Caching installs and chaining tests
+
+Note: some of this section applies to tests that need to manipulate
+the L0 host, but the cache concepts is also useful to those that just
+mean to launch a test against an install host from the cache.  This
+section can possibly been split to separate those concerns.
+
+To allow launching all those install steps one by one, and applying
+tests on the resulting L1 hosts, the state of those hosts are cached
+in L0 as a clone of the L1 (seen from L0 as a guest).
+
+The test-chaining mechanism uses the `name-description` field to
+identify the output of a given test, for example `[Cache for
+install.test::Nested::upgrade[uefi-83nightly-83nightly-host1-iso-ext]-vm1-1857a3f0ef69640d10348e9f0adef09f6e9a7c5d]`.
+This includes a the shortened test name with its full test arguments,
+the ID of the L1 "host VM" (as a test can launch more than one), and
+the git revision of the test repo.
+
+Currently the tests producing the image necessary for a given test are
+recorded using `pytest-dependencies`.  As a consequence, when running
+a test that needs the image produced by an earlier test, the
+`--ignore-unknown-dependency` flag must be used, or the test will be
+skipped, as in:
+```
+$ XCPNG83_NIGHTLY=~/iso/xcp-ng-8.3.0.iso pytest --hosts=172.16.210.11 tests/install/test.py::TestNested::test_boot_inst[uefi-83nightly-host1-iso-ext]
+...
+SKIPPED (test_boot_inst[uefi-83nightly-host1-iso-ext] depends on TestNested::test_tune_firstboot[None-uefi-83nightly-host1-iso-ext])
+```
+
+This git revision ensures consistency of the test runs, avoiding
+during test development the inadvertent use of the output of an
+outdated version of a given test.  It thus creates a strong
+constraint, that all changes be committed before the test is launched.
+As a consequence when working on a test that needs a VM from the
+cache, as we don't want to rerun all the preceding tests, it requires
+an explicit waiver to use an image with a different revision; this is
+done in `data.py` in a dict specifying an equivalent cache ID to be
+used when one is not found:
+
+IMAGE_EQUIVS = {
+    'install.test::Nested::upgrade[bios-75-821.1-host1-iso-nosr]-vm1-6ab478747815979b22e0d0555aa2782bf33850ed':
+    'install.test::Nested::upgrade[bios-75-821.1-host1-iso-nosr]-vm1-17ba75d798499b563bfadca98f8d22a2cb81efdc',
+}
+
+Note: the git revision is read at the start of the test run, so you
+can safely go on working with your codebase while you have a test
+running.
+
+If you're not sure of the cache IDs to put in there:
+* attempt to run your test, it will disclose the missing cache ID, you
+  can paste it as a key in `IMAGE_EQUIVS`:
+  ```
+  Mar 17 14:53:51.599 INFO Could not find a VM in cache for 'install.test::Nested::tune_firstboot[None-uefi-83nightly-host1-iso-ext]-vm1-ce73023e06d680355dbfb0b726aae8eeee0c07ff'
+  Mar 17 14:53:51.600 ERROR exception caught...
+  ```
+* for the value, use the same string and just replace the git revision
+  with the one you want to use, which must exist in your cache.
+  Depending on the situation, you may find the revision using `git
+  log` or `git revlog`, or possibly list the ones available in your
+  cache with something like:
+  ```
+  xe vm-list params=name-description | grep -F 'install.test::Nested::upgrade[bios-75-821.1-host1-iso-nosr]-vm1-'
+  ```
+
+Note: this mechanism can surely be improved, suggestions welcomed.
+Note that a pending PR introduces the notion of "default git revision
+to try for a given list of images".
+
+## Running classical XCP-ng tests
+
+Running classical XCP-ng tests, which in our case are meant to run
+against the L1 host, uses a specific syntax for the `--hosts` flag,
+along with the `--nest=<L0-host>` flag, as in:
+
+```
+pytest --nest=172.16.210.11 \
+  --hosts=cache://install.test::Nested::boot_inst[uefi-83nightly-host1-iso-ext]-vm1-1857a3f0ef69640d10348e9f0adef09f6e9a7c5d \
+  tests/misc/test_basic_without_ssh.py::test_vm_start_stop
+```