Skip to content

Commit c4c0af2

Browse files
authored
Add sync versions command for Kolla version synchronization (#1863)
Introduces a new CLI command `osism sync versions [kolla]` that extracts version information from SBOM container images and renders them to the configuration repository. Uses skopeo for container image extraction (no Docker required). Features: - Extracts versions from registry.osism.cloud/kolla/sbom:<version> - Automatically uses kolla/release namespace for versions starting with 'v' - Renders Kolla version template to environments/kolla/versions.yml - Supports --dry-run mode for preview without writing files - Positional type argument (currently: kolla) for future extensibility AI-assisted: Claude Code Signed-off-by: Christian Berendt <berendt@osism.tech>
1 parent 449dc2e commit c4c0af2

4 files changed

Lines changed: 270 additions & 0 deletions

File tree

Containerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ apk add --no-cache \
4848
libyang \
4949
openssh-client \
5050
procps \
51+
skopeo \
5152
tini
5253

5354
# install python packages

osism/commands/sync.py

Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,18 @@
11
# SPDX-License-Identifier: Apache-2.0
22

3+
import os
4+
import subprocess
5+
import tarfile
6+
import tempfile
7+
from pathlib import Path
8+
39
from cliff.command import Command
10+
import jinja2
411
from loguru import logger
12+
from yaml import safe_load, YAMLError
513

614
from osism import utils
15+
from osism.data import TEMPLATE_KOLLA_VERSIONS
716
from osism.tasks import ansible, conductor, handle_task
817

918

@@ -96,3 +105,201 @@ def take_action(self, parsed_args):
96105

97106
rc = handle_task(task, wait=wait)
98107
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

osism/data/__init__.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,3 +135,64 @@
135135
build_date: {{ image_builddate }}
136136
137137
"""
138+
139+
TEMPLATE_KOLLA_VERSIONS = """---
140+
kolla_aodh_version: "{{ versions['aodh']|default(openstack_version) }}"
141+
kolla_barbican_version: "{{ versions['barbican']|default(openstack_version) }}"
142+
kolla_ceilometer_version: "{{ versions['ceilometer']|default(openstack_version) }}"
143+
kolla_cinder_version: "{{ versions['cinder']|default(openstack_version) }}"
144+
kolla_cloudkitty_version: "{{ versions['cloudkitty']|default(openstack_version) }}"
145+
kolla_common_version: "{{ versions['kolla_toolbox']|default(openstack_version) }}"
146+
kolla_cron_version: "{{ versions['cron']|default(openstack_version) }}"
147+
kolla_designate_version: "{{ versions['designate']|default(openstack_version) }}"
148+
kolla_dnsmasq_version: "{{ versions['dnsmasq']|default(openstack_version) }}"
149+
kolla_fluentd_version: "{{ versions['fluentd']|default(openstack_version) }}"
150+
kolla_glance_version: "{{ versions['glance']|default(openstack_version) }}"
151+
kolla_gnocchi_version: "{{ versions['gnocchi']|default(openstack_version) }}"
152+
kolla_grafana_version: "{{ versions['grafana']|default(openstack_version) }}"
153+
kolla_haproxy_version: "{{ versions['haproxy']|default(openstack_version) }}"
154+
kolla_haproxy_ssh_version: "{{ versions['haproxy_ssh']|default(openstack_version) }}"
155+
kolla_horizon_version: "{{ versions['horizon']|default(openstack_version) }}"
156+
kolla_ironic_inspector_version: "{{ versions['ironic_inspector']|default(openstack_version) }}"
157+
kolla_ironic_version: "{{ versions['ironic']|default(openstack_version) }}"
158+
kolla_iscsid_version: "{{ versions['iscsid']|default(openstack_version) }}"
159+
kolla_keepalived_version: "{{ versions['keepalived']|default(openstack_version) }}"
160+
kolla_keystone_version: "{{ versions['keystone']|default(openstack_version) }}"
161+
kolla_magnum_version: "{{ versions['magnum']|default(openstack_version) }}"
162+
kolla_manila_version: "{{ versions['manila']|default(openstack_version) }}"
163+
kolla_mariadb_version: "{{ versions['mariadb']|default(openstack_version) }}"
164+
kolla_memcached_version: "{{ versions['memcached']|default(openstack_version) }}"
165+
kolla_multipathd_version: "{{ versions['multipathd']|default(openstack_version) }}"
166+
kolla_neutron_version: "{{ versions['neutron']|default(openstack_version) }}"
167+
kolla_nova_version: "{{ versions['nova']|default(openstack_version) }}"
168+
kolla_octavia_version: "{{ versions['octavia']|default(openstack_version) }}"
169+
kolla_opensearch_version: "{{ versions['opensearch']|default(openstack_version) }}"
170+
kolla_openvswitch_version: "{{ versions['openvswitch']|default(openstack_version) }}"
171+
kolla_ovn_version: "{{ versions['ovn']|default(openstack_version) }}"
172+
kolla_placement_version: "{{ versions['placement']|default(openstack_version) }}"
173+
kolla_prometheus_version: "{{ versions['prometheus']|default(openstack_version) }}"
174+
kolla_proxysql_version: "{{ versions['proxysql']|default(openstack_version) }}"
175+
kolla_rabbitmq_version: "{{ versions['rabbitmq']|default(openstack_version) }}"
176+
kolla_redis_version: "{{ versions['redis']|default(openstack_version) }}"
177+
kolla_skyline_version: "{{ versions['skyline']|default(openstack_version) }}"
178+
kolla_tgtd_version: "{{ versions['tgtd']|default(openstack_version) }}"
179+
kolla_watcher_version: "{{ versions['watcher']|default(openstack_version) }}"
180+
181+
kolla_nova_libvirt_version: "{{ versions['nova_libvirt']|default(openstack_version) }}"
182+
kolla_opensearch_dashboards_version: "{{ versions['opensearch_dashboards']|default(openstack_version) }}"
183+
kolla_skyline_console_version: "{{ versions['skyline_console']|default(openstack_version) }}"
184+
185+
kolla_prometheus_alertmanager_version: "{{ versions['prometheus_alertmanager']|default(openstack_version) }}"
186+
kolla_prometheus_blackbox_exporter_version: "{{ versions['prometheus_blackbox_exporter']|default(openstack_version) }}"
187+
kolla_prometheus_cadvisor_version: "{{ versions['prometheus_cadvisor']|default(openstack_version) }}"
188+
kolla_prometheus_elasticsearch_exporter_version: "{{ versions['prometheus_elasticsearch_exporter']|default(openstack_version) }}"
189+
kolla_prometheus_libvirt_exporter_version: "{{ versions['prometheus_libvirt_exporter']|default(openstack_version) }}"
190+
kolla_prometheus_memcached_exporter_version: "{{ versions['prometheus_memcached_exporter']|default(openstack_version) }}"
191+
kolla_prometheus_mysqld_exporter_version: "{{ versions['prometheus_mysqld_exporter']|default(openstack_version) }}"
192+
kolla_prometheus_node_exporter_version: "{{ versions['prometheus_node_exporter']|default(openstack_version) }}"
193+
kolla_prometheus_openstack_exporter_version: "{{ versions['prometheus_openstack_exporter']|default(openstack_version) }}"
194+
kolla_ironic_prometheus_exporter_version: "{{ versions['ironic']|default(openstack_version) }}"
195+
196+
kolla_letsencrypt_lego_version: "{{ versions['letsencrypt_lego']|default(openstack_version) }}"
197+
kolla_letsencrypt_webserver_version: "{{ versions['letsencrypt_webserver']|default(openstack_version) }}"
198+
"""

setup.cfg

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,7 @@ osism.commands:
141141
sync ironic = osism.commands.netbox:Ironic
142142
sync netbox = osism.commands.netbox:Sync
143143
sync sonic = osism.commands.sync:Sonic
144+
sync versions = osism.commands.sync:Versions
144145
task list = osism.commands.get:Tasks
145146
task revoke = osism.commands.task:Revoke
146147
lock = osism.commands.lock:Lock

0 commit comments

Comments
 (0)