Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 140 additions & 23 deletions osism/commands/apply.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from osism import utils
from osism.data import enums
from osism.data.enums import Role
from osism.data.releases import format_release


def _collect_result(result):
Expand Down Expand Up @@ -106,6 +107,19 @@ def get_parser(self, prog_name):
help="Dry run, do not initiate tasks (for collections only)",
action="store_true",
)
parser.add_argument(
"--openstack-version",
default=None,
type=str,
help=(
"OpenStack release the collection is applied to, e.g. 2026.1. "
"Collections deploy a few roles only on certain releases (redis "
"up to 2025.1, valkey from 2025.2). Read from openstack_version "
"in /interface/versions/kolla-ansible.yml when not given. Must "
"be given before the collection name, or it is swallowed as an "
"Ansible argument. (env: OPENSTACK_VERSION)"
),
)
parser.add_argument(
"--show-tree",
dest="show_tree",
Expand Down Expand Up @@ -165,6 +179,7 @@ def _handle_collection(
retry,
dry_run,
show_tree,
release=None,
):
from celery import chain, group
from osism.tasks import ansible
Expand All @@ -182,27 +197,47 @@ def _handle_collection(
role_name = item.name
dependencies = item.dependencies

logger.info(f"A [{counter}] {'-' * (counter + 1)} {role_name}")

if show_tree:
# Only show the tree, don't create tasks
if release is not None and not item.deployed_in(release):
# Not a warning: on a release where the role does not belong,
# this is correct output on every run, and a warning that always
# fires teaches operators to ignore warnings.
logger.info(
f"Skipping {role_name}: not deployed by this collection "
f"on OpenStack {format_release(release)} "
f"(deployed {item.bound_description()})"
)
pt = None
elif dry_run:
pt = ansible.noop.si()
else:
pt = self._prepare_task(
arguments,
environment,
overwrite,
sub,
role_name,
action,
wait,
format,
timeout,
task_timeout,
)
if release is None and item.release_bounded:
logger.warning(
f"{role_name} has release bounds "
f"({item.bound_description()}) but no OpenStack "
f"release was resolved; its bounds are being ignored "
f"and it will be scheduled unconditionally."
)

logger.info(f"A [{counter}] {'-' * (counter + 1)} {role_name}")

if show_tree:
# Only show the tree, don't create tasks
pt = None
elif dry_run:
pt = ansible.noop.si()
else:
pt = self._prepare_task(
arguments,
environment,
overwrite,
sub,
role_name,
action,
wait,
format,
timeout,
task_timeout,
)

st = None
if dependencies:
logger.debug(f"X [{counter + 1}] --> {dependencies}")
st = self._handle_collection(
Expand All @@ -221,12 +256,33 @@ def _handle_collection(
retry,
dry_run,
show_tree,
release,
)
if not show_tree:
g.append(chain(pt, st))
else:
if not show_tree:
g.append(pt)

if show_tree:
continue

# Both halves can be absent: pt when the role is excluded, st when
# its dependencies all were. Appending either unconditionally would
# build chain(pt, None) or group([None]).
if pt is not None and st is not None:
g.append(chain(pt, st))
elif pt is not None:
g.append(pt)
elif st is not None:
# Promotion keeps the subtree and its position among retained
# siblings, but not what the excluded role supplied to it:
# chain(pt, st) ran the role BEFORE its dependencies, so they
# are its dependents and it is their prerequisite. Dropping it
# leaves them with no predecessor, and nothing here can know
# which retained or replacement role belongs in that place.
#
# Nothing checks that at runtime, deliberately. The catalog
# cannot grow a bounded role with dependencies without turning
# test_no_bounded_role_has_dependencies red first, so by the
# time this branch runs the ordering has already been settled
# by whoever made that test pass.
g.append(st)

if g:
return group(g)
Expand All @@ -246,6 +302,7 @@ def handle_collection(
retry,
dry_run,
show_tree,
release=None,
):
if dry_run:
logger.info(f"Dry run for collection {collection}. No tasks are scheduled.")
Expand All @@ -270,6 +327,7 @@ def handle_collection(
retry,
dry_run,
show_tree,
release,
)

# Only apply tasks if not in show_tree mode
Expand All @@ -284,6 +342,49 @@ def handle_collection(

return 0

def _resolve_release(self, override, bounded):
"""Return the deployed release, or exit reporting why it is unknown.

``bounded`` maps collection name to the release-bounded roles it
contains; it is used only to name what needed the release.
"""
from osism.data.releases import (
ReleaseUndetermined,
ReleaseUnparseable,
openstack_release,
)

try:
return openstack_release(override)
except ReleaseUnparseable as exc:
# Already a complete sentence.
logger.error(str(exc))
except ReleaseUndetermined as exc:
names = ", ".join(sorted(bounded))
noun = "Collection" if len(bounded) == 1 else "Collections"
verb = "contains" if len(bounded) == 1 else "contain"
logger.error(
f"{noun} {names} {verb} roles that depend on the OpenStack "
f"release, but the release could not be determined: {exc}"
)

# One role can appear in several collections; report it once.
descriptions = {}
for roles in bounded.values():
for role in roles:
descriptions[role.name] = role.bound_description()

affected = ", ".join(
f"{name} ({descriptions[name]})" for name in sorted(descriptions)
)
logger.error(f"Affected roles: {affected}")
logger.error(
"Supply the release with OPENSTACK_VERSION=<release> osism apply "
"<collection>, or with --openstack-version <release> before the "
"collection name."
)
exit(1)

def _prepare_task(
self,
arguments,
Expand Down Expand Up @@ -457,6 +558,21 @@ def take_action(self, parsed_args):

rc = 0

# Resolve the release before the dispatch loop below, not inside it: each
# iteration ends in apply_async(), so resolving per entry would let an
# earlier collection reach the cluster before a later one failed.
release = None
if role:
bounded = {}
for entry in role.split("//"):
if entry in enums.MAP_ROLE2ROLE:
found = list(enums.bounded_roles(enums.MAP_ROLE2ROLE[entry]))
if found:
bounded[entry] = found

if bounded:
release = self._resolve_release(parsed_args.openstack_version, bounded)

if not role:
table = []
for role in MAP_ROLE2ENVIRONMENT:
Expand Down Expand Up @@ -484,6 +600,7 @@ def take_action(self, parsed_args):
retry,
dry_run,
show_tree,
release=release,
)
if rc != 0:
outer_break = True
Expand Down
85 changes: 80 additions & 5 deletions osism/data/enums.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# SPDX-License-Identifier: Apache-2.0

from osism.data.releases import format_release, parse_release


class Role:
"""
Expand All @@ -8,6 +10,20 @@ class Role:
Args:
name: The name of the role (string)
dependencies: Optional list of dependent Role objects
since: Earliest OpenStack release, inclusive, on which a collection
should deploy this role (string, e.g. "2025.2")
until: Latest such release, inclusive (string, e.g. "2025.1")

``since`` and ``until`` govern **collection membership only**: whether a
collection should deploy this role on a given release. They are NOT a
statement that the role's playbook exists, or that the role may be applied,
on those releases. ``valkey`` is the illustration -- its play ships from
OpenStack 2025.1, but collections deploy it only from 2025.2, because
``osism/defaults`` leaves ``enable_valkey`` off until then. Reading the
bound as availability would wrongly reject ``osism apply valkey`` on 2025.1.

Only collection expansion consults these bounds. ``osism apply <role>``
never does.

Example:
>>> role = Role("keystone", dependencies=[Role("glance"), Role("cinder")])
Expand All @@ -17,10 +33,64 @@ class Role:
2
"""

def __init__(self, name, dependencies=None):
"""Initialize a Role with a name and optional dependencies."""
def __init__(self, name, dependencies=None, since=None, until=None):
"""Initialize a Role with a name, optional dependencies and bounds."""
self.name = name
self.dependencies = dependencies or []
# Parsed eagerly, unlike the deployed release: these are literals in
# this file, so a bad value is a bug here that should surface at once
# rather than on the one deployment whose release happens to test it.
self.since = parse_release(since) if since else None
self.until = parse_release(until) if until else None

@property
def release_bounded(self):
"""Whether collection membership depends on the OpenStack release."""
return self.since is not None or self.until is not None

def deployed_in(self, release):
"""Whether a collection should deploy this role on ``release``.

``release`` is a ``(year, minor)`` tuple from ``osism.data.releases``.
"""
if self.since is not None and release < self.since:
return False

if self.until is not None and release > self.until:
return False

return True

def bound_description(self):
"""Render the bounds for log and error text, e.g. "from 2025.2".

Only meaningful for a release-bounded role; raises ``ValueError`` on
one with neither bound, rather than silently rendering nonsense.
"""
if self.since is not None and self.until is not None:
return f"from {format_release(self.since)} to {format_release(self.until)}"

if self.since is not None:
return f"from {format_release(self.since)}"

if self.until is not None:
return f"up to {format_release(self.until)}"

raise ValueError(f"{self.name!r} has no release bounds to describe")


def bounded_roles(roles):
"""Yield every release-bounded Role reachable from ``roles``.

Used to decide whether expanding a collection needs the release at all: a
collection with no bounded roles never triggers the lookup, and so can never
fail on it.
"""
for role in roles:
if role.release_bounded:
yield role

yield from bounded_roles(role.dependencies)


VALIDATE_PLAYBOOKS = {
Expand Down Expand Up @@ -158,7 +228,10 @@ def __init__(self, name, dependencies=None):
),
Role("openvswitch", dependencies=[Role("ovn")]),
Role("memcached"),
Role("redis"),
# kolla replaced redis with valkey at OpenStack 2025.2; on 2025.1
# both plays exist but osism/defaults leaves enable_valkey off.
Role("redis", until="2025.1"),
Role("valkey", since="2025.2"),
Role("rabbitmq"),
],
),
Expand Down Expand Up @@ -219,7 +292,8 @@ def __init__(self, name, dependencies=None):
),
Role("openvswitch", dependencies=[Role("ovn")]),
Role("memcached"),
Role("redis"),
Role("redis", until="2025.1"),
Role("valkey", since="2025.2"),
Role("rabbitmq"),
],
),
Expand Down Expand Up @@ -363,7 +437,8 @@ def __init__(self, name, dependencies=None):
),
Role("openvswitch", dependencies=[Role("ovn")]),
Role("memcached"),
Role("redis"),
Role("redis", until="2025.1"),
Role("valkey", since="2025.2"),
Role("rabbitmq"),
],
),
Expand Down
Loading