]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph-build.git/commitdiff
builder-reimage: add Jenkins pipelines, configs, and fixes
authorjitendrasahu1803 <jitendra.sahu1803@gmail.com>
Mon, 1 Jun 2026 10:05:35 +0000 (15:35 +0530)
committerjitendrasahu1803 <jitendra.sahu1803@gmail.com>
Tue, 9 Jun 2026 04:31:39 +0000 (10:01 +0530)
- implement end-to-end reimage workflow
- add OS detection from inventory labels
- integrate Ansible execution with retries
- introduce sync_labels.py for Jenkins label consistency
- resolve dynamic Jenkins node names and API handling
- improve credential handling and pipeline stability

builder-reimage: add pipelines, configs and fixes

builder-reimage/README.md [new file with mode: 0644]
builder-reimage/build/Jenkins_builder-reimage.py [new file with mode: 0755]
builder-reimage/build/Jenkinsfile [new file with mode: 0644]
builder-reimage/build/ansible_runner.sh [new file with mode: 0644]
builder-reimage/build/bootstrap_env.sh [new file with mode: 0755]
builder-reimage/build/get_node_os.py [new file with mode: 0644]
builder-reimage/build/maas.conf [new file with mode: 0644]
builder-reimage/build/prepare_env.sh [new file with mode: 0644]
builder-reimage/build/requirements.txt [new file with mode: 0644]
builder-reimage/build/sync_labels.py [new file with mode: 0644]
builder-reimage/config/definitions/builder-reimage.yml [new file with mode: 0644]

diff --git a/builder-reimage/README.md b/builder-reimage/README.md
new file mode 100644 (file)
index 0000000..31e1a78
--- /dev/null
@@ -0,0 +1,304 @@
+<<<<<<< HEAD
+# Builder Reimage Pipeline
+
+## Overview
+
+This pipeline is used to reimage Jenkins builder nodes and then perform post-reimage configuration using Ansible.
+
+It supports one or more target nodes and can process multiple nodes in parallel. The pipeline derives the target operating system from builder labels defined in the `ceph-sepia-secrets` inventory instead of relying on manually supplied OS input. After reimage, it prepares the execution environment, clones the required repositories, and runs a sequence of Ansible playbooks to configure the builder.
+
+The pipeline is designed so that it can be reproduced with the correct Jenkins configuration, required credentials, repository access, and workspace dependencies.
+
+---
+
+## Purpose
+
+The pipeline is responsible for the following:
+
+- reimage one or more Jenkins builder nodes using the MaaS-based reimage utility
+- detect the target OS from the source-of-truth inventory labels
+- prepare workspace-local dependencies
+- clone or update the required repositories
+- run post-reimage Ansible playbooks
+- support parallel execution for multiple nodes
+- capture and archive Ansible logs for troubleshooting
+
+---
+
+## Source of truth for OS detection
+
+The operating system is not passed manually as a Jenkins parameter.
+
+Instead, the pipeline reads:
+
+```text
+ceph-sepia-secrets/ansible/inventory/group_vars/jenkins_builders.yml
+```
+From that file, it reads the `jenkins_labels` mapping and extracts:
+
+- `installed-os-*` to determine the target operating system
+- Optional feature labels such as libvirt
+
+Example label string:
+```
+braggi noble small huge sepia x86_64 installed-os-noble libvirt
+```
+From the above, the pipeline derives:
+
+- OS = noble
+- libvirt = true
+
+## High level workflow
+For each node provided in the MACHINE parameter, the pipeline performs the following steps:
+
+1. Checkout the repository containing the pipeline and helper scripts
+2. Prepare the node-specific workspace and execution environment
+3. Clone or update the required repositories
+4. Read the builder inventory file and detect the OS and feature labels
+5. Reimage the node using the detected OS
+6. Run post-reimage Ansible playbooks
+7. Archive node-specific Ansible logs
+
+If multiple nodes are passed, each node is processed in parallel.
+
+## Jenkins job parameters
+
+### ***MACHINE***
+Comma-separated list of target builder short names.
+
+Examples:
+```
+braggi01
+braggi01,braggi16
+```
+
+### ***ACTION***
+Supported values:
+- reimage
+- reimage-all
+- wait_online
+
+Current execution primarily uses the reimage flow.
+
+### ***SKIP_REIMAGE***
+
+If set to true, the MaaS reimage step is skipped and only the post-reimage Ansible tasks are run.
+
+## Pipeline configuration
+
+***Jenkins pipeline***
+```
+builder-reimage/build/Jenkinsfile
+```
+Defines the pipeline stages, parallel execution, OS detection, reimage, Ansible execution, and log archiving.
+
+***Environment preparation***
+
+```
+builder-reimage/build/prepare_env.sh
+```
+Clones or updates required repositories, creates the virtual environment, installs Ansible if needed, and prepares workspace-local Ansible configuration.
+
+***Ansible runner***
+```
+builder-reimage/build/ansible_runner.sh
+```
+Runs the post-reimage Ansible playbooks, handles retries, writes logs, and prints the final execution summary.
+
+***OS and feature detection***
+```
+builder-reimage/build/get_node_os.py
+```
+Reads jenkins_builders.yml and extracts the installed OS and relevant labels such as libvirt.
+
+***Reimage utility***
+```
+builder-reimage/build/Jenkins_builder-reimage.py
+```
+Performs the MaaS-based release and deploy operations.
+
+## Jenkins credentials used
+
+The pipeline depends on the following Jenkins credentials.
+
+### ***maas-api-key***
+Used by the reimage utility to interact with MaaS.
+
+### ***builder-api-token***
+Passed to the builder playbook.
+
+### ***ansible-vault-pass***
+Used to unlock Ansible Vault protected data.
+
+### ***ansible-ssh-key***
+Used to clone and update private Git repositories over SSH.
+
+## Required repositories
+The pipeline uses these repositories:
+
+```
+git@github.com:ceph/ceph-build.git
+git@github.com:ceph/ceph-cm-ansible.git
+git@github.com:ceph/ceph-sepia-secrets.git
+```
+
+## Ansible playbooks executed
+The pipeline currently runs these playbooks in order:
+
+```
+ansible_managed.yml
+users.yml
+tools/jenkins-builder-disk.yml
+examples/builder.yml
+```
+
+ansible_runner.sh retries each playbook up to three times and prints a final summary at the end.
+
+## Reproducing this pipeline
+To reproduce this pipeline, the following are required:
+
+- Jenkins pipeline job configured with the JJB definition `builder-reimage/config/definitions/builder-reimage.yml`
+- Jenkins agent with the teuthology label
+- Jenkins credentials:
+
+  - maas-api-key
+  - builder-api-token
+  - ansible-vault-pass
+  - ansible-ssh-key
+
+
+- access to the required GitHub repositories
+- access to MaaS
+- Python 3, virtual environment support, Git, SSH, and Ansible installation capability on the Jenkins agent
+
+## Notes
+
+- OS selection is inventory-driven and no longer passed manually.
+- The pipeline supports parallel execution for multiple nodes.
+- Logs are written under ci-work-<node>/ansible-logs/ and archived by Jenkins.
+- Optional labels such as libvirt can be passed through to Ansible when needed.
+=======
+# Builder Reimage
+
+Builder Reimage is a lightweight automation utility for interacting with **MAAS (Metal as a Service)**.  
+It allows you to list, query, check status, and redeploy machines efficiently, with or without specifying an operating system.
+
+This script simplifies MAAS machine management tasks and helps maintain consistent environment setups through a command-line interface.
+
+---
+
+## Features
+- List all managed MAAS machines  
+- Query machine details  
+- Check deployment or power status  
+- Redeploy single or multiple machines  
+- Redeploy with or without specifying an OS version  
+
+---
+
+## Requirements
+- Python 3.8 or higher  
+- MAAS CLI installed and configured  
+- Valid MAAS API credentials
+
+---
+
+## Cloning the Repository
+
+To get started, clone the repository and navigate into the project directory:
+
+```bash
+git clone https://github.com/jitendrasahu1803/builder-reimage.git
+
+cd builder-reimage
+```
+
+## Setup your environment to use the tool
+
+Run bootstrap script:
+```bash
+bash bootstrap_env.sh
+```
+Active your virtual environment:
+```bash
+source .venv/bin/activate
+```
+Edit ```bash maas.conf``` file and update it with the correct MAAS URL (Sepia/Tucson):
+```bash
+cat maas.conf
+[maas]
+maas_url=http://<Enter_MAAS_URL>/MAAS
+```
+To setup your credential to connect with MAAS API, run (Replace <YOUR_MAAS_API_KEY> with your actual MAAS API key):
+```bash
+echo -n "<YOUR_MAAS_API_KEY>" | python3 -c 'import sys; from cryptography.fernet import Fernet; key=Fernet.generate_key(); open("maas_api.key","wb").write(key); data=sys.stdin.buffer.read(); open("maas_api_key.encrypted","wb").write(Fernet(key).encrypt(data)); print("wrote: maas_api.key and maas_api_key.encrypted")'
+```
+---
+
+## Usage
+Activate your virtual environment (if not already active), then run:
+```bash
+python3 maas-reimage.py [OPTIONS]
+```
+
+## Examples
+List all machines
+```bash
+--action list
+```
+Query details for one machine
+```bash
+--action query --machine node01
+```
+Check machine status
+```bash
+--action status --machine node01
+```
+Deploy a machine with a specific OS:
+```bash
+--action deploy --machine node01 --os 9.6
+```
+Reimage one machine (same OS)
+```bash
+--action reimage --machine node01
+```
+Reimage one machine with a specific OS
+```bash
+--action reimage --machine node01 --os jammy
+```
+Redeploy all machines
+```bash
+--action reimage-all
+```
+Redeploy all machines with a specific OS
+```bash
+--action reimage-all --os jammy
+```
+Find last deployed machine
+```bash
+--action last-deployed
+```
+
+## Additional option of (--owner)
+
+When you deploy or reimage a machine, it is automatically tagged with the default owner ```jitendra```. If you want to assign a different owner, you can specify it using the option shown below.
+
+Reimage a machine with a custom owner tag:
+```bash
+--action reimage --machine node01 --owner "<owner_name>"
+```
+
+This option works for deploy, reimage, reimage_all, and all other relevant commands.
+
+---
+
+## Author
+
+Jitendra Sahu
+
+GitHub: jitendrasahu1803
+
+## License
+
+This project is licensed under the MIT License.
+>>>>>>> 5c056f6f (Add builder reimage Jenkins pipelines and configs)
diff --git a/builder-reimage/build/Jenkins_builder-reimage.py b/builder-reimage/build/Jenkins_builder-reimage.py
new file mode 100755 (executable)
index 0000000..a0f2a60
--- /dev/null
@@ -0,0 +1,219 @@
+#!/usr/bin/env python3
+"""
+MAAS Reimage Automation Script (Enhanced)
+
+Supports:
+- Single machine 
+- Multiple machines: host1,host2
+- Per-machine OS: host1:jammy,host2:centos9
+- Mixed mode: host1,host2:jammy
+"""
+
+import asyncio
+import argparse
+import sys
+import time
+from datetime import datetime
+import os
+
+from maas.client import connect
+from aiohttp.client_exceptions import (
+    ClientConnectorError,
+    ServerDisconnectedError,
+    ClientResponseError,
+    ClientOSError
+)
+
+# -------------------------------------------------------------------------
+# Global Defaults
+# -------------------------------------------------------------------------
+LOG_FILE_DEFAULT = "maas_reimage.log"
+STATUS_WAIT_TIMEOUT = 1200
+DEFAULT_OWNER = "jitendra"
+
+MAAS_URL = "http://soko02.front.sepia.ceph.com:5240/MAAS"
+MAAS_API_KEY = os.environ.get("MAAS_API_KEY")
+
+if not MAAS_API_KEY:
+    print("Error: MAAS_API_KEY env variable not set")
+    sys.exit(1)
+
+# -------------------------------------------------------------------------
+# Helpers
+# -------------------------------------------------------------------------
+def log(msg, log_file=None):
+    print(msg)
+    if log_file:
+        with open(log_file, "a") as f:
+            f.write(f"{datetime.now().isoformat()} - {msg}\n")
+
+def get_normalized_status(machine):
+    return str(
+        getattr(machine, "status_name", "") or
+        getattr(getattr(machine, "status", None), "name", "")
+    ).lower()
+
+# NEW: parse machine + OS mapping
+def parse_machine_list(machine_arg, default_os=None):
+    result = []
+
+    items = [x.strip() for x in machine_arg.split(",") if x.strip()]
+
+    for item in items:
+        if ":" in item:
+            host, os_val = item.split(":", 1)
+            result.append((host.strip(), os_val.strip()))
+        else:
+            result.append((item.strip(), default_os))
+
+    return result
+
+# -------------------------------------------------------------------------
+# Connect
+# -------------------------------------------------------------------------
+async def connect_maas():
+    try:
+        return await connect(MAAS_URL, apikey=MAAS_API_KEY)
+    except Exception as e:
+        print(f"Connection failed: {e}")
+        sys.exit(1)
+
+async def get_cached_machines(client):
+    if getattr(client, "_machines_cache", None) is None:
+        client._machines_cache = await client.machines.list()
+    return client._machines_cache
+
+# -------------------------------------------------------------------------
+# Wait for status
+# -------------------------------------------------------------------------
+async def wait_for_status(client, system_id, expected):
+    expected = expected.lower()
+    start = time.time()
+
+    while time.time() - start < STATUS_WAIT_TIMEOUT:
+        m = await client.machines.get(system_id=system_id)
+        if get_normalized_status(m) == expected:
+            return True
+        await asyncio.sleep(5)
+
+    print(f"Timeout waiting for {system_id}")
+    return False
+
+# -------------------------------------------------------------------------
+# Deploy
+# -------------------------------------------------------------------------
+async def deploy_machine(client, hostname, os_release, log_file):
+    machines = await get_cached_machines(client)
+    m = next((x for x in machines if x.hostname == hostname), None)
+
+    if not m:
+        log(f"[ERROR] {hostname} not found", log_file)
+        return False
+
+    m = await client.machines.get(system_id=m.system_id)
+
+    status = get_normalized_status(m)
+
+    if status == "deployed":
+        log(f"[INFO] {hostname} already deployed", log_file)
+        return True
+
+    os_to_use = os_release or getattr(m, "distro_series", "focal")
+
+    log(f"[ACTION] Deploying {hostname} with {os_to_use}", log_file)
+
+    try:
+        await m.deploy(distro_series=os_to_use)
+    except Exception as e:
+        log(f"[ERROR] Deploy failed: {e}", log_file)
+        return False
+
+    if await wait_for_status(client, m.system_id, "deployed"):
+        log(f"[SUCCESS] {hostname} deployed", log_file)
+        return True
+
+    return False
+
+# -------------------------------------------------------------------------
+# Release
+# -------------------------------------------------------------------------
+async def release_machine(machine):
+    if get_normalized_status(machine) == "deployed":
+        await machine.release()
+
+# -------------------------------------------------------------------------
+# Reimage
+# -------------------------------------------------------------------------
+async def reimage_machine(client, hostname, os_release, log_file):
+    machines = await get_cached_machines(client)
+    m = next((x for x in machines if x.hostname == hostname), None)
+
+    if not m:
+        log(f"[ERROR] {hostname} not found", log_file)
+        return False
+
+    m = await client.machines.get(system_id=m.system_id)
+
+    status = get_normalized_status(m)
+
+    if status == "deployed":
+        log(f"[ACTION] Releasing {hostname}", log_file)
+        await release_machine(m)
+        if not await wait_for_status(client, m.system_id, "ready"):
+            return False
+
+    return await deploy_machine(client, hostname, os_release, log_file)
+
+# -------------------------------------------------------------------------
+# Main
+# -------------------------------------------------------------------------
+async def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument("--action", required=True)
+    parser.add_argument("--machine")
+    parser.add_argument("--os")
+    parser.add_argument("--log-file")
+
+    args = parser.parse_args()
+    log_file = args.log_file or LOG_FILE_DEFAULT
+
+    client = await connect_maas()
+    client._machines_cache = await client.machines.list()
+
+    # UPDATED: multi-machine support
+    if args.action == "reimage":
+        if not args.machine:
+            print("Missing --machine")
+            return
+
+        machine_list = parse_machine_list(args.machine, args.os)
+
+        success = 0
+        failed = 0
+
+        for hostname, os_val in machine_list:
+            log(f"\n[START] {hostname} (OS={os_val})", log_file)
+            try:
+                result = await reimage_machine(client, hostname, os_val, log_file)
+                if result:
+                    success += 1
+                else:
+                    failed += 1
+            except Exception as e:
+                log(f"[ERROR] {hostname}: {e}", log_file)
+                failed += 1
+
+        log("\n===== SUMMARY =====", log_file)
+        log(f"Total: {len(machine_list)}", log_file)
+        log(f"Success: {success}", log_file)
+        log(f"Failed: {failed}", log_file)
+
+# -------------------------------------------------------------------------
+
+if __name__ == "__main__":
+    try:
+        asyncio.run(main())
+    except KeyboardInterrupt:
+        print("Interrupted by user.")
+    except Exception as e:
+        print(f"Fatal error: {e}")
diff --git a/builder-reimage/build/Jenkinsfile b/builder-reimage/build/Jenkinsfile
new file mode 100644 (file)
index 0000000..9adfa97
--- /dev/null
@@ -0,0 +1,226 @@
+pipeline {
+    agent { label 'teuthology' }
+
+    environment {
+        VENV_DIR = ".venv"
+        DOMAIN   = "front.sepia.ceph.com"
+    }
+
+    parameters {
+        booleanParam(name: 'SKIP_REIMAGE', defaultValue: false)
+        choice(name: 'ACTION', choices: ['reimage','reimage-all','wait_online'])
+        string(
+            name: 'MACHINE',
+            defaultValue: '',
+            description: 'node1 or node1,node2'
+        )
+    }
+
+    stages {
+
+        stage('Parallel Execution') {
+            steps {
+                script {
+                    if (!params.MACHINE?.trim()) {
+                        error("MACHINE parameter is required")
+                    }
+
+                    def machines = params.MACHINE.split(',').collect { it.trim() }
+                    def branches = [:]
+
+                    for (int i = 0; i < machines.size(); i++) {
+                        def machine = machines[i]
+
+                        branches["Node-${machine}"] = {
+                            node('teuthology') {
+
+                                stage("Checkout-${machine}") {
+                                    checkout scm
+                                }
+
+                                def WORK_DIR = "${env.WORKSPACE}/ci-work-${machine}"
+                                def TARGET_FQDN = "${machine}.${env.DOMAIN}"
+                                def INVENTORY_FILE = "${WORK_DIR}/repos/secret-repo/ansible/inventory/group_vars/jenkins_builders.yml"
+
+                                stage("Prepare-${machine}") {
+                                    withCredentials([
+                                        sshUserPrivateKey(credentialsId: 'ansible-ssh-key', keyFileVariable: 'SSH_KEY')
+                                    ]) {
+                                        sh """#!/bin/bash
+                                            set -euo pipefail
+
+                                            mkdir -p "${WORK_DIR}"
+
+                                            cp "${SSH_KEY}" /tmp/jenkins_git_key
+                                            chmod 600 /tmp/jenkins_git_key
+
+                                            bash builder-reimage/build/prepare_env.sh \
+                                                "${TARGET_FQDN}" \
+                                                "${WORK_DIR}" \
+                                                true \
+                                                git@github.com:ceph/ceph-build.git \
+                                                git@github.com:ceph/ceph-cm-ansible.git \
+                                                git@github.com:ceph/ceph-sepia-secrets.git
+                                        """
+                                    }
+                                }
+                                
+                                stage("Sync Labels-${machine}") {
+                                    withCredentials([
+                                        usernamePassword(
+                                            credentialsId: 'jenkins-api-token',
+                                            usernameVariable: 'JOB_BUILDER_USER',
+                                            passwordVariable: 'JOB_BUILDER_PASS'
+                                        )
+                                    ]) {
+                                        withEnv([
+                                            "SYNC_MACHINE=${machine}",
+                                            "SYNC_TARGET_FQDN=${TARGET_FQDN}",
+                                            "SYNC_INVENTORY_FILE=${INVENTORY_FILE}"
+                                        ]) {
+                                            sh '''#!/bin/bash
+                                                set -euo pipefail
+                                
+                                                echo "[INFO] Syncing Jenkins labels for $SYNC_MACHINE"
+                                                echo "JENKINS_URL is: $JENKINS_URL"
+                                
+                                                python3 builder-reimage/build/sync_labels.py \
+                                                  --file "$SYNC_INVENTORY_FILE" \
+                                                  --jenkins_url "$JENKINS_URL" \
+                                                  --user "$JOB_BUILDER_USER" \
+                                                  --token "$JOB_BUILDER_PASS" \
+                                                  --node "$SYNC_TARGET_FQDN"
+                                            '''
+                                        }
+                                    }
+                                }
+                                stage("Detect OS-${machine}") {
+                                    def DETECTED_OS = sh(
+                                        script: """#!/bin/bash
+                                            python3 builder-reimage/build/get_node_os.py \
+                                                "${INVENTORY_FILE}" \
+                                                "${machine}"
+                                        """,
+                                        returnStdout: true
+                                    ).trim()
+
+                                    echo "Detected OS for ${machine} is ${DETECTED_OS}"
+
+                                    if (!DETECTED_OS) {
+                                        error("OS detection failed for ${machine}")
+                                    }
+
+                                    def parts = DETECTED_OS.split(",")
+
+                                    env.DETECTED_OS = parts[0]
+                                    env.LIBVIRT_ENABLED = parts.size() > 1 ? parts[1] : "false"
+                                    
+                                    echo "Detected OS: ${env.DETECTED_OS}"
+                                    echo "Libvirt enabled: ${env.LIBVIRT_ENABLED}"
+
+                                }
+
+                                stage("Reimage-${machine}") {
+                                    if (!params.SKIP_REIMAGE) {
+                                        withCredentials([string(credentialsId: 'maas-api-key', variable: 'MAAS_API_KEY')]) {
+                                            sh """#!/bin/bash
+                                                set -euo pipefail
+
+                                                REPO_DIR="\$WORKSPACE/builder-reimage/build"
+                                                VENV_PATH="\$REPO_DIR/.venv"
+
+                                                cd "\$REPO_DIR"
+
+                                                if [ ! -d "\$VENV_PATH" ]; then
+                                                    echo "Creating virtual environment"
+                                                    python3 -m venv "\$VENV_PATH"
+                                                    source "\$VENV_PATH/bin/activate"
+                                                    pip install --upgrade pip
+                                                    pip install -r requirements.txt
+                                                else
+                                                    source "\$VENV_PATH/bin/activate"
+                                                fi
+
+                                                echo "Starting reimage for ${machine} with OS ${env.DETECTED_OS}"
+
+                                                python Jenkins_builder-reimage.py \
+                                                  --action reimage \
+                                                  --machine ${machine} \
+                                                  --os ${env.DETECTED_OS}
+                                            """
+                                        }
+                                    }
+                                }
+
+                                stage("Ansible-${machine}") {
+                                    withCredentials([
+                                        string(credentialsId: 'builder-api-token', variable: 'BUILDER_TOKEN'),
+                                        string(credentialsId: 'ansible-vault-pass', variable: 'VAULT_PASS'),
+                                        sshUserPrivateKey(credentialsId: 'ansible-ssh-key', keyFileVariable: 'SSH_KEY')
+                                    ]) {
+
+                                        sh """#!/bin/bash
+                                            set -euo pipefail
+
+                                            REPO_DIR="\$WORKSPACE/builder-reimage"
+                                            BUILD_DIR="\$REPO_DIR/build"
+                                            WORK_DIR="${WORK_DIR}"
+                                            TARGET_FQDN="${TARGET_FQDN}"
+
+                                            echo "[INFO] Running Ansible for \$TARGET_FQDN"
+
+                                            mkdir -p "\$WORK_DIR"
+
+                                            cp "${SSH_KEY}" /tmp/jenkins_git_key
+                                            chmod 600 /tmp/jenkins_git_key
+
+                                            export GIT_SSH_COMMAND="ssh -i /tmp/jenkins_git_key -o StrictHostKeyChecking=no"
+
+                                            VAULT_FILE="/home/jenkins-build/.vault_pass.txt"
+                                            echo "\$VAULT_PASS" > "\$VAULT_FILE"
+
+                                            cd "\$BUILD_DIR"
+
+                                            bash prepare_env.sh "\$TARGET_FQDN" "\$WORK_DIR" true \
+                                              git@github.com:ceph/ceph-build.git \
+                                              git@github.com:ceph/ceph-cm-ansible.git \
+                                              git@github.com:ceph/ceph-sepia-secrets.git
+
+                                            bash ansible_runner.sh \
+                                              "\$TARGET_FQDN" \
+                                              "\$WORK_DIR" \
+                                              "${env.VENV_DIR}" \
+                                              "${env.DETECTED_OS}" \
+                                              "\$BUILDER_TOKEN" \
+                                              "${env.LIBVIRT_ENABLED}"
+                                        """
+
+                                        archiveArtifacts artifacts: "ci-work-${machine}/ansible-logs/*.log", allowEmptyArchive: true
+                                    }
+                                }
+                            }
+                        }
+                    }
+
+                    branches.failFast = false
+                    parallel branches
+                }
+            }
+        }
+    }
+
+    post {
+        always {
+            sh 'rm -f /home/jenkins-build/.vault_pass.txt || true'
+            echo 'Build finished'
+        }
+
+        failure {
+            echo 'One or more nodes failed'
+        }
+
+        success {
+            echo 'All nodes completed successfully'
+        }
+    }
+}
diff --git a/builder-reimage/build/ansible_runner.sh b/builder-reimage/build/ansible_runner.sh
new file mode 100644 (file)
index 0000000..74d2995
--- /dev/null
@@ -0,0 +1,251 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# ansible_runner.sh
+#
+# Usage:
+#   ansible_runner.sh <target_fqdn> <work_dir> <venv_dir> <os> <builder_token> <libvirt_flag>
+#
+#   libvirt_flag:
+#       "true"  -> enable libvirt-specific configuration
+#       "false" -> default behavior
+
+
+# Use default Ansible output format
+export ANSIBLE_STDOUT_CALLBACK=default
+
+# Disable creation of retry files
+export ANSIBLE_RETRY_FILES_ENABLED=False
+
+# Fully qualified domain name of the target node
+TARGET_FQDN="$1"
+
+# Per-node workspace directory used for execution
+WORK_DIR="$2"
+
+# Virtual environment directory name
+VENV_DIR="$3"
+
+# Detected OS value (converted to lowercase)
+OS_VALUE="${4,,}"
+
+# Token used by builder playbook for authentication
+BUILDER_TOKEN="$5"
+
+# Libvirt flag passed from pipeline (normalized to true/false)
+LIBVIRT=$(echo "${6:-false}" | tr '[:upper:]' '[:lower:]' | xargs)
+
+# Debug logs for libvirt flag
+echo "[ansible_runner][DEBUG] LIBVIRT raw value: '${6:-unset}'"
+echo "[ansible_runner][DEBUG] LIBVIRT normalized: '${LIBVIRT}'"
+
+# Path to Ansible repository (playbooks, roles)
+ANSIBLE_DIR="${WORK_DIR}/repos/ansible"
+
+# Path to main repository containing builder playbook
+MAIN_DIR="${WORK_DIR}/repos/main"
+
+# Path to store log files
+LOG_DIR="${WORK_DIR}/ansible-logs"
+
+# failure tracking
+FAILED_PLAYBOOKS=()
+FAILED_LOGS=()
+
+
+# Ensure secrets path exists (independent of prepare_env.sh)
+
+SECRETS_PATH="${WORK_DIR}/repos/secret-repo/ansible/secrets"
+
+# Backward compatibility for playbook expecting this env var
+export ANSIBLE_SECRETS_PATH="${ANSIBLE_SECRETS_PATH:-${SECRETS_PATH}}"
+
+echo "[ansible_runner] ANSIBLE_SECRETS_PATH=${ANSIBLE_SECRETS_PATH}"
+
+
+# consume paths exported by prepare_env.sh
+INVENTORY_PATH="${INVENTORY_PATH:-${WORK_DIR}/repos/secret-repo/ansible/inventory}"
+SECRETS_PATH="${SECRETS_PATH:-${WORK_DIR}/repos/secret-repo/ansible/secrets}"
+
+# Vault comes from this file (created by Jenkinsfile)
+VAULT_FILE="/home/jenkins-build/.vault_pass.txt"
+VAULT_ARG="--vault-password-file=${VAULT_FILE}"
+
+mkdir -p "${LOG_DIR}"
+
+# Activate virtualenv if present
+if [[ -f "${WORK_DIR}/${VENV_DIR}/bin/activate" ]]; then
+    echo "[ansible_runner] Activating venv: ${WORK_DIR}/${VENV_DIR}"
+    # shellcheck disable=SC1090
+    source "${WORK_DIR}/${VENV_DIR}/bin/activate"
+else
+    echo "[ansible_runner] WARNING: venv not found at ${WORK_DIR}/${VENV_DIR}. Continuing without venv."
+fi
+
+# Determine SSH user based on OS
+if [[ "$OS_VALUE" =~ (rhel|centos|rocky|almai|9-stream|rhel10|centos70|8) ]]; then
+    SSH_USER="cloud-user"
+else
+    SSH_USER="ubuntu"
+fi
+
+echo "[ansible_runner] SSH user selected = ${SSH_USER}"
+echo "[ansible_runner] Using inventory = ${INVENTORY_PATH}"
+echo "[ansible_runner] Using secrets = ${SECRETS_PATH}"
+
+# Admin user JSON builder
+ADMIN_USERS=(
+  "akraitma"
+  "dgalloway"
+  "dmick"
+  "falcocer"
+  "jitendra"
+  "zack"
+)
+
+build_admin_users_json() {
+    local json='{"managed_admin_users":['
+    for u in "${ADMIN_USERS[@]}"; do
+        json+="{\"name\":\"${u}\"},"
+    done
+    json="${json%,}]}"
+    echo "${json}"
+}
+
+# Playbook runner with retries (3 attempts)
+run_playbook() {
+    local pb_name="$1"
+    local cmd="$2"
+    local logfile="${LOG_DIR}/${TARGET_FQDN}-${pb_name}.log"
+
+    echo "[ansible_runner] Running ${pb_name} -> ${logfile}"
+
+    attempts=0
+    max_attempts=3
+
+    until [[ $attempts -ge $max_attempts ]]; do
+        attempts=$((attempts + 1))
+
+        eval "${cmd}" 2>&1 | tee "${logfile}"
+        rc=${PIPESTATUS[0]}
+
+        if [[ $rc -eq 0 ]]; then
+            echo "[ansible_runner] ${pb_name} succeeded on attempt ${attempts}"
+            return 0
+        fi
+
+        echo "[ansible_runner] ${pb_name} failed (rc=${rc}), attempt ${attempts}/${max_attempts}"
+
+        if [[ $attempts -lt $max_attempts ]]; then
+            sleep $((attempts * 10))
+        else
+            echo "[ansible_runner] Max attempts reached for ${pb_name}"
+
+            # NEW: Track failure instead of exiting
+            FAILED_PLAYBOOKS+=("${pb_name}")
+            FAILED_LOGS+=("${logfile}")
+
+            return 1
+        fi
+    done
+}
+
+##############################################
+# PLAYBOOK 1 — ansible_managed.yml
+##############################################
+(
+  cd "${ANSIBLE_DIR}"
+
+  CMD="ANSIBLE_HOST_KEY_CHECKING=False ANSIBLE_STDOUT_CALLBACK=json ansible-playbook ansible_managed.yml \
+        -i '${INVENTORY_PATH}' \
+        --limit='${TARGET_FQDN}' \
+        -e ansible_ssh_user='${SSH_USER}'"
+
+  run_playbook "play1-ansible_managed" "${CMD}" || true
+)
+
+##############################################
+# PLAYBOOK 2 — users.yml
+##############################################
+(
+  cd "${ANSIBLE_DIR}"
+
+  ADMIN_USERS_JSON="$(build_admin_users_json)"
+
+  CMD="ANSIBLE_HOST_KEY_CHECKING=False ANSIBLE_STDOUT_CALLBACK=json ansible-playbook users.yml -v \
+        -i '${INVENTORY_PATH}' \
+        --limit='${TARGET_FQDN}' \
+        --tags='user,pubkeys' \
+        --extra-vars='${ADMIN_USERS_JSON}'"
+
+  run_playbook "play2-users" "${CMD}" || true
+)
+
+##############################################
+# PLAYBOOK 3 — jenkins-builder-disk.yml
+##############################################
+(
+  cd "${ANSIBLE_DIR}"
+
+  CMD="ANSIBLE_HOST_KEY_CHECKING=False ANSIBLE_STDOUT_CALLBACK=json ansible-playbook -v tools/jenkins-builder-disk.yml \
+        -i '${INVENTORY_PATH}' \
+        --limit='${TARGET_FQDN}'"
+
+  run_playbook "play3-tools-disk" "${CMD}" || true
+)
+
+##############################################
+# PLAYBOOK 4 — builder.yml (USES VAULT)
+##############################################
+EXTRA_VARS=""
+
+if [[ "${LIBVIRT}" == "true" ]]; then
+    echo "[ansible_runner] libvirt detected for ${TARGET_FQDN}"
+    EXTRA_VARS="-e libvirt=true"
+fi
+
+(
+  cd "${MAIN_DIR}/ansible"
+
+  CMD="ANSIBLE_HOST_KEY_CHECKING=False ANSIBLE_STDOUT_CALLBACK=json ansible-playbook -v \
+        -i '${INVENTORY_PATH}' \
+        ${VAULT_ARG} \
+        -M ./library/ \
+        examples/builder.yml \
+        -e '{\"token\":\"${BUILDER_TOKEN}\", \"jenkins_credentials_uuid\":\"jenkins-build\", \"api_uri\":\"https://jenkins.ceph.com\"}' \
+        -e permanent=true \
+        ${EXTRA_VARS} \
+        --limit='${TARGET_FQDN}'"
+
+  echo "[ansible_runner][DEBUG] EXTRA_VARS=${EXTRA_VARS}"
+  echo "[ansible_runner][DEBUG] FINAL CMD=${CMD}"
+
+  run_playbook "play4-main-builder" "${CMD}" || true
+)
+
+echo "========================================"
+echo "[ansible_runner] FINAL EXECUTION SUMMARY"
+echo "========================================"
+
+if [[ ${#FAILED_PLAYBOOKS[@]} -eq 0 ]]; then
+    echo "All playbooks completed successfully for ${TARGET_FQDN}"
+    exit 0
+fi
+
+echo "Failed playbooks detected:"
+
+for i in "${!FAILED_PLAYBOOKS[@]}"; do
+    echo "----------------------------------------"
+    echo "Playbook : ${FAILED_PLAYBOOKS[$i]}"
+    echo "Log file : ${FAILED_LOGS[$i]}"
+    echo "Extracting failure details..."
+
+    # Extract Ansible failures
+    grep -E "FAILED!|fatal:" "${FAILED_LOGS[$i]}" || echo "No detailed failure lines found"
+done
+
+echo "========================================"
+echo "Build FAILED due to above errors"
+echo "========================================"
+
+exit 1
diff --git a/builder-reimage/build/bootstrap_env.sh b/builder-reimage/build/bootstrap_env.sh
new file mode 100755 (executable)
index 0000000..0a37e72
--- /dev/null
@@ -0,0 +1,87 @@
+#!/usr/bin/env bash
+set -e
+
+# -----------------------------------------------------------------------------
+# Script Name: bootstrap_env.sh
+# Description:
+#   Bootstraps the local Python environment for running the MAAS reimage script.
+#   - Installs MAAS CLI (Linux/macOS)
+#   - Creates Python virtual environment
+#   - Installs dependencies
+# -----------------------------------------------------------------------------
+
+echo "Starting environment setup..."
+
+# -----------------------------------------------------------------------------
+# Step 1: Install MAAS CLI based on OS
+# -----------------------------------------------------------------------------
+if [[ "$OSTYPE" == "linux-gnu"* ]]; then
+    # Detect Linux distribution
+    if [ -f /etc/os-release ]; then
+        . /etc/os-release
+        DISTRO=$ID
+    else
+        DISTRO="unknown"
+    fi
+
+    echo "Detected Linux distribution: $DISTRO"
+
+    case "$DISTRO" in
+        ubuntu|debian)
+            echo "Installing MAAS CLI (Debian/Ubuntu)..."
+            sudo apt update -y
+            sudo apt install -y maas-cli 
+            ;;
+        rhel|centos|fedora|rocky|almalinux)
+            echo "Installing MAAS CLI (RHEL/CentOS/Fedora)..."
+            # Ensure EPEL repo is enabled for dependencies
+            if command -v dnf >/dev/null 2>&1; then
+                sudo dnf install -y epel-release || true
+                sudo dnf install -y snapd 
+            else
+                sudo yum install -y epel-release || true
+                sudo yum install -y snapd
+            fi
+
+            # Enable and use snap to install MAAS CLI
+            sudo systemctl enable --now snapd.socket
+            sudo ln -sf /var/lib/snapd/snap /snap
+            echo "Installing MAAS CLI via snap..."
+            sudo snap install maas --classic
+            ;;
+        *)
+            echo "Unsupported Linux distribution: $DISTRO"
+            echo "Please install MAAS CLI manually."
+            exit 1
+            ;;
+    esac
+
+elif [[ "$OSTYPE" == "darwin"* ]]; then
+    echo "Installing MAAS CLI (macOS)..."
+    brew install maas || true
+else
+    echo "Unsupported OS type: $OSTYPE"
+    exit 1
+fi
+
+# -----------------------------------------------------------------------------
+# Step 2: Create and activate Python virtual environment
+# -----------------------------------------------------------------------------
+echo "Creating Python virtual environment..."
+python3 -m venv .venv
+source .venv/bin/activate
+
+echo "Installing Python dependencies..."
+pip install --upgrade pip
+pip install -r requirements.txt
+
+# -----------------------------------------------------------------------------
+# Step 3: Check for encrypted secrets
+# -----------------------------------------------------------------------------
+if [[ ! -f "maas_api.key" || ! -f "maas_api_key.encrypted" ]]; then
+    echo "Missing 'maas_api.key' or 'maas_api_key.encrypted'. Ensure both files are present."
+    exit 1
+fi
+
+echo
+echo "Setup complete."
diff --git a/builder-reimage/build/get_node_os.py b/builder-reimage/build/get_node_os.py
new file mode 100644 (file)
index 0000000..d594aac
--- /dev/null
@@ -0,0 +1,54 @@
+#!/usr/bin/env python3
+
+"""
+get_node_os.py
+
+Description:
+------------
+This script reads the Jenkins builders inventory file and extracts:
+  - The target operating system from the "installed-os-*" label
+  - Whether the "libvirt" label is present
+
+It returns the result in the format:
+  <os_name>,<libvirt_flag>
+
+Where:
+  os_name        -> e.g. jammy, centos9
+  libvirt_flag   -> "true" or "false"
+
+This output is consumed by the Jenkins pipeline to:
+  - determine which OS to use for MaaS reimage
+  - optionally enable libvirt-specific configuration in Ansible
+
+Usage:
+------
+  get_node_os.py <inventory_file> <node_name>
+"""
+
+import yaml
+import sys
+
+inventory_file = sys.argv[1]
+node_input = sys.argv[2]
+
+node_name = node_input if "." in node_input else node_input + ".front.sepia.ceph.com"
+
+with open(inventory_file) as f:
+    data = yaml.safe_load(f)
+
+labels_map = data.get("jenkins_labels", {})
+labels_str = labels_map.get(node_name, "")
+
+labels = labels_str.split()
+
+os_name = ""
+libvirt = "false"
+
+for label in labels:
+    if label.startswith("installed-os-"):
+        os_name = label.replace("installed-os-", "")
+    if label == "libvirt":
+        libvirt = "true"
+
+# Print both values in structured way
+print(f"{os_name},{libvirt}")
diff --git a/builder-reimage/build/maas.conf b/builder-reimage/build/maas.conf
new file mode 100644 (file)
index 0000000..a582282
--- /dev/null
@@ -0,0 +1,2 @@
+[maas]
+maas_url=http://soko02.front.sepia.ceph.com:5240/MAAS
diff --git a/builder-reimage/build/prepare_env.sh b/builder-reimage/build/prepare_env.sh
new file mode 100644 (file)
index 0000000..da87768
--- /dev/null
@@ -0,0 +1,146 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# prepare_env.sh
+# Usage:
+#   prepare_env.sh <target_fqdn> <work_dir> <ssh_available> <ansible_repo> <main_repo> <secrets_repo>
+
+TARGET_FQDN="$1"
+WORK_DIR="$2"
+SSH_AVAILABLE="${3:-false}"
+MAIN_REPO="${4:-git@github.com:ceph/ceph-build.git}"
+ANSIBLE_REPO="${5:-git@github.com:ceph/ceph-cm-ansible.git}"
+SECRETS_REPO="${6:-git@github.com:ceph/ceph-sepia-secrets.git}"
+
+SHORTNAME="${TARGET_FQDN%%.*}"
+
+REPOS_DIR="${WORK_DIR}/repos"
+ANSIBLE_DIR="${REPOS_DIR}/ansible"
+MAIN_DIR="${REPOS_DIR}/main"
+SECRETS_DIR="${REPOS_DIR}/secret-repo"
+VENV_DIR="${WORK_DIR}/.venv"
+
+mkdir -p "${REPOS_DIR}"
+cd "${REPOS_DIR}"
+
+log() { echo "[prepare_env] $*"; }
+
+adjust_url() {
+  local url="$1"
+  if [ "${SSH_AVAILABLE}" = "true" ]; then
+    echo "${url}"
+  else
+    echo "${url}" | sed 's|git@github.com:|https://github.com/|'
+  fi
+}
+
+clone_repo() {
+  local url="$1"
+  local dir="$2"
+  if [ -d "${dir}/.git" ]; then
+    log "Updating existing repo ${dir}"
+    (cd "${dir}" && git fetch --all --prune)
+  else
+    log "Cloning ${url} -> ${dir}"
+    if [ "${SSH_AVAILABLE}" = "true" ] && [ -f /tmp/jenkins_git_key ]; then
+      GIT_SSH_COMMAND='ssh -i /tmp/jenkins_git_key -o StrictHostKeyChecking=no' git clone --depth 1 "${url}" "${dir}"
+    else
+      git clone --depth 1 "${url}" "${dir}"
+    fi
+  fi
+}
+
+ANSIBLE_URL=$(adjust_url "${ANSIBLE_REPO}")
+MAIN_URL=$(adjust_url "${MAIN_REPO}")
+SECRETS_URL=$(adjust_url "${SECRETS_REPO}")
+
+clone_repo "${ANSIBLE_URL}" "${ANSIBLE_DIR}"
+clone_repo "${MAIN_URL}" "${MAIN_DIR}"
+clone_repo "${SECRETS_URL}" "${SECRETS_DIR}"
+
+# Ensure venv exists
+if [ ! -d "${VENV_DIR}/bin" ]; then
+  log "Virtualenv not found at ${VENV_DIR}, creating..."
+  python3 -m venv "${VENV_DIR}"
+  source "${VENV_DIR}/bin/activate"
+  pip install --upgrade pip
+  pip install -r "${ANSIBLE_DIR}/requirements.txt" 2>/dev/null || true
+else
+  source "${VENV_DIR}/bin/activate"
+fi
+
+# Check ansible installation
+if command -v ansible-playbook >/dev/null 2>&1; then
+  log "Ansible already installed."
+else
+  log "Ansible not found, attempting to install into venv..."
+
+  install_ansible_system() {
+    if command -v apt-get >/dev/null 2>&1; then
+      log "Detected apt-based system. Installing ansible..."
+      sudo apt-get update && sudo apt-get install -y ansible || true
+
+    elif command -v dnf >/dev/null 2>&1; then
+      log "Detected dnf-based system. Installing ansible-core..."
+      sudo dnf install -y ansible-core || sudo dnf install -y ansible || true
+
+    elif command -v yum >/dev/null 2>&1; then
+      log "Detected yum-based system. Installing ansible..."
+      sudo yum install -y ansible || true
+
+    else
+      log "No supported package manager found. Cannot install ansible."
+    fi
+  }
+
+  if [ -n "${VENV_DIR}" ] && [ -f "${VENV_DIR}/bin/activate" ]; then
+    pip install ansible || {
+      log "Failed to install ansible in venv; trying system install (requires sudo)"
+      install_ansible_system
+    }
+  else
+    log "No venv available; attempting system install (requires sudo)"
+    install_ansible_system
+  fi
+fi
+
+# -------------------------------------------------------------------
+# NEW: Workspace-local ansible configuration (replaces /etc/ansible)
+# -------------------------------------------------------------------
+
+INVENTORY_PATH="${SECRETS_DIR}/ansible/inventory"
+SECRETS_PATH="${SECRETS_DIR}/ansible/secrets"
+ANSIBLE_CFG="${WORK_DIR}/ansible.cfg"
+
+# Validate inventory presence
+if [ ! -e "${INVENTORY_PATH}" ]; then
+  log "ERROR: Inventory not found at ${INVENTORY_PATH}"
+  exit 1
+fi
+
+# Create ansible.cfg in workspace
+cat > "${ANSIBLE_CFG}" <<EOF
+[defaults]
+inventory = ${INVENTORY_PATH}
+host_key_checking = False
+retry_files_enabled = False
+stdout_callback = yaml
+interpreter_python = auto
+roles_path = ${ANSIBLE_DIR}/roles
+EOF
+
+export ANSIBLE_CONFIG="${ANSIBLE_CFG}"
+export INVENTORY_PATH
+export SECRETS_PATH
+export ANSIBLE_SECRETS_PATH="${SECRETS_PATH}"
+
+log "Using ANSIBLE_CONFIG=${ANSIBLE_CONFIG}"
+log "Inventory path: ${INVENTORY_PATH}"
+log "Secrets path: ${SECRETS_PATH}"
+
+# Optional: Warn if target not present in inventory
+if ! grep -R -q -E "^${TARGET_FQDN}\\b" "${INVENTORY_PATH}" 2>/dev/null; then
+  log "Warning: ${TARGET_FQDN} not found in inventory. Ensure dynamic inventory or update inventory."
+fi
+
+log "prepare_env completed for ${TARGET_FQDN} (shortname=${SHORTNAME})"
diff --git a/builder-reimage/build/requirements.txt b/builder-reimage/build/requirements.txt
new file mode 100644 (file)
index 0000000..4b60425
--- /dev/null
@@ -0,0 +1,9 @@
+requests>=2.31.0
+aiohttp>=3.9.0
+asyncio>=3.4.3
+python-dotenv>=1.0.1
+tabulate>=0.9.0
+cryptography>=36.0.0
+maas>=1.3.2
+python-libmaas>=0.6.8
+setuptools>=65.0.0
diff --git a/builder-reimage/build/sync_labels.py b/builder-reimage/build/sync_labels.py
new file mode 100644 (file)
index 0000000..6ea1c94
--- /dev/null
@@ -0,0 +1,118 @@
+#!/usr/bin/env python3
+
+"""
+Sync Jenkins node labels with jenkins_builders.yml
+
+- Reads labels from inventory
+- Resolves actual Jenkins node name (handles IP+hostname format)
+- Fetches current labels from Jenkins
+- Updates only if mismatch
+"""
+
+import yaml
+import requests
+import argparse
+from urllib.parse import quote
+import xml.etree.ElementTree as ET
+
+parser = argparse.ArgumentParser()
+parser.add_argument("--file", required=True)
+parser.add_argument("--jenkins_url", required=True)
+parser.add_argument("--user", required=True)
+parser.add_argument("--token", required=True)
+parser.add_argument("--node", required=True)
+
+args = parser.parse_args()
+
+# --- Normalize base URL ---
+base_url = args.jenkins_url.rstrip('/')
+
+# --- Read inventory ---
+with open(args.file) as f:
+    data = yaml.safe_load(f)
+
+labels_map = data.get("jenkins_labels", {})
+inventory_labels = labels_map.get(args.node, "").strip()
+
+if not inventory_labels:
+    print(f"[WARN] No labels found in inventory for {args.node}")
+    exit(0)
+
+# Normalize labels (sorted for consistent comparison)
+inventory_labels = " ".join(sorted(inventory_labels.split()))
+
+# --- Resolve Jenkins node name dynamically ---
+short_name = args.node.split('.')[0]
+
+api_url = f"{base_url}/computer/api/json"
+r = requests.get(api_url, auth=(args.user, args.token))
+r.raise_for_status()
+
+nodes = r.json().get("computer", [])
+
+node_name = None
+for n in nodes:
+    display_name = n.get("displayName", "")
+    if short_name in display_name:
+        node_name = display_name
+        break
+
+if not node_name:
+    print(f"[ERROR] Could not find Jenkins node for {short_name}")
+    exit(1)
+
+# Encode node name (important for '+' → '%2B')
+encoded_node = quote(node_name, safe='')
+
+# --- Build config.xml URL ---
+url = f"{base_url}/computer/{encoded_node}/config.xml"
+
+print(f"[DEBUG] Matched Jenkins node: {node_name}")
+print(f"[DEBUG] URL: {url}")
+
+# --- Fetch Jenkins config.xml ---
+r = requests.get(url, auth=(args.user, args.token))
+r.raise_for_status()
+
+xml_data = r.text
+
+# Strip XML declaration if needed (handles XML 1.1 safely)
+if xml_data.startswith("<?xml"):
+    xml_data = xml_data.split("?>", 1)[1]
+
+root = ET.fromstring(xml_data)
+
+label_node = root.find("label")
+
+if label_node is None:
+    print(f"[ERROR] <label> tag not found for {node_name}")
+    exit(1)
+
+current_labels = label_node.text or ""
+current_labels = " ".join(sorted(current_labels.split()))
+
+# --- Compare ---
+if current_labels == inventory_labels:
+    print(f"[SYNC] {args.node}: labels already in sync")
+    exit(0)
+
+# --- Update labels ---
+print(f"[SYNC] Updating {args.node}")
+print(f"  OLD: {current_labels}")
+print(f"  NEW: {inventory_labels}")
+
+label_node.text = inventory_labels
+
+updated_xml = ET.tostring(root, encoding="unicode")
+
+# --- Push update ---
+resp = requests.post(
+    url,
+    data=updated_xml,
+    headers={"Content-Type": "application/xml"},
+    auth=(args.user, args.token)
+)
+
+resp.raise_for_status()
+
+print(f"[SYNC] {args.node} labels updated successfully")
diff --git a/builder-reimage/config/definitions/builder-reimage.yml b/builder-reimage/config/definitions/builder-reimage.yml
new file mode 100644 (file)
index 0000000..31aa2cd
--- /dev/null
@@ -0,0 +1,46 @@
+- job:
+    name: builder-reimage
+    project-type: pipeline
+    description: "Pipeline to reimage Jenkins builders and run post-reimage ansible tasks (OS auto-detected from labels)"
+
+    concurrent: false
+
+    parameters:
+      - choice:
+          name: ACTION
+          choices:
+            - reimage
+            - reimage-all
+            - wait_online (debug)
+          default: reimage
+          description: "Reimage action"
+
+      - string:
+          name: MACHINE
+          default: ""
+          description: |
+            Target node(s)
+            Examples:
+              node1
+              node1,node2
+
+      - bool:
+          name: SKIP_REIMAGE
+          default: false
+          description: "Skip reimage and only run post-reimage tasks"
+
+    scm:
+      - git:
+          url: https://github.com/ceph/ceph-build.git
+          branches:
+            - main  
+          clean: true
+
+    pipeline-scm:
+      scm:
+        - git:
+            url: https://github.com/ceph/ceph-build.git
+            branches:
+              - main
+      script-path: builder-reimage/build/Jenkinsfile
+      lightweight-checkout: true