Repository navigation
Add --plugin option to operator-versions reconcile command #688
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8b37875
73f1af9
2e63284
fb8b138
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
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('') }}" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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] | ||
|
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", | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'd prefer a separate
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I see. What about
|
||
| ) | ||
| @click.option("--dry-run/--no-dry-run", default=False) | ||
| def operator_versions( | ||
|
|
@@ -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) | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.