|
1 | 1 | # SPDX-License-Identifier: Apache-2.0 |
2 | 2 |
|
| 3 | +import os |
| 4 | +import subprocess |
| 5 | +import tarfile |
| 6 | +import tempfile |
| 7 | +from pathlib import Path |
| 8 | + |
3 | 9 | from cliff.command import Command |
| 10 | +import jinja2 |
4 | 11 | from loguru import logger |
| 12 | +from yaml import safe_load, YAMLError |
5 | 13 |
|
6 | 14 | from osism import utils |
| 15 | +from osism.data import TEMPLATE_KOLLA_VERSIONS |
7 | 16 | from osism.tasks import ansible, conductor, handle_task |
8 | 17 |
|
9 | 18 |
|
@@ -96,3 +105,201 @@ def take_action(self, parsed_args): |
96 | 105 |
|
97 | 106 | rc = handle_task(task, wait=wait) |
98 | 107 | return rc |
| 108 | + |
| 109 | + |
| 110 | +class Versions(Command): |
| 111 | + """Sync Kolla versions from SBOM container image to configuration repository.""" |
| 112 | + |
| 113 | + def get_parser(self, prog_name): |
| 114 | + parser = super(Versions, self).get_parser(prog_name) |
| 115 | + parser.add_argument( |
| 116 | + "type", |
| 117 | + nargs="?", |
| 118 | + default="kolla", |
| 119 | + choices=["kolla"], |
| 120 | + help="Type of versions to sync (default: kolla)", |
| 121 | + ) |
| 122 | + parser.add_argument( |
| 123 | + "--openstack-version", |
| 124 | + type=str, |
| 125 | + default=os.environ.get("OPENSTACK_VERSION", "2025.1"), |
| 126 | + help="OpenStack version (default: 2025.1, env: OPENSTACK_VERSION)", |
| 127 | + ) |
| 128 | + parser.add_argument( |
| 129 | + "--configuration-path", |
| 130 | + type=str, |
| 131 | + default="/opt/configuration", |
| 132 | + help="Path to configuration repository (default: /opt/configuration)", |
| 133 | + ) |
| 134 | + parser.add_argument( |
| 135 | + "--sbom-image", |
| 136 | + type=str, |
| 137 | + default=None, |
| 138 | + help="SBOM container image (default: registry.osism.cloud/kolla/sbom:<version>)", |
| 139 | + ) |
| 140 | + parser.add_argument( |
| 141 | + "--dry-run", |
| 142 | + default=False, |
| 143 | + help="Show rendered versions without writing to file", |
| 144 | + action="store_true", |
| 145 | + ) |
| 146 | + return parser |
| 147 | + |
| 148 | + def _extract_sbom_with_skopeo(self, image_ref: str) -> dict: |
| 149 | + """ |
| 150 | + Extract SBOM from container image using skopeo. |
| 151 | +
|
| 152 | + Args: |
| 153 | + image_ref: Container image reference |
| 154 | +
|
| 155 | + Returns: |
| 156 | + Parsed SBOM data dictionary |
| 157 | + """ |
| 158 | + with tempfile.TemporaryDirectory() as tmpdir: |
| 159 | + oci_dir = Path(tmpdir) / "oci" |
| 160 | + |
| 161 | + logger.info(f"Copying image {image_ref} using skopeo...") |
| 162 | + try: |
| 163 | + subprocess.run( |
| 164 | + [ |
| 165 | + "skopeo", |
| 166 | + "copy", |
| 167 | + f"docker://{image_ref}", |
| 168 | + f"oci:{oci_dir}:latest", |
| 169 | + ], |
| 170 | + check=True, |
| 171 | + capture_output=True, |
| 172 | + text=True, |
| 173 | + ) |
| 174 | + except subprocess.CalledProcessError as e: |
| 175 | + logger.error(f"Failed to copy image with skopeo: {e.stderr}") |
| 176 | + raise RuntimeError(f"skopeo copy failed: {e.stderr}") |
| 177 | + except FileNotFoundError: |
| 178 | + logger.error("skopeo not found. Please install skopeo.") |
| 179 | + raise RuntimeError("skopeo not found") |
| 180 | + |
| 181 | + logger.info("Extracting images.yml from OCI image...") |
| 182 | + |
| 183 | + # Read the OCI index to find the manifest |
| 184 | + index_path = oci_dir / "index.json" |
| 185 | + with open(index_path) as f: |
| 186 | + index = safe_load(f) |
| 187 | + |
| 188 | + # Get the manifest digest |
| 189 | + manifest_digest = index["manifests"][0]["digest"] |
| 190 | + manifest_hash = manifest_digest.split(":")[1] |
| 191 | + |
| 192 | + # Read the manifest |
| 193 | + manifest_path = oci_dir / "blobs" / "sha256" / manifest_hash |
| 194 | + with open(manifest_path) as f: |
| 195 | + manifest = safe_load(f) |
| 196 | + |
| 197 | + # Extract each layer and look for images.yml |
| 198 | + sbom = None |
| 199 | + for layer in manifest["layers"]: |
| 200 | + layer_digest = layer["digest"] |
| 201 | + layer_hash = layer_digest.split(":")[1] |
| 202 | + layer_path = oci_dir / "blobs" / "sha256" / layer_hash |
| 203 | + |
| 204 | + try: |
| 205 | + with tarfile.open(layer_path, "r:*") as tar: |
| 206 | + for member in tar.getmembers(): |
| 207 | + if member.name == "images.yml" or member.name.endswith( |
| 208 | + "/images.yml" |
| 209 | + ): |
| 210 | + extracted = tar.extractfile(member) |
| 211 | + if extracted: |
| 212 | + sbom = safe_load(extracted.read()) |
| 213 | + logger.success("Found and extracted images.yml") |
| 214 | + break |
| 215 | + except (tarfile.TarError, EOFError): |
| 216 | + # Not a tar file or empty, skip |
| 217 | + continue |
| 218 | + |
| 219 | + if sbom: |
| 220 | + break |
| 221 | + |
| 222 | + if sbom is None: |
| 223 | + raise RuntimeError("images.yml not found in container image") |
| 224 | + |
| 225 | + return sbom |
| 226 | + |
| 227 | + def take_action(self, parsed_args): |
| 228 | + sync_type = parsed_args.type |
| 229 | + openstack_version = parsed_args.openstack_version |
| 230 | + config_path = Path(parsed_args.configuration_path) |
| 231 | + dry_run = parsed_args.dry_run |
| 232 | + |
| 233 | + if sync_type == "kolla": |
| 234 | + return self._sync_kolla_versions( |
| 235 | + openstack_version, config_path, parsed_args.sbom_image, dry_run |
| 236 | + ) |
| 237 | + |
| 238 | + logger.error(f"Unknown sync type: {sync_type}") |
| 239 | + return 1 |
| 240 | + |
| 241 | + def _sync_kolla_versions( |
| 242 | + self, |
| 243 | + openstack_version: str, |
| 244 | + config_path: Path, |
| 245 | + sbom_image: str | None, |
| 246 | + dry_run: bool, |
| 247 | + ) -> int: |
| 248 | + """Sync Kolla versions from SBOM container image.""" |
| 249 | + |
| 250 | + # Construct SBOM image reference if not provided |
| 251 | + if sbom_image is None: |
| 252 | + # Use kolla/release namespace for versions starting with 'v' (e.g. v0.20251128.0) |
| 253 | + if openstack_version.startswith("v"): |
| 254 | + sbom_image = f"registry.osism.cloud/kolla/release:{openstack_version}" |
| 255 | + else: |
| 256 | + sbom_image = f"registry.osism.cloud/kolla/sbom:{openstack_version}" |
| 257 | + |
| 258 | + logger.info(f"OpenStack version: {openstack_version}") |
| 259 | + logger.info(f"Configuration path: {config_path}") |
| 260 | + logger.info(f"SBOM image: {sbom_image}") |
| 261 | + |
| 262 | + # Check configuration path exists |
| 263 | + if not dry_run and not config_path.exists(): |
| 264 | + logger.error(f"Configuration path does not exist: {config_path}") |
| 265 | + return 1 |
| 266 | + |
| 267 | + # Extract SBOM from container |
| 268 | + try: |
| 269 | + sbom = self._extract_sbom_with_skopeo(sbom_image) |
| 270 | + except RuntimeError as e: |
| 271 | + logger.error(str(e)) |
| 272 | + return 1 |
| 273 | + except YAMLError as e: |
| 274 | + logger.error(f"Failed to parse SBOM YAML: {e}") |
| 275 | + return 1 |
| 276 | + |
| 277 | + versions = sbom.get("versions", {}) |
| 278 | + logger.info(f"Found {len(versions)} version entries in SBOM") |
| 279 | + |
| 280 | + # Render template |
| 281 | + environment = jinja2.Environment() |
| 282 | + template = environment.from_string(TEMPLATE_KOLLA_VERSIONS) |
| 283 | + result = template.render( |
| 284 | + { |
| 285 | + "openstack_version": openstack_version, |
| 286 | + "versions": versions, |
| 287 | + } |
| 288 | + ) |
| 289 | + |
| 290 | + if dry_run: |
| 291 | + logger.info("Dry run - rendered versions.yml:") |
| 292 | + print(result) |
| 293 | + return 0 |
| 294 | + |
| 295 | + # Write to configuration repository |
| 296 | + output_path = config_path / "environments" / "kolla" / "versions.yml" |
| 297 | + |
| 298 | + # Ensure directory exists |
| 299 | + output_path.parent.mkdir(parents=True, exist_ok=True) |
| 300 | + |
| 301 | + with open(output_path, "w") as f: |
| 302 | + f.write(result) |
| 303 | + |
| 304 | + logger.success(f"Versions written to {output_path}") |
| 305 | + return 0 |
0 commit comments