debian-forge/composer_client.py
robojerk 502e1469ae
Some checks failed
Checks / Spelling (push) Has been cancelled
Checks / Python Linters (push) Has been cancelled
Checks / Shell Linters (push) Has been cancelled
Checks / 📦 Packit config lint (push) Has been cancelled
Checks / 🔍 Check for valid snapshot urls (push) Has been cancelled
Checks / 🔍 Check JSON files for formatting consistency (push) Has been cancelled
Generate / Documentation (push) Has been cancelled
Generate / Test Data (push) Has been cancelled
Tests / Unittest (push) Has been cancelled
Tests / Assembler test (legacy) (push) Has been cancelled
Tests / Smoke run: unittest as normal user on default runner (push) Has been cancelled
Move composer scripts to root directory and add comprehensive Debian Atomic support
2025-08-23 08:02:45 -07:00

287 lines
10 KiB
Python

#!/usr/bin/env python3
"""
Debian Forge Composer Client
This module provides a client interface for interacting with OSBuild Composer
to submit builds, monitor status, and manage Debian atomic image creation.
"""
import json
import requests
import time
from typing import Dict, List, Optional, Any
from dataclasses import dataclass
from pathlib import Path
@dataclass
class BuildRequest:
"""Represents a build request for Debian atomic images"""
blueprint: str
target: str
architecture: str = "amd64"
compose_type: str = "debian-atomic"
priority: str = "normal"
metadata: Optional[Dict[str, Any]] = None
@dataclass
class BuildStatus:
"""Represents the status of a build"""
build_id: str
status: str
created_at: str
blueprint: str
target: str
architecture: str
progress: Optional[Dict[str, Any]] = None
logs: Optional[List[str]] = None
class ComposerClient:
"""Client for interacting with OSBuild Composer"""
def __init__(self, base_url: str = "http://localhost:8700", api_version: str = "v1"):
self.base_url = base_url.rstrip('/')
self.api_version = api_version
self.session = requests.Session()
self.session.headers.update({
'Content-Type': 'application/json',
'Accept': 'application/json'
})
def _make_request(self, method: str, endpoint: str, data: Optional[Dict] = None) -> requests.Response:
"""Make an HTTP request to the composer API"""
url = f"{self.base_url}/api/{self.api_version}/{endpoint.lstrip('/')}"
try:
if method.upper() == 'GET':
response = self.session.get(url)
elif method.upper() == 'POST':
response = self.session.post(url, json=data)
elif method.upper() == 'PUT':
response = self.session.put(url, json=data)
elif method.upper() == 'DELETE':
response = self.session.delete(url)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
return response
except requests.exceptions.RequestException as e:
raise ConnectionError(f"Failed to connect to composer: {e}")
def submit_blueprint(self, blueprint_path: str) -> Dict[str, Any]:
"""Submit a blueprint to composer"""
if not Path(blueprint_path).exists():
raise FileNotFoundError(f"Blueprint file not found: {blueprint_path}")
with open(blueprint_path, 'r') as f:
blueprint_data = json.load(f)
response = self._make_request('POST', 'blueprints/new', blueprint_data)
if response.status_code == 201:
return response.json()
else:
raise RuntimeError(f"Failed to submit blueprint: {response.status_code} - {response.text}")
def get_blueprint(self, blueprint_name: str) -> Dict[str, Any]:
"""Get blueprint details"""
response = self._make_request('GET', f'blueprints/info/{blueprint_name}')
if response.status_code == 200:
return response.json()
else:
raise RuntimeError(f"Failed to get blueprint: {response.status_code} - {response.text}")
def list_blueprints(self) -> List[str]:
"""List all available blueprints"""
response = self._make_request('GET', 'blueprints/list')
if response.status_code == 200:
return response.json()
else:
raise RuntimeError(f"Failed to list blueprints: {response.status_code} - {response.text}")
def start_compose(self, build_request: BuildRequest) -> str:
"""Start a compose for a blueprint"""
compose_data = {
"blueprint_name": build_request.blueprint,
"compose_type": build_request.compose_type,
"branch": "main",
"distro": "debian-12",
"arch": build_request.architecture,
"image_type": build_request.target,
"size": 0,
"upload": False
}
if build_request.metadata:
compose_data["metadata"] = build_request.metadata
response = self._make_request('POST', 'compose', compose_data)
if response.status_code == 201:
compose_info = response.json()
return compose_info.get('id', '')
else:
raise RuntimeError(f"Failed to start compose: {response.status_code} - {response.text}")
def get_compose_status(self, compose_id: str) -> BuildStatus:
"""Get the status of a compose"""
response = self._make_request('GET', f'compose/status/{compose_id}')
if response.status_code == 200:
status_data = response.json()
return BuildStatus(
build_id=compose_id,
status=status_data.get('status', 'unknown'),
created_at=status_data.get('created_at', ''),
blueprint=status_data.get('blueprint', ''),
target=status_data.get('image_type', ''),
architecture=status_data.get('arch', ''),
progress=status_data.get('progress', {}),
logs=status_data.get('logs', [])
)
else:
raise RuntimeError(f"Failed to get compose status: {response.status_code} - {response.text}")
def list_composes(self) -> List[Dict[str, Any]]:
"""List all composes"""
response = self._make_request('GET', 'compose/list')
if response.status_code == 200:
return response.json()
else:
raise RuntimeError(f"Failed to list composes: {response.status_code} - {response.text}")
def cancel_compose(self, compose_id: str) -> bool:
"""Cancel a running compose"""
response = self._make_request('DELETE', f'compose/cancel/{compose_id}')
if response.status_code == 200:
return True
else:
raise RuntimeError(f"Failed to cancel compose: {response.status_code} - {response.text}")
def get_compose_logs(self, compose_id: str) -> List[str]:
"""Get logs for a compose"""
response = self._make_request('GET', f'compose/logs/{compose_id}')
if response.status_code == 200:
return response.json()
else:
raise RuntimeError(f"Failed to get compose logs: {response.status_code} - {response.text}")
def download_image(self, compose_id: str, target_dir: str = ".") -> str:
"""Download the generated image"""
response = self._make_request('GET', f'compose/image/{compose_id}')
if response.status_code == 200:
# Save the image file
filename = f"debian-atomic-{compose_id}.{self._get_image_extension(compose_id)}"
filepath = Path(target_dir) / filename
with open(filepath, 'wb') as f:
f.write(response.content)
return str(filepath)
else:
raise RuntimeError(f"Failed to download image: {response.status_code} - {response.text}")
def _get_image_extension(self, compose_id: str) -> str:
"""Get the appropriate file extension for the image type"""
# This would need to be determined from the compose type
return "qcow2"
def wait_for_completion(self, compose_id: str, timeout: int = 3600, poll_interval: int = 30) -> BuildStatus:
"""Wait for a compose to complete"""
start_time = time.time()
while True:
if time.time() - start_time > timeout:
raise TimeoutError(f"Compose {compose_id} did not complete within {timeout} seconds")
status = self.get_compose_status(compose_id)
if status.status in ['FINISHED', 'FAILED']:
return status
time.sleep(poll_interval)
class DebianAtomicBuilder:
"""High-level interface for building Debian atomic images"""
def __init__(self, composer_client: ComposerClient):
self.client = composer_client
def build_base_image(self, output_format: str = "qcow2") -> str:
"""Build a base Debian atomic image"""
build_request = BuildRequest(
blueprint="debian-atomic-base",
target=output_format,
architecture="amd64"
)
return self._build_image(build_request)
def build_workstation_image(self, output_format: str = "qcow2") -> str:
"""Build a Debian atomic workstation image"""
build_request = BuildRequest(
blueprint="debian-atomic-workstation",
target=output_format,
architecture="amd64"
)
return self._build_image(build_request)
def build_server_image(self, output_format: str = "qcow2") -> str:
"""Build a Debian atomic server image"""
build_request = BuildRequest(
blueprint="debian-atomic-server",
target=output_format,
architecture="amd64"
)
return self._build_image(build_request)
def _build_image(self, build_request: BuildRequest) -> str:
"""Internal method to build an image"""
print(f"Starting build for {build_request.blueprint}...")
# Start the compose
compose_id = self.client.start_compose(build_request)
print(f"Compose started with ID: {compose_id}")
# Wait for completion
print("Waiting for build to complete...")
status = self.client.wait_for_completion(compose_id)
if status.status == 'FAILED':
raise RuntimeError(f"Build failed for {build_request.blueprint}")
print(f"Build completed successfully!")
# Download the image
print("Downloading image...")
image_path = self.client.download_image(compose_id)
print(f"Image downloaded to: {image_path}")
return image_path
def main():
"""Example usage of the composer client"""
# Create client
client = ComposerClient()
# Create builder
builder = DebianAtomicBuilder(client)
try:
# Build a base image
image_path = builder.build_base_image("qcow2")
print(f"Successfully built base image: {image_path}")
except Exception as e:
print(f"Build failed: {e}")
if __name__ == '__main__':
main()