Skip to content
Open
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
9 changes: 7 additions & 2 deletions docs/UPGRADE.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,23 @@ Each Enclave tarball release includes:
```sh
./sync.sh
```
7. **Execute upgrade script** - Run the upgrade automation to apply configuration migrations, then upgrade the management cluster and operators to the versions pinned in the tarball. This executes the `playbooks/upgrade.yaml` playbook, which runs migrations followed by `enclave reconcile mgmt-cluster-version --use-defaults` and `enclave reconcile operator-versions --use-defaults`:
7. **Execute upgrade script** - Run the upgrade automation to apply configuration migrations, then upgrade the management cluster and operators to the versions pinned in the tarball. This executes the `playbooks/upgrade.yaml` playbook, which runs migrations followed by `enclave reconcile mgmt-cluster-version --use-defaults`, `enclave reconcile operator-versions --use-defaults`, and `enclave reconcile operator-versions --plugin <name>` for each enabled plugin that installs operators directly on the management cluster (`installOperators: true` and `installOperatorsFleet: false`, e.g. ODF, LVMS):

```sh
./upgrade.sh
```
8. **(Optional) Run cluster/operator upgrades separately** - By default `upgrade.yaml` performs both the management cluster and operator upgrades. To skip either step (e.g. to control upgrade timing independently) pass `upgrade_mgmt_cluster=false` and/or `upgrade_operators=false`, then run the corresponding command manually when ready:

8. **(Optional) Run cluster/operator upgrades separately** - By default `upgrade.yaml` performs both the management cluster and operator upgrades (including plugin operators). To skip either step (e.g. to control upgrade timing independently) pass `upgrade_mgmt_cluster=false` and/or `upgrade_operators=false`, then run the corresponding command manually when ready:

```sh
ansible-playbook playbooks/upgrade.yaml -e workingDir=<your global.yaml workingDir variable> -e upgrade_mgmt_cluster=false -e upgrade_operators=false

WORKING_DIR=<your global.yaml workingDir variable>
export KUBECONFIG=$WORKING_DIR/ocp-cluster/auth/kubeconfig
enclave reconcile mgmt-cluster-version --use-defaults
enclave reconcile operator-versions --use-defaults
enclave reconcile operator-versions --plugin odf
enclave reconcile operator-versions --plugin lvms
Comment thread
coderabbitai[bot] marked this conversation as resolved.
```

---
Expand Down
68 changes: 68 additions & 0 deletions playbooks/tasks/upgrade_plugins.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
# Auto-discover plugins whose operators are installed directly on the
# management cluster and reconcile their operator versions to the
# versions pinned in each plugin's plugin.yaml.
#
# Only plugins listed in enabled_plugins with installOperators: true and
# installOperatorsFleet: false are reconciled here: those are the ones
# whose operators are approved/upgraded on the management cluster via
# InstallPlans. Plugins with installOperatorsFleet: true roll out
# operators to fleet clusters via ACM policies instead, which this
# single-cluster reconcile flow does not manage.
#
# Called from upgrade.yaml.

- name: Find all plugin descriptors
ansible.builtin.find:
paths: "{{ playbook_dir }}/../plugins"
patterns: "plugin.yaml"
recurse: true
depth: 2
register: r_plugin_find

- name: Read plugin descriptors
ansible.builtin.slurp:
src: "{{ plugin_path }}"
loop: "{{ r_plugin_find.files | map(attribute='path') | list }}"
register: r_plugin_slurp
loop_control:
loop_var: plugin_path

- name: Build list of plugins to upgrade operator versions for
ansible.builtin.set_fact:
_plugin_operator_reconcile_list: >-
{{ r_plugin_slurp.results
| map(attribute='content')
| map('b64decode')
| map('from_yaml')
| selectattr('name', 'in', enabled_plugins)
| selectattr('installOperators', 'equalto', true)
| selectattr('installOperatorsFleet', 'equalto', false)
| map(attribute='name')
| list }}

- name: Display plugins to upgrade operator versions for
ansible.builtin.debug:
msg: "Plugins to upgrade operator versions for: {{ _plugin_operator_reconcile_list }}"

- name: Upgrade plugin operator versions
ansible.builtin.command:
argv:
- enclave
- reconcile
- operator-versions
- --plugin
- "{{ _plugin_name }}"
loop: "{{ _plugin_operator_reconcile_list }}"
loop_control:
loop_var: _plugin_name
register: r_plugin_operator_versions_upgrade
changed_when: "'[UPDATE]' in r_plugin_operator_versions_upgrade.stderr"
when: _plugin_operator_reconcile_list | length > 0
Comment thread
coderabbitai[bot] marked this conversation as resolved.

- name: Print plugin operator versions upgrade output
ansible.builtin.debug:
var: item.stderr_lines
loop: "{{ r_plugin_operator_versions_upgrade.results | default([]) }}"
loop_control:
label: "{{ item._plugin_name | default('') }}"
5 changes: 5 additions & 0 deletions playbooks/upgrade.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -51,3 +51,8 @@
ansible.builtin.debug:
var: r_operator_versions_upgrade.stderr_lines
when: upgrade_operators | default(true) | bool

- name: Upgrade plugin operator versions
ansible.builtin.include_tasks:
file: tasks/upgrade_plugins.yaml
when: upgrade_operators | default(true) | bool
210 changes: 180 additions & 30 deletions src/enclave/reconcile/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,161 @@ def defaults_path(filename: str) -> Path:
return path


def plugin_descriptor_path(plugin_name: str) -> Path:
"""Resolve the plugin.yaml path for a plugin name.

Plugins are not packaged into the built wheel, so this only resolves
against a repo checkout: src/enclave/reconcile/cli.py → src/enclave/ →
repo_root/plugins/<plugin_name>/plugin.yaml.
Comment on lines +32 to +34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why not packaging them into the wheel as we do with operator defaults?


Raises:
click.ClickException: If plugin_name is empty, contains path
separators or '..', or the resolved descriptor path escapes
the plugins directory.
"""
# Normalize Unicode and reject traversal/separator patterns
plugin_name = plugin_name.strip()
if (
not plugin_name
or "/" in plugin_name
or "\\" in plugin_name
or ".." in plugin_name
):
raise click.ClickException(
f"Invalid plugin name: {plugin_name!r}. Plugin name must be a "
"simple name without path separators or '..'."
)

enclave_pkg = Path(__file__).resolve().parent.parent
plugins_root = (enclave_pkg.parent.parent / "plugins").resolve()
descriptor_path = (plugins_root / plugin_name / "plugin.yaml").resolve()

# Ensure the resolved descriptor is actually under the plugins root
try:
descriptor_path.relative_to(plugins_root)
except ValueError as exc:
raise click.ClickException(
f"Invalid plugin name: {plugin_name!r} escapes the plugins directory"
) from exc

return descriptor_path


def _reconcile_operators_from_list(
operators: list[dict[str, object]], dry_run: bool
) -> None:
"""Call operator_versions_reconcile for each operator in a list.

Each operator dict must have 'name', 'version', 'namespace', and
optionally 'csvNames' (defaults to [name] when absent).

Validates all operator entries before reconciling any. If validation
fails for any entry, no reconciliation occurs.

Raises:
click.ClickException: If any operator entry is missing required
fields or has invalid field types.
"""
# Validate all operators first, before reconciling any
validated_operators: list[tuple[str, str, list[str]]] = []

for idx, op in enumerate(operators):
# Validate operator entry structure
if not isinstance(op, dict):
raise click.ClickException(f"Operator entry {idx} is not a mapping: {op!r}")

# Validate required string fields
for field in ("name", "version", "namespace"):
value = op.get(field)
if not isinstance(value, str) or not value.strip():
raise click.ClickException(
f"Operator entry {idx} has invalid or missing '{field}': {value!r}"
)

# Validate optional csvNames field
csv_names_raw = op.get("csvNames")
if csv_names_raw is not None and (
not isinstance(csv_names_raw, list)
or not all(isinstance(name, str) and name.strip() for name in csv_names_raw)
):
raise click.ClickException(
f"Operator entry {idx} has invalid 'csvNames': must be a list of non-blank strings"
)

op_name = cast("str", op["name"])
op_csv_names = cast("list[str] | None", csv_names_raw) or [op_name]
Comment thread
coderabbitai[bot] marked this conversation as resolved.
validated_operators.append((
cast("str", op["version"]),
cast("str", op["namespace"]),
op_csv_names,
))

# Reconcile only after all operators pass validation
for version, namespace, csv_names in validated_operators:
operator_versions_reconcile(version, namespace, csv_names, dry_run)


def _load_defaults_operators() -> list[dict[str, object]]:
"""Load the operators list from defaults/operators.yaml.

Raises:
click.ClickException: If the file is missing, not valid YAML, or
does not contain an 'operators' list.
"""
defaults_file = defaults_path("operators.yaml")
try:
with defaults_file.open(encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except FileNotFoundError as exc:
raise click.ClickException(
f"{defaults_file} not found; run from the repo root"
) from exc
except yaml.YAMLError as exc:
raise click.ClickException(f"Failed to parse {defaults_file}") from exc

if not isinstance(data, dict) or not isinstance(data.get("operators"), list):
raise click.ClickException(
f"{defaults_file} does not contain an 'operators' list"
)
return cast("list[dict[str, object]]", data["operators"])


def _load_plugin_operators(plugin_name: str) -> list[dict[str, object]]:
"""Load the operators list from a plugin's plugin.yaml descriptor.

Raises:
click.ClickException: If the plugin name is invalid, the plugin
descriptor is missing or not valid YAML, the plugin has
installOperators set to false, or it defines no operators.
"""
plugin_file = plugin_descriptor_path(plugin_name)
try:
with plugin_file.open(encoding="utf-8") as fh:
data = yaml.safe_load(fh)
except FileNotFoundError as exc:
raise click.ClickException(
f"Plugin {plugin_name!r} not found; check plugin name or run "
"from the repo root"
) from exc
except yaml.YAMLError as exc:
raise click.ClickException(f"Failed to parse {plugin_file}") from exc

if not isinstance(data, dict):
raise click.ClickException(f"{plugin_file} is empty or not a mapping")

if data.get("installOperators") is False:
raise click.ClickException(
f"Plugin {plugin_name!r} has installOperators set to false"
)

operators = data.get("operators")
if not isinstance(operators, list) or not operators:
raise click.ClickException(
f"Plugin {plugin_name!r} has no operators defined in {plugin_file}"
)
return cast("list[dict[str, object]]", operators)


@click.group(cls=KubeconfigGroup)
@click.option(
"--log-level",
Expand Down Expand Up @@ -53,7 +208,11 @@ def cli(log_level: str) -> None:
"--use-defaults",
is_flag=True,
default=False,
help="Load all operators from defaults/operators.yaml (mutually exclusive with --name, --version, --namespace, --csv-name)",
help="Load all operators from defaults/operators.yaml (mutually exclusive with --name, --version, --namespace, --csv-name, --plugin)",
)
@click.option(
"--plugin",
help="Load operators from plugins/<name>/plugin.yaml (mutually exclusive with --name, --version, --namespace, --csv-name, --use-defaults)",
Comment on lines +213 to +215

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd prefer a separate reconcile subcommand, since operator-versions looks very much related to operators/. Why not a plugin-version command to avoid the overload of the operator-versions one? This would also remove the mutual exclusivity logic. We could retain much of the current logic.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thought about naming and structure a lot here. it's not strictly a plugin update, since operators are a part of a plugin, but update of other pieces is not yet determined.

@rporres rporres Aug 24, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see. What about plugin-operator-versions subcommand then? What I don't like is that operator-versions is naturally bound with operators/ directory and defaults/operators.yaml making things easy to understand without diving deep into it. You either use defaults or a single operator. Now we're introducing a complexity that makes it harder to understand (e.g. what defaults are used by --use-defaults, plugin or operators?) and maintain instead of the simpler:

  • operator-versions handles things under operators/
  • plugin-operator-versions handles things under plugins/

)
@click.option("--dry-run/--no-dry-run", default=False)
def operator_versions(
Expand All @@ -62,44 +221,35 @@ def operator_versions(
namespace: str,
csv_names: tuple[str, ...],
use_defaults: bool,
plugin: str | None,
dry_run: bool,
) -> None:
if use_defaults and any([name, version, namespace, csv_names]):
if use_defaults and any([name, version, namespace, csv_names, plugin]):
raise click.UsageError(
"--use-defaults is mutually exclusive with --name, --version, --namespace, --csv-name"
"--use-defaults is mutually exclusive with --name, --version, --namespace, --csv-name, --plugin"
)

if not use_defaults:
missing = [
f"--{f}"
for f, v in [("name", name), ("version", version), ("namespace", namespace)]
if not v
]
if missing:
raise click.UsageError(f"Missing option(s): {', '.join(missing)}")
operator_versions_reconcile(
version, namespace, list(csv_names) or [name], dry_run
if plugin and any([name, version, namespace, csv_names]):
raise click.UsageError(
"--plugin is mutually exclusive with --name, --version, --namespace, --csv-name"
)

if plugin:
_reconcile_operators_from_list(_load_plugin_operators(plugin), dry_run)
return

defaults_file = defaults_path("operators.yaml")
if use_defaults:
_reconcile_operators_from_list(_load_defaults_operators(), dry_run)
return

try:
with defaults_file.open(encoding="utf-8") as fh:
operators = yaml.safe_load(fh)["operators"]
except FileNotFoundError as exc:
raise click.ClickException(
f"{defaults_file} not found; run from the repo root"
) from exc
except (yaml.YAMLError, KeyError) as exc:
raise click.ClickException(f"Failed to parse {defaults_file}: {exc}") from exc

for op in operators:
op_name: str = op["name"]
op_csv_names: list[str] = op.get("csvNames") or [op_name]
operator_versions_reconcile(
op["version"], op["namespace"], op_csv_names, dry_run
)
missing = [
f"--{f}"
for f, v in [("name", name), ("version", version), ("namespace", namespace)]
if not v
]
if missing:
raise click.UsageError(f"Missing option(s): {', '.join(missing)}")
operator_versions_reconcile(version, namespace, list(csv_names) or [name], dry_run)


@cli.command(no_args_is_help=True)
Expand Down
Loading
Loading